Skip to content

mv: recreate special files when moving them across devices - #13334

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

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

Conversation

@abendrothj

@abendrothj abendrothj commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

A cross-device mv of a socket or device node went through the regular file copy, which removed the destination and then failed to open the source. A FIFO also removed the destination before the new one was created, and lost its mode.

FIFOs, sockets and device nodes are now created with their mode and ownership in a private directory next to the destination, then renamed over it relative to the parent's descriptor. If the node can't be created, the destination is left alone. The parent is opened with DirFd::open_anchor, so symlinked and write/search-only parents work. As with regular files, setuid and setgid are dropped when ownership can't be kept. Adds DirFd::mknod_at.

On aix, hurd and redox there's a simpler inline fallback that removes the destination first, without the staging.

Special files inside a directory moved across devices are in #15123.

Based on #15120 and #15122; until they merge, the diff here shows their commits too.

Closes #13145

@github-actions

github-actions Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

GNU testsuite comparison:

Congrats! The gnu test tests/mv/mv-special-1 is no longer failing!

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 88fd411, though not with O_NOFOLLOW on the parent: it's opened once with DirFd::open_anchor, which follows symlinks like a normal lookup (see the comment below). The node is created in a private staging directory under it and renamed over the destination relative to that descriptor, and cleanup goes through the same descriptor.

Comment thread tests/by-util/test_mv.rs Outdated
// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The ignore list keeps myfolder, SRCDATA, DSTDATA and REALDATA now (and adds Nofile), so nothing used later was dropped.

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

codspeed Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 12.3%

⚡ 5 improved benchmarks
❌ 12 regressed benchmarks
✅ 382 untouched benchmarks
⏩ 54 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Memory dd_copy_default 19.7 KB 28.6 KB -31.08%
❌ Memory dd_copy_4k_blocks 23.1 KB 31.9 KB -27.83%
❌ Memory dd_copy_partial 23.1 KB 32 KB -27.82%
❌ Memory dd_copy_with_skip 23.4 KB 32.3 KB -27.56%
❌ Memory dd_copy_with_seek 23.4 KB 32.3 KB -27.56%
❌ Memory dd_copy_8k_blocks 27.1 KB 35.9 KB -24.73%
❌ Simulation three_39_bit_primes 371.3 ms 468.3 ms -20.7%
❌ Simulation true_consecutive_calls 294.7 ns 350.2 ns -15.86%
❌ Memory dd_copy_64k_blocks 83.1 KB 91.9 KB -9.67%
❌ Simulation five_38_bit_primes 1.7 s 1.9 s -7.77%
❌ Simulation hostname_ip_lookup[100000] 169.1 µs 178.5 µs -5.27%
❌ Memory dd_copy_separate_blocks 185.4 KB 194.8 KB -4.83%
⚡ Simulation join_french_locale 26.7 ms 24 ms +11.08%
⚡ Simulation join_full_match 26.7 ms 24 ms +11.05%
⚡ Simulation join_partial_overlap 22.1 ms 20.4 ms +8.36%
⚡ Simulation join_unicode_locale 2.6 ms 2.4 ms +6.69%
⚡ Simulation join_custom_separator 25.9 ms 24.4 ms +6.05%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing abendrothj:fix/mv-special-file-dest-loss (7c744c8) with main (8cf2e4f)

Open in CodSpeed

Footnotes

  1. 54 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. ↩

@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

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.

Copilot AI review requested due to automatic review settings September 8, 2026 23:19
@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch from f6ec44e to 44dd7d7 Compare September 8, 2026 23:20
@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch from 44dd7d7 to c05f50e Compare September 22, 2026 01:26
@abendrothj

Copy link
Copy Markdown
Contributor Author

Redox code is out of mv.rs now: the implementations live in src/uu/mv/src/platform/redox.rs behind #[cfg(target_os = "redox")], re-exported through platform/mod.rs, and mv.rs keeps only the dispatch plus the shared random_temp_name helper.

Two other things from the rebase onto main. The branch's local replace_symlink is gone — main grew uucore::fs::replace_link meanwhile and it does the same job, so the non-Redox arm calls that. And renameat/fchown go through rustix now; mknodat stays on libc because rustix doesn't expose it on Apple targets, with a SAFETY comment on the remaining block.

The failing musl job isn't from this PR: it's uu_dd::tests::copy_buffer_does_not_touch_its_pages panicking with attempt to subtract with overflow, which is flaky on main as well. Fix in #14804.

@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch 2 times, most recently from cb7be93 to 35ac41f Compare October 5, 2026 06:13
Comment thread src/uu/mv/src/mv.rs Outdated
let basename = to
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid destination path"))?;
let dir_fd = DirFd::open(parent, SymlinkBehavior::NoFollow)?;

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.

with NoFollow, mv sock /dev/shm/link/dest fails when link is a symlink to a dir, no?
replace_link in uucore follows the parent on purpose. please add a test for this case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it did. The parent is followed now, through DirFd::open_anchor(parent) like replace_link. test_mv_cross_device_special_file_into_symlinked_parent covers it, replacing a socket and creating a FIFO through a symlinked directory.

Comment thread src/uu/mv/src/mv.rs Outdated
/// Open the source's parent directory following symlinks as normal path
/// resolution does, while returning a pinned directory fd for source removal.
#[cfg(all(unix, not(target_os = "redox")))]
fn open_source_parent(from: &Path) -> io::Result<(DirFd, OsString)> {

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.

almost the same function as open_destination_parent, could be dedup, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both are gone. The one remaining parent open is DirFd::open_anchor in uucore, so mv has no wrapper of its own.

Comment thread src/uu/mv/src/mv.rs Outdated
/// Recreate the source special file (fifo, socket, or device node) at `to`,
/// preserving its ownership and permissions.
#[cfg(all(unix, not(target_os = "redox")))]
fn copy_special_file(_from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Result<()> {

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.

_from is unused here, do we need it in the signature?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed, it's copy_special_file(to, metadata) now.

@sylvestre

Copy link
Copy Markdown
Contributor

this is very complex, I think it would benefit to be smaller

Comment thread src/uu/mv/src/mv.rs Outdated
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not allocate a unique temp name in destination directory",

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.

please use translate!() (same string is duplicated in redox.rs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The temp-name exhaustion moved into uucore's create_temp_at, which uses translate!("error-no-unique-temp-name") (en-US and fr-FR), and redox.rs is gone, so the string is in one place.

@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch from 35ac41f to 88fd411 Compare October 6, 2026 07:15
@abendrothj abendrothj changed the title fix(mv): don't destroy the destination when a cross-device move fails mv: recreate special files when moving them across devices Oct 6, 2026
@abendrothj

Copy link
Copy Markdown
Contributor Author

I split it up. The shared pieces are separate PRs: DirFd::open_anchor (#15120) and replace_entry_at (#15122), which also moves replace_link onto it. Special files inside directories moved across devices are in #15123. What's left here is 4 files, +423/-15, and 252 of those lines are tests. Until the two helper PRs land, the diff shown here still includes them.

About redox.rs: I went the other way from what I said earlier. The module is gone, and aix, hurd and redox get one inline copy_special_file fallback of about 30 lines. It removes the destination before mknod and uses pathname metadata, so on those targets there's no staging and the destination isn't kept if creating the node fails. None of the three was run.

Open a directory readable, and on EACCES retry with O_PATH or O_SEARCH,
which anchor *at calls without read access. The targets with either flag
are named by the has_o_path and has_o_search cfg aliases. If the retry
fails, its own error is returned.
Move the temporary name and renameat logic of replace_link into
replace_entry_at, which creates the entry through a callback relative to
an open directory, so other callers can replace entries the same way.

The parent is now opened with DirFd::open_anchor, so ln -sf replacing a
link in a directory with write and search but no read permission works,
as it does with GNU, instead of failing with EACCES.
Create fifos, sockets and device nodes relative to an open directory.
nix and rustix do not provide mknodat on Apple targets, so this calls
libc directly.
A cross-device move of a socket or device node went through the
regular file copy, which removed the destination and then failed to
open the source. A fifo also removed the destination before creating
the new one, and lost its mode.

Fifos, sockets and device nodes are now created with their mode and
ownership in a private directory next to the destination and renamed
over it, so the destination is kept if the node cannot be created, and
the ownership and mode cannot land on another file linked over the
destination name meanwhile. As for regular files, setuid and setgid
are dropped when the ownership cannot be kept.

Fixes uutils#13145
The private directory that a special file is created in was made under a
temporary umask of 077, so that the owner could create the node inside
whatever the umask. Change its mode to 0700 by name right after creating
it instead, before opening it: the check after the open still rejects a
directory moved there in between, and mv no longer changes the
process-wide umask or needs uucore's mode feature.
@abendrothj
abendrothj force-pushed the fix/mv-special-file-dest-loss branch from ccd87db to 7c744c8 Compare October 7, 2026 00:54
@abendrothj

Copy link
Copy Markdown
Contributor Author

one more commit: the staging directory gets its 0700 from a chmod right after the mkdir, instead of a temporary umask of 077, so mv no longer changes the process umask and this no longer needs #15121. test_mv_cross_device_special_file_under_owner_umask covers it, on Linux only.
on redox.rs: no redox-only code is left here. main already handles the symlink case, and the one path-based fallback left is shared by aix, hurd and redox, so it stays inline next to the safe_traversal version.

This branch has not been deployed

No deployments
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