diff --git a/src/uu/mv/Cargo.toml b/src/uu/mv/Cargo.toml index 88199c80470..10680abd4e8 100644 --- a/src/uu/mv/Cargo.toml +++ b/src/uu/mv/Cargo.toml @@ -29,6 +29,7 @@ uucore = { workspace = true, features = [ "backup-control", "fs", "fsxattr", + "perms", "update-control", ] } fluent = { workspace = true } diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index b5f789ff0ba..11c2ca752df 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -907,6 +907,8 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { { let _ = copy_xattrs_if_supported(from, to); } + // Preserve ownership (uid/gid) from the source symlink + let _ = preserve_ownership(from, to); fs::remove_file(from) } @@ -1005,6 +1007,12 @@ fn copy_dir_contents( // Create the destination directory fs::create_dir_all(to)?; + // Preserve ownership (uid/gid) of the top-level directory + #[cfg(unix)] + { + let _ = preserve_ownership(from, to); + } + // Recursively copy contents #[cfg(unix)] { @@ -1085,6 +1093,12 @@ fn copy_dir_contents_recursive( // Recursively copy subdirectory (only real directories, not symlinks) fs::create_dir_all(&to_path)?; + // Preserve ownership (uid/gid) of the subdirectory + #[cfg(unix)] + { + let _ = preserve_ownership(&from_path, &to_path); + } + print_verbose(&from_path, &to_path); copy_dir_contents_recursive( @@ -1148,9 +1162,12 @@ fn copy_file_with_hardlinks_helper( if from.is_symlink() { // Copy a symlink file (no-follow). + // rename_symlink_fallback already preserves ownership and removes the source. rename_symlink_fallback(from, to)?; } else if is_fifo(from.symlink_metadata()?.file_type()) { make_fifo(to)?; + // Preserve ownership (uid/gid) from the source + let _ = preserve_ownership(from, to); } else { // Copy a regular file. fs::copy(from, to)?; @@ -1159,6 +1176,8 @@ fn copy_file_with_hardlinks_helper( { let _ = copy_xattrs_if_supported(from, to); } + // Preserve ownership (uid/gid) from the source + let _ = preserve_ownership(from, to); } Ok(()) @@ -1208,11 +1227,54 @@ fn rename_file_fallback( let _ = copy_xattrs_if_supported(from, to); } + // Preserve ownership (uid/gid) from the source file + #[cfg(unix)] + { + let _ = preserve_ownership(from, to); + } + fs::remove_file(from) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; Ok(()) } +/// Preserve ownership (uid/gid) from source to destination. +/// Uses lchown so it works on symlinks without following them. +/// Errors are silently ignored for non-root users who cannot chown. +#[cfg(unix)] +fn preserve_ownership(from: &Path, to: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + + let source_meta = from.symlink_metadata()?; + let uid = source_meta.uid(); + let gid = source_meta.gid(); + + let dest_meta = to.symlink_metadata()?; + let dest_uid = dest_meta.uid(); + let dest_gid = dest_meta.gid(); + + // Only chown if ownership actually differs + if uid != dest_uid || gid != dest_gid { + use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; + // Use follow=false so lchown is used (works on symlinks) + // Silently ignore errors: non-root users typically cannot chown to + // arbitrary uid, matching GNU mv behavior which also uses best-effort. + let _ = wrap_chown( + to, + &dest_meta, + Some(uid), + Some(gid), + false, + Verbosity { + groups_only: false, + level: VerbosityLevel::Silent, + }, + ); + } + + Ok(()) +} + /// Copy xattrs from source to destination, ignoring ENOTSUP/EOPNOTSUPP errors. /// These errors indicate the filesystem doesn't support extended attributes, /// which is acceptable when moving files across filesystems. diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 80448ba1b84..33d54383620 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -3006,3 +3006,157 @@ fn test_mv_cross_device_file_symlink_preserved() { "target content" ); } + +/// Find a supplementary group that differs from `current`. +/// Non-root users can chgrp to any group they belong to. +#[cfg(target_os = "linux")] +fn find_other_group(current: u32) -> Option { + nix::unistd::getgroups().ok()?.iter().find_map(|group| { + let gid = group.as_raw(); + (gid != current).then_some(gid) + }) +} + +/// Test that group ownership is preserved during cross-device file moves. +/// Uses chgrp to a supplementary group (no root needed). +/// See https://github.com/uutils/coreutils/issues/9714 +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_preserves_ownership() { + use std::fs; + use std::os::unix::fs::MetadataExt; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("owned_file", "owned content"); + let file_path = at.plus("owned_file"); + + let original_meta = fs::metadata(&file_path).expect("Failed to get metadata"); + let Some(other_gid) = find_other_group(original_meta.gid()) else { + println!("SKIPPED: no supplementary group available for chgrp"); + return; + }; + + // chgrp to a different group (non-root users can do this for their own groups) + uucore::perms::wrap_chown( + &file_path, + &original_meta, + None, + Some(other_gid), + false, + uucore::perms::Verbosity::default(), + ) + .expect("Failed to chgrp file"); + + let meta = fs::metadata(&file_path).expect("Failed to get metadata"); + assert_eq!(meta.gid(), other_gid, "chgrp should have changed the gid"); + + // 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_file = target_dir.path().join("owned_file"); + + scene + .ucmd() + .arg("owned_file") + .arg(target_file.to_str().unwrap()) + .succeeds() + .no_stderr(); + + let moved_meta = fs::metadata(&target_file).expect("Failed to get metadata of moved file"); + assert_eq!( + moved_meta.gid(), + other_gid, + "gid should be preserved after cross-device move (expected {other_gid}, got {})", + moved_meta.gid() + ); + assert_eq!( + moved_meta.uid(), + meta.uid(), + "uid should be preserved after cross-device move" + ); +} + +/// Test that group ownership is preserved for files inside directories during cross-device moves. +/// Uses chgrp to a supplementary group (no root needed). +/// See https://github.com/uutils/coreutils/issues/9714 +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_preserves_ownership_recursive() { + use std::fs; + use std::os::unix::fs::MetadataExt; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("owned_dir"); + at.mkdir("owned_dir/sub"); + at.write("owned_dir/file1", "content1"); + at.write("owned_dir/sub/file2", "content2"); + + let dir_meta = fs::metadata(at.plus("owned_dir")).expect("Failed to get metadata"); + let Some(other_gid) = find_other_group(dir_meta.gid()) else { + println!("SKIPPED: no supplementary group available for chgrp"); + return; + }; + + // chgrp all entries to a different group + for path in &[ + "owned_dir", + "owned_dir/sub", + "owned_dir/file1", + "owned_dir/sub/file2", + ] { + let p = at.plus(path); + let m = fs::metadata(&p).expect("Failed to get metadata"); + uucore::perms::wrap_chown( + &p, + &m, + None, + Some(other_gid), + false, + uucore::perms::Verbosity::default(), + ) + .expect("Failed to chgrp"); + } + + // 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("owned_dir"); + + scene + .ucmd() + .arg("owned_dir") + .arg(target_path.to_str().unwrap()) + .succeeds() + .no_stderr(); + + // Check ownership of the directory itself + let moved_dir_meta = fs::metadata(&target_path).expect("Failed to get dir metadata"); + assert_eq!( + moved_dir_meta.gid(), + other_gid, + "directory gid should be preserved after cross-device move" + ); + + // Check ownership of a file inside the directory + let file1_meta = fs::metadata(target_path.join("file1")).expect("Failed to get file1 metadata"); + assert_eq!( + file1_meta.gid(), + other_gid, + "file gid should be preserved after cross-device move" + ); + + // Check ownership of a file in a subdirectory + let file2_meta = + fs::metadata(target_path.join("sub/file2")).expect("Failed to get file2 metadata"); + assert_eq!( + file2_meta.gid(), + other_gid, + "nested file gid should be preserved after cross-device move" + ); +}