host: local validates the journalled prepared-manifest binding before skipping the build (Task 6 fix round 1, finding 3)
preparedManifestJournalled replaces filesManifestBuilt: the step counts as done only with this install's binding (decodePreparedManifest: P's key, the op, the captured stamp, a digest and a version). The legacy "skipped" value still counts as not done and is rebuilt. An empty or malformed value, or a binding for another key, op or stamp, is an error: the install neither advances over it nor rebuilds over it, and the journal keeps the value. Test: a replay over each damaged value leaves the record at local, writes nothing under the location's files prefix, keeps the value and never reaches files_gen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+77
−7
internal/host/movesinstall.go+22 −7internal/host/movesland_test.go+55 −0
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index 6a72ed6ce..72c5d5293 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -293,13 +293,24 @@ func (installAuthorityStore) DeleteVersion(context.Context, string, string, stri |
| return fmt.Errorf("%w: the prepared build never deletes", errLandingChanged) |
| } |
| -// filesManifestBuilt reports whether the journal records the prepared |
| -// files manifest as built: the step done with a binding, not the "skipped" |
| -// value journals written before C8 existed, which a replay builds (ruling |
| -// Q3 item 2; the step itself is create-only and replay-safe). |
| -func filesManifestBuilt(j *moves.Journal) bool { |
| +// preparedManifestJournalled reports whether the journal records the |
| +// prepared files manifest as built: the step done with this install's |
| +// binding (decodePreparedManifest: P's key, the op and the captured |
| +// stamp), not the "skipped" value journals written before C8 existed, |
| +// which a replay builds (ruling Q3 item 2; the step itself is create-only |
| +// and replay-safe). Any other value is an error (fix round 1, finding 3): |
| +// an empty or malformed step, or a binding for another key, op or stamp, |
| +// is a damaged or foreign journal, and the install neither advances over |
| +// it nor rebuilds over it. |
| +func preparedManifestJournalled(j *moves.Journal, key, op string, stamp uint64) (bool, error) { |
| v, ok := j.Steps[landStepFilesManifest] |
| - return ok && v != landingFilesManifestSkipped |
| + if !ok || v == landingFilesManifestSkipped { |
| + return false, nil |
| + } |
| + if _, err := decodePreparedManifest(v, key, op, stamp); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| } |
| // fault is the test seam after a side effect (the landing's points). |
| @@ -846,7 +857,11 @@ func (ir *installRun) local_(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| } |
| - if !filesManifestBuilt(j) { |
| + built, err := preparedManifestJournalled(j, hostfiles.ManifestKey(movesFilesPrefix(holder.Location, gen)), id, captured.Stamp) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !built { |
| // Spec rev 7.22 §7.6 (L17, R19-1; C8, Task 6): the prepared files |
| // manifest under files/g-<generation>/ in the location, bound to |
| // {location, g-<generation>, op, stamp}, where the generation is the |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 0c6477db3..a93862691 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -1406,6 +1406,61 @@ func TestSkippedManifestStepIsBuiltOnReplay(t *testing.T) { |
| } |
| } |
| +// A journalled files_manifest value that is not this install's binding |
| +// (fix round 1, finding 3): empty, malformed, or a binding for another op, |
| +// stamp or key. The install neither skips the build over it nor rebuilds |
| +// over it: the replay refuses, the record stays at local, nothing is |
| +// written under the files prefix and the journal keeps the value for an |
| +// operator to see. |
| +func TestLocalRefusesADamagedFilesManifestJournal(t *testing.T) { |
| + good := func(h *landingHarness) preparedManifest { |
| + c := h.claim(t) |
| + return preparedManifest{Key: hostfiles.ManifestKey(movesFilesPrefix(c.Holder.Location, p1ID)), SHA256: sha([]byte("x")), Version: "v1", Op: p1ID, Stamp: c.Holder.Stamp} |
| + } |
| + for name, plant := range map[string]func(h *landingHarness) string{ |
| + "empty": func(*landingHarness) string { return "" }, |
| + "malformed": func(*landingHarness) string { return `{"key":` }, |
| + "another op": func(h *landingHarness) string { pm := good(h); pm.Op = "mv_other"; return string(mustJSON(t, pm)) }, |
| + "another stamp": func(h *landingHarness) string { pm := good(h); pm.Stamp++; return string(mustJSON(t, pm)) }, |
| + "another key": func(h *landingHarness) string { |
| + pm := good(h) |
| + pm.Key = hostfiles.ManifestKey(movesFilesPrefix(h.claim(t).Holder.Location, "mv_other")) |
| + return string(mustJSON(t, pm)) |
| + }, |
| + "no version": func(h *landingHarness) string { pm := good(h); pm.Version = ""; return string(mustJSON(t, pm)) }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.landAt = "after-verify" |
| + h.crashLand(t) |
| + value := plant(h) |
| + j := h.landJournal(t) |
| + j.Mark(landStepFilesManifest, value) |
| + if err := j.Commit(h.tdir); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.freshTarget() |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandLocal || m.Reason != "" { |
| + t.Fatalf("the record moved over a damaged journal: %+v", m) |
| + } |
| + j = h.landJournal(t) |
| + if got := j.Steps[landStepFilesManifest]; got != value { |
| + t.Fatalf("the journal value changed: %q", got) |
| + } |
| + if _, done := j.Steps[landStepFilesGen]; done { |
| + t.Fatal("the install went on past the damaged step") |
| + } |
| + for k := range h.f.buckets["data-uk"] { |
| + if strings.HasPrefix(k, cutRoot+"/files/") { |
| + t.Fatalf("written over a damaged journal: %s", k) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| // A cross-region landing's prepared manifest names destination-local |
| // objects it materialized from the sealed archive (claim P6-D3): every |
| // object lies under the manifest's own prefix in the target's bucket, and |