Make backup_previous_artifact link-based (#87) - #88
Conversation
`backup_previous_artifact` was a `cp -fp`, and an interrupted copy leaves a truncated `.previous` that a later revert succeeds onto — a broken binary installed by a rollback that reported success. An interrupted link leaves no `.previous` at all, so the revert fails where anyone looking can see it, and the shared inode carries the mode and timestamps `cp -p` had to preserve separately. Expressing that needs a primitive the executor lacked, so it gains `hard_link_over`: link to a temporary sibling, rename over the destination, fsync its directory. Not `ln -f`, which unlinks the destination before linking and exposes a window with no backup at all; not a bare link, because a directory entry is not durable until its directory is flushed. A symlink at the artifact is refused rather than followed, and so is a directory or any other non-regular file — the applier is specified for the one path `place_file` puts down. Both call sites are unchanged, and so is the error a consumer renders: an on-host failure still folds into the subject-labelled `CoreError::Command`, while a transport or elevation failure stays the executor's own error as it was when a `cp` could not be run. The backup remains non-idempotent, which is a property of when it runs rather than of copying versus linking; the caller's apply journal is still what guards it, and is also the only answer available after a power loss, since nothing between the rename and the flush is claimed portably. Closes #87
The interruption tests split on whether a `.previous` already existed, and the shell half only had the case where one did. The case where none does is the one whose correct intermediate state is absence rather than a file, so it asserts recovery instead: the artifact a retry links is still the live one, the leftover temporary is a whole link rather than truncated bytes, and the retry lands. The script's directory-at-the-destination guard was untested too. Without it `mv` would move the temporary inside that directory and exit zero, reporting a backup taken under a path nobody named. Part of #87
The source guards say what happens to a symlink at the artifact, and nothing said what happens to one at `.previous` — which is the crate's own name rather than a path the caller was asked about, so no guard covers it. The rename does: it replaces the entry it was given without resolving it, so the operator's file keeps its inode and its bytes, where the copy this replaced would have opened the symlink and landed the artifact on the far end while leaving no backup at all. Asserted on both the native sequence and the script, and stated in `hard_link_over`'s contract, since replacing is neither of the two things that doc already promises about a symlink. Part of #87
The shell transports guard a directory at the destination before the link, because `mv` would move the temporary inside it and exit zero, and that guard has a test. The native sequence needs no guard — `rename(2)` cannot move a file into a directory — but nothing held it to the same outcome, so a future rewrite could start reporting a backup taken under a path nobody named. Part of #87
The shell link sequence handed `mv` two names for one inode whenever an unchanged artifact was backed up a second time. `rename(2)` is defined to succeed and do nothing there, which is what the native path relies on, but `mv` does not pass the case through: GNU's refuses it with `are the same file` and exits non-zero, so the backup failed on every host with coreutils while passing where the local `mv` tolerates the pair. Compare the inode the landing confirmation already needs against the destination first and run the `mv` only where the two differ. The temporary is then left standing by the skip rather than consumed by the rename, which is what the trailing `rm -f` was already there for. The regression is invisible to a host whose `mv` accepts the pair, so the new test stubs one that refuses however it is called. Part of #87
|
[Reviewer Round 1] Request changes.
The PR body has the correct |
|
[Review Verdict Round 1: NOT_APPROVED] |
`test -e` resolves the path it is given, so a symlink pointing at nothing answered exactly as an absent path did: the backup was skipped as a newly-added artifact, and the swap that follows replaced the operator's link with no backup taken and nothing said. The probe now looks for the directory entry, so the refusal `hard_link_over` already made of a symlink is actually reached. The flush's one degradation on the shell transports — a host carrying no working `sync` warns rather than failing — is now stated where it is made and pinned by a test, instead of being left to be read out of the script. Part of #87
|
[Author Round 1] Pushed as [P1] Dangling artifact symlinks are silently skipped — FixedThe reviewer is right:
[P1] Shell flush failure reported as success — Partially addressedI added the test you asked for and documented the divergence, but did not make the flush failure fatal. Kept the behaviour. The only way to reach that The two paths are also not failing at the same thing: natively it is What I changed: CI-equivalent checks all pass locally: fmt, clippy |
|
[Reviewer Round 2] Request changes.
The prior dangling-symlink finding is resolved: |
|
[Review Verdict Round 2: NOT_APPROVED] |
A host carrying no working `sync` had the link script warn on stderr and let the backup stand, so `hard_link_over` returned `Ok` for an entry that was never flushed. The caller writes its backup-taken record from that success, and a record standing over a flush that never ran suppresses the retake the un-flushed entry is precisely what needs. The flush is a required step of the sequence rather than a best effort, so it now fails the script and arrives as `ExecutorError::Transfer`, exactly as the native `fsync` failure already did. What the issue declines to claim portably is a fault between the rename and a flush that ran; that is untouched. `PUT_FILE_SCRIPT` keeps warning and standing, and the divergence is the point: its caller still holds the bytes and can write them again, where nothing holds the artifact's pre-apply inode once the backup is the only other name for it. The new test drives the failure through the executor-facing path, since what a caller acts on is the error rather than the script's exit status. Part of #87
|
[Author Round 2] Pushed as [P1, unresolved] Shell backups succeed without the required directory flush — FixedI accepted this and reversed my Round 1 position. The reviewer's chain is right and I under-weighted it:
Partial, with reasoning: I changed only
CI-equivalent checks pass locally: fmt |
|
[Reviewer Round 3] Approved. The remaining Round 2 finding is resolved: the shell The prior dangling-symlink finding remains correctly fixed via the entry-presence probe. The link → rename → directory-flush sequence now meets the issue across native and shell transports, with meaningful interruption and failure-path coverage. PR linkage and test-plan hygiene are also sound. |
|
[Review Verdict Round 3: APPROVED] |
Suggested squash commitTitle Body |
apply::backup_previous_artifactwas acp -fp. An interrupted copy leaves a truncated.previousthat a later revert succeeds onto — a broken artifact installed by a rollback that reported success. It is now link-based, so an interrupted backup leaves no.previousat all and the revert fails where anyone looking can see it.The
Executorgainshard_link_over, the primitive that expresses the sequence: refuse a non-regular source,link(2)to a temporary sibling of the destination,rename(2)that over it, then flush the containing directory. Notln -f, becauselink(2)fails withEEXISTandln -funlinks first — a window with no backup at all; not a barelink, because a directory entry is not durable until its directory is flushed.InDaemonExecutorruns it as direct syscalls, being root already; the shell transports run it as a single elevatedsh -cscript, so nothing can interleave between the link and the rename by a second elevation. Where the destination already names the artifact's inode — a second backup of an unchanged artifact —rename(2)is defined to succeed and do nothing, and the native path lets that stand;mvdoes not pass the case through, GNU's refusing it withare the same file, so the script compares the inode it already has against the destination and skips the rename rather than running it. Both transports report every on-host failure — of the link, the rename, the flush and the confirmation — asExecutorError::Transfer, whichbackup_previous_artifactfolds into the same subject-labelledCoreError::Commandthecpraised — so neither consumer's call site nor its rendering changes.The flush is the step whose mechanism differs most between the transports, and both are held to the same contract. Natively it is
fsyncon an open descriptor. On the shell transports it is thesyncutility, which some hosts refuse an operand for; the script selects between the two forms exactly asPUT_FILE_SCRIPTdoes, falling back to the host-widesync, because which form a target carries cannot be told from the name. Where the two scripts end differs, and deliberately: a host on which both forms fail carries no workingsyncat all, and this one fails the backup there rather than warning and letting it stand.PUT_FILE_SCRIPTcan let that case stand because the caller still holds the bytes and can write them again; nothing holds the artifact's pre-apply inode once the backup is the only other name for it, and the caller writes its backup-taken record from this call's success — a record standing over a flush that never ran would suppress the retake the un-flushed entry is precisely what needs. What the issue declines to claim portably is a fault between the rename and a flush that ran, and that is untouched: after a power loss there, the caller's journal is what decides.A symlink at the artifact is refused rather than followed, whether or not it resolves, and so is a directory or any other non-regular file. The presence probe that guards the backup looks for the directory entry rather than for what it resolves to, since a plain
test -ereads a dangling symlink exactly as it reads an absent path and would skip it as a newly-added artifact. Past that probe, the refusal is made twice on the native path and twice in the script: the second check is against the inode the link actually captured, so a path swapped under the guard is caught rather than published. The backup's own name is the crate's rather than the caller's, so it is not guarded the same way — the publish is arename, which displaces a symlink planted at.previousinstead of writing through it, while a directory there is a failure both natively and in the script, which refuses it before the link becausemvwould otherwise take the temporary inside it and exit0.Closes #87
Test plan
place_file's rename-over-the-destination swap does not write through the link and destroy the backup.previousis invented.previousis replaced rather than written through, and whatever it pointed at keeps its inode and bytes.previousis a failure, refused before the link on the shell transportslinkand therename, and between therenameand the flush, leaves the old.previousor the new one and never a partial or absent file — native and shell.previouslands leaves none, with the artifact still live for the resumed apply to re-take — native and shellsyncfails both with the operand and without it fails the backup rather than reporting one it could not make durable, asserted through the executor-facing path asExecutorError::TransferExecutorError::Transfernaming the destination, folded into the subject- and host-labelledCoreError::Commandcargo fmt -- --check --config group_imports=StdExternalCratecargo clippy --all-targets -- -D warningsand the same with--features test-supportcargo testandcargo test --features test-support