diff --git a/src/uu/mv/src/hardlink.rs b/src/uu/mv/src/hardlink.rs index 8ef687e3562..22d8efb35a5 100644 --- a/src/uu/mv/src/hardlink.rs +++ b/src/uu/mv/src/hardlink.rs @@ -204,18 +204,23 @@ impl HardlinkGroupScanner { fn scan_single_path(&mut self, path: &Path) -> io::Result<()> { use std::os::unix::fs::MetadataExt; - if path.is_dir() { + let metadata = path.symlink_metadata()?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + // Hardlink preservation does not apply to symlinks. + return Ok(()); + } + + if file_type.is_dir() { // Recursively scan directory contents self.scan_directory_recursive(path)?; - } else { - let metadata = path.metadata()?; - if metadata.nlink() > 1 { - let key = (metadata.dev(), metadata.ino()); - self.hardlink_groups - .entry(key) - .or_default() - .push(path.to_path_buf()); - } + } else if metadata.nlink() > 1 { + let key = (metadata.dev(), metadata.ino()); + self.hardlink_groups + .entry(key) + .or_default() + .push(path.to_path_buf()); } Ok(()) } @@ -229,14 +234,19 @@ impl HardlinkGroupScanner { let entry = entry?; let path = entry.path(); - if path.is_dir() { + let metadata = path.symlink_metadata()?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + // Skip symlinks to avoid following targets (including dangling links). + continue; + } + + if file_type.is_dir() { self.scan_directory_recursive(&path)?; - } else { - let metadata = path.metadata()?; - if metadata.nlink() > 1 { - let key = (metadata.dev(), metadata.ino()); - self.hardlink_groups.entry(key).or_default().push(path); - } + } else if metadata.nlink() > 1 { + let key = (metadata.dev(), metadata.ino()); + self.hardlink_groups.entry(key).or_default().push(path); } } Ok(()) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 517e70a4fc6..8e5e07aa75a 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -804,6 +804,72 @@ fn is_fifo(_filetype: fs::FileType) -> bool { false } +#[cfg(unix)] +/// Best-effort ownership preservation for `to` using `from_meta`. +/// +/// On Unix, this tries to set `uid`/`gid` on `to`. If `follow_symlinks` is +/// true it uses `chown`, otherwise it uses `lchown` so the link itself (not its +/// target) is updated. `chown`/`lchown` failures are non-fatal; permission +/// errors are ignored, and other failures emit a warning because ownership +/// preservation is optional. +fn try_preserve_ownership(from_meta: &fs::Metadata, to: &Path, follow_symlinks: bool) { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::MetadataExt as _; + + let uid = from_meta.uid() as libc::uid_t; + let gid = from_meta.gid() as libc::gid_t; + + let Ok(to_cstr) = CString::new(to.as_os_str().as_bytes()) else { + return; + }; + + let result = unsafe { + if follow_symlinks { + libc::chown(to_cstr.as_ptr(), uid, gid) + } else { + libc::lchown(to_cstr.as_ptr(), uid, gid) + } + }; + if result != 0 { + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::PermissionDenied { + eprintln!( + "mv: warning: failed to preserve ownership for {}: {err}", + to.quote() + ); + } + } +} + +#[cfg(unix)] +/// Best-effort permission preservation for `to` using `from_meta`. +/// +/// Only the mode bits are applied (`chmod` does not accept file type bits). +/// Failures are non-fatal; permission errors are ignored, and other failures +/// emit a warning because this is optional. +fn try_preserve_permissions(from_meta: &fs::Metadata, to: &Path) { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + // Keep mode bits only (file type bits are not allowed in chmod). + let mode = from_meta.mode() & 0o7777; + if let Err(err) = fs::set_permissions(to, fs::Permissions::from_mode(mode)) { + if err.kind() != io::ErrorKind::PermissionDenied { + eprintln!( + "mv: warning: failed to preserve permissions for {}: {err}", + to.quote() + ); + } + } +} + +#[cfg(unix)] +fn try_preserve_ownership_and_permissions(from_meta: &fs::Metadata, to: &Path) { + // `chown` can clear setuid/setgid bits, so restore the mode afterwards. + try_preserve_ownership(from_meta, to, true); + try_preserve_permissions(from_meta, to); +} + /// A wrapper around `fs::rename`, so that if it fails, we try falling back on /// copying and removing. fn rename_with_fallback( @@ -880,10 +946,14 @@ fn rename_with_fallback( /// Replace the destination with a new pipe with the same name as the source. #[cfg(unix)] fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> { + let from_meta = from.symlink_metadata()?; if to.try_exists()? { fs::remove_file(to)?; } - make_fifo(to).and_then(|_| fs::remove_file(from)) + make_fifo(to).and_then(|_| { + try_preserve_ownership_and_permissions(&from_meta, to); + fs::remove_file(from) + }) } #[cfg(not(unix))] @@ -897,19 +967,28 @@ fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> { /// Move the given symlink to the given destination. On Windows, dangling /// symlinks return an error. -#[cfg(unix)] fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { + copy_symlink(from, to)?; + fs::remove_file(from) +} + +/// Copy the given symlink to the given destination without dereferencing. +/// On Windows, dangling symlinks return an error. +#[cfg(unix)] +fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> { + let from_meta = from.symlink_metadata()?; let path_symlink_points_to = fs::read_link(from)?; unix::fs::symlink(path_symlink_points_to, to)?; #[cfg(not(any(target_os = "macos", target_os = "redox")))] { let _ = copy_xattrs_if_supported(from, to); } - fs::remove_file(from) + try_preserve_ownership(&from_meta, to, false); + Ok(()) } #[cfg(windows)] -fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { +fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> { let path_symlink_points_to = fs::read_link(from)?; if path_symlink_points_to.exists() { if path_symlink_points_to.is_dir() { @@ -917,7 +996,7 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { } else { windows::fs::symlink_file(&path_symlink_points_to, to)?; } - fs::remove_file(from) + Ok(()) } else { Err(io::Error::new( io::ErrorKind::NotFound, @@ -927,8 +1006,8 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { } #[cfg(not(any(windows, unix)))] -fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { - let path_symlink_points_to = fs::read_link(from)?; +fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> { + let _ = (from, to); Err(io::Error::new( io::ErrorKind::Other, translate!("mv-error-no-symlink-support"), @@ -943,6 +1022,9 @@ fn rename_dir_fallback( #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>, #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>, ) -> io::Result<()> { + #[cfg(unix)] + let from_meta = from.symlink_metadata()?; + // We remove the destination directory if it exists to match the // behavior of `fs::rename`. As far as I can tell, `fs_extra`'s // `move_dir` would otherwise behave differently. @@ -988,6 +1070,9 @@ fn rename_dir_fallback( result?; + #[cfg(unix)] + try_preserve_ownership_and_permissions(&from_meta, to); + // Remove the source directory after successful copy fs::remove_dir_all(from)?; @@ -1065,7 +1150,9 @@ fn copy_dir_contents_recursive( pb.set_message(from_path.to_string_lossy().to_string()); } - if from_path.is_symlink() { + let entry_type = entry.file_type()?; + + if entry_type.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)] @@ -1083,7 +1170,7 @@ fn copy_dir_contents_recursive( } print_verbose(&from_path, &to_path); - } else if from_path.is_dir() { + } else if entry_type.is_dir() { // Recursively copy subdirectory (only real directories, not symlinks) fs::create_dir_all(&to_path)?; @@ -1100,6 +1187,11 @@ fn copy_dir_contents_recursive( progress_bar, display_manager, )?; + + #[cfg(unix)] + if let Ok(from_meta) = fs::symlink_metadata(&from_path) { + try_preserve_ownership_and_permissions(&from_meta, &to_path); + } } else { // Copy file with or without hardlink support based on platform #[cfg(unix)] @@ -1137,6 +1229,8 @@ fn copy_file_with_hardlinks_helper( hardlink_tracker: &mut HardlinkTracker, hardlink_scanner: &HardlinkGroupScanner, ) -> io::Result<()> { + let from_meta = from.symlink_metadata()?; + // Check if this file should be a hardlink to an already-copied file use crate::hardlink::HardlinkOptions; let hardlink_options = HardlinkOptions::default(); @@ -1148,10 +1242,10 @@ fn copy_file_with_hardlinks_helper( return Ok(()); } - if from.is_symlink() { + if from_meta.file_type().is_symlink() { // Copy a symlink file (no-follow). - rename_symlink_fallback(from, to)?; - } else if is_fifo(from.symlink_metadata()?.file_type()) { + copy_symlink(from, to)?; + } else if is_fifo(from_meta.file_type()) { make_fifo(to)?; } else { // Copy a regular file. @@ -1163,6 +1257,8 @@ fn copy_file_with_hardlinks_helper( } } + try_preserve_ownership_and_permissions(&from_meta, to); + Ok(()) } @@ -1172,6 +1268,9 @@ fn rename_file_fallback( #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>, #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>, ) -> io::Result<()> { + #[cfg(unix)] + let from_meta = from.symlink_metadata()?; + // Remove existing target file if it exists if to.is_symlink() { fs::remove_file(to).map_err(|err| { @@ -1210,6 +1309,11 @@ fn rename_file_fallback( let _ = copy_xattrs_if_supported(from, to); } + #[cfg(unix)] + { + try_preserve_ownership_and_permissions(&from_meta, to); + } + fs::remove_file(from) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; Ok(()) diff --git a/tests/by-util/test_df.rs b/tests/by-util/test_df.rs index c1cf50d2518..77b1ad47489 100644 --- a/tests/by-util/test_df.rs +++ b/tests/by-util/test_df.rs @@ -16,7 +16,7 @@ use std::collections::HashSet; use uutests::at_and_ucmd; use uutests::new_ucmd; #[cfg(target_os = "linux")] -use uutests::util::TestScenario; +use uutests::util::{TestScenario, run_in_rootless_unshare}; #[test] fn test_invalid_arg() { @@ -1098,26 +1098,12 @@ fn test_df_hides_binfmt_misc_by_default() { /// Returns (success, stdout, stderr). #[cfg(target_os = "linux")] fn run_df_with_masked_proc(args: &str) -> Option<(bool, String, String)> { - use std::process::Command; - - // Check if user namespaces are available - if !Command::new("unshare") - .args(["-rm", "true"]) - .status() - .is_ok_and(|s| s.success()) - { - return None; - } - let df_path = TestScenario::new("df").bin_path.clone(); - let output = Command::new("unshare") - .args(["-rm", "sh", "-c"]) - .arg(format!( - "mount -t tmpfs tmpfs /proc && {} df {args}", - df_path.display() - )) - .output() - .ok()?; + let script = format!( + "mount -t tmpfs tmpfs /proc && {} df {args}", + df_path.display() + ); + let output = run_in_rootless_unshare(&script)?; Some(( output.status.success(), diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 80448ba1b84..e00d1c0da36 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -16,6 +16,8 @@ use uutests::new_ucmd; #[cfg(unix)] use uutests::util::TerminalSimulation; use uutests::util::TestScenario; +#[cfg(target_os = "linux")] +use uutests::util::run_in_rootless_unshare_with_env; use uutests::{at_and_ucmd, util_name}; #[test] @@ -2081,6 +2083,71 @@ mod inter_partition_copying { ); } + // Test that ownership is preserved when moving files across partitions as root. + // + // This specifically guards the EXDEV (copy+delete) fallback path, which must not + // change uid/gid compared to a same-filesystem rename. + #[test] + #[cfg(target_os = "linux")] + pub(crate) fn test_mv_preserves_ownership_across_partitions_when_root() { + use std::ffi::CString; + use std::fs::metadata; + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::MetadataExt as _; + use tempfile::TempDir; + use uutests::util::TestScenario; + + // Requires root to set an arbitrary uid/gid. + if unsafe { libc::geteuid() } != 0 { + return; + } + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("file", "test content"); + + let src_path = at.plus("file"); + let src_path_c = CString::new(src_path.as_os_str().as_bytes()).unwrap(); + + // Pick a non-root uid/gid. If chown isn't possible in this environment, skip. + let target_uid: libc::uid_t = 1; + let target_gid: libc::gid_t = 1; + let chown_result = unsafe { libc::chown(src_path_c.as_ptr(), target_uid, target_gid) }; + if chown_result != 0 { + return; + } + + let src_meta = metadata(&src_path).expect("Failed to get metadata for source file"); + assert_eq!(src_meta.uid(), target_uid); + assert_eq!(src_meta.gid(), target_gid); + + // Force cross-filesystem move using /dev/shm (tmpfs) + let other_fs_tempdir = TempDir::new_in("/dev/shm/") + .expect("Unable to create temp directory in /dev/shm - test requires tmpfs"); + + let dest_meta = + metadata(other_fs_tempdir.path()).expect("Failed to get metadata for destination dir"); + if src_meta.dev() == dest_meta.dev() { + println!( + "test skipped: source and destination are on the same filesystem (dev={})", + src_meta.dev() + ); + return; + } + + scene + .ucmd() + .arg("file") + .arg(other_fs_tempdir.path().to_str().unwrap()) + .succeeds(); + + let moved_file = other_fs_tempdir.path().join("file"); + let moved_meta = metadata(&moved_file).expect("Failed to get metadata for moved file"); + assert_eq!(moved_meta.uid(), target_uid); + assert_eq!(moved_meta.gid(), target_gid); + } + // Test that hardlinks are preserved even with multiple sets of hardlinked files #[test] #[cfg(unix)] @@ -2639,6 +2706,63 @@ fn test_mv_cross_device_permission_denied() { .expect("Unable to restore directory permissions"); } +/// Rootless cross-device move using unshare + tmpfs mounts. +/// This mirrors the GNU part-fail scenario but avoids sudo by using user namespaces. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_rootless_unshare_tmpfs_dir_with_dangling_symlink() { + use std::fs; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let base = at.plus("unshare-rootless"); + fs::create_dir_all(&base).unwrap(); + + let script = r#"set -eu +cleanup() { + umount -l "$BASE/a" 2>/dev/null || true + umount -l "$BASE/b" 2>/dev/null || true + rmdir "$BASE/a" "$BASE/b" 2>/dev/null || true +} +trap cleanup EXIT +mkdir -p "$BASE/a" "$BASE/b" +mount -t tmpfs tmpfs "$BASE/a" +mount -t tmpfs tmpfs "$BASE/b" +mkdir -p "$BASE/a/d" +ln -s miss "$BASE/a/d/dang" +"$UUTILS" mv -v "$BASE/a/d" "$BASE/b" +test -L "$BASE/b/d/dang" +test ! -e "$BASE/a/d" +"#; + + let output = match run_in_rootless_unshare_with_env( + script, + &[ + ("BASE", base.as_path()), + ("UUTILS", scene.bin_path.as_path()), + ], + ) { + Some(output) => output, + None => { + println!("test skipped: unshare not available or not permitted"); + return; + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("Operation not permitted") + || stderr.contains("permission denied") + || stderr.contains("not permitted") + { + println!("test skipped: unshare/mount not permitted: {stderr}"); + return; + } + panic!("unshare rootless mv test failed: {stderr}"); + } +} + #[test] #[cfg(feature = "selinux")] fn test_mv_selinux_context() { diff --git a/tests/uutests/src/lib/util.rs b/tests/uutests/src/lib/util.rs index 16258400710..6c7f4b76402 100644 --- a/tests/uutests/src/lib/util.rs +++ b/tests/uutests/src/lib/util.rs @@ -3188,6 +3188,47 @@ pub fn run_ucmd_as_root_with_stdin_stdout( } } +/// Check whether rootless unshare is available in this environment. +#[cfg(target_os = "linux")] +pub fn is_rootless_unshare_available() -> bool { + Command::new("unshare") + .args(["-rm", "sh", "-c", "true"]) + .status() + .is_ok_and(|status| status.success()) +} + +/// Run a shell script in a rootless unshare namespace (`-rm`). +/// +/// Returns `None` when unshare is unavailable/not permitted or if execution fails. +#[cfg(target_os = "linux")] +pub fn run_in_rootless_unshare(script: &str) -> Option { + if !is_rootless_unshare_available() { + return None; + } + + Command::new("unshare") + .args(["-rm", "sh", "-c", script]) + .output() + .ok() +} + +/// Run a shell script in a rootless unshare namespace (`-rm`) with extra environment variables. +/// +/// Returns `None` when unshare is unavailable/not permitted or if execution fails. +#[cfg(target_os = "linux")] +pub fn run_in_rootless_unshare_with_env(script: &str, envs: &[(&str, &Path)]) -> Option { + if !is_rootless_unshare_available() { + return None; + } + + let mut command = Command::new("unshare"); + command.args(["-rm", "sh", "-c", script]); + for (key, value) in envs { + command.env(key, value); + } + command.output().ok() +} + /// Sanity checks for test utils #[cfg(test)] mod tests {