Skip to content

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

Open
abendrothj wants to merge 3 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 3 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/date/date-locale-hour (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/date/resolution (passes in this run but fails in the 'main' branch)

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

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

2 participants