Skip to content

Commit 6dda6b4

Browse files
committed
mv,cp: fix xattr and symlink handling in cross-device operations
1 parent eb38e8a commit 6dda6b4

3 files changed

Lines changed: 88 additions & 12 deletions

File tree

src/uu/cp/src/cp.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,8 +1711,6 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy
17111711
/// user-writable if needed and restoring its original permissions afterward. This avoids "Operation
17121712
/// not permitted" errors on read-only files. Returns an error if permission or metadata operations fail,
17131713
/// or if xattr copying fails.
1714-
///
1715-
/// Uses file descriptor-based operations to avoid TOCTOU races during xattr copying.
17161714
#[cfg(all(unix, not(target_os = "android")))]
17171715
fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> {
17181716
use std::fs::File;

src/uu/mv/src/mv.rs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command};
1515
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
1616

1717
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
18-
use rustc_hash::FxHashMap;
19-
use rustc_hash::FxHashSet;
18+
use rustc_hash::{FxHashMap, FxHashSet};
2019
use std::env;
2120
use std::ffi::OsString;
2221
use std::fs;
@@ -967,7 +966,8 @@ fn rename_dir_fallback(
967966
(_, _) => None,
968967
};
969968

970-
// Retrieve xattrs using file descriptor to avoid TOCTOU races
969+
// Retrieve xattrs before copying (directories use path-based operations
970+
// since they cannot be opened in write mode for xattr operations)
971971
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
972972
let xattrs = {
973973
use std::fs::File;
@@ -989,12 +989,12 @@ fn rename_dir_fallback(
989989
display_manager,
990990
);
991991

992-
// Apply xattrs using file descriptor to avoid TOCTOU races
992+
// Apply xattrs after directory contents are copied, ignoring ENOTSUP errors
993+
// (filesystem doesn't support xattrs, which is acceptable for cross-device moves)
993994
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
994-
{
995-
use std::fs::OpenOptions;
996-
if let Ok(f) = OpenOptions::new().write(true).open(to) {
997-
fsxattr::apply_xattrs_fd(&f, xattrs)?;
995+
if let Err(e) = fsxattr::apply_xattrs(to, xattrs) {
996+
if e.raw_os_error() != Some(libc::EOPNOTSUPP) {
997+
return Err(e);
998998
}
999999
}
10001000

@@ -1063,8 +1063,35 @@ fn copy_dir_contents_recursive(
10631063
pb.set_message(from_path.to_string_lossy().to_string());
10641064
}
10651065

1066-
if from_path.is_dir() {
1067-
// Recursively copy subdirectory
1066+
if from_path.is_symlink() {
1067+
// Handle symlinks first, before checking is_dir() which follows symlinks.
1068+
// This prevents symlinks to directories from being expanded into full copies.
1069+
#[cfg(unix)]
1070+
{
1071+
copy_file_with_hardlinks_helper(
1072+
&from_path,
1073+
&to_path,
1074+
hardlink_tracker,
1075+
hardlink_scanner,
1076+
)?;
1077+
}
1078+
#[cfg(not(unix))]
1079+
{
1080+
rename_symlink_fallback(&from_path, &to_path)?;
1081+
}
1082+
1083+
// Print verbose message for symlink
1084+
if verbose {
1085+
let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1086+
match display_manager {
1087+
Some(pb) => pb.suspend(|| {
1088+
println!("{message}");
1089+
}),
1090+
None => println!("{message}"),
1091+
}
1092+
}
1093+
} else if from_path.is_dir() {
1094+
// Recursively copy subdirectory (only real directories, not symlinks)
10681095
fs::create_dir_all(&to_path)?;
10691096

10701097
// Print verbose message for directory

tests/by-util/test_mv.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2864,3 +2864,54 @@ fn test_mv_xattr_enotsup_silent() {
28642864
std::fs::remove_file("/dev/shm/mv_test").ok();
28652865
}
28662866
}
2867+
2868+
/// Test that symlinks inside directories are preserved during cross-device moves
2869+
/// (not expanded into full copies of their targets)
2870+
#[test]
2871+
#[cfg(target_os = "linux")]
2872+
fn test_mv_cross_device_symlink_preserved() {
2873+
use std::fs;
2874+
use std::os::unix::fs::symlink;
2875+
use tempfile::TempDir;
2876+
2877+
let scene = TestScenario::new(util_name!());
2878+
let at = &scene.fixtures;
2879+
2880+
// Create a directory with a symlink to /etc inside
2881+
at.mkdir("src_dir");
2882+
at.write("src_dir/local.txt", "local content");
2883+
symlink("/etc", at.plus("src_dir/etc_link")).expect("Failed to create symlink");
2884+
2885+
// Verify initial state
2886+
assert!(at.is_symlink("src_dir/etc_link"));
2887+
2888+
// Force cross-filesystem move using /dev/shm (tmpfs)
2889+
let target_dir =
2890+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2891+
let target_path = target_dir.path().join("dst_dir");
2892+
2893+
scene
2894+
.ucmd()
2895+
.arg("src_dir")
2896+
.arg(target_path.to_str().unwrap())
2897+
.succeeds()
2898+
.no_stderr();
2899+
2900+
// Verify source was removed
2901+
assert!(!at.dir_exists("src_dir"));
2902+
2903+
// Verify the symlink was preserved (not expanded)
2904+
let moved_symlink = target_path.join("etc_link");
2905+
assert!(
2906+
moved_symlink.is_symlink(),
2907+
"etc_link should still be a symlink after cross-device move"
2908+
);
2909+
assert_eq!(
2910+
fs::read_link(&moved_symlink).expect("Failed to read symlink"),
2911+
std::path::Path::new("/etc"),
2912+
"symlink should still point to /etc"
2913+
);
2914+
2915+
// Verify the regular file was also moved
2916+
assert!(target_path.join("local.txt").exists());
2917+
}

0 commit comments

Comments
 (0)