Skip to content

Recover interrupted link backups (#89) - #90

Merged
sehkone merged 7 commits into
mainfrom
sehkone/issue-89
Sep 7, 2026
Merged

Recover interrupted link backups (#89)#90
sehkone merged 7 commits into
mainfrom
sehkone/issue-89

Conversation

@sehkone

@sehkone sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

hard_link_over no longer assumes the temporary sibling it links to is free. Both transports now walk a bounded sequence of candidate names — .bootler.link.<pid>.<attempt> — and step over an occupied one instead of failing on it, so a completed link stranded by an attempt interrupted between the ln and the rename cannot stop a resumed apply from publishing .previous. That mattered because the old names could not distinguish this run from the one that left the leftover: the shell script used $$ alone, and the native path's NATIVE_TEMP_COUNTER restarts at 0 in every process, so pid reuse alone was enough to make the first candidate a retry drew the exact name it had to avoid — and the resulting error left the host stuck until somebody removed the file by hand.

  • LINK_ASIDE_SCRIPT walks candidates in one sh -c invocation, linking with link and with nothing else. ln is the one step here that does not fail on a taken name: given a directory, or a symlink to one, it links the source inside it and exits 0. No option suppresses that portably — -T is GNU's, -h and -n are the BSDs' and cover only the symlink — and no test run ahead of it closes it either, since an entry created between the test and the ln is followed exactly as one that was already there. link is a different utility rather than another spelling of the same one: it passes the two names it was given to link(2) and does nothing else, so an occupied candidate is EEXIST there whatever kind of entry sits at it, with no name resolved and no window to race. Only a failure that left the name taken advances the walk; every other failure is reported with the linker's own diagnostic.
  • link is not POSIX, so a host could in principle carry none. That is refused before the walk begins rather than degraded onto ln: refusing a planted entry is what this recovery is for, and an ln path would give that up on exactly the hosts nobody checked. Such a host loses the backup — reported as ExecutorError::Transfer naming the destination, like every other on-host failure here — which is a diagnosable stop rather than a link made through somebody else's directory. The set is close to empty in practice: coreutils, busybox, toybox and the BSDs all ship link, and the check is a command -v, not a probe run against the candidate.
  • The trap stays below the walk for the reason it always did: $tmp names whichever candidate is in hand, so a trap armed inside the loop would rm -f a refused leftover this sequence must leave standing. Below it, $tmp can only be the entry link(2) just created, so the trap is armed there — above the temporary's type guard, whose refusal that same rm -f then cleans up.
  • link_aside takes the destination's directory rather than a caller-composed name, returns the name it linked under, and advances only on EEXIST. Nothing is unlinked, adopted, or resolved through — and link(2) does not resolve the new name at all, so a directory sitting at a candidate is EEXIST there like anything else. That is the same syscall, and the same refusal, the shell side reaches through link(1).
  • Both sides share the bound LINK_TEMP_ATTEMPTS = 64, and exhausting it is reported as ExecutorError::Transfer naming the destination rather than walked forever — so backup_previous_artifact keeps folding it into its subject-labelled CoreError::Command unchanged.
  • The pre-existing kill-and-retry test's comment is corrected: its retry merely happens not to collide, and the new tests are the ones that stage the collision on purpose.

The link → rename → directory-flush sequence, the regular-file refusal, the sibling-on-the-same-filesystem constraint, and the refusal to clear a planted entry are all unchanged.

Closes #89
Part of #87

Test plan

  • a_stale_native_candidate_is_stepped_over_rather_than_reused — two leftovers made by link_aside itself occupy the first candidates a retry draws under the same pid, then a symlink to an operator file, a directory, and a symlink to an operator directory; the retry still publishes .previous from a free sibling while leaving all five untouched and both directories empty.
  • a_stale_shell_candidate_is_stepped_over_rather_than_reused — the two leftovers and the symlink to an operator file staged the same way for the shell path, planted from inside a wrapper that execs the script so the leftovers carry the pid the script actually runs under.
  • a_shell_candidate_that_is_a_directory_is_neither_followed_nor_removed — a directory and a symlink to one occupy the first two candidates; the backup lands out of a free sibling, both entries are still what they were, and both directories are still empty. It fails on the revision before link was preferred, with the symlink deleted by the trap and the diverted link left in the operator's directory.
  • a_shell_host_without_link_refuses_the_backup_rather_than_linking_with_ln — the same staging on a host that carries no link, reached by handing the script a PATH with neither link nor ln on it, which is what such a host is. No .previous is published, the failure names the missing utility, and both planted entries and both directories are untouched. Deleting the requirement from the script fails this test and the shape test below, and nothing else.
  • a_temporary_stranded_by_an_interrupted_attempt_does_not_block_the_resumed_backup (apply.rs) — the caller-level view: a stranded link at attempt 0 does not stop backup_previous_artifact, and the leftover is still there afterwards.
  • a_directory_with_no_free_candidate_fails_rather_than_walking_forever and its shell counterpart — the bound is reported as ExecutorError::Transfer naming the destination, no .previous is published, and no occupied candidate is cleared.
  • a_link_failure_that_is_not_occupancy_is_reported_as_itself — a stubbed link failing for a non-occupancy reason is reported with its own diagnostic after exactly one call, not folded into the bound's message.
  • the_link_script_never_leaves_the_destination_absent pins the script's shape: the link goes through link, the word ln appears nowhere in it, a host without link is refused before the walk begins, and the trap sits below the walk and above the temporary's type guard.
  • Both staged-collision tests pin the wrapper against LINK_ASIDE_SCRIPT's own candidate line, and the shell bound test against the script's own -ge 64, so a rename of the production spelling cannot leave them silently colliding with nothing.
  • cargo fmt -- --check --config group_imports=StdExternalCrate
  • cargo clippy --all-targets -- -D warnings and cargo clippy --all-targets --features test-support -- -D warnings
  • cargo test and cargo test --features test-support

An attempt interrupted between the link and the rename leaves a
completed temporary sibling behind, and the shell script's trap is
registered on the line *after* the link, so an ordinary TERM strands one
as surely as a SIGKILL does. Both transports then drew a name that a
resumed apply could draw again: the script used $$ alone, and the native
path a pid with a counter that restarts at 0 in every process. Pids are
reused, quickly inside a container, so the first candidate a resumed
apply tries is exactly the name the interrupted one left. link(2)
refuses an occupied name on purpose, which turned that collision into a
host stuck until somebody removed the file by hand.

The refusal stays; the choice of name changes. Both transports now carry
an attempt number and walk it upwards until a name is free, stepping
over an occupied candidate rather than adopting, unlinking or resolving
through it, and reporting a directory in which 64 consecutive candidates
are taken instead of walking forever. The link, rename and
directory-flush sequence is otherwise untouched.

Closes #89
The walk advances only on an occupied candidate, and nothing held it to
that: an `ln` failing for any other reason could have been stepped over
64 times and reported as a directory with no free name, naming the wrong
problem. A stubbed `ln` now pins the diagnostic and the single call.

The shell script carries its bound as a literal, so the native constant
and the script could drift apart in the direction the staged-collision
test cannot see — raising the constant leaves the script failing earlier
and the test still passing. It now asserts the script's own number.

Recovery is also asserted where the criterion states it, through
`backup_previous_artifact` rather than only through the executor: a
leftover under this process's pid is what a resumed apply meets first,
and the backup lands out of a free sibling with the leftover untouched.

Part of #89
Both stale-candidate tests spell the temporary name a second time — the
native one through a helper, the shell one inside the wrapper that
plants under the script's own pid. Neither was held to the name the
production walk actually draws, and a drift there would not fail them:
a leftover at a name nothing collides with leaves the backup published,
the leftovers standing and the stray count unchanged, so every
assertion passes while the recovery under test never happens.

The native leftovers are now made by step one itself, twice, which is
what two attempts interrupted between the link and the publish leave,
and the names it returns are asserted. The shell wrapper is held to the
script's own candidate line.
The recovery this sequence gained is built on a leftover being left
standing, and the window that strands one is the gap between the `ln`
and the trap on the line after it. That reads as an invitation to hoist
the trap above the walk, which would arm `rm -f "$tmp"` while `$tmp`
still names a candidate the walk is about to refuse — clearing away the
very leftover the sequence is required to leave alone. Record the
trade-off where the next reader will meet it.

The same paragraph named the walk's bound as a bare 64 while
`LINK_TEMP_ATTEMPTS` holds it, so point at the constant instead.

Part of #89
@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1]

Request changes.

  • [P1] Shell transport follows a planted directory candidatesrc/executor.rs:352 invokes ln "$source" "$tmp" without preventing destination-directory dereferencing. If .bootler.link.<pid>.0 is a directory, or a symlink to one, ln succeeds by creating a hard link named after the source inside that directory. The subsequent type guard fails, but the EXIT trap at src/executor.rs:363 removes a symlink candidate and leaves the unintended hard link in its target directory. This directly violates the issue’s requirement that shell transports neither delete nor follow an occupied/planted entry. Use a non-dereferencing ln form suitable for supported hosts, and add shell regression coverage for both a directory candidate and a symlink-to-directory candidate, asserting neither entry nor target directory changes.

The PR body otherwise has the required Closes #89, inherited Part of #87, and a test plan.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: NOT_APPROVED]

`ln` is the one step of this sequence that does not fail on a name that
is taken. Handed a directory, or a symlink to one, it links the source
*inside* it and exits 0, so the walk broke out on a link it had not made
at the name it thought it had taken. The type guard below caught that,
but only after the trap was armed: `rm -f "$tmp"` then deleted a planted
symlink on the way out and left the stray link standing in the directory
it pointed at — deleting the operator's entry and writing through it in
one step, which is exactly what the walk exists to avoid.

No portable `ln` suppresses the dereference — `-T` is GNU's, `-h` and
`-n` are the BSDs' and cover only the symlink — so the walk now asks
whether the candidate exists before linking, `-e` for an entry and `-h`
for a dangling symlink `-e` does not see. What is left is the window
between that test and the `ln`, and the type guard closes it: the trap
moves below the guard, and the guard removes the link `ln` was diverted
into making by the name it was given rather than the candidate it was
handed. Nothing on either path touches the planted entry.

The native side needed no change — `link(2)` does not resolve the new
name, so a directory there is `EEXIST` like anything else — but the
criterion covers both transports, so its staged collision now includes
both spellings too.

Part of #89
@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Author Round 1]

Round 1 disposition

[P1] Shell transport follows a planted directory candidate — Fixed.

The finding is correct, and I confirmed the failure empirically before and after the change. ln src dir and ln src symlink-to-dir both create the link inside the target and exit 0 (verified on BSD ln; GNU behaves the same), so the walk broke out on a link it had not made at the name it thought it had taken. The type guard caught it, but only after the trap was armed, so rm -f "$tmp" deleted the planted symlink on the way out and left the stray link standing inside the operator's directory — deleting a planted entry and writing through it in one step.

What changed in LINK_ASIDE_SCRIPT (src/executor.rs:348):

  • The candidate is tested before ln runs, rather than leaving the refusal to ln: [ -e "$tmp" ] || [ -h "$tmp" ] (the -h catches a dangling symlink -e does not see) steps the walk on without ln touching the entry. I did not take the "non-dereferencing ln form" route the review suggested — there is none that is portable. -T is GNU-only (BSD ln errors illegal option -- T), and BSD's -h/-n only cover the symlink case, not a real directory. A probe-and-fall-back chain would also have to distinguish "option rejected" from "link failed", which is exactly the ambiguity the walk cannot afford. So the requirement is met by a portable test rather than by a flag — that is the one part of the suggestion I varied, and the outcome asserted is the one the review asked for.
  • The race window between that test and the ln is closed by the type guard, which now runs before the trap is armed and removes the link ln was diverted into making (rm -f "$tmp/${source##*/}" when $tmp is a directory) rather than the candidate it was handed. The planted entry is untouched on every path. The trap still stays below the walk, for the reason the previous commit recorded.
  • The bound message now carries the last candidate's occupancy reason (taken), since a candidate skipped by the pre-test never produced an ln diagnostic.

Coverage added, as requested — both spellings:

  • a_shell_candidate_that_is_a_directory_is_neither_followed_nor_removed stages a directory at candidate 0 and a symlink to the operator's directory at candidate 1, then asserts .previous is published from a free sibling, both entries are still what they were, and both directories are still empty. Reverting only the script hunk makes it fail with exactly the reported symptom (rm: …link.<pid>.0: is a directory, backup not taken).
  • the_link_script_never_leaves_the_destination_absent now pins the script's shape: the candidate test precedes the ln, and the temporary's type guard precedes the trap.
  • The staging helper was generalized from (stale: u32, victim) to a &[Stale] plan (Link / SymlinkToFile / Directory / SymlinkToDirectory), still planted from inside the exec-ing wrapper so the names carry the script's own pid.
  • The native staged-collision test gained the same two entries. link(2) does not resolve the new name, so native never had the bug — but the acceptance criterion names both transports, so it is now held to that for these spellings too.

Docs: the LINK_ASIDE_SCRIPT and link_aside doc comments explain why the candidate is tested rather than left to ln, and why the trap sits below the type guard. No CHANGELOG.md entry — the unreleased ### Added entry for hard_link_over already describes stepping onto a free name rather than clearing an occupied one away, and this is a fix inside that same unreleased window.

CI commands all green locally (fmt check, both clippy configurations, cargo test and cargo test --features test-support: 502 tests each). PR body updated, Closes #89 and the inherited Part of #87 kept. Pushed as 7433b9c.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 2]

Request changes.

  • [P1] Shell candidate check remains TOCTOU-vulnerablesrc/executor.rs:370-387 tests whether $tmp exists and then separately runs ln. A directory or symlink-to-directory created between those operations is followed by ln, which creates $tmp/${source##*/} inside the directory. The subsequent guard then dereferences that entry again to remove it. Besides violating the explicit “neither … follow” constraint, an entry swapped in between ln and the cleanup can be deleted as root. The new tests cover candidates present before the probe, but not this interleaving. The author’s static-candidate fix is correct, but the claimed closure of the remaining race is not.

The PR body has the required Closes #89, inherited Part of #87, and a test plan.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 2: NOT_APPROVED]

The shell walk tested each candidate and then ran `ln` on it, which
left a window: a directory or a symlink to one appearing between the
test and the link is followed, and the source lands inside it. The
type guard caught that and removed the diverted link, but only by
resolving through the very entry the sequence must not touch.

`link(1)` closes the window rather than narrowing it. It hands
`link(2)` the two names it was given and resolves nothing, so an
occupied candidate is EEXIST there whatever sits at it -- the same
refusal `link_aside` already gets from the same syscall. It is not
POSIX, so a host without it keeps the tested `ln`, whose remaining
window needs write access to the root-owned directory holding the
artifact this sequence protects.

Part of #89
@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Author Round 2]

Round 2 disposition

[P1] Shell candidate check remains TOCTOU-vulnerable — Fixed.

The finding is correct. The test-then-ln pair does have a window, and the guard that was supposed to close it resolves through the planted entry to do its cleanup — so the previous round's claim that the guard closed the race was an overclaim.

The fix is not a tighter test but a different utility. LINK_ASIDE_SCRIPT now links with link rather than ln (src/executor.rs:366):

if command -v link >/dev/null 2>&1; then
  claim() { link "$1" "$2"; }
else
  claim() { …existence test…; ln "$1" "$2"; }
fi

link(1) is a separate utility, not a spelling of ln: it passes its two operands to link(2) and does nothing else. There is no name to resolve and therefore no window — an occupied candidate is EEXIST whatever kind of entry sits at it, which is exactly the refusal link_aside already gets from that syscall on the native side. The review suggested a "non-dereferencing ln form"; there is none that is portable (-T is GNU's, -h/-n are the BSDs' and cover only the symlink), so I took the same guarantee from the utility that is a direct link(2) instead. command -v makes the choice unambiguously, so this is not the probe-and-fall-back chain I pushed back on last round.

Partially addressed: link is not POSIX, so I kept the tested ln as a fallback rather than failing outright on a host that lacks it (coreutils, busybox, toybox and the BSDs all carry it, so that set is close to empty in practice, but a shipping installer should degrade rather than break). That fallback retains the window the review names. Exploiting it requires creating an entry in the destination's own root-owned directory — where the artifact this sequence is protecting lives — so anyone who can win the race can replace the artifact outright. The type guard stays on both paths, unchanged, since removing round 1's cleanup would reinstate round 1's finding.

Coverage for the interleaving branch, which the review correctly noted was missing:

  • a_shell_host_without_link_refuses_a_directory_candidate_before_ln_runs stages the same directory and symlink-to-directory candidates, with the script handed a PATH carrying every utility it uses except link — prepending a stub cannot express absence, since command -v would find it, so the wrapper replaces PATH after planting and immediately before the exec.
  • Both branches are pinned to the test that drives them: breaking the fallback's candidate test fails the new test while a_shell_candidate_that_is_a_directory_is_neither_followed_nor_removed still passes, and stubbing the fallback body out entirely leaves that one passing. Verified both mutations.
  • a_link_failure_that_is_not_occupancy_is_reported_as_itself now stubs link and ln into one log, asserting exactly one call, since which the script reaches depends on the host.
  • the_link_script_never_leaves_the_destination_absent pins the new shape: the linker is chosen before the walk, the primary branch spells link "$1" "$2", and the fallback tests the candidate before ln.

Docs: the LINK_ASIDE_SCRIPT and link_aside doc comments now explain why the link goes through link(1), what the fallback gives up, and why that residue is bounded by directory ownership. No CHANGELOG.md entry — the unreleased ### Added entry already says the sequence steps onto a free name rather than clearing an occupied one away, and which utility performs the link is not observable to a user of the last release.

CI green locally: fmt check, both clippy configurations, cargo test (503) and cargo test --features test-support (503). PR body updated with Closes #89 and the inherited Part of #87 intact. Pushed as a340f5c.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 3]

Request changes.

  • [P1, unresolved] Shell fallback still follows a raced-in candidatesrc/executor.rs:379-src/executor.rs:405 deliberately retains a ln fallback when link is absent. A directory or symlink-to-directory created after the -e/-h check at line 380 and before ln at line 384 is followed by ln; the subsequent guard then resolves through it to remove a basename under that directory. This remains the Round 2 issue on every host lacking link, contrary to the issue’s explicit requirement that shell transports not follow a planted entry. The stated directory-ownership rationale does not narrow that requirement, and this product-neutral executor cannot assume every destination directory has that property. The new fallback test stages candidates before the probe, so it does not exercise this interleaving.

The PR body’s Closes #89, inherited Part of #87, and test-plan checklist are present.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 3: NOT_APPROVED]

The shell walk kept `ln` behind a test of the candidate for hosts
carrying no `link`, and that test is a window: a directory, or a
symlink to one, created between it and the `ln` is followed just as
one that was already there would be, so the source is linked inside
somebody else's directory and the guard that cleans up afterwards
resolves through the same planted entry to do it.

There is no portable non-following `ln`, so the fallback cannot be
repaired, only removed. A host with no `link` now fails the backup
before the walk begins, reported as a transfer failure naming the
destination like every other on-host failure here. That is a
diagnosable stop instead of a link made through a planted entry, and
the set of hosts it stops is close to empty: coreutils, busybox,
toybox and the BSDs all ship `link`.

With `ln` gone the temporary can only be the entry `link(2)` just
made, so the trap moves above the type guard, which no longer needs
cleanup of its own.

Part of #89
@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Author Round 3]

Round 3 disposition

[P1, unresolved] Shell fallback still follows a raced-in candidate — Fixed.

The finding is correct, and it cannot be answered by tightening the test: there is no portable non-dereferencing ln, and any check run ahead of ln leaves a window in which an entry created after the check is followed exactly as one that was already there. Round 2 kept that fallback and argued the residue was bounded by directory ownership; the reviewer is right that a product-neutral executor cannot assume that. So the fallback is removed rather than repaired.

LINK_ASIDE_SCRIPT now links with link and with nothing else. A host carrying no link is refused before the walk begins:

if ! command -v link >/dev/null 2>&1; then
  echo "no link utility: a temporary beside $dest cannot be claimed without following it" >&2
  exit 1
fi

That host loses the backup — surfaced as ExecutorError::Transfer naming the destination, like every other on-host failure here, so backup_previous_artifact's CoreError::Command rendering is unchanged. A diagnosable stop is the right trade against a link made through somebody else's directory, and the set it stops is close to empty: coreutils, busybox (verified: busybox --list carries link), toybox and the BSDs all ship it.

Two things simplified out with the fallback:

  • The type guard's -d "$tmp" branch, which existed only to clean up a link ln had been diverted into making, is gone. After a successful link(2) the entry at $tmp can only be the one this script made.
  • The trap therefore moves above the type guard, back to immediately below the walk. It still stays below the walk for the original reason — $tmp names whichever candidate is in hand, so a trap armed inside the loop would rm -f a refused leftover this sequence must leave standing — but the guard now needs no cleanup of its own, and the window between the link and the trap is at its narrowest.

Coverage:

  • a_shell_host_without_link_refuses_a_directory_candidate_before_ln_runs is replaced by a_shell_host_without_link_refuses_the_backup_rather_than_linking_with_ln, which stages the same directory and symlink-to-directory candidates and asserts no .previous is published, the failure names the missing utility, and both entries and both directories are untouched. The stand-in PATH now carries neither link nor ln, so the script cannot reach one.
  • Deleting the requirement from the script fails that test and the_link_script_never_leaves_the_destination_absent and nothing else — verified by mutation.
  • The shape test now asserts the word ln appears nowhere in the script, that the command -v link refusal precedes the walk, and the new trap/guard order.
  • a_link_failure_that_is_not_occupancy_is_reported_as_itself stubs only link now; the "either spelling" hedge is gone.

Docs: the LINK_ASIDE_SCRIPT and link_aside doc comments now say why ln is not reachable at all, why no test ahead of it would have helped, and what a host without link loses. No CHANGELOG.md entry — the unreleased ### Added entry already describes the sequence stepping onto a free name, and which utility performs the link is invisible to a user of the last release.

CI green locally: fmt check, both clippy configurations, cargo test (503) and cargo test --features test-support (503). PR body updated with Closes #89 and the inherited Part of #87 intact. Pushed as 67f0cf8.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 4]

[Reviewer Round 4]

Approved — no unresolved findings.

Round 3’s P1 is resolved. The shell script now requires link before the candidate walk and uses only link for candidate creation, so a directory or symlink-to-directory candidate cannot be followed by an ln fallback (src/executor.rs). The native path likewise advances only on AlreadyExists and leaves occupied candidates untouched (src/executor.rs).

The staged shell/native collision tests and caller-level recovery test meaningfully cover the required retry behavior. PR linkage and test-plan hygiene are correct.

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 4: APPROVED]

@sehkone

sehkone commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Choose a free temporary name for the link backup

Body

An attempt interrupted between the link and the rename leaves a
completed temporary sibling behind — an ordinary TERM is enough, since
the trap is registered on the line after the link — and the caller's
journal correctly records no backup, so a resumed apply must be able to
take one. The old names could not tell this run apart from the one that
stranded the leftover: the shell script drew `$$` and nothing else, and
the native path's counter restarts at 0 in every process, so pid reuse
alone made the first candidate a retry drew the exact name it had to
avoid. The refusal of an occupied candidate is deliberate and stays, so
what had to change is the choice of name: both transports now walk a
bounded sequence of siblings and step over a taken one rather than
failing the apply and leaving the host needing a file removed by hand.

The shell side links with `link` rather than `ln`. `ln` is the one step
that does not fail on a taken name — handed a directory, or a symlink to
one, it links the source inside it and exits 0 — and no portable option
suppresses that, nor does a test run ahead of it, which only narrows the
window. `link(1)` passes its two names to `link(2)` and does nothing
else, so an occupied candidate is EEXIST there whatever sits at it. A
host carrying no `link` is refused before the walk begins rather than
degraded onto `ln`, because refusing a planted entry is what this
recovery is for.

The link, rename and directory-flush sequence is unchanged, as are the
regular-file refusal and the same-filesystem sibling constraint. On-host
failures, including an exhausted bound, still reach the caller as
ExecutorError::Transfer naming the destination.

Closes #89
Part of #87

@sehkone
sehkone merged commit 44bde25 into main Sep 7, 2026
4 checks passed
@sehkone
sehkone deleted the sehkone/issue-89 branch September 7, 2026 07:13
@sehkone sehkone mentioned this pull request Sep 7, 2026
7 tasks
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.

Recover interrupted link backups

1 participant