Skip to content

Commit 6e40932

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

3 files changed

Lines changed: 87 additions & 10 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: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,8 @@ fn rename_dir_fallback(
967967
(_, _) => None,
968968
};
969969

970-
// Retrieve xattrs using file descriptor to avoid TOCTOU races
970+
// Retrieve xattrs before copying (directories use path-based operations
971+
// since they cannot be opened in write mode for xattr operations)
971972
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
972973
let xattrs = {
973974
use std::fs::File;
@@ -989,12 +990,12 @@ fn rename_dir_fallback(
989990
display_manager,
990991
);
991992

992-
// Apply xattrs using file descriptor to avoid TOCTOU races
993+
// Apply xattrs after directory contents are copied, ignoring ENOTSUP errors
994+
// (filesystem doesn't support xattrs, which is acceptable for cross-device moves)
993995
#[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)?;
996+
if let Err(e) = fsxattr::apply_xattrs(to, xattrs) {
997+
if e.raw_os_error() != Some(libc::EOPNOTSUPP) {
998+
return Err(e);
998999
}
9991000
}
10001001

@@ -1063,8 +1064,35 @@ fn copy_dir_contents_recursive(
10631064
pb.set_message(from_path.to_string_lossy().to_string());
10641065
}
10651066

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

10701098
// 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+
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)