diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 3c3b057098e..34fa7f50d6c 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -16,7 +16,7 @@ use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf, StripPrefixError}; use std::{fmt, io}; #[cfg(all(unix, not(target_os = "android")))] -use uucore::fsxattr::{copy_xattrs, copy_xattrs_skip_selinux}; +use uucore::fsxattr::{copy_xattrs_fd, copy_xattrs_skip_selinux}; use uucore::translate; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser}; @@ -1713,6 +1713,8 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy /// or if xattr copying fails. #[cfg(all(unix, not(target_os = "android")))] fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> { + use std::fs::File; + use uucore::fsxattr::copy_xattrs; let metadata = fs::symlink_metadata(dest)?; // Check if the destination file is currently read-only for the user. @@ -1732,6 +1734,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe // When -Z is used, skip copying security.selinux xattr so that // the default context can be set instead of preserving from source copy_xattrs_skip_selinux(source, dest) + } else if metadata.is_file() { + // Use file descriptor-based operations for regular files to avoid TOCTOU races. + // Directories cannot be opened with write mode for xattr operations + // Symlinks (especially dangling ones) cannot be opened via File::open + let source_file = File::open(source)?; + let dest_file = OpenOptions::new().write(true).open(dest)?; + copy_xattrs_fd(&source_file, &dest_file) } else { copy_xattrs(source, dest) }; diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 740f6fbf1e7..23afb643315 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -967,8 +967,15 @@ fn rename_dir_fallback( (_, _) => None, }; + // Retrieve xattrs before copying (directories use path-based operations + // since they cannot be opened in write mode for xattr operations) #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| FxHashMap::default()); + let xattrs = { + use std::fs::File; + File::open(from) + .and_then(|f| fsxattr::retrieve_xattrs_fd(&f)) + .unwrap_or_else(|_| FxHashMap::default()) + }; // Use directory copying (with or without hardlink support) let result = copy_dir_contents( @@ -983,8 +990,14 @@ fn rename_dir_fallback( display_manager, ); + // Apply xattrs after directory contents are copied, ignoring ENOTSUP errors + // (filesystem doesn't support xattrs, which is acceptable for cross-device moves) #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - fsxattr::apply_xattrs(to, xattrs)?; + if let Err(e) = fsxattr::apply_xattrs(to, xattrs) { + if e.raw_os_error() != Some(libc::EOPNOTSUPP) { + return Err(e); + } + } result?; @@ -1051,8 +1064,35 @@ fn copy_dir_contents_recursive( pb.set_message(from_path.to_string_lossy().to_string()); } - if from_path.is_dir() { - // Recursively copy subdirectory + if from_path.is_symlink() { + // Handle symlinks first, before checking is_dir() which follows symlinks. + // This prevents symlinks to directories from being expanded into full copies. + #[cfg(unix)] + { + copy_file_with_hardlinks_helper( + &from_path, + &to_path, + hardlink_tracker, + hardlink_scanner, + )?; + } + #[cfg(not(unix))] + { + rename_symlink_fallback(&from_path, &to_path)?; + } + + // Print verbose message for symlink + if verbose { + let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote()); + match display_manager { + Some(pb) => pb.suspend(|| { + println!("{message}"); + }), + None => println!("{message}"), + } + } + } else if from_path.is_dir() { + // Recursively copy subdirectory (only real directories, not symlinks) fs::create_dir_all(&to_path)?; // Print verbose message for directory @@ -1206,12 +1246,19 @@ fn rename_file_fallback( Ok(()) } -/// Copy xattrs from source to destination, ignoring ENOTSUP/EOPNOTSUPP errors. +/// Copy xattrs from source to destination using file descriptors, ignoring ENOTSUP/EOPNOTSUPP errors. +/// This version avoids TOCTOU races by operating on file descriptors rather than paths. /// These errors indicate the filesystem doesn't support extended attributes, /// which is acceptable when moving files across filesystems. #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] fn copy_xattrs_if_supported(from: &Path, to: &Path) -> io::Result<()> { - match fsxattr::copy_xattrs(from, to) { + use std::fs::{File, OpenOptions}; + + // Open both files to pin their inodes, avoiding TOCTOU races during xattr operations + let source_file = File::open(from)?; + let dest_file = OpenOptions::new().write(true).open(to)?; + + match fsxattr::copy_xattrs_fd(&source_file, &dest_file) { Ok(()) => Ok(()), Err(e) if e.raw_os_error() == Some(libc::EOPNOTSUPP) => Ok(()), Err(e) => Err(e), diff --git a/src/uucore/src/lib/features/fsxattr.rs b/src/uucore/src/lib/features/fsxattr.rs index d9683264652..e2fc76d9c78 100644 --- a/src/uucore/src/lib/features/fsxattr.rs +++ b/src/uucore/src/lib/features/fsxattr.rs @@ -9,9 +9,11 @@ use itertools::Itertools; use rustc_hash::FxHashMap; use std::ffi::{OsStr, OsString}; +use std::fs::File; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; use std::path::Path; +use xattr::FileExt; /// Copies extended attributes (xattrs) from one file or directory to another. /// @@ -45,6 +47,29 @@ pub fn copy_xattrs_skip_selinux>(source: P, dest: P) -> std::io:: Ok(()) } +/// Copies extended attributes (xattrs) from one file to another using file descriptors. +/// +/// This version avoids TOCTOU (time-of-check to time-of-use) races by operating +/// on open file descriptors rather than paths, ensuring all operations target +/// the same inodes throughout. +/// +/// # Arguments +/// +/// * `source` - A reference to the source file (open file descriptor). +/// * `dest` - A reference to the destination file (open file descriptor). +/// +/// # Returns +/// +/// A result indicating success or failure. +pub fn copy_xattrs_fd(source: &File, dest: &File) -> std::io::Result<()> { + for attr_name in source.list_xattr()? { + if let Some(value) = source.get_xattr(&attr_name)? { + dest.set_xattr(&attr_name, &value)?; + } + } + Ok(()) +} + /// Retrieves the extended attributes (xattrs) of a given file or directory. /// /// # Arguments @@ -64,6 +89,28 @@ pub fn retrieve_xattrs>(source: P) -> std::io::Result std::io::Result>> { + let mut attrs = FxHashMap::default(); + for attr_name in source.list_xattr()? { + if let Some(value) = source.get_xattr(&attr_name)? { + attrs.insert(attr_name, value); + } + } + Ok(attrs) +} + /// Applies extended attributes (xattrs) to a given file or directory. /// /// # Arguments @@ -84,6 +131,26 @@ pub fn apply_xattrs>( Ok(()) } +/// Applies extended attributes (xattrs) to a given file using a file descriptor. +/// +/// This version avoids TOCTOU races by operating on an open file descriptor +/// rather than a path, ensuring all operations target the same inode. +/// +/// # Arguments +/// +/// * `dest` - A reference to the file (open file descriptor). +/// * `xattrs` - A HashMap containing attribute names and their corresponding values. +/// +/// # Returns +/// +/// A result indicating success or failure. +pub fn apply_xattrs_fd(dest: &File, xattrs: FxHashMap>) -> std::io::Result<()> { + for (attr, value) in xattrs { + dest.set_xattr(&attr, &value)?; + } + Ok(()) +} + /// Checks if a file has an Access Control List (ACL) based on its extended attributes. /// /// # Arguments @@ -299,4 +366,59 @@ mod tests { assert!(has_security_cap_acl(&file_path)); } } + + #[test] + fn test_copy_xattrs_fd() { + use std::fs::OpenOptions; + + let temp_dir = tempdir().unwrap(); + let source_path = temp_dir.path().join("source.txt"); + let dest_path = temp_dir.path().join("dest.txt"); + + File::create(&source_path).unwrap(); + File::create(&dest_path).unwrap(); + + let test_attr = "user.test_fd"; + let test_value = b"test value fd"; + xattr::set(&source_path, test_attr, test_value).unwrap(); + + let source_file = File::open(&source_path).unwrap(); + let dest_file = OpenOptions::new().write(true).open(&dest_path).unwrap(); + + copy_xattrs_fd(&source_file, &dest_file).unwrap(); + + let copied_value = xattr::get(&dest_path, test_attr).unwrap().unwrap(); + assert_eq!(copied_value, test_value); + } + + #[test] + fn test_apply_and_retrieve_xattrs_fd() { + use std::fs::OpenOptions; + + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_file.txt"); + + File::create(&file_path).unwrap(); + + let mut test_xattrs = FxHashMap::default(); + let test_attr = "user.test_attr_fd"; + let test_value = b"test value fd"; + test_xattrs.insert(OsString::from(test_attr), test_value.to_vec()); + + // Apply using file descriptor + let file = OpenOptions::new().write(true).open(&file_path).unwrap(); + apply_xattrs_fd(&file, test_xattrs).unwrap(); + drop(file); + + // Retrieve using file descriptor + let file = File::open(&file_path).unwrap(); + let retrieved_xattrs = retrieve_xattrs_fd(&file).unwrap(); + assert!(retrieved_xattrs.contains_key(OsString::from(test_attr).as_os_str())); + assert_eq!( + retrieved_xattrs + .get(OsString::from(test_attr).as_os_str()) + .unwrap(), + test_value + ); + } } diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 266c812df59..250a12a5ef6 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -2864,3 +2864,54 @@ fn test_mv_xattr_enotsup_silent() { std::fs::remove_file("/dev/shm/mv_test").ok(); } } + +/// Test that symlinks inside directories are preserved during cross-device moves +/// (not expanded into full copies of their targets) +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_symlink_preserved() { + use std::fs; + use std::os::unix::fs::symlink; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // Create a directory with a symlink to /etc inside + at.mkdir("src_dir"); + at.write("src_dir/local.txt", "local content"); + symlink("/etc", at.plus("src_dir/etc_link")).expect("Failed to create symlink"); + + // Verify initial state + assert!(at.is_symlink("src_dir/etc_link")); + + // Force cross-filesystem move using /dev/shm (tmpfs) + let target_dir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let target_path = target_dir.path().join("dst_dir"); + + scene + .ucmd() + .arg("src_dir") + .arg(target_path.to_str().unwrap()) + .succeeds() + .no_stderr(); + + // Verify source was removed + assert!(!at.dir_exists("src_dir")); + + // Verify the symlink was preserved (not expanded) + let moved_symlink = target_path.join("etc_link"); + assert!( + moved_symlink.is_symlink(), + "etc_link should still be a symlink after cross-device move" + ); + assert_eq!( + fs::read_link(&moved_symlink).expect("Failed to read symlink"), + Path::new("/etc"), + "symlink should still point to /etc" + ); + + // Verify the regular file was also moved + assert!(target_path.join("local.txt").exists()); +}