Repository navigation
fix(host): the state journal refuses a hard link at its own path - #2133
Conversation
|
@Birfy — review request at this tip. Tip What it fixes (#2131). Red first on base Verify at this tip ( 6 files +148/−9, |
`journal/state.jsonl` must be a plain file this host owns, but a hard link is a plain file to `lstat` while its bytes are also another name's. The start-up read then took that file as the team's history, `chmod`-ed it and the append wrote the next lines into it, all without a warning. Both places now require the file to carry only this one name (`nlink === 1`), so a shared inode is refused exactly like a symbolic link and a journal with one name records as before.
|
Verdict: PASS at 12e42db Ran at this tip: Review points, against the 6-file compare (
Metadata: body said Follow-up, deliberately not here: folding these two clauses into |
|
Verdict: PASS at 6b541bd Ran at this tip: The head moved while the first verdict was up: Review points, against the 6-file compare (
Metadata: body |
Fixes #2131
Written-by: Dev-3
Owner: Dev-3
Reviewer: Reviewer
Claim-Id: 2b732716ea8e4d7890f73a91a8ba55dc
Branch: dev/3-389-journal-hardlink
Base: e6dfffe
Tip SHA: 12e42db
What changed
core/src/host/journal.jskeeps the team's history injournal/state.jsonl, and a file whose bytes are also a name outside this home must never be read as that history or written through. The module does not usefileKind(:19imports onlydirKind); it writes its own two leaf tests, and both askedisFile()alone — start-up:254lstat→:259throw, append:308lstat→:312throw (base line numbers).lstatreports a hard link as a plain file that is not a symbolic link, so both places let a shared inode through: the start-up read took that file as the team's history andchmod-ed it to0600, and the append wrote the next line into an inode another name still owns. Measured on the base, with the outside file a legitimate 130-bytebaseline hard-linked asjournal/state.jsonl(nlink 2):openJournalplus onerecordgave warnings[], the outside filechmod-ed to0600and grown to 322 bytes by two appended lines. Same shape on the swapped name:recordonce,rm, then link the journal name to an outside 13-byte file → warnings[], the outside file grown to 213 bytes.Both places now also require
info.nlink === 1(:259and:313at the tip; the append'slstatmoves to:309because the comment above it grew by a line). A shared inode is refused exactly like a symbolic link:stop()says the one… is not recording: …warning, nothing is read,chmod-ed or appended, the other name keeps its bytes and its mode, and a journal that carries exactly one name records as before.Red first (base
e6dfffe, each ×3)Two tests in the shape of the existing symbolic-link case (
core/test/state-journal.test.js:370on the base), source untouched:baseline hard-linked as the journal name. AfteropenJournaland onerecord, the outside file readsbase line + {"seq":2,…,"op":"set","key":["revision"],"prev":7,"next":8,"actor":"host"}.recordonce,rm, hard link to an outsidePRIVATE LINE\n(13 B). The secondrecordleaves'PRIVATE LINE\n' + {"seq":2,…,"prev":1,"next":2,…}.Each run 18 tests / 16 pass / 2 fail. In both, the assertion that trips first is the one on the outside file's bytes — not the warning count and not the mode — so the write really reached the shared inode. The control (a plain file, two
records) is green on the base too.After the fix (each ×3)
18 tests / 18 pass / 0 fail ×3. A refusal has the symbolic-link shape: one warning with the same
… is not recording: …sentence, the outside name keeps its bytes and its0644mode, nothing is appended and nochmodhappens; on the start-up face nothing is read either, and the link is left in place (nlink stays 2).core/test/state-journal.test.jsgoes from 15 tests to 18.Mutations (each ×1)
nlink === 1withdrawn (start-up kept): 18 / 17 / 1, only the swapped-face test red.nlink === 1withdrawn (append kept): 18 / 17 / 1, only the start-up test red.Verification at the tip
Ran:
TZ=UTC ARCH_DOCS_BASE=e6dfffe npm run verify;node scripts/ds-bot-patches.mjs generatetwice;node core/scripts/build-client.mjs --check.github/scripts598, interop 37, live-e2e 24 = 4419 tests / 0 fail.arch-docs: OK (6 changed file(s));10 patches apply cleanly in order;core/ equals ds-bot/ + patches/ds-bot/;patches/ds-bot/ is what generate writes (generate is a no-op);build-client --checkgreen.generatetwice: both exit 0,git status --porcelainempty after each; the tree is clean at the tip.Not run: smoke — this box has no
../deepseek-harnesscheckout andDSH_HARNESSis unset, so the bench cannot start; the change is covered by the journal suite above.Not converging to
fileKindis deliberate#2114addedcore/src/host/real-dir.js, andfileKindis the shared leaf test — but it does not carrynlinkyet, that arrives with P-387, which has not landed. Importing it here would put this fix behind a different pull request and would change nothing at the two call sites today: both would still askisFile()alone. So the two sites take the one extra clause now; folding them intofileKindonce it carriesnlinkis a follow-up, deliberately outside this change.Security review — three points
grep -n "nlink" core/src/host/journal.jsreturns exactly the start-up test (:259) and the append test (:313);grep -n "lstat" core/src/host/journal.jsreturns those two plus their reads (:254,:309) and the temp-file cleanup, which does not open the journal path.chmod, the link untouched. Append: one warning, no byte appended, the other name's bytes and mode unchanged, the link left in place. A journal with one name is unaffected (the control test records twice and replays to revision 2).Not in scope
The journal folder check (
dirKind, fail-closed since#2055/#2056); the prune path that writes${path}.${pid}.tmpwith flagwxand renames it into place;fileKinditself and P-387. The journal format, the replay and the rotation path are untouched.Checkpoint
{ "pr": 2133, "claim": "Dev-3", "issue": 2131, "summary": "The state journal's `journal/state.jsonl` is now refused when it is a hard link: `lstat` reports a shared inode as a plain file, so the start-up read took it as the team's history, `chmod`-ed it and the append wrote into it. Both leaf tests now also require `nlink === 1`, so a hard link is refused exactly like a symbolic link — one warning, nothing read, `chmod`-ed or appended — while a journal with one name records as before. Folding this into `fileKind` waits for it to carry `nlink`, so it stays a follow-up.", "files": [ "core/src/host/journal.js", "core/test/state-journal.test.js", "docs/subsystems/core-host.md", "changelog.d/2131-journal-hardlink.fixed.md", "patches/ds-bot/files/src/host/journal.js", "patches/ds-bot/files/test/state-journal.test.js" ], "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=e6dfffe npm run verify; node scripts/ds-bot-patches.mjs generate (twice); node core/scripts/build-client.mjs --check", "result": "green", "at": "12e42db1ba11186939a6cdb46edc88c509dfca71" }, "next": "none — ready for review" }