Skip to content

mv:fix file ownership changes when a file is mv'ed by root to a different file system#9672

Open
mattsu2020 wants to merge 9 commits intouutils:mainfrom
mattsu2020:mv_fix
Open

mv:fix file ownership changes when a file is mv'ed by root to a different file system#9672
mattsu2020 wants to merge 9 commits intouutils:mainfrom
mattsu2020:mv_fix

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 (c911eb8) with main (54566dd)

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)

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!

@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!

@ChrisDryden
Copy link
Collaborator

I think we have a chmod helper that we can use here that implements all of the logic in the PR already in the uucore perms lib

@sylvestre
Copy link
Contributor

and it conflicts

@mattsu2020
Copy link
Contributor Author

I think we have a chmod helper that we can use here that implements all of the logic in the PR already in the uucore perms lib

wrap_chown is too high-level for mv compatibility logic. It returns formatted Result<String, String>, so mv cannot inspect errno to implement GNU-like behavior (owner+group attempt, optional group-only fallback, and selective suppression for EPERM/EACCES/EINVAL).

To avoid behavior regressions, I’d like to add a public low-level helper in uucore::perms that returns io::Result<()> (preserving errno), then switch mv to that helper while keeping GNU-compatible branching in mv. This still removes syscall duplication, but without changing user-visible behavior.

mattsu2020 and others added 8 commits February 14, 2026 20:21
… 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
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/pr/bounded-memory. tests/pr/bounded-memory is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/rm/isatty. tests/rm/isatty is passing on 'main'. Maybe you have to rebase?
Congrats! The gnu test tests/mv/meta-to-xpart is no longer failing!
Note: The gnu test tests/printf/printf-surprise is now being skipped but was previously passing.
Congrats! The gnu test tests/expand/bounded-memory is now passing!

Add a check to skip the inter_partition_copying test when source and destination directories are on the same filesystem, preventing false test failures when /dev/shm is not available or mounted on the same device as the source.
@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/parent-perm-race. tests/cp/parent-perm-race is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/factor/t10. tests/factor/t10 is passing on 'main'. Maybe you have to rebase?
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/factor/t29 is no longer failing!
Congrats! The gnu test tests/mv/meta-to-xpart is no longer failing!
Congrats! The gnu test tests/pr/bounded-memory is no longer failing!
Note: The gnu test tests/dd/no-allocate is now being skipped but was previously passing.
Congrats! The gnu test tests/cut/cut-huge-range is now passing!
Congrats! The gnu test tests/rm/many-dir-entries-vs-OOM is now passing!

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.

4 participants