hostfiles: the path cap applies to live entries only (LiveEntriesWithinCap), so a manifest written before the cap that holds a tombstone or skipped entry past it stays readable, and a moves recovery that selects it is not stranded (#236, moves' review of a8cebad)
Restore writes neither a tombstone nor a skipped entry: it lists a skipped path and removes a tombstoned one, so neither needs the cap. A live entry past the cap is still refused everywhere: by the readers (checkManifest, ValidateManifest, CopyManifest, Restore), and by Open, OpenAt and Commit. New writes still refuse a path past the cap: Stage, StageSkipped, StageDelete and the walk. LiveEntriesWithinCap is exported as the one spelling of the rule, for moves' cut preflight (#236 item 2). Open, OpenAt and Commit call it under its old name, manifestWithinCap, so the bodies the Plan 9 record-write census and the Plan 14 root-read legacy freeze pin are byte-identical. The Plan 14 hostfiles envelope moves in a new envelope-only overlay (plan14-root-reads-hostfiles-cap-236.tsv); the legacy seal stands. Tests: - fix4 and fix5 now use a live over-cap entry as the refused case. - New: TestATombstoneOrSkippedEntryPastTheCapStaysReadable. It covers Open, continue and commit, CopyManifest, ValidateManifest and Restore over over-cap tombstones and skipped entries, plus the live-entry refusal. It fails with the reader cap applied to every entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+108
−15
internal/hostfiles/fix4_linux_test.go+4 −3internal/hostfiles/fix5_linux_test.go+75 −5internal/hostfiles/publisher.go+15 −4internal/hostfiles/restore.go+6 −3internal/provision/plan14_root_reads_test.go+6 −0internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv+2 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/hostfiles/fix4_linux_test.go b/internal/hostfiles/fix4_linux_test.go| index 5924e8715..e093826ea 100644 |
| --- a/internal/hostfiles/fix4_linux_test.go |
| +++ b/internal/hostfiles/fix4_linux_test.go |
| @@ -80,13 +80,14 @@ func TestTheWritersRefuseAPathPastTheCapAsTheReadersDo(t *testing.T) { |
| if err := p.StageDelete(past); !errors.Is(err, ErrTooDeep) { |
| t.Fatalf("StageDelete past the cap: %v", err) |
| } |
| - // A manifest the publisher continued that already holds such a path |
| - // (written before the cap) is refused at Commit, never written again. |
| + // A manifest the publisher continued that already holds such a live |
| + // path (written before the cap) is refused at Commit, never written |
| + // again. A tombstone or a skipped entry is exempt (LiveEntriesWithinCap). |
| s := release.NewDirStore(t.TempDir()) |
| q := open(t, s) |
| q.mu.Lock() |
| q.next++ |
| - q.man.Entries[past], q.dirty = Entry{Seq: q.next, Skipped: "too-large"}, true |
| + q.man.Entries[past], q.dirty = liveEntry(q.next), true |
| q.mu.Unlock() |
| if _, err := q.Commit(ctx); !errors.Is(err, ErrTooDeep) { |
| t.Fatalf("Commit of a manifest holding a path past the cap: %v", err) |
diff --git a/internal/hostfiles/fix5_linux_test.go b/internal/hostfiles/fix5_linux_test.go| index b029c6af0..56207dc7e 100644 |
| --- a/internal/hostfiles/fix5_linux_test.go |
| +++ b/internal/hostfiles/fix5_linux_test.go |
| @@ -8,6 +8,9 @@ import ( |
| "encoding/json" |
| "errors" |
| "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| "testing" |
| "time" |
| @@ -17,7 +20,23 @@ import ( |
| // inheritOverCap writes, under pfx, the manifest a writer from before the |
| // path cap could have left: a healthy published file beside an entry whose |
| // path is past MaxPathBytes. It returns the manifest's bytes. |
| +// liveEntry is a live entry at seq whose object this package would have |
| +// written under pfx. |
| +func liveEntry(seq int64) Entry { |
| + sha := strings.Repeat("a", 64) |
| + return Entry{Object: pfx + objectKey(seq, sha), SHA256: sha, Seq: seq, Size: 1} |
| +} |
| + |
| +// inheritOverCap writes, over a healthy manifest at pfx, one holding a live |
| +// path past the cap, as a writer before the cap could have. |
| func inheritOverCap(t *testing.T, s release.Store) []byte { |
| + t.Helper() |
| + return inheritPastTheCap(t, s, func(seq int64) map[string]Entry { |
| + return map[string]Entry{pathOfBytes(MaxPathBytes + 1): liveEntry(seq)} |
| + }) |
| +} |
| + |
| +func inheritPastTheCap(t *testing.T, s release.Store, extra func(seq int64) map[string]Entry) []byte { |
| t.Helper() |
| ctx := context.Background() |
| p := open(t, s) |
| @@ -28,7 +47,9 @@ func inheritOverCap(t *testing.T, s release.Store) []byte { |
| t.Fatal(err) |
| } |
| m.Seq++ |
| - m.Entries[pathOfBytes(MaxPathBytes+1)] = Entry{Seq: m.Seq, Skipped: "too-large"} |
| + for p, e := range extra(m.Seq) { |
| + m.Entries[p] = e |
| + } |
| body, err := json.Marshal(m) |
| if err != nil { |
| t.Fatal(err) |
| @@ -43,9 +64,9 @@ func inheritOverCap(t *testing.T, s release.Store) []byte { |
| return body |
| } |
| -// Fix 5 (Astra confirming P2): a manifest inherited with a path past the |
| -// cap is refused by Open, terminally, and by every reader, and nothing is |
| -// committed over it. |
| +// Fix 5 (Astra confirming P2): a manifest inherited with a live path past |
| +// the cap is refused by Open, terminally, and by every reader, and nothing |
| +// is committed over it. |
| func TestAnInheritedManifestPastTheCapIsRefusedByOpenAndTheReaders(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| @@ -91,7 +112,7 @@ func TestRunStopsOnAManifestItCannotCommit(t *testing.T) { |
| } |
| p.mu.Lock() |
| p.next++ |
| - p.man.Entries[pathOfBytes(MaxPathBytes+1)] = Entry{Seq: p.next, Skipped: "too-large"} |
| + p.man.Entries[pathOfBytes(MaxPathBytes+1)] = liveEntry(p.next) |
| p.mu.Unlock() |
| write(t, dir, "healthy.txt", "changed") // the first scan stages it, and Commit is attempted |
| ctx, cancel := context.WithCancel(context.Background()) |
| @@ -114,3 +135,52 @@ func TestRunStopsOnAManifestItCannotCommit(t *testing.T) { |
| t.Fatalf("something was committed: %v", err) |
| } |
| } |
| + |
| +// Tombstones and skipped entries past the cap are exempt (moves' review of |
| +// a8cebad, #236): Restore writes neither, so a manifest written before the |
| +// cap that holds them is still opened, continued, validated, copied and |
| +// restored, and a recovery that selects it is not stranded. The tombstone |
| +// is removed, as any other tombstone is. |
| +func TestATombstoneOrSkippedEntryPastTheCapStaysReadable(t *testing.T) { |
| + ctx := context.Background() |
| + s, dir := release.NewDirStore(t.TempDir()), t.TempDir() |
| + dead, skipped := pathOfBytes(MaxPathBytes+1), pathOfBytes(MaxPathBytes+2) |
| + inheritPastTheCap(t, s, func(seq int64) map[string]Entry { |
| + return map[string]Entry{dead: {Seq: seq, Deleted: true}, skipped: {Seq: seq, Skipped: "too-large"}} |
| + }) |
| + m, _, err := Load(ctx, s, pfx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := LiveEntriesWithinCap(m); err != nil { |
| + t.Fatalf("LiveEntriesWithinCap: %v", err) |
| + } |
| + if err := ValidateManifest(m, []string{pfx}); err != nil { |
| + t.Fatalf("ValidateManifest: %v", err) |
| + } |
| + p, err := Open(ctx, s, pfx) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + stage(t, p, "more.txt", "more") |
| + commit(t, p) |
| + const to = "instances/shop.test/abc/files/g9/" |
| + if _, err := CopyManifest(ctx, s, pfx, to, 0, nil); err != nil { |
| + t.Fatalf("CopyManifest: %v", err) |
| + } |
| + got, st, err := Restore(ctx, s, pfx, dir, nil) |
| + if err != nil { |
| + t.Fatalf("Restore: %v", err) |
| + } |
| + if st.Written != 2 || !slices.Equal(st.Skipped, []string{skipped}) || !got.Entries[dead].Deleted { |
| + t.Fatalf("restore: %+v", st) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(dir, "healthy.txt")); err != nil || string(b) != "fine" { |
| + t.Fatalf("healthy.txt: %q %v", b, err) |
| + } |
| + // A live path past the cap is still refused by the same rule. |
| + m.Entries[pathOfBytes(MaxPathBytes+3)] = liveEntry(m.Seq) |
| + if err := LiveEntriesWithinCap(m); !errors.Is(err, ErrTooDeep) { |
| + t.Fatalf("a live path past the cap: %v", err) |
| + } |
| +} |
diff --git a/internal/hostfiles/publisher.go b/internal/hostfiles/publisher.go| index 6fa47d036..17cc6e834 100644 |
| --- a/internal/hostfiles/publisher.go |
| +++ b/internal/hostfiles/publisher.go |
| @@ -57,11 +57,22 @@ func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, erro |
| return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil |
| } |
| -// manifestWithinCap holds every entry path of m to withinTreeCap, the cap |
| -// the readers apply: Open refuses to continue a manifest that fails it, |
| -// and Commit refuses to write one. |
| +// LiveEntriesWithinCap holds every live entry path of m to 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 tombstone or a |
| +// skipped entry is exempt: Restore writes neither, so a manifest written |
| +// before the cap that holds one past it stays readable and continuable. |
| +// A live entry past the cap is ErrTooDeep: no restore could recreate it. |
| +func LiveEntriesWithinCap(m Manifest) error { return manifestWithinCap(m) } |
| + |
| +// manifestWithinCap is LiveEntriesWithinCap. Open, OpenAt and Commit call |
| +// it by this name, which the root-read and record-write censuses pin. |
| func manifestWithinCap(m Manifest) error { |
| - for path := range m.Entries { |
| + for path, e := range m.Entries { |
| + if e.Deleted || e.Skipped != "" { |
| + continue |
| + } |
| if err := withinTreeCap(path); err != nil { |
| return err |
| } |
diff --git a/internal/hostfiles/restore.go b/internal/hostfiles/restore.go| index acf477529..3fc5af304 100644 |
| --- a/internal/hostfiles/restore.go |
| +++ b/internal/hostfiles/restore.go |
| @@ -264,13 +264,16 @@ func checkManifest(m Manifest, origins []string) error { |
| return fmt.Errorf("hostfiles: platform item %q: %w", name, err) |
| } |
| } |
| + // A live path past the cap: no restore could recreate it, so it is |
| + // refused before anything is touched (tombstones and skipped entries |
| + // are exempt; Restore writes neither). |
| + if err := LiveEntriesWithinCap(m); err != nil { |
| + return err |
| + } |
| for p, e := range m.Entries { |
| if _, err := CleanPath(p); err != nil { |
| return err |
| } |
| - if err := withinTreeCap(p); err != nil { |
| - return err // no restore could recreate it: refused before anything is touched |
| - } |
| if err := checkEntry(e, origins); err != nil { |
| return fmt.Errorf("hostfiles: %s: %w", p, err) |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 1c7a2443b..79105e458 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -691,6 +691,12 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-census-ruling-275.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay hostfiles-cap-236 (amadan #236): envelope-only, for the |
| + // hostfiles path cap applying to live entries only. No root-read unit |
| + // changed or was added, so the legacy seal above stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv b/internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv| new file mode 100644 |
| index 000000000..1e1c1c83a |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv |
| @@ -0,0 +1,2 @@ |
| +# Plan 14 census overlay hostfiles-cap-236 (amadan #236, owner region HA, basis moves' review of a8cebad): the hostfiles path cap applies to live entries only (LiveEntriesWithinCap). Tombstones and skipped entries past the cap are exempt, since Restore writes neither, so a manifest written before the cap stays readable and a moves recovery that selects it is not stranded. manifestWithinCap's body and checkManifest change; Open, OpenAt and Commit are byte-identical. Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added, so the legacy seal stands. |
| +@coverage internal/hostfiles 1b7630f1bd066784e51a5b9cbb4d300a4c590fbaae4b08d43013373e9d50c65e |