Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/uu/mv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ uucore = { workspace = true, features = [
"backup-control",
"fs",
"fsxattr",
"perms",
"update-control",
] }
fluent = { workspace = true }
Expand Down
62 changes: 62 additions & 0 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)]
{
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)?;
Expand All @@ -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(())
Expand Down Expand Up @@ -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.
Expand Down
154 changes: 154 additions & 0 deletions tests/by-util/test_mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> {
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"
);
}
Loading