Summary
A dangling symlink sitting at the destination path is invisible to the
conflict detection, because Path::exists() follows the link and answers
false when the target is missing. Two consequences, one cosmetic and one that
can destroy the symlink.
1. Confusing error message (Linux)
$ ln -s nowhere.txt cafe.txt # dangling
$ printf 'x' > 'Café.txt'
$ rename-simple 'Café.txt'
[E] .: Café.txt -> File exists (os error 17)
Nothing is lost — renameat2(RENAME_NOREPLACE) refuses the rename, exactly as
designed — but the raw errno leaks out instead of the usual
File name already exists. The op slipped past filter_conflicts
(src/main.rs:328, exists() → false) and only got caught at the syscall,
where the error is reported verbatim (src/main.rs:414).
2. The dangling symlink is silently replaced (non-Linux)
On macOS/BSD/Windows, rename_no_clobber falls back to a try_exists()
pre-check followed by std::fs::rename (src/main.rs:392-401).
try_exists() follows the link too, so it also answers false for a dangling
symlink, and fs::rename on Unix then overwrites it. The symlink is
destroyed without a word — the destination looked free but was not.
This contradicts the guarantee stated in the README and the man page: an
existing destination is never overwritten.
Suggested fix
Both come from the same root cause: existence is tested through the link
instead of on the entry itself. symlink_metadata() answers about the entry —
it is what compare_entries already switched to in 0.7.1 (src/lib.rs:739).
- In
filter_conflicts, detect the destination with
to.symlink_metadata().is_ok() rather than to.exists(), so a dangling
symlink is reported as a normal conflict with the usual message and never
reaches the rename.
- In
rename_no_clobber, use the same check in the non-Linux fallback, closing
the overwrite. The Linux path already refuses at the syscall level.
compare_entries needs no change: a symlink on either side already answers
NotComparable, so -D will not touch it.
Worth adding a tests/unix_tests.rs case asserting that a dangling symlink at
the destination survives, and that the error message is the regular one.
Found during the code review that led to 0.7.1. Pre-existing on all platforms.
Summary
A dangling symlink sitting at the destination path is invisible to the
conflict detection, because
Path::exists()follows the link and answersfalsewhen the target is missing. Two consequences, one cosmetic and one thatcan destroy the symlink.
1. Confusing error message (Linux)
Nothing is lost —
renameat2(RENAME_NOREPLACE)refuses the rename, exactly asdesigned — but the raw errno leaks out instead of the usual
File name already exists. The op slipped pastfilter_conflicts(
src/main.rs:328,exists()→false) and only got caught at the syscall,where the error is reported verbatim (
src/main.rs:414).2. The dangling symlink is silently replaced (non-Linux)
On macOS/BSD/Windows,
rename_no_clobberfalls back to atry_exists()pre-check followed by
std::fs::rename(src/main.rs:392-401).try_exists()follows the link too, so it also answersfalsefor a danglingsymlink, and
fs::renameon Unix then overwrites it. The symlink isdestroyed without a word — the destination looked free but was not.
This contradicts the guarantee stated in the README and the man page: an
existing destination is never overwritten.
Suggested fix
Both come from the same root cause: existence is tested through the link
instead of on the entry itself.
symlink_metadata()answers about the entry —it is what
compare_entriesalready switched to in 0.7.1 (src/lib.rs:739).filter_conflicts, detect the destination withto.symlink_metadata().is_ok()rather thanto.exists(), so a danglingsymlink is reported as a normal conflict with the usual message and never
reaches the rename.
rename_no_clobber, use the same check in the non-Linux fallback, closingthe overwrite. The Linux path already refuses at the syscall level.
compare_entriesneeds no change: a symlink on either side already answersNotComparable, so-Dwill not touch it.Worth adding a
tests/unix_tests.rscase asserting that a dangling symlink atthe destination survives, and that the error message is the regular one.
Found during the code review that led to 0.7.1. Pre-existing on all platforms.