Skip to content

Commit 2598f34

Browse files
committed
mv,cp: fix xattr and symlink handling in cross-device operations
1 parent e3b063d commit 2598f34

File tree

3 files changed

+92
-32
lines changed

3 files changed

+92
-32
lines changed

src/uu/cp/src/cp.rs

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::os::unix::net::UnixListener;
1616
use std::path::{Path, PathBuf, StripPrefixError};
1717
use std::{fmt, io};
1818
#[cfg(all(unix, not(target_os = "android")))]
19-
use uucore::fsxattr::copy_xattrs_fd;
19+
use uucore::fsxattr::copy_xattrs;
2020
use uucore::translate;
2121

2222
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
@@ -1675,13 +1675,8 @@ fn handle_preserve<F: Fn() -> CopyResult<()>>(p: &Preserve, f: F) -> CopyResult<
16751675
/// user-writable if needed and restoring its original permissions afterward. This avoids "Operation
16761676
/// not permitted" errors on read-only files. Returns an error if permission or metadata operations fail,
16771677
/// or if xattr copying fails.
1678-
///
1679-
/// Uses file descriptor-based operations to avoid TOCTOU races during xattr copying.
16801678
#[cfg(all(unix, not(target_os = "android")))]
16811679
fn copy_extended_attrs(source: &Path, dest: &Path) -> CopyResult<()> {
1682-
use std::fs::File;
1683-
use uucore::fsxattr::copy_xattrs;
1684-
16851680
let metadata = fs::symlink_metadata(dest)?;
16861681

16871682
// Check if the destination file is currently read-only for the user.
@@ -1695,18 +1690,9 @@ fn copy_extended_attrs(source: &Path, dest: &Path) -> CopyResult<()> {
16951690
fs::set_permissions(dest, perms)?;
16961691
}
16971692

1698-
// Use file descriptor-based operations for regular files to avoid TOCTOU races.
1699-
// For directories and symlinks, we must use path-based operations since:
1700-
// - Directories cannot be opened with write mode for xattr operations
1701-
// - Symlinks (especially dangling ones) cannot be opened via File::open
1702-
let copy_xattrs_result = if metadata.is_file() {
1703-
// Open both files to pin their inodes, avoiding TOCTOU races during xattr operations
1704-
let source_file = File::open(source)?;
1705-
let dest_file = OpenOptions::new().write(true).open(dest)?;
1706-
copy_xattrs_fd(&source_file, &dest_file)
1707-
} else {
1708-
copy_xattrs(source, dest)
1709-
};
1693+
// Perform the xattr copy and capture any potential error,
1694+
// so we can restore permissions before returning.
1695+
let copy_xattrs_result = copy_xattrs(source, dest);
17101696

17111697
// Restore read-only if we changed it.
17121698
if was_readonly {

src/uu/mv/src/mv.rs

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -975,14 +975,10 @@ fn rename_dir_fallback(
975975
(_, _) => None,
976976
};
977977

978-
// Retrieve xattrs using file descriptor to avoid TOCTOU races
978+
// Retrieve xattrs before copying (directories use path-based operations
979+
// since they cannot be opened in write mode for xattr operations)
979980
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
980-
let xattrs = {
981-
use std::fs::File;
982-
File::open(from)
983-
.and_then(|f| fsxattr::retrieve_xattrs_fd(&f))
984-
.unwrap_or_else(|_| HashMap::new())
985-
};
981+
let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| HashMap::new());
986982

987983
// Use directory copying (with or without hardlink support)
988984
let result = copy_dir_contents(
@@ -997,12 +993,12 @@ fn rename_dir_fallback(
997993
display_manager,
998994
);
999995

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

@@ -1071,8 +1067,35 @@ fn copy_dir_contents_recursive(
10711067
pb.set_message(from_path.to_string_lossy().to_string());
10721068
}
10731069

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

10781101
// 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
@@ -2851,3 +2851,54 @@ fn test_mv_xattr_enotsup_silent() {
28512851
std::fs::remove_file("/dev/shm/mv_test").ok();
28522852
}
28532853
}
2854+
2855+
/// Test that symlinks inside directories are preserved during cross-device moves
2856+
/// (not expanded into full copies of their targets)
2857+
#[test]
2858+
#[cfg(target_os = "linux")]
2859+
fn test_mv_cross_device_symlink_preserved() {
2860+
use std::fs;
2861+
use std::os::unix::fs::symlink;
2862+
use tempfile::TempDir;
2863+
2864+
let scene = TestScenario::new(util_name!());
2865+
let at = &scene.fixtures;
2866+
2867+
// Create a directory with a symlink to /etc inside
2868+
at.mkdir("src_dir");
2869+
at.write("src_dir/local.txt", "local content");
2870+
symlink("/etc", at.plus("src_dir/etc_link")).expect("Failed to create symlink");
2871+
2872+
// Verify initial state
2873+
assert!(at.is_symlink("src_dir/etc_link"));
2874+
2875+
// Force cross-filesystem move using /dev/shm (tmpfs)
2876+
let target_dir =
2877+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2878+
let target_path = target_dir.path().join("dst_dir");
2879+
2880+
scene
2881+
.ucmd()
2882+
.arg("src_dir")
2883+
.arg(target_path.to_str().unwrap())
2884+
.succeeds()
2885+
.no_stderr();
2886+
2887+
// Verify source was removed
2888+
assert!(!at.dir_exists("src_dir"));
2889+
2890+
// Verify the symlink was preserved (not expanded)
2891+
let moved_symlink = target_path.join("etc_link");
2892+
assert!(
2893+
moved_symlink.is_symlink(),
2894+
"etc_link should still be a symlink after cross-device move"
2895+
);
2896+
assert_eq!(
2897+
fs::read_link(&moved_symlink).expect("Failed to read symlink"),
2898+
std::path::Path::new("/etc"),
2899+
"symlink should still point to /etc"
2900+
);
2901+
2902+
// Verify the regular file was also moved
2903+
assert!(target_path.join("local.txt").exists());
2904+
}

0 commit comments

Comments
 (0)