Skip to content

fix(mv): don't destroy the destination when a cross-device move fails - #13334

Open
abendrothj wants to merge 8 commits into
uutils:mainfrom
abendrothj:fix/mv-special-file-dest-loss
Open

fix(mv): don't destroy the destination when a cross-device move fails#13334
abendrothj wants to merge 8 commits into
uutils:mainfrom
abendrothj:fix/mv-special-file-dest-loss

Conversation

@abendrothj

Copy link
Copy Markdown
Contributor

Fixes #13145 (reported via security advisory GHSA-xw28-j282-p74c).

Problem

When mv falls back to copy+remove for a cross-filesystem (EXDEV) move, it removes an existing destination before it knows the source can be recreated there. For a special file like a socket this always fails — sockets fell through to the regular-file copy path, which cannot open them — so the destination was deleted and the move failed:

$ echo important > a
$ mv /path/on/tmpfs/sock a
mv: Permission denied (os error 13)
$ cat a
cat: a: No such file or directory      # 'a' was deleted

GNU mv recreates the socket at the destination instead. The same destroy-before-create ordering also affected fifos (mkfifo after removing the destination) and regular files (destination removed before the source was even opened, so an unreadable source cost the destination).

Fix

  • Special files are now recreated, like GNU (copy_special.c): fifos via mkfifo, sockets and device nodes via mknod, preserving ownership and permissions (the fifo path previously hardcoded mode 0666).
  • The destination can no longer be destroyed by a failure: the node is created under a random temporary name (same CuXXXXXX-from-/dev/urandom scheme as the existing create_symlink_replace, extracted into a shared helper) and then atomically renamed over the destination. If creation fails (e.g. mknod of a device node as non-root), the error propagates with the destination untouched. mknod/mkfifo fail with EEXIST on a planted symlink rather than following it, so the unlink-window symlink concerns from mv copy TOCTOU Race #10015 don't reappear here.
  • Sockets and device nodes inside directories moved across devices are recreated the same way; previously they were fed to fs::copy, failing the whole directory move.
  • Regular files: the source is now opened before the destination is touched, so a source that cannot be opened for reading (e.g. mode 000) no longer costs the destination.

Not addressed here (happy to follow up): the two-argument error path still prints without the cannot move 'src' to 'dest' context mentioned in the issue; wrapping it touches the stderr format of several unrelated error kinds, so it seemed better as a separate change.

Testing

New integration tests (Linux, /dev/shm pattern like the existing cross-device tests):

  • test_mv_cross_device_socket_replaces_dest — the exact GHSA repro: now succeeds, destination becomes the socket (was: destination deleted, move failed).
  • test_mv_cross_device_fifo_replaces_dest_and_preserves_mode — fifo replaces an existing destination and keeps its 0604 mode (was: hardcoded 0666).
  • test_mv_dir_with_socket_across_partitions — directory containing a socket survives the move (was: whole move failed).
  • test_mv_cross_device_unreadable_source_preserves_dest — failed move of an unreadable source leaves the destination intact (skipped as root).

Full mv suite passes on Linux (130 tests, run as an unprivileged user in a container) and macOS (105 tests). Also manually verified on macOS across a RAM-disk filesystem boundary, including the failure-preservation case: mknod(S_IFSOCK) needs root there, so the socket move fails cleanly and the destination survives — before this change it was deleted.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/cut/bounded-memory (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/tail/tail-n0f (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/tail/retry (passes in this run but fails in the 'main' branch)
Note: The gnu test tests/dd/no-allocate is now being skipped but was previously passing.
Congrats! The gnu test tests/rm/many-dir-entries-vs-OOM is now passing!

The cross-device (EXDEV) fallback removed an existing destination before
it knew the source could be recreated there, so a failed move destroyed
the destination:

- A socket or device node fell through to the regular-file copy path,
  which removed the destination and then failed to open the source,
  losing the destination entirely.
- A fifo was recreated only after the destination was removed, so a
  failed mkfifo also lost the destination. The fifo was also recreated
  with a hardcoded 0666 mode instead of the source's.
- A regular source that could not be opened for reading (e.g. mode 000)
  cost the destination as well, because the destination was removed
  before the source was opened.

Special files (fifos, sockets, and device nodes) are now recreated with
mkfifo/mknod like GNU mv does, preserving ownership and permissions. The
node is created under a random temporary name and renamed over the
destination, so a failure to create it can never destroy an existing
destination. Sockets and device nodes inside directories moved across
devices are recreated the same way instead of failing the whole move.
For regular files, the source is now opened before the destination is
touched.

Reported via security advisory GHSA-xw28-j282-p74c.

Fixes uutils#13145
Copilot AI lite review requested due to automatic review settings August 17, 2026 03:46
@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch from da75e2c to eaf69db Compare August 17, 2026 03:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes mv cross-device (EXDEV) fallback behavior to prevent data loss when moving special files (e.g., sockets/fifos/device nodes) and when the source cannot be opened. It updates the mv implementation to recreate special files and to avoid destroying an existing destination unless replacement is guaranteed.

Changes:

  • Recreate FIFOs/sockets/device nodes during EXDEV fallback (instead of attempting content copy) and preserve metadata.
  • Replace destinations via temp-name creation + atomic rename to avoid leaving the destination missing on failure.
  • Add Linux integration tests covering socket/fifo replacement, directory-with-socket moves, and unreadable source preservation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tests/by-util/test_mv.rs Adds regression/integration tests for cross-device special-file moves and destination-preservation behavior.
src/uu/mv/src/mv.rs Implements special-file recreation and safer destination replacement logic in EXDEV fallback paths.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/uu/mv/src/mv.rs Outdated
Comment on lines +1046 to +1060
let parent = to
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));

let mut urandom = fs::File::open("/dev/urandom")?;

for _ in 0..32 {
let tmp_bytes = random_temp_name(&mut urandom)?;
let tmp = parent.join(OsStr::from_bytes(&tmp_bytes));

match copy_special_file(from, metadata, &tmp) {
Ok(()) => {
if let Err(e) = fs::rename(&tmp, to) {
let _ = fs::remove_file(&tmp);
Comment thread tests/by-util/test_mv.rs
// file that was distributed with this source code.
//
// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable myfolder SRCDATA DSTDATA REALDATA
// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable GHSA
Copilot AI review requested due to automatic review settings August 17, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/uu/mv/src/mv.rs:1623

  • open_destination_parent(from) opens the source’s parent directory with SymlinkBehavior::NoFollow. That makes cross-device moves fail (before touching the destination) when the source path is under a symlinked directory (e.g. mv link/file dest where link -> realdir). Previously the source was removed via fs::remove_file(from), which follows symlinks in parent components as normal path resolution does.

Consider opening the source parent with SymlinkBehavior::Follow (while keeping NoFollow for the destination side) so normal mv semantics continue to work for sources located under symlinked directories.

    #[cfg(all(unix, not(target_os = "redox")))]
    let (src_parent_fd, src_basename) = open_destination_parent(from)
        .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;

src/uu/mv/src/mv.rs:1121

  • rename_special_fallback uses open_destination_parent(from) for the source cleanup path. Since open_destination_parent intentionally opens parents with SymlinkBehavior::NoFollow, this makes cross-device moves of special files fail if the source lives under a symlinked directory (even though opening/reading the source itself follows symlinks in parent components).

Recommendation: keep NoFollow for the destination parent (security), but open the source parent with SymlinkBehavior::Follow so source paths under symlinked directories continue to work as they did previously.

This issue also appears on line 1621 of the same file.

    let (dir_fd, basename) = open_destination_parent(to)?;
    let (src_parent_fd, src_basename) = open_destination_parent(from)?;
    let basename_cstr = CString::new(basename.as_bytes())

Copilot AI review requested due to automatic review settings August 18, 2026 03:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 26, 2026 08:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 26, 2026 09:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codspeed-hq

codspeed-hq Bot commented Aug 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks
⏩ 404 skipped benchmarks1


Comparing abendrothj:fix/mv-special-file-dest-loss (69e8f71) with main (982ac4d)2

Open in CodSpeed

Footnotes

  1. 404 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.

  2. No successful run was found on main (76374fa) during the generation of this report, so 982ac4d was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@sylvestre

Copy link
Copy Markdown
Contributor

this is a lot of cfg for redox
maybe the redox specific code should be moved into src/uu/mv/src/platform/redox.rs like in other programs
wdyt?

@abendrothj

Copy link
Copy Markdown
Contributor Author

Yes, I think the Redox-specific implementations should move into src/uu/mv/src/platform/redox.rs.

mv.rs now has several sizeable Redox branches, particularly for special-file and symlink fallbacks, which makes the generic fallback flow harder to follow.

The extraction will move the actual Redox behavior while leaving only small dispatch points and shared helpers, such as random_temp_name, in mv.rs. Capability-based conditionals for xattrs and safe traversal can remain inline since they also cover non-Redox platforms.

This keeps the change focused without introducing a broader platform abstraction than necessary.

Copilot AI review requested due to automatic review settings August 26, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 26, 2026 22:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@abendrothj

Copy link
Copy Markdown
Contributor Author

Infrastructure failure with precommit.ci

…-dest-loss

# Conflicts:
#	src/uu/mv/src/mv.rs
Copilot AI review requested due to automatic review settings August 31, 2026 23:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

mv: moving a special file across filesystems deletes the destination on failure

3 participants