Skip to content

Conversation

@mattsu2020
Copy link
Contributor

Summary

Fix cross-filesystem mv (EXDEV copy+delete fallback) so that file ownership does not change to the invoking user (e.g. root) when moving a file across filesystems.

Fixes #9635.

Background / Problem

When mv cannot rename(2) across devices (EXDEV), uutils falls back to copy+delete. The copy path used std::fs::copy, which creates the destination owned by the caller. If root moves a file owned by another user to a different filesystem, the destination ends up owned by root (compatibility + security concern).

Changes

  • On Unix, preserve uid/gid and mode after the copy step in the EXDEV fallback:
    • regular files (including the hardlink-aware copy path)
    • directories (including recursively created subdirectories)
    • symlinks (use lchown, do not follow)
    • FIFOs
  • Restore mode after chown to keep correct permission bits (since chown may clear setuid/setgid).
  • Add a Linux-only regression test that runs only as root and verifies uid/gid are preserved across partitions (/dev/shm tmpfs).
  • Address clippy (bind_instead_of_map) in the copy path.

Testing

  • cargo test -p uu_mv
  • cargo clippy -p uu_mv -- -D warnings

related
#9635

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/tail/overlay-headers (passes in this run but fails in the 'main' branch)

@oech3

This comment was marked as resolved.

.env("BASE", &base)
.env("UUTILS", &scene.bin_path)
.output()
.expect("failed to run unshare");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might better to create a new function for uhshare (at different PR).
Ref: #9973

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/timeout/timeout (passes in this run but fails in the 'main' branch)

}

#[cfg(unix)]
fn try_preserve_ownership(from_meta: &fs::Metadata, to: &Path, follow_symlinks: bool) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document this function please


unsafe {
if follow_symlinks {
let _ = libc::chown(to_cstr.as_ptr(), uid, gid);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please manage the errors

}

#[cfg(unix)]
fn try_preserve_permissions(from_meta: &fs::Metadata, to: &Path) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, please document it


// Keep mode bits only (file type bits are not allowed in chmod).
let mode = from_meta.mode() & 0o7777;
let _ = fs::set_permissions(to, fs::Permissions::from_mode(mode));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please manage the error

@codspeed-hq
Copy link

codspeed-hq bot commented Jan 19, 2026

Merging this PR will not alter performance

✅ 284 untouched benchmarks
⏩ 38 skipped benchmarks1


Comparing mattsu2020:mv_fix (0d0453d) with main (ec7e81e)

Open in CodSpeed

Footnotes

  1. 38 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions
Copy link

GNU testsuite comparison:

Skip an intermittent issue tests/tty/tty-eof (fails in this run but passes in the 'main' branch)

… Unix

Add functions to preserve file ownership (UID/GID) and permissions (mode) during mv operations when fs::rename fails and falls back to copy+remove. This ensures consistency with GNU mv behavior across filesystems, applying preservation in rename fallbacks for files, directories, symlinks, FIFOs, and recursive copies. Changes are Unix-specific, using libc::chown/lchown and fs::set_permissions.
Add a new Linux-only test to verify cross-device move behavior using user namespaces and tmpfs mounts, avoiding sudo. This mirrors GNU's part-fail scenario for directories containing dangling symlinks, ensuring the mv command preserves symlinks correctly in rootless environments.
- Modified HardlinkGroupScanner to skip symlinks using symlink_metadata, as hardlink preservation does not apply to them
- Added copy_symlink functions for Unix, Windows, and other platforms to copy symlinks without dereferencing
- Updated copy_dir_contents_recursive to detect and copy symlinks, including verbose output, preventing dereferencing during directory moves
Updated the copy_symlink function to use .map() combinator instead of .and_then() for clarity, as the closure performs a side effect (preserving ownership) and returns a unit value without chaining Results. This improves code readability and appropriateness of the combinator used.
Simplify the map closure by removing the explicit return of an empty tuple, as it is implicit in Rust when no value is needed. This improves code clarity without affecting functionality.
…ervation

- Capture return values from chown/lchown and set_permissions calls
- Emit warnings for non-permission errors to inform users of preservation failures
- Keep operations non-fatal as preservation is best-effort on Unix systems
/// 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<()> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicates logic from rename_symlink_fallback(). Refactor rename_symlink_fallback to call copy_symlink() + fs::remove_file()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix it

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).map(|_| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should copy xattrs like rename_symlink_fallback does. no ?

@github-actions
Copy link

GNU testsuite comparison:

Congrats! The gnu test tests/mv/meta-to-xpart is no longer failing!

mattsu2020 and others added 2 commits February 11, 2026 14:06
Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
Replace the duplicated symlink handling logic in rename_symlink_fallback functions across Unix, Windows, and other platforms with a call to the existing copy_symlink function. This reduces code duplication and centralizes symlink copying logic while maintaining the same functionality of copying the symlink and then removing the original file.
@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/timeout/timeout-group. tests/timeout/timeout-group is passing on 'main'. Maybe you have to rebase?
Congrats! The gnu test tests/mv/meta-to-xpart is no longer failing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG/Compatibility BUG/Security BUG] File ownership changes when a file is mv'ed by root to a different file system.

3 participants