feat(core): remove a file on Unix by moving it aside and identifying the open object (#445)
Part of #359 (R-15 Gate 4, slice H1a-2), part 2 of 5 (base main; #1068 is merged). test-support only: no production caller, no production deletion release.
What this adds
The Unix removal of a file: PinnedDir::remove_authenticated_file on Unix. The authenticated file is held open; renameat2(RENAME_NOREPLACE) moves whatever the name is bound to into a private quarantine directory (generated name, mode 0700 set with fchmod so a restrictive umask cannot break it); the open object is identified there; only then it is unlinked. Anything else (a live file, a link, a directory, a file this process cannot open) is put back or, if the name was taken, kept in the quarantine directory; never unlinked. Every failure after the move is handled at one point: the entry goes back, or the outcome is Stranded { cause } and says where it is. The parent is synced whenever it changed (a failed sync is an error; sync() repeats it). Directories and leftovers still answer Unsupported (part 3); Windows is part 4. rustix gets its process feature (geteuid); no new package, no new unsafe.
The window, stated (needs the maintainer's decision before H1b)
The quarantine directory is not assumed exclusive. After the last identification no Unix primitive unlinks "this inode"; an actor that wins that one race can have its own file removed instead. It is detected after the fact, not prevented (Disturbed, by the link count of the authenticated object; a heuristic, as Codex pointed out on the first draft) and is never a success. A test forces exactly that window and pins the outcome.
Proof
The observer forces each interleaving; the baseline (naive check-then-unlinkat against the same swap) destroys the live file, so the hook is not vacuous. Swaps of the last name, of a directory / symlink / FIFO / unopenable file (identified by inode), a name retaken while the entry is aside, a swap inside the quarantine directory before identification, the last window (foreign file / directory / hard link), vanished name, renamed root, mode and umask, failure injected at each stage, failed sync for a removal and a put-back, Stranded (quarantine loses its search permission; the entry stays the authenticated inode). Mutation-checked (identity comparison: 5 cases fail; link-count verification: the forced window; rollback of a failed inspection: the Stranded case). Local: fmt, clippy -D warnings on Linux / x86_64-pc-windows-msvc / aarch64-apple-darwin, lib and isolation tests; macOS and Windows execution is the hosted runners' job.
Review findings of the first draft that this part answers
CodeRabbit / CodeAnt / Codex on the first draft (#1068 history): rollback after a failed inspection, umask 0200, the honesty of the link-count check, sync after a put-back, CodeScene argument counts. Each is answered on its thread with this commit.
Status: H1A_2_UNIX_FILE_REMOVAL=IMPLEMENTED_TEST_SUPPORT_ONLY, H1_PROVED=NO, R1_PRODUCTION_RECLAMATION=NOT_ADMITTED, GATE4_TERMINAL=NO.
Update: the hardening moved to #1072
Through its review waves this part grew to 1,306 meaningful lines (tier 1,200). The hardening against a same-user actor in the quarantine directory and the durability of the failure paths (commits eb9eb870, 54dcf086, 60d6db2b) was cut out with one revert commit (586f6638, no force-push) and is re-applied in #1072 (part 2b), stacked on this PR. This head (1,109 lines) keeps the removal itself with its single rollback point, Stranded, the sync of the changes that are made, the forced-swap tests and the link-count window. Replies on the threads that cite the three commits refer to #1072 now.