fix(mv): don't destroy the destination when a cross-device move fails - #13334
fix(mv): don't destroy the destination when a cross-device move fails#13334abendrothj wants to merge 3 commits into
Conversation
|
GNU testsuite comparison: |
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
da75e2c to
eaf69db
Compare
There was a problem hiding this comment.
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.
| 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); |
| // 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 |
There was a problem hiding this comment.
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 withSymlinkBehavior::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 destwherelink -> realdir). Previously the source was removed viafs::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_fallbackusesopen_destination_parent(from)for the source cleanup path. Sinceopen_destination_parentintentionally opens parents withSymlinkBehavior::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())
Fixes #13145 (reported via security advisory GHSA-xw28-j282-p74c).
Problem
When
mvfalls 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:GNU mv recreates the socket at the destination instead. The same destroy-before-create ordering also affected fifos (
mkfifoafter removing the destination) and regular files (destination removed before the source was even opened, so an unreadable source cost the destination).Fix
copy_special.c): fifos viamkfifo, sockets and device nodes viamknod, preserving ownership and permissions (the fifo path previously hardcoded mode 0666).CuXXXXXX-from-/dev/urandomscheme as the existingcreate_symlink_replace, extracted into a shared helper) and then atomically renamed over the destination. If creation fails (e.g.mknodof a device node as non-root), the error propagates with the destination untouched.mknod/mkfifofail withEEXISTon a planted symlink rather than following it, so the unlink-window symlink concerns from mv copy TOCTOU Race #10015 don't reappear here.fs::copy, failing the whole directory move.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/shmpattern 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.