One doctrine with walk_committed's fatal-malformed rejection: committed paths hold regular files; a non-regular entry at one (a planted symlink, a directory) is rejected loudly everywhere it can be touched. - open_committed (behind get/size/has): portable symlink_metadata pre-check rejects before any open; the unix open rides O_NOFOLLOW (errno-ELOOP mapped to the same doctrine message — the TOCTOU close); the handle's own metadata re-checks the type; NotFound stays None - has inherits through size (pinned by test) - fsync_publish's AlreadyExists arm re-stats the destination: a regular file dedup-maps exactly as before; a symlink or any non-regular entry rejects loudly on both publish forms (zero stage residue, the failed hardlink never touches the destination); a destination vanished in the window keeps the CAS racing-publisher Ok - error shape: Error::Io with a doctrine-naming message (no new variant — the family is ADR-pinned, the F-4 refusal's precedent) - the staging discard's stage.exists() judged out of scope (remove_file on a symlink unlinks the link, never the target) — recorded in the task Verification: cargo test (149 lib + 54 conformance + 4 git-interop), cargo test --all-features server-less (324 + 125 + 4) and against the live pglo-poc harness, clippy --all-targets -- -D warnings across the feature variants, cargo fmt --check, cargo doc --no-deps. Task: tasks/s6-local-symlink-doctrine.md
11 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| s6-local-symlink-doctrine | S-6 — the local tier's stat/get paths stop following symlinks: one doctrine with walk_committed's loud rejection | completed | narrow | low | component | implementation |
|
Description
Review-001 S-6. The local tier follows symlinks on the stat/get
paths while walk_committed rejects them loudly — one behavior, two
doctrines. A symlink planted at a committed path makes get/size
serve the target (and pre-F-3 made put/commit silently dedup;
post-F-3 the no-replace hardlink publish fails on an existing
destination — verify that arm holds for a symlink destination).
The current follow points (post-F-3 locations):
- src/local.rs:730 —
size:fs::metadata(&committed)follows symlinks. - src/local.rs:623-625 —
get:File::open(&committed)+file.metadata()follow symlinks. - src/local.rs:491 — the staging discard's
stage.exists()follows (staging area, lower stakes — judge whether the doctrine extends). - The publish path (
fsync_publish, src/local.rs:390-415):fs::hard_link(stage, committed)fails when the destination exists — including a symlink destination — so put/commit should already reject loudly. Verify by test (the review's silent-dedup premise predates F-3's no-replace publish).
The fix: symlink_metadata-based checks (portable — the engine
has a non-unix read_exact path, so O_NOFOLLOW alone is not the
shape) on the stat/get paths: a symlink at a committed path is
rejected loudly — the same doctrine as walk_committed's
fatal-malformed rejection, not silently served and not silently
Missing. Design points, record in Notes:
- The rejection's error shape:
Error::Iowith a doctrine-naming message (no new variant — the family is ADR-pinned; the F-4 refusal's precedent). get's TOCTOU window: thesymlink_metadatapre-check beforeFile::opennarrows but does not close the race; on unix,OpenOptionscustom flags (O_NOFOLLOW) close it. Judge: portablesymlink_metadatacheck as the base, the unixO_NOFOLLOWclose as an optional riding fix (the F-3 posture — unix/non-unix agreement — is the precedent for judging both platforms' shapes together).- Whether
has(which ridessize, src/local.rs:614-616) inherits the doctrine automatically (it does, ifsizerejects) — confirm and record.
Tests: a symlink planted at a committed path — get/size/has
reject loudly (the doctrine message); put/commit against the
planted path fail (the hardlink destination-exists arm — the
publish's loud rejection verified); walk_committed's existing loud
rejection stays green (the doctrine's other half, unmodified).
Acceptance Criteria
size/get(andhasthrough them) reject a symlink at a committed path loudly (symlink_metadata-based; the error shape recorded in Notes)- The unix
O_NOFOLLOWTOCTOU close judged (landed or consciously deferred, recorded) - The publish path's loud rejection against a planted symlink verified by test (the post-F-3 no-replace arm)
- Tests: the planted-symlink round (get/size/has reject; put/ commit fail; walk_committed unmodified green)
- Gates green:
cargo test(default),cargo test --all-featuresserver-less,cargo test --all-features+ live harness, clippy--all-targets -- -D warningsacross the feature variants,cargo fmt --check
References
- docs/reviews/001-storage-surface-general-review.md (S-6 — pre-split coordinates; the F-3 stamp — the publish path's post-fix shape)
- docs/architecture/decisions/015-local-engine-durability-posture.md (the no-replace publish the verification rides)
- docs/plans/implementation.md (§Wave 10; §Review gates — the security gate)
- src/local.rs (
size,get,has,fsync_publish,walk_committed— the doctrine's loud-rejection contrast) - src/local.rs tests (the walk_committed doctrine tests — the planted-symlink round's home)
Notes
Filled by implementation agent (2026-10-15):
- The rejection's error shape (the task's design point): the
existing family's honest member —
Error::Io(String) with a doctrine-naming message, no new variant (the family is ADR-pinned; the F-4 refusal's precedent). One builder,malformed_committed_entry(path, found)over three helpers insrc/local.rs: the message form is"malformed committed entry: {path:?} holds a {kind}, not a regular file — rejected loudly, never served or dedup-mapped (the committed-tree doctrine)"— "malformed" vocabulary matchingwalk_committed's messages, so the pool's fatal-malformed shape reads with one voice across list, stat/get, and publish. - The doctrine generalizes to non-regular, not just symlinks
(recorded judgment): the doctrine statement is "committed paths
hold regular files", so the pre-check rejects any non-regular
entry (symlink, directory, other) with the same message family —
a directory at a committed path was previously served silently by
size/get(a dir's stat answersOk,File::opensucceeds on unix) while the walk rejected it loudly at list time; the same one-behavior-two-doctrine shape the review named for symlinks. The new portable test pins the directory arm too. - The unix
O_NOFOLLOWTOCTOU close: landed (judged per the F-3 posture — both platforms' shapes judged together). The portablesymlink_metadatapre-check is the base on both platforms; on unix the open additionally carriesO_NOFOLLOW, so a symlink slipped in after the pre-check fails the open (final-component loop → errnoELOOP), mapped to the same doctrine message. Tooling note:ErrorKind::FilesystemLoopis unstable on the toolchain (rustc 1.99), so the arm matchesraw_os_error() == Some(ELOOP)vialibc— the dep is target-gated (cfg(unix)) and already in the lock transitively (tokio), so the lean-compile posture holds. The opened handle's own metadata re-checks the type again (the non-unix shape of the race) — on non-unix the pre-check narrows the window but does not close it; that residual is the recorded posture (std offers no reparse-point open flag), same-message rejection on both platforms. hasinherits the doctrine automatically — confirmed: it ridessize(src/local.rshas→size), so a planted symlink/entry is anErronhastoo; pinned by the test round.- The publish's dedup arm refined, verified by test: the
AlreadyExists arm re-stats the destination with
symlink_metadata— a regular file is the dedup form (Ok, stage discarded, the CAS first-writer-wins contract unchanged, the existing no-replace race test and itsSyncEventsequence stay green); a symlink or any non-regular entry is rejected loudly (put and staged commit both reject, zero stage residue, nothing written through the link's target — the failed link never touches the destination, ADR-015's own statement); a destination vanished between the failed link and the re-stat (a sweep reaped the first writer in the window) maps to the CAS racing-publisher formOk— the same shape the plain dedup arm shares when a sweep reaps between a loser's link and its return, recorded rather than re-litigated. - The staging discard's
stage.exists()(line-491 arm): the doctrine does not extend (judged): staging paths are engine-internal in-flight files, never committed paths; a planted symlink at a stage path is removed byremove_file, which unlinks the link itself and never the target — no serving, no write-through, no replacement. The doctrine holds at the committed paths. - Windows residual (recorded, non-goal): std's
DirEntry::file_typefollows symlinks on Windows, sowalk_committed's blob-level loud rejection of a planted symlink is unix-true (this task's planted-symlink tests arecfg(unix)); on Windows the doctrine is carried by thesymlink_metadatapre-check on the stat/get paths. The portable (non-symlink-API) directory-arm test runs on both platforms. get's return type neededDebugfor the tests'unwrap_err()shape (LocalCursornow derives it —Filein a cursor is alreadyDebug; no behavior change), and the dedup-SyncEventsequence is untouched by the destination re-stat (no new events betweenLinkandStageUnlinked).
Summary
Landed the committed-tree doctrine in src/local.rs (review-001
S-6): one behavior with walk_committed's fatal-malformed
rejection, on every path that touches a committed path. The new
open_committed helper (behind get/size/has) pre-checks with
portable symlink_metadata — never following the link — and rejects
any non-regular entry at the path loudly with a doctrine-naming
Error::Io; on unix the open rides O_NOFOLLOW (the TOCTOU close,
errno-ELOOP mapped to the same message; target-gated libc dep,
already transitively in the lock), and the opened handle's metadata
re-checks the type; NotFound stays the absent-entry None.
has inherits through size (confirmed, pinned). The publish's
losing-link arm (fsync_publish's AlreadyExists path) re-stats the
destination: a regular file dedup-maps exactly as before (the CAS
contract and the no-replace race test's SyncEvent sequence
unchanged); a symlink — or any non-regular entry — rejects loudly on
both the whole-value put and the staged commit (zero stage residue;
the failed hardlink never touches the destination, so nothing is
written through the planted link); a destination vanished in the
window keeps the CAS racing-publisher Ok shape. The staging
discard's stage.exists() arm is judged out of the doctrine's
scope (remove_file on a symlink unlinks the link, never the target)
— recorded in Notes. The module doc carries the doctrine statement.
Tests: the planted-symlink round (get/size/has reject loudly —
both the replaced-a-real-entry and never-published shapes; the
walk's blob-level rejection pinned unmodified; decoy untouched), the
portable directory arm (stat/get/has plus both publish forms reject),
and the planted-symlink publish round (put/commit reject, decoy
stands, link stands, zero stage residue).
Verification: cargo test green (149 lib + 54 conformance +
4 git-interop), cargo test --all-features green server-less
(324 + 125 + 4) and against the live pglo-poc harness, cargo clippy --all-targets -- -D warnings and cargo clippy --all-features --all-targets -- -D warnings green, cargo fmt --check green, cargo doc --no-deps clean.