hostfiles: the cap exemption covers only a skipped entry not also marked deleted, and superseded() skips a path past the cap (Astra round 4, two P2s)
1. An entry marked both Deleted and Skipped passed the exemption, but Restore treats it as a tombstone and walks it. Now only Skipped != "" && !Deleted is exempt. Only a crafted manifest holds such an entry. 2. superseded(), shared by Restore and Present, walked every ancestor of an exempt skipped path: quadratic in depth and not cancellable. It now skips a path past the cap. Only a skipped entry can be one, and any live entry beneath it is refused. Tests: - TestATombstoneMarkedSkippedPastTheCapIsRefused - TestAVeryDeepSkippedEntryCostsRestoreNoAncestorWalk: a 200,000-byte path, where Restore took 15s without the skip. Each fails under its mutation. Astra's round 4 review is committed unedited.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+86
−4
docs/superpowers/plans/CODEX-REVIEW-HOSTFILES-CAP-4.md+35 −0internal/hostfiles/fix5_linux_test.go+43 −0internal/hostfiles/publisher.go+3 −2internal/hostfiles/restore.go+5 −2
diff --git a/docs/superpowers/plans/CODEX-REVIEW-HOSTFILES-CAP-4.md b/docs/superpowers/plans/CODEX-REVIEW-HOSTFILES-CAP-4.md| new file mode 100644 |
| index 000000000..1b9d1fece |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-REVIEW-HOSTFILES-CAP-4.md |
| @@ -0,0 +1,35 @@ |
| +**Request changes: two P2 findings. HEAD `c6dc57d0` is not ready to merge.** |
| + |
| +1. **P2 — Exempt skipped paths still cause unbounded ancestor processing.** The exemption at [publisher.go:74](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/hostfiles-cap-live-entries/internal/hostfiles/publisher.go:74) admits arbitrarily deep skipped paths. Both Restore and Present call [superseded():226](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/hostfiles-cap-live-entries/internal/hostfiles/restore.go:226), which walks every ancestor, including those of skipped entries. Each `path.Dir` cleans the remaining prefix, making this quadratic in depth, with no cancellation check. A valid skipped entry at `strings.Repeat("d/", 100000)+"f"` therefore causes substantial uncancellable work even on an empty destination. Bound or redesign overlap processing while preserving skipped entries’ sequence semantics. |
| + |
| +2. **P2 — A skipped flag bypasses the cap on a tombstone.** At [publisher.go:74](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/hostfiles-cap-live-entries/internal/hostfiles/publisher.go:74), an over-cap entry containing both `Deleted: true` and `Skipped: "too-large"` passes validation. Neither decoding nor `checkEntry` rejects that combination. Restore gives `Deleted` precedence at [restore.go:109](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/hostfiles-cap-live-entries/internal/hostfiles/restore.go:109), then performs the ancestor marking and removal that the revised design intends to forbid. This requires a malformed or crafted manifest; normal writers do not produce it. Exempt only `!e.Deleted && e.Skipped != ""`, or reject contradictory states consistently. |
| + |
| +The remaining requested checks: |
| + |
| +- Round 3’s findings against `removePastCap` are moot: that implementation is gone. |
| +- For ordinary skipped entries, the StageDelete no-op is safe: it preserves an unpublished entry and cannot resurrect content. Restore → scan → commit succeeds when the deep file and over-cap directories are absent. |
| +- An existing deep file or directory still makes scanning—and therefore flushing—fail. That is the existing scan-cap limitation described in the brief as #279-class. |
| +- CopyManifest, NewFromCopy, and moves’ Establish consistently carry ordinary skipped entries through the shared validation. |
| +- The census overlay is envelope-only; its hash matches exactly. Plan 9 and Plan 14 legacy units and the legacy seal are unchanged. |
| + |
| +Static review only; no tests ran, including the full `internal/host` suite. |
| + |
| +--- |
| + |
| +**FIRST PROMPT:** |
| +Review the round-4 brief at `c6dc57d0`, read-only; avoid the full internal/host suite. |
| + |
| +**WHAT:** |
| +Assess the revised cap, downstream recovery behavior, census, and merge readiness. |
| + |
| +**STATUS:** |
| +Complete: two P2 findings; request changes. |
| + |
| +**NEEDED:** |
| +Nothing from you for this review. |
| + |
| +**RECOMMENDED:** |
| +Address both findings and add focused regressions before merging. |
| + |
| +**SESSION:** |
| +Safe to close. HEAD and working tree are unchanged; no files or background processes were created. |
| \ No newline at end of file |
diff --git a/internal/hostfiles/fix5_linux_test.go b/internal/hostfiles/fix5_linux_test.go| index 18e13546e..1c826162e 100644 |
| --- a/internal/hostfiles/fix5_linux_test.go |
| +++ b/internal/hostfiles/fix5_linux_test.go |
| @@ -243,3 +243,46 @@ func TestAScanKeepsAnInheritedSkippedEntryPastTheCap(t *testing.T) { |
| t.Fatalf("healthy.txt after the scan: %+v", e) |
| } |
| } |
| + |
| +// Astra's round 4 (P2 2): an entry marked both deleted and skipped is a |
| +// tombstone to Restore, so the exemption does not cover it. |
| +func TestATombstoneMarkedSkippedPastTheCapIsRefused(t *testing.T) { |
| + m := Manifest{V: ManifestVersion, Seq: 1, Entries: map[string]Entry{ |
| + pathOfBytes(MaxPathBytes + 1): {Seq: 1, Deleted: true, Skipped: "too-large"}, |
| + }, Platform: map[string]Entry{}} |
| + if err := RestorableWithinCap(m); !errors.Is(err, ErrTooDeep) { |
| + t.Fatalf("RestorableWithinCap: %v", err) |
| + } |
| + if err := ValidateManifest(m, []string{pfx}); !errors.Is(err, ErrTooDeep) { |
| + t.Fatalf("ValidateManifest: %v", err) |
| + } |
| +} |
| + |
| +// Astra's round 4 (P2 1): a skipped entry far past the cap costs Restore |
| +// and Present no walk of its ancestors (superseded skips it), so even a |
| +// path of 200,000 bytes restores at once. |
| +func TestAVeryDeepSkippedEntryCostsRestoreNoAncestorWalk(t *testing.T) { |
| + ctx := context.Background() |
| + s, dir := release.NewDirStore(t.TempDir()), t.TempDir() |
| + deep := strings.Repeat("d/", 100000) + "f" |
| + inheritPastTheCap(t, s, func(seq int64) map[string]Entry { |
| + return map[string]Entry{deep: {Seq: seq, Skipped: "too-large"}} |
| + }) |
| + start := time.Now() |
| + m, st, err := Restore(ctx, s, pfx, dir, nil) |
| + if err != nil { |
| + t.Fatalf("Restore: %v", err) |
| + } |
| + if took := time.Since(start); took > 2*time.Second { |
| + t.Fatalf("Restore took %v over a deep skipped entry", took) |
| + } |
| + if !slices.Equal(st.Skipped, []string{deep}) { |
| + t.Fatalf("restore: %+v", st) |
| + } |
| + if ok, err := Present(dir, filepath.Join(t.TempDir(), "shop.example.env"), m, true); err != nil || !ok { |
| + t.Fatalf("Present: %v %v", ok, err) |
| + } |
| + if took := time.Since(start); took > 4*time.Second { |
| + t.Fatalf("Restore and Present took %v over a deep skipped entry", took) |
| + } |
| +} |
diff --git a/internal/hostfiles/publisher.go b/internal/hostfiles/publisher.go| index 517051dc8..366138929 100644 |
| --- a/internal/hostfiles/publisher.go |
| +++ b/internal/hostfiles/publisher.go |
| @@ -61,7 +61,8 @@ func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, erro |
| // the tree's length cap (withinTreeCap): the one rule the readers |
| // (checkManifest), Open, OpenAt and Commit apply, and the check a caller |
| // makes before it starts work that will need m restored (a move's cut). A |
| -// skipped entry is exempt: Restore never touches its path, so a manifest |
| +// skipped entry (one not also marked deleted, which Restore would treat as |
| +// a tombstone) is exempt: Restore never touches its path, so a manifest |
| // written before the cap that holds one past it stays readable and |
| // continuable. A live entry or tombstone past the cap is ErrTooDeep: no |
| // restore could recreate or remove it in bounded work. |
| @@ -71,7 +72,7 @@ func RestorableWithinCap(m Manifest) error { return manifestWithinCap(m) } |
| // it by this name, which the root-read and record-write censuses pin. |
| func manifestWithinCap(m Manifest) error { |
| for path, e := range m.Entries { |
| - if e.Skipped != "" { |
| + if e.Skipped != "" && !e.Deleted { |
| continue |
| } |
| if err := withinTreeCap(path); err != nil { |
diff --git a/internal/hostfiles/restore.go b/internal/hostfiles/restore.go| index 7dd4af4b5..8a42b12b0 100644 |
| --- a/internal/hostfiles/restore.go |
| +++ b/internal/hostfiles/restore.go |
| @@ -216,11 +216,14 @@ func restore(ctx context.Context, s release.Store, prefix string, origins []stri |
| // wins and the other is superseded; equal sequences keep the deeper one. |
| // Restore leaves a superseded entry unwritten and Present does not look |
| // for it, so what Restore produces is exactly what Present accepts |
| -// (stage B final review A1). Tombstones take no part. |
| +// (stage B final review A1). Tombstones take no part, and nor does a path |
| +// past the cap: only a skipped entry can be one (RestorableWithinCap), and |
| +// walking its ancestors would cost work without bound. Any live entry |
| +// beneath such a path is past the cap too, and refused. |
| func superseded(m Manifest) map[string]bool { |
| loser := map[string]bool{} |
| for p, e := range m.Entries { |
| - if e.Deleted { |
| + if e.Deleted || withinTreeCap(p) != nil { |
| continue |
| } |
| for a := path.Dir(p); a != "."; a = path.Dir(a) { |