fix: serialize issue-ledger transitions + validate ForceID (iss-71) - #32
Merged
Conversation
Lift the flock-acquire/release primitive (ensure dirs, open lock with the symlink/regular-file guards, acquire within lockTimeout, release) out of reservePath into a reusable withLedgerLock helper so a second ledger mutator (status transitions) can serialize on the same lock without a duplicate copy. Behaviour-preserving; the capture suite stays green. Assisted-by: Claude:claude-opus-4-8
Two ledger-mutation-safety fixes: - C4: abcd capture resolve/wontfix ran their find->read->move with no ledger lock (only id allocation locked). Two concurrent conflicting transitions on one issue (a resolve and a wontfix) could both pass commitTransition's checksum re-read before either removed the source, landing the issue in TWO status dirs — permanent split-brain (findIssue then always ErrDuplicateIssueID). The find->move critical section now runs under withLedgerLock, the same lock allocation takes, so the second transition sees the issue already moved and conflicts cleanly. Deterministic test: with the lock held, Resolve returns ErrAllocatorContention and leaves the issue in open/. - P13: reservePath built a path from and O_EXCL-created a placeholder for a migrator-supplied ForceID before validating it. A traversal id (../../evil) escaped the ledger open/ dir (swept only on the later validation failure). ForceID is now validated against ^iss-[0-9]+$ before any filesystem op. Assisted-by: Claude:claude-opus-4-8
Ledger move trailing the fix in this branch, so resolution lands atomically on merge (no separate chore PR). Assisted-by: Claude:claude-opus-4-8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves iss-71 (native ledger) — two ledger-mutation-safety fixes found by the clean-slate sweep.
The bugs
abcd capture resolve/wontfixran their find→read→move with no ledger lock (only id allocation locked). Two concurrent conflicting transitions on one issue — aresolveand awontfix— could both passcommitTransition's checksum re-read before either removed the source, landing the issue in two status directories at once. From then onfindIssuealways returnsErrDuplicateIssueIDand the issue can never be transitioned again.reservePathbuilt a path from andO_EXCL-created a placeholder for a migrator-suppliedForceIDbefore validating it. A traversal id (../../evil) escaped the ledgeropen/dir (swept only on the later validation failure).Fixes (
internal/core/capture)withLedgerLockfromreservePath— the flock-acquire/release primitive — so a second mutator can share it (reuse-before-reinvent; behaviour-preserving, suite stays green).transition's find→read→commit critical section now runs insidewithLedgerLock, the same lock allocation takes. The second of two racing transitions acquires the lock only after the first has moved the issue out ofopen/, so it seesstatus != openand conflicts cleanly.ForceIDis validated against^iss-[0-9]+$before any filesystem op.Tests (TDD, each watched fail→pass)
TestTransitionSerializesOnLedgerLock— with the ledger lock held externally,ResolvereturnsErrAllocatorContentionand leaves the issue inopen/(deterministic; a shortlockTimeoutoverride). Fails on revert (lock-freeResolvesucceeds).TestReservePathRejectsUnsafeForceID— traversal/space/newline/non-idForceIDs all rejected before any fs op; no escaped file. Fails on revert.make preflight+make record-lintgreen; capture package green under-race. Reviewed: ruthless-reviewer SHIP, security-reviewer PASS (both attacked every interleaving and the anchor-bypass classes).Not in scope (noted by review)
The capture content write happens outside the lock (only the placeholder reservation is locked). A transition racing a mid-capture zero-byte placeholder reads empty content and fails
parseFrontmatterAndBody→ errors out safely, so it cannot move an empty file. Pre-existing and fail-safe; this change neither regresses nor fixes it.Assisted-by: Claude:claude-opus-4-8