Skip to content

mv: use 'cannot overwrite TARGET: Directory not empty' for non-empty dir target - #12124

Draft
sylvestre wants to merge 6 commits into
uutils:mainfrom
sylvestre:fix/mv-cannot-overwrite-not-empty
Draft

mv: use 'cannot overwrite TARGET: Directory not empty' for non-empty dir target#12124
sylvestre wants to merge 6 commits into
uutils:mainfrom
sylvestre:fix/mv-cannot-overwrite-not-empty

Conversation

@sylvestre

Copy link
Copy Markdown
Contributor

No description provided.

@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/tail/symlink (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)
Skipping an intermittent issue tests/tail/tail-n0f (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/mv/dir2dir is no longer failing!
Note: The gnu test tests/unexpand/bounded-memory is now being skipped but was previously passing.

@oech3

This comment was marked as resolved.

@sylvestre
sylvestre force-pushed the fix/mv-cannot-overwrite-not-empty branch from c3fa3d3 to c1023eb Compare May 5, 2026 18:42
@sylvestre

Copy link
Copy Markdown
Contributor Author

i will rebase it

sylvestre added 5 commits July 4, 2026 17:44
Add `fsxattr::copy_xattrs_fd` that operates on two open file
descriptors via the `xattr::FileExt` trait. Path-based xattr copies
re-resolve the source and destination path on every list / get /
set syscall, so a concurrent renamer could redirect individual
operations to different inodes mid-copy and produce a non-atomic
snapshot — a correctness bug that becomes security-relevant when
privileged processes move files carrying security xattrs like
SELinux labels or file capabilities.

Refactor mv's cross-device regular-file fallback
(`rename_file_fallback`) to open source and destination itself, do
the content copy via `io::copy`, and invoke the new fd-based xattr
helper while both fds are still live. Both inodes are pinned for
the whole copy + xattr window. Non-Unix builds keep the previous
`fs::copy` path.

Both opens go through `uucore::safe_copy` with `nofollow=true`, so
the path-swap-to-symlink TOCTOU that cp uutils#10017 closes for cp's
regular-file path is closed here for mv too — symmetrically on the
source (so a planted symlink can't redirect the read) and on the
destination (so a planted symlink can't redirect the truncate and
write to a victim file the caller has permission to write).

The path-based `copy_xattrs` is kept for callers that cannot pin
fds (notably directory traversal).

Extend `util/check-safe-traversal.sh` with a cross-device mv strace
check that fails if any path-based listxattr/getxattr/setxattr lands
on the source or destination paths. The check skips gracefully on
systems where the tmpfs used for the cross-device probe doesn't
support user xattrs.
`rename_file_fallback` (the cross-device / EXDEV fallback) unlinked
the destination before calling `fs::copy`, creating a TOCTOU window
unique to uutils: between the unlink and the subsequent open an
attacker with write access to the destination directory could
recreate the path as a symlink to a privileged file, causing the
copy to write through the symlink.

Match GNU mv's pattern: rely on `fs::copy`'s `O_WRONLY|O_CREAT|O_TRUNC`
to truncate an existing regular file in place, and do not unlink
first. The separate symlink-destination unlink is kept here; issue
uutils#10010 / uutils#10014 address atomic symlink replacement more thoroughly.

Updates `test_mv_cross_device_permission_denied` to target a
non-existent destination (where the create itself requires dir
write), since mv now correctly succeeds on truncating an existing
writable file in a read-only dir — matching GNU.
Commit 17b8b1e (uutils#10014) made `copy_xattrs` and `copy_xattrs_fd`
silently absorb `ENOTSUP`/`EOPNOTSUPP`. mv only ever discards their
return value (`let _ = ...`), so its behavior was unchanged — but the
two functions are also called by cp, where `handle_preserve` propagates
the error so `cp --preserve=xattr` (required-preserve) can exit 1 to
match GNU cp when the destination filesystem rejects xattrs.

Verified against GNU cp 9.10 with `strace -e inject=lsetxattr,fsetxattr:
error=EOPNOTSUPP`:

  cp src dst                  → exit 0  (GNU and uutils agree)
  cp -a src dst               → exit 0  (suppressed by handle_preserve)
  cp --preserve=xattr src dst → exit 1  "setting attributes for 'X':
                                         Operation not supported"

Pre-uutils#10014 uutils cp matched all three; post-uutils#10014 it silently exited
0 on the `--preserve=xattr` case, diverging from GNU.

Restore the original strict semantics on `copy_xattrs` and
`copy_xattrs_fd`, and add `copy_xattrs_ignore_unsupported` /
`copy_xattrs_fd_ignore_unsupported` wrappers for the best-effort
callers. mv's three call sites switch to the new wrappers — the
behavior is identical to the previous `let _ =` discard, but the
helper name now documents the intent so a future maintainer doesn't
accidentally start surfacing the error.
@sylvestre
sylvestre force-pushed the fix/mv-cannot-overwrite-not-empty branch from c1023eb to 8ea0c47 Compare July 4, 2026 16:32
The rebase resolution in 28b11b9 dropped the pre-copy unlink of an
existing cross-device destination, reasoning that fs::copy's
O_WRONLY|O_CREAT|O_TRUNC truncating in place "matches GNU mv". It
doesn't: GNU's copy.c explicitly unlinks any pre-existing destination
before a cross-device copy so that the operation fails the same way a
same-device rename() would when the destination directory forbids
removing entries ("inter-device move failed ... unable to remove
target"). Truncating in place instead only requires write permission
on the destination *file*, silently bypassing that check and causing
tests/mv/part-fail.sh (GNU testsuite) to regress: mv succeeded when it
should have failed with Permission denied.

Restore the unlink in rename_file_fallback. This does not reopen the
symlink TOCTOU window uutils#10015 was about: the subsequent open of `to`
already uses O_NOFOLLOW (via create_dest_restrictive, added after
uutils#10015 as part of the fd-based xattr copy work), so an attacker
recreating `to` as a symlink between the unlink and that open gets
ELOOP rather than a followed write.

Report the unlink failure via mv's own "inter-device move failed"
diagnostic directly (show! + the empty-message "already reported"
sentinel) instead of letting the generic "cannot move SRC to DEST"
context wrap it a second time, which previously produced a garbled,
doubly-wrapped message.

Updates test_mv_cross_device_truncate_in_readonly_dir, which encoded
the incorrect assumption from 28b11b9, to expect the correct
failure instead.

tests/cp/sparse-to-pipe.sh was also flagged as regressed by CI, but
cp.rs and uucore's fsxattr.rs are byte-identical to origin/main on
this branch, and the test passes consistently (5/5 runs) both before
and after this change; it could not have been caused by this branch's
diff and is most likely an unrelated CI flake.
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.

2 participants