D3-held: the held stage applies D3's proof check as adoption does, and fails closed
MaterializeHeld, PublishHeld and ServeHeldStageRequests take D3's ProofGate and run main's ProofGate.admit before staging (so an unproven release is neither downloaded nor relayed to root) and ProofGate.bind on the staged bytes before publishing. A refusal is release.ErrHeldNotProven and live/<host> is untouched; the root relay drops the request; T7 reports t7NotProven (MV-N153, pending). A nil gate is refused. A policy that does not load refuses every held route. With no policy, or one that does not list the app, behaviour is unchanged. proofgate.go and adopt.go are not touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+598
−39
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −0internal/host/heldstage.go+6 −1internal/host/heldstage_test.go+1 −1internal/host/movespass.go+5 −1internal/host/movesreport.go+1 −0internal/host/movesreport_test.go+9 −9internal/host/movesstage.go+13 −2internal/host/movesstage_proof_test.go+145 −0internal/release/materializeheld.go+100 −11internal/release/materializeheld_proof_test.go+296 −0internal/release/materializeheld_test.go+20 −13
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index ffcd6f407..65002c84b 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -64,7 +64,7 @@ var movesCopyIDs = []string{ |
| "MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138", |
| "MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144", |
| "MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150", |
| - "MV-N151", "MV-N152", |
| + "MV-N151", "MV-N152", "MV-N153", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index c23bf82c7..af56be0d2 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -192,6 +192,7 @@ Columns: id, file, text, status. |
| | MV-N150 | internal/moves/readiness.go | the target checked release <release> and the source now runs <release> | pending | |
| | MV-N151 | internal/host/movesphase1.go | the files beside the database could not be archived | pending | |
| | MV-N152 | internal/moves/readiness.go | the cut budget could not be priced | pending | |
| +| MV-N153 | internal/host/movesreport.go | this box requires a proof for the source's release and has not accepted one | pending | |
| ## Plan 2 |
diff --git a/internal/host/heldstage.go b/internal/host/heldstage.go| index 23621d040..dfe5a201a 100644 |
| --- a/internal/host/heldstage.go |
| +++ b/internal/host/heldstage.go |
| @@ -182,8 +182,13 @@ func heldStageClaim(obs hostclaim.Observed, req release.HeldStageRequest) error |
| // for this root pass. A no-op that reads nothing unless a request exists, |
| // and none exist while CARLOS_MOVES_PHASE1 is off: only a move's target |
| // writes one. Never fatal. |
| +// |
| +// D3-held: each request passes D3 as adoption would, through a fresh gate |
| +// (this is a fresh root process, as for the adoption pass before it) whose |
| +// bake directory is the healthy history that pass reads. |
| func heldStagePass(ctx context.Context, dataDir, optDir string, reg *registry.Registry, store release.Store, box string) { |
| - if err := release.ServeHeldStageRequests(ctx, store, optDir, dataDir, heldStageAuthority(reg, store, box)); err != nil { |
| + proof := release.NewProofGate(release.ProofPolicyPath, dataDir) |
| + if err := release.ServeHeldStageRequests(ctx, store, optDir, dataDir, proof, heldStageAuthority(reg, store, box)); err != nil { |
| log.Printf("adopt: held stage requests: %v", err) |
| } |
| } |
diff --git a/internal/host/heldstage_test.go b/internal/host/heldstage_test.go| index a9e002782..c1744adde 100644 |
| --- a/internal/host/heldstage_test.go |
| +++ b/internal/host/heldstage_test.go |
| @@ -360,7 +360,7 @@ func TestHeldPublicationIsGuardedByTheAuthority(t *testing.T) { |
| t.Fatalf("StageHeld: %v", err) |
| } |
| tc.during(t, reg, s) |
| - err := release.PublishHeld(opt, rt, req.Version, func(publish func() error) error { return auth(req, publish) }) |
| + err := release.PublishHeld(ctx, s, opt, rt, req.Version, offProofGate(t), func(publish func() error) error { return auth(req, publish) }) |
| got, _ := release.LiveVersion(opt, hsHost) |
| if tc.stale { |
| if !errors.Is(err, release.ErrStaleHeldStage) || got != "" { |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 866dd4d61..a8bbd7bc3 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -258,7 +258,11 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.MeasureDisk = measureDiskIn(dataDir, time.Now) |
| cfg.MeasureS3 = measureS3(func(ctx context.Context) (lease.MirrorStore, error) { return w.mirror(ctx, hib.Region) }, hib.Bucket, time.Now) |
| if reg != nil { |
| - cfg.ReleaseReady = heldReleaseReady(reg, cfg.OptDir, dataDir, cfg.Box, stageRequester(dataDir)) |
| + // D3-held: the held path's own gate, for the life of the worker, so |
| + // its miss cache holds between passes; its bake directory is the |
| + // healthy history adoption reads. |
| + cfg.ReleaseReady = heldReleaseReady(reg, cfg.OptDir, dataDir, cfg.Box, |
| + release.NewProofGate(release.ProofPolicyPath, dataDir), stageRequester(dataDir)) |
| if svc != nil { |
| // S1 asks the instance itself, woken through the activator. |
| cfg.ReadyProbe = func(ctx context.Context, host, path string) moves.Check { |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index 20606fc52..7ddde5b17 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -45,6 +45,7 @@ const ( |
| t7NoRelease = "the source has not reported its release yet" // MV-N76 |
| t7Waiting = "waiting for the root stage pass" // MV-N75 |
| t7Failed = "this box could not stage the source's release" |
| + t7NotProven = "this box requires a proof for the source's release and has not accepted one" |
| t9NotThisMove = "the host claim is not held by this move" |
| t9Incarnation = "the host claim names another incarnation of this instance" |
| t9NoMoveJSON = "the staging lane has no move.json yet" |
diff --git a/internal/host/movesreport_test.go b/internal/host/movesreport_test.go| index cede77dc6..cd710b240 100644 |
| --- a/internal/host/movesreport_test.go |
| +++ b/internal/host/movesreport_test.go |
| @@ -269,12 +269,12 @@ func TestPreparingRelaysHeldStageOnAHardenedBox(t *testing.T) { |
| targetData, targetOpt := t.TempDir(), t.TempDir() |
| prev := heldMaterialize |
| t.Cleanup(func() { heldMaterialize = prev }) |
| - heldMaterialize = func(context.Context, release.Store, string, registry.Route, string, func(func() error) error) (*release.Manifest, error) { |
| + heldMaterialize = func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) { |
| return nil, fmt.Errorf("%w: %w", release.ErrStageBlocked, &os.PathError{Op: "mkdir", Path: "/opt/carlos/versions", Err: syscall.EROFS}) |
| } |
| tc := h.targetCfg() |
| tc.DataDir = targetData |
| - tc.ReleaseReady = heldReleaseReady(reg, targetOpt, targetData, "uk-box", stageRequester(targetData)) |
| + tc.ReleaseReady = heldReleaseReady(reg, targetOpt, targetData, "uk-box", offProofGate(t), stageRequester(targetData)) |
| h.targetPass(t, tc) |
| if f := failing(h.targetReport(t)); f[moves.CheckRelease] != t7Waiting { |
| t.Fatalf("T7 = %q, want %q", f[moves.CheckRelease], t7Waiting) |
| @@ -293,7 +293,7 @@ func TestPreparingRelaysHeldStageOnAHardenedBox(t *testing.T) { |
| // The root pass, with the relay's own authority, materializes it. |
| heldMaterialize = prev |
| - if err := release.ServeHeldStageRequests(ctx, h.s, targetOpt, targetData, heldStageAuthority(reg, h.s, "uk-box")); err != nil { |
| + if err := release.ServeHeldStageRequests(ctx, h.s, targetOpt, targetData, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box")); err != nil { |
| t.Fatal(err) |
| } |
| if v, err := release.LiveVersion(targetOpt, p1Host); err != nil || v != "v7" { |
| @@ -313,7 +313,7 @@ func TestPreparingRelaysHeldStageOnAHardenedBox(t *testing.T) { |
| if err := writeHeldStageRequest(targetData, bad); err != nil { |
| t.Fatal(err) |
| } |
| - if err := release.ServeHeldStageRequests(ctx, h.s, opt, targetData, heldStageAuthority(reg, h.s, "uk-box")); err != nil { |
| + if err := release.ServeHeldStageRequests(ctx, h.s, opt, targetData, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box")); err != nil { |
| t.Fatal(err) |
| } |
| if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| @@ -420,7 +420,7 @@ func TestHeldReleaseReadyOnAWritableBox(t *testing.T) { |
| h.pass(t) |
| reg := testRegistry(t) |
| data, opt := t.TempDir(), t.TempDir() |
| - ready := heldReleaseReady(reg, opt, data, "uk-box", nil) |
| + ready := heldReleaseReady(reg, opt, data, "uk-box", offProofGate(t), nil) |
| if c := ready(ctx, h.s, rec, "v7"); !c.OK { |
| t.Fatalf("T7 = %+v", c) |
| } |
| @@ -431,12 +431,12 @@ func TestHeldReleaseReadyOnAWritableBox(t *testing.T) { |
| // publication's authority refuses, nothing is published. |
| prev := heldMaterialize |
| t.Cleanup(func() { heldMaterialize = prev }) |
| - heldMaterialize = func(ctx context.Context, s release.Store, optDir string, rt registry.Route, v string, guard func(func() error) error) (*release.Manifest, error) { |
| + heldMaterialize = func(ctx context.Context, s release.Store, optDir string, rt registry.Route, v string, proof *release.ProofGate, guard func(func() error) error) (*release.Manifest, error) { |
| rewriteClaimOp(t, h.s, func(op *hostclaim.Op) { op.ID = p1ID2 }) |
| - return prev(ctx, s, optDir, rt, v, guard) |
| + return prev(ctx, s, optDir, rt, v, proof, guard) |
| } |
| opt2 := t.TempDir() |
| - if c := heldReleaseReady(reg, opt2, data, "uk-box", nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| + if c := heldReleaseReady(reg, opt2, data, "uk-box", offProofGate(t), nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| t.Fatalf("T7 when the op changed during staging = %+v", c) |
| } |
| if v, _ := release.LiveVersion(opt2, p1Host); v != "" { |
| @@ -445,7 +445,7 @@ func TestHeldReleaseReadyOnAWritableBox(t *testing.T) { |
| // Under another op from the start: refused before anything is staged. |
| heldMaterialize = prev |
| opt3 := t.TempDir() |
| - if c := heldReleaseReady(reg, opt3, data, "uk-box", nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| + if c := heldReleaseReady(reg, opt3, data, "uk-box", offProofGate(t), nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| t.Fatalf("T7 under another op = %+v", c) |
| } |
| if _, err := os.Stat(filepath.Join(opt3, "versions")); !errors.Is(err, os.ErrNotExist) { |
diff --git a/internal/host/movesstage.go b/internal/host/movesstage.go| index c51d72b60..9d826bba7 100644 |
| --- a/internal/host/movesstage.go |
| +++ b/internal/host/movesstage.go |
| @@ -27,7 +27,15 @@ var heldMaterialize = release.MaterializeHeld |
| // On a hardened box the stage is blocked, so a held-stage request is left |
| // for the root stage pass (release.ServeHeldStageRequests) and its marker |
| // touched; T7 passes once LiveVersion equals the pinned version. |
| -func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, requestStage func(host, ver string, err error)) func(context.Context, release.Store, provision.Record, string) moves.Check { |
| +// |
| +// proof is D3's gate (controller ruling D3-held): the held stage admits the |
| +// pinned release as adoption would, before staging and so before any |
| +// relay, and again on the staged bytes before publishing. A refusal is |
| +// t7NotProven and live/<host> is untouched. It is the held path's own gate, |
| +// never agentProofGate: a wait it records for a held host would sit on the |
| +// adoption waiting list, which the fast loop sweeps, for a host adoption |
| +// never visits. |
| +func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, proof *release.ProofGate, requestStage func(host, ver string, err error)) func(context.Context, release.Store, provision.Record, string) moves.Check { |
| return func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check { |
| if live, err := release.LiveVersion(optDir, rec.Host); err == nil && live == version { |
| return moves.Check{OK: true} |
| @@ -39,8 +47,11 @@ func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, reque |
| return moves.Check{Reason: t7Failed} |
| } |
| rt := registry.Route{Host: rec.Host, Kind: "instance", App: rec.App, Account: rec.Account} |
| - _, err := heldMaterialize(ctx, s, optDir, rt, version, func(publish func() error) error { return auth(req, publish) }) |
| + _, err := heldMaterialize(ctx, s, optDir, rt, version, proof, func(publish func() error) error { return auth(req, publish) }) |
| switch { |
| + case errors.Is(err, release.ErrHeldNotProven): |
| + logMoveStage(rec.Host, version, err) |
| + return moves.Check{Reason: t7NotProven} |
| case errors.Is(err, release.ErrStageBlocked): |
| if werr := writeHeldStageRequest(dataDir, req); werr != nil { |
| logMoveStage(rec.Host, version, werr) |
diff --git a/internal/host/movesstage_proof_test.go b/internal/host/movesstage_proof_test.go| new file mode 100644 |
| index 000000000..b751659fe |
| --- /dev/null |
| +++ b/internal/host/movesstage_proof_test.go |
| @@ -0,0 +1,145 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// offProofGate is D3's gate on a box with no proof policy: the held stage |
| +// behaves exactly as it did before D3-held. |
| +func offProofGate(t *testing.T) *release.ProofGate { |
| + t.Helper() |
| + return release.NewProofGate(filepath.Join(t.TempDir(), "proof-policy.json"), t.TempDir()) |
| +} |
| + |
| +// heldProofBox is a target box with a proof policy file and its bake |
| +// directory, for T7 under D3-held. |
| +type heldProofBox struct { |
| + policy, bake string |
| + prover ed25519.PrivateKey |
| + proverPub string |
| +} |
| + |
| +func newHeldProofBox(t *testing.T) *heldProofBox { |
| + t.Helper() |
| + seed, pub, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &heldProofBox{policy: filepath.Join(t.TempDir(), "proof-policy.json"), bake: t.TempDir(), prover: priv, proverPub: pub} |
| +} |
| + |
| +func (b *heldProofBox) require(t *testing.T, app string) { |
| + t.Helper() |
| + body := fmt.Sprintf(`{"schema":1,"require":[{"app":%q,"prover":"dev","key":%q}]}`, app, b.proverPub) |
| + if err := os.WriteFile(b.policy, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (b *heldProofBox) gate() *release.ProofGate { return release.NewProofGate(b.policy, b.bake) } |
| + |
| +func (b *heldProofBox) prove(t *testing.T, s release.Store, app string, body []byte) { |
| + t.Helper() |
| + sum := sha256.Sum256(body) |
| + p, err := release.SignProof(b.prover, release.Proof{App: app, Platform: release.AdoptPlatform(), SHA256: hex.EncodeToString(sum[:]), |
| + Version: "v7", Prover: "dev", ActiveSince: time.Now().Add(-time.Hour).UTC().Truncate(time.Second), ProvenAt: time.Now().UTC().Truncate(time.Second)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PutProof(context.Background(), s, "bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func shipPinnedV7(t *testing.T, s release.Store) { |
| + t.Helper() |
| + plat := runtime.GOOS + "-" + runtime.GOARCH |
| + if err := release.Ship(context.Background(), s, "bab", release.Manifest{App: "app", Version: "v7", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}, |
| + map[string][]byte{plat: []byte("pinned")}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// D3-held, through the target's report: on a box whose policy lists the |
| +// app, an unproven pinned release is T7's not-proven sentence in move |
| +// status, nothing is staged or relayed and live/<host> is untouched. With |
| +// a proof it goes live; with a policy that lists another app T7 is |
| +// unchanged. |
| +func TestHeldReleaseReadyAppliesTheProofGate(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("covered and unproven is refused", func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + shipPinnedV7(t, h.s) |
| + h.pass(t) |
| + box := newHeldProofBox(t) |
| + box.require(t, "app") |
| + data, opt := t.TempDir(), t.TempDir() |
| + tc := h.targetCfg() |
| + tc.DataDir = data |
| + tc.ReleaseReady = heldReleaseReady(testRegistry(t), opt, data, "uk-box", box.gate(), stageRequester(data)) |
| + h.targetPass(t, tc) |
| + if f := failing(h.targetReport(t)); f[moves.CheckRelease] != t7NotProven { |
| + t.Fatalf("T7 = %q, want %q", f[moves.CheckRelease], t7NotProven) |
| + } |
| + if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| + t.Fatalf("live = %q, want no live link", v) |
| + } |
| + if _, err := os.Stat(filepath.Join(opt, "versions")); !os.IsNotExist(err) { |
| + t.Fatalf("an unproven release was staged: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(release.HeldStageDir(data), p1Host+".json")); !os.IsNotExist(err) { |
| + t.Fatalf("an unproven release was relayed to the root pass: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("covered and proven publishes", func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + rec := h.start(t, "manual") |
| + shipPinnedV7(t, h.s) |
| + h.pass(t) |
| + box := newHeldProofBox(t) |
| + box.require(t, "app") |
| + box.prove(t, h.s, "app", []byte("pinned")) |
| + opt := t.TempDir() |
| + if c := heldReleaseReady(testRegistry(t), opt, t.TempDir(), "uk-box", box.gate(), nil)(ctx, h.s, rec, "v7"); !c.OK { |
| + t.Fatalf("T7 = %+v", c) |
| + } |
| + if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q, want v7", v) |
| + } |
| + }) |
| + |
| + t.Run("uncovered is unchanged", func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + rec := h.start(t, "manual") |
| + shipPinnedV7(t, h.s) |
| + h.pass(t) |
| + box := newHeldProofBox(t) |
| + box.require(t, "other") |
| + opt := t.TempDir() |
| + if c := heldReleaseReady(testRegistry(t), opt, t.TempDir(), "uk-box", box.gate(), nil)(ctx, h.s, rec, "v7"); !c.OK { |
| + t.Fatalf("T7 = %+v", c) |
| + } |
| + if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q, want v7", v) |
| + } |
| + }) |
| +} |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index 7567b833a..1c4d5bdd6 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -35,6 +35,18 @@ var ErrStaleHeldStage = errors.New("release: held stage request no longer matche |
| // here as it is to appconfig.RenderHeld). |
| var ErrHeldClaimWired = errors.New("release: held rows are not staged while CARLOS_HOST_CLAIM is on") |
| +// ErrHeldNotProven is a held stage D3 refused (controller ruling D3-held): |
| +// this box's proof policy lists the app (or does not load), and the pinned |
| +// release has neither a verified proof for its bytes nor a place in this |
| +// box's healthy history, or its staged bytes are not the proven ones. |
| +// Returned before anything is published, so live/<host> is untouched. |
| +var ErrHeldNotProven = errors.New("release: the held release is not proven for this box") |
| + |
| +var ( |
| + errHeldNoGuard = errors.New("release: a held publication needs its authority") |
| + errHeldNoProofGate = errors.New("release: a held stage needs its proof gate") |
| +) |
| + |
| // materializeHeldStage is MaterializeHeld's staging step, ensureStaged, as |
| // a variable so a test can make it fail the way a read-only mount does. |
| var materializeHeldStage = ensureStaged |
| @@ -45,12 +57,32 @@ var materializeHeldStage = ensureStaged |
| // bake-hold check and no restart. When this process cannot write optDir it |
| // returns ErrStageBlocked wrapping the cause and has written nothing to |
| // live/<host>. |
| -func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, guard func(publish func() error) error) (*Manifest, error) { |
| +// |
| +// proof is D3's gate, applied as adoption applies it (ProofGate.admit via |
| +// heldProofAdmit, ProofGate.bind in publishHeld): admit before staging, so an unproven release is neither |
| +// downloaded nor relayed to a root pass, and bind before publishing. A |
| +// refusal is ErrHeldNotProven and live/<host> is untouched. A nil proof is |
| +// refused, as a nil guard is. |
| +func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) (*Manifest, error) { |
| + if err := heldStageable(rt, version); err != nil { |
| + return nil, err |
| + } |
| + if guard == nil { |
| + return nil, errHeldNoGuard |
| + } |
| + if proof == nil { |
| + return nil, errHeldNoProofGate |
| + } |
| + proof.BeginPass() |
| + ptr, v, err := heldProofAdmit(ctx, s, optDir, rt, version, proof) |
| + if err != nil { |
| + return nil, err |
| + } |
| m, err := StageHeld(ctx, s, optDir, rt, version) |
| if err != nil { |
| return nil, err |
| } |
| - if err := PublishHeld(optDir, rt, version, guard); err != nil { |
| + if err := publishHeld(optDir, rt, version, proof, ptr, v, guard); err != nil { |
| return nil, err |
| } |
| return m, nil |
| @@ -81,12 +113,39 @@ func StageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, v |
| // an authority checked only before it could be stale by now: a move aborted |
| // or replaced meanwhile must never have live/<host> repointed for it. A nil |
| // guard is refused: nothing is published on a request's word alone. |
| -func PublishHeld(optDir string, rt registry.Route, version string, guard func(publish func() error) error) error { |
| +// |
| +// Before guard it runs D3 as adoption does (admit, then bind on the staged |
| +// bytes), so a release the box's proof policy does not admit is |
| +// ErrHeldNotProven and live/<host> is untouched. A nil proof is refused. |
| +func PublishHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) error { |
| + if err := heldStageable(rt, version); err != nil { |
| + return err |
| + } |
| + if guard == nil { |
| + return errHeldNoGuard |
| + } |
| + if proof == nil { |
| + return errHeldNoProofGate |
| + } |
| + proof.BeginPass() |
| + ptr, v, err := heldProofAdmit(ctx, s, optDir, rt, version, proof) |
| + if err != nil { |
| + return err |
| + } |
| + return publishHeld(optDir, rt, version, proof, ptr, v, guard) |
| +} |
| + |
| +// publishHeld is PublishHeld after admit: bind, then guard and PointLive. |
| +func publishHeld(optDir string, rt registry.Route, version string, proof *ProofGate, ptr *ChannelPointer, v proofVerdict, guard func(publish func() error) error) error { |
| if err := heldStageable(rt, version); err != nil { |
| return err |
| } |
| if guard == nil { |
| - return errors.New("release: a held publication needs its authority") |
| + return errHeldNoGuard |
| + } |
| + cur, _ := LiveVersion(optDir, rt.Host) |
| + if !proof.bind(optDir, rt, ptr, v, cur, proof.now()) { |
| + return fmt.Errorf("%w: %s at %s: its staged bytes are not the proven ones", ErrHeldNotProven, rt.Host, version) |
| } |
| return guard(func() error { |
| if err := PointLive(optDir, rt.Host, RouteAccount(rt), version, rt.App); err != nil { |
| @@ -99,6 +158,32 @@ func PublishHeld(optDir string, rt registry.Route, version string, guard func(pu |
| }) |
| } |
| +// heldProofAdmit is D3's cheap check (ProofGate.admit, unchanged) for a |
| +// held stage. A held row runs a pinned release, not a channel head, so the |
| +// pointer it is asked about carries only the version: admit and bind read |
| +// nothing else from it but PromotedAt, which is zero here (the sha cache |
| +// key, and a binding refusal's PointerPromotedAt, which no real pointer |
| +// matches, so the record never blocks a later adoption). |
| +// |
| +// One difference from adoption, on the closed side: a policy that does not |
| +// load refuses every held route. Adoption closes only named-unit routes |
| +// then, but a held route is built from a move's record or a relayed |
| +// request, whose unit binding this path cannot vouch for. |
| +func heldProofAdmit(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate) (*ChannelPointer, proofVerdict, error) { |
| + ptr := &ChannelPointer{Version: version} |
| + if _, perr := proof.Requires(rt.App); perr { |
| + _, err := proof.state() |
| + fmt.Printf(" %s: held stage of %s: %v; not staged\n", rt.Host, version, err) |
| + return nil, proofVerdict{}, fmt.Errorf("%w: %s at %s: %v", ErrHeldNotProven, rt.Host, version, err) |
| + } |
| + cur, _ := LiveVersion(optDir, rt.Host) |
| + v, ok := proof.admit(ctx, s, optDir, rt, ptr, cur) |
| + if !ok { |
| + return nil, proofVerdict{}, fmt.Errorf("%w: %s at %s", ErrHeldNotProven, rt.Host, version) |
| + } |
| + return ptr, v, nil |
| +} |
| + |
| // heldStageable is the admission StageHeld and PublishHeld share. |
| func heldStageable(rt registry.Route, version string) error { |
| if hostwrite.Enabled(os.Getenv) { |
| @@ -148,9 +233,10 @@ const heldStageRequestMax = 4096 |
| // equal to name and a valid host shape, valid account, app and version, a |
| // non-empty op and box), asks authorize, stages, asks authorize again with |
| // the publication as its then, writes the HeldStaged ack and removes the |
| -// request. A request that fails validation, or that authorize refuses at |
| -// either point with ErrStaleHeldStage, is removed with a log line, and |
| -// live/<host> is never repointed for it. A request whose staging, ack or |
| +// request. A request that fails validation, that authorize refuses at |
| +// either point with ErrStaleHeldStage, or that D3 refuses (proof, as |
| +// MaterializeHeld applies it: ErrHeldNotProven) is removed with a log line, |
| +// and live/<host> is never repointed for it. A request whose staging, ack or |
| // publication fails any other way is kept for the next pass. With no |
| // request directory it reads nothing and creates nothing. |
| // |
| @@ -161,7 +247,7 @@ const heldStageRequestMax = 4096 |
| // The directory is agent-owned and this runs as root, so every read, write |
| // and removal goes through instancefs: no symlink is followed at any |
| // component, and nothing but a regular file is read. |
| -func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string, authorize func(req HeldStageRequest, then func() error) error) error { |
| +func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string, proof *ProofGate, authorize func(req HeldStageRequest, then func() error) error) error { |
| reqDir := HeldStageDir(dataDir) |
| ents, err := os.ReadDir(reqDir) |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -175,7 +261,7 @@ func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string |
| if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") { |
| continue // a writer's temp file, or not a request |
| } |
| - if err := serveHeldStageRequest(ctx, s, optDir, dataDir, reqDir, name, authorize); err != nil { |
| + if err := serveHeldStageRequest(ctx, s, optDir, dataDir, reqDir, name, proof, authorize); err != nil { |
| return err |
| } |
| } |
| @@ -184,7 +270,7 @@ func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string |
| // serveHeldStageRequest serves one request file. It returns an error only |
| // when this build cannot do the work at all (instancefs unsupported). |
| -func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir, name string, authorize func(HeldStageRequest, func() error) error) error { |
| +func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir, name string, proof *ProofGate, authorize func(HeldStageRequest, func() error) error) error { |
| drop := func(format string, args ...any) error { |
| fmt.Printf(" held stage request %q: "+format+"; removed\n", append([]any{name}, args...)...) |
| if err := instancefs.RemoveBeneath(reqDir, name); err != nil { |
| @@ -214,10 +300,13 @@ func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir |
| } |
| rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App} |
| guard := func(publish func() error) error { return authorize(req, publish) } |
| - if _, err := MaterializeHeld(ctx, s, optDir, rt, req.Version, guard); err != nil { |
| + if _, err := MaterializeHeld(ctx, s, optDir, rt, req.Version, proof, guard); err != nil { |
| if errors.Is(err, ErrStaleHeldStage) { |
| return drop("refused at publication: %v", err) |
| } |
| + if errors.Is(err, ErrHeldNotProven) { |
| + return drop("refused: %v", err) |
| + } |
| fmt.Printf(" %s: held stage of %s for %s: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err) |
| return nil |
| } |
diff --git a/internal/release/materializeheld_proof_test.go b/internal/release/materializeheld_proof_test.go| new file mode 100644 |
| index 000000000..426ce872b |
| --- /dev/null |
| +++ b/internal/release/materializeheld_proof_test.go |
| @@ -0,0 +1,296 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// D3-held (controller ruling): a held stage applies D3 exactly as adoption |
| +// does (ProofGate.admit before staging, ProofGate.bind before publishing) |
| +// and fails closed. Where adoption would refuse, the held stage returns |
| +// ErrHeldNotProven before publishing and live/<host> is untouched; with no |
| +// policy, or a policy that does not list the app, nothing changes. |
| + |
| +// heldProofEnv is heldFixture with the held host already live on "v0" (so |
| +// "untouched" means something), a policy path, a bake directory, a prover |
| +// key and a gate. |
| +type heldProofEnv struct { |
| + ctx context.Context |
| + store Store |
| + opt, bake string |
| + policy string |
| + gate *ProofGate |
| + proverPub string |
| + prover ed25519.PrivateKey |
| + pinnedSHA string |
| +} |
| + |
| +func newHeldProofEnv(t *testing.T) *heldProofEnv { |
| + t.Helper() |
| + ctx, s, opt := heldFixture(t) |
| + if err := StageArtifact(opt, "bab", "v0", "console", []byte("earlier build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(opt, heldTestHost, "bab", "v0", "console"); err != nil { |
| + t.Fatal(err) |
| + } |
| + prover, _, pubB64 := proverKey(t) |
| + e := &heldProofEnv{ctx: ctx, store: s, opt: opt, bake: t.TempDir(), |
| + policy: filepath.Join(t.TempDir(), "proof-policy.json"), proverPub: pubB64, prover: prover, |
| + pinnedSHA: etagOf([]byte("pinned build"))} |
| + e.gate = NewProofGate(e.policy, e.bake) |
| + return e |
| +} |
| + |
| +func (e *heldProofEnv) writePolicy(t *testing.T, body string) { |
| + t.Helper() |
| + if err := os.WriteFile(e.policy, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (e *heldProofEnv) require(t *testing.T, app string) { |
| + e.writePolicy(t, fmt.Sprintf(`{"schema":1,"require":[{"app":%q,"prover":"dev","key":%q}]}`, app, e.proverPub)) |
| +} |
| + |
| +func (e *heldProofEnv) prove(t *testing.T, sha string) { |
| + t.Helper() |
| + p, err := SignProof(e.prover, Proof{App: "console", Platform: adoptPlatform, SHA256: sha, Version: "v1", Prover: "dev", |
| + ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC), ProvenAt: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", sha, "dev"), b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (e *heldProofEnv) live() string { v, _ := LiveVersion(e.opt, heldTestHost); return v } |
| + |
| +// materialize runs MaterializeHeld with a guard that records whether it |
| +// was asked to publish, and returns the error, whether the guard ran, and |
| +// what was printed. |
| +func (e *heldProofEnv) materialize(t *testing.T, s Store) (error, bool, string) { |
| + t.Helper() |
| + var ( |
| + err error |
| + guarded bool |
| + out bytes.Buffer |
| + ) |
| + withCapturedStdout(t, &out, func() { |
| + _, err = MaterializeHeld(e.ctx, s, e.opt, heldRoute(), "v1", e.gate, func(publish func() error) error { |
| + guarded = true |
| + return publish() |
| + }) |
| + }) |
| + return err, guarded, out.String() |
| +} |
| + |
| +func (e *heldProofEnv) refusedUntouched(t *testing.T, err error, guarded bool) { |
| + t.Helper() |
| + if !errors.Is(err, ErrHeldNotProven) { |
| + t.Fatalf("MaterializeHeld: %v, want ErrHeldNotProven", err) |
| + } |
| + if guarded { |
| + t.Fatal("the held stage asked its authority to publish an unproven release") |
| + } |
| + if e.live() != "v0" { |
| + t.Fatalf("live = %q, want v0 untouched", e.live()) |
| + } |
| +} |
| + |
| +func TestHeldStageProofCoveredUnprovenIsRefused(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + err, guarded, out := e.materialize(t, e.store) |
| + e.refusedUntouched(t, err, guarded) |
| + if !strings.Contains(out, "is not proven on dev") { |
| + t.Fatalf("printed %q, want adoption's not-proven line", out) |
| + } |
| + // admit runs before staging: an unproven build is never downloaded. |
| + if _, err := os.Stat(VersionPath(e.opt, "bab", "v1", "console")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("v1 was staged for an unproven held stage: %v", err) |
| + } |
| + // A missing proof is not a refusal record, as in adoption. |
| + if r, _ := ReadRefusal(e.bake, heldTestHost); r != nil { |
| + t.Fatalf("a missing proof wrote a refusal: %+v", r) |
| + } |
| +} |
| + |
| +func TestHeldStageProofBadProofIsRefused(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + other, _, _ := proverKey(t) |
| + p, err := SignProof(other, Proof{App: "console", Platform: adoptPlatform, SHA256: e.pinnedSHA, Version: "v1", Prover: "dev", |
| + ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC), ProvenAt: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := json.Marshal(p) |
| + if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", e.pinnedSHA, "dev"), b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + err, guarded, _ := e.materialize(t, e.store) |
| + e.refusedUntouched(t, err, guarded) |
| +} |
| + |
| +func TestHeldStageProofCoveredAdmittedPublishes(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + if err, guarded, _ := e.materialize(t, e.store); err != nil || !guarded { |
| + t.Fatalf("MaterializeHeld: %v (guarded %v)", err, guarded) |
| + } |
| + if e.live() != "v1" { |
| + t.Fatalf("live = %q, want the proven v1", e.live()) |
| + } |
| +} |
| + |
| +func TestHeldStageProofHealthyHistoryPublishes(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.pinnedSHA, Version: "v1", How: "settle", At: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cs := &proofReadCountingStore{Store: e.store} |
| + if err, _, _ := e.materialize(t, cs); err != nil { |
| + t.Fatalf("MaterializeHeld: %v", err) |
| + } |
| + if e.live() != "v1" || cs.proofGets() != 0 { |
| + t.Fatalf("live = %q, %d proof GETs; want v1 from the healthy history and no proof read", e.live(), cs.proofGets()) |
| + } |
| +} |
| + |
| +func TestHeldStageProofUncoveredIsUnchanged(t *testing.T) { |
| + for name, setup := range map[string]func(t *testing.T, e *heldProofEnv){ |
| + "no policy file": func(*testing.T, *heldProofEnv) {}, |
| + "policy lists another app": func(t *testing.T, e *heldProofEnv) { e.require(t, "other") }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + setup(t, e) |
| + cs := &proofReadCountingStore{Store: e.store} |
| + if err, guarded, _ := e.materialize(t, cs); err != nil || !guarded { |
| + t.Fatalf("MaterializeHeld: %v (guarded %v)", err, guarded) |
| + } |
| + if e.live() != "v1" { |
| + t.Fatalf("live = %q, want v1", e.live()) |
| + } |
| + if n := cs.proofGets(); n != 0 { |
| + t.Fatalf("%d proof GETs for an app no policy covers, want 0", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A policy that does not load is closed for every held route, named unit |
| +// or not: the held path cannot vouch for the route's unit binding. |
| +func TestHeldStageProofBrokenPolicyIsRefused(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.writePolicy(t, `{"schema":1,"require":[`) |
| + err, guarded, _ := e.materialize(t, e.store) |
| + e.refusedUntouched(t, err, guarded) |
| +} |
| + |
| +// bind: the manifest's sha256 is proven, but the staged file holds other |
| +// bytes. Refused before publishing, with adoption's not-proven record. |
| +func TestHeldStageProofBindingRefusesOtherStagedBytes(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + stale := []byte("not the shipped bytes") |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + err, guarded, out := e.materialize(t, e.store) |
| + e.refusedUntouched(t, err, guarded) |
| + if !strings.Contains(out, "not pointed live") { |
| + t.Fatalf("printed %q, want the binding refusal", out) |
| + } |
| + r, _ := ReadRefusal(e.bake, heldTestHost) |
| + if r == nil || r.Kind != RefusalNotProven || !strings.Contains(r.Reason, etagOf(stale)) || r.RestoredVersion != "v0" { |
| + t.Fatalf("refusal = %+v, want a not-proven record naming the staged hash, still on v0", r) |
| + } |
| +} |
| + |
| +// PublishHeld called on its own (after StageHeld) runs the same check. |
| +func TestHeldStageProofPublishHeldChecksToo(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + if _, err := StageHeld(e.ctx, e.store, e.opt, heldRoute(), "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.require(t, "console") |
| + var out bytes.Buffer |
| + var err error |
| + withCapturedStdout(t, &out, func() { |
| + err = PublishHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, openGuard) |
| + }) |
| + if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| + t.Fatalf("PublishHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| + } |
| +} |
| + |
| +// A held stage with no gate cannot publish: D3 is not optional here. |
| +func TestHeldStageProofNeedsAGate(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + if _, err := MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", nil, openGuard); err == nil { |
| + t.Fatal("MaterializeHeld published with no proof gate") |
| + } |
| + if err := PublishHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", nil, openGuard); err == nil { |
| + t.Fatal("PublishHeld published with no proof gate") |
| + } |
| + if e.live() != "v0" { |
| + t.Fatalf("live = %q, want v0", e.live()) |
| + } |
| + if _, err := os.Stat(VersionPath(e.opt, "bab", "v1", "console")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("v1 was staged with no gate: %v", err) |
| + } |
| +} |
| + |
| +// The root relay: an unproven request is removed, never published and |
| +// never acknowledged; a proven one goes live. |
| +func TestServeHeldStageRequestsAppliesTheProofGate(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + dd := t.TempDir() |
| + path := writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + if err := ServeHeldStageRequests(e.ctx, e.store, e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + }) |
| + gone(t, path) |
| + gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| + if e.live() != "v0" { |
| + t.Fatalf("live = %q, want v0 untouched", e.live()) |
| + } |
| + if !strings.Contains(out.String(), "refused") { |
| + t.Fatalf("printed %q, want the refusal", out.String()) |
| + } |
| + |
| + e.prove(t, e.pinnedSHA) |
| + e.gate = NewProofGate(e.policy, e.bake) // a fresh root pass: no cached miss |
| + writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| + withCapturedStdout(t, &out, func() { |
| + if err := ServeHeldStageRequests(e.ctx, e.store, e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + }) |
| + if e.live() != "v1" { |
| + t.Fatalf("live = %q, want the proven v1", e.live()) |
| + } |
| +} |
diff --git a/internal/release/materializeheld_test.go b/internal/release/materializeheld_test.go| index 6510dc427..25c7e45df 100644 |
| --- a/internal/release/materializeheld_test.go |
| +++ b/internal/release/materializeheld_test.go |
| @@ -42,7 +42,7 @@ func heldRoute() registry.Route { |
| // pinned release, never the channel head, and LiveVersion reads it back. |
| func TestMaterializeHeldStagesThePinnedVersion(t *testing.T) { |
| ctx, s, opt := heldFixture(t) |
| - m, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", openGuard) |
| + m, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), openGuard) |
| if err != nil { |
| t.Fatalf("MaterializeHeld: %v", err) |
| } |
| @@ -70,7 +70,7 @@ func TestMaterializeHeldStageBlocked(t *testing.T) { |
| materializeHeldStage = func(context.Context, Store, string, string, string, string, string) (*Manifest, error) { |
| return nil, &os.PathError{Op: "mkdir", Path: filepath.Join(opt, "versions"), Err: errno} |
| } |
| - _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", openGuard) |
| + _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), openGuard) |
| if !errors.Is(err, ErrStageBlocked) || !errors.Is(err, errno) { |
| t.Fatalf("MaterializeHeld: %v, want ErrStageBlocked wrapping %v", err, errno) |
| } |
| @@ -91,7 +91,7 @@ func TestMaterializeHeldStageBlocked(t *testing.T) { |
| func TestMaterializeHeldRefusesWhileHostClaimIsWired(t *testing.T) { |
| ctx, s, opt := heldFixture(t) |
| t.Setenv("CARLOS_HOST_CLAIM", "1") |
| - if _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", openGuard); !errors.Is(err, ErrHeldClaimWired) { |
| + if _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), openGuard); !errors.Is(err, ErrHeldClaimWired) { |
| t.Fatalf("MaterializeHeld: %v, want ErrHeldClaimWired", err) |
| } |
| if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| @@ -154,7 +154,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| rec := &heldRecordingStore{Store: s} |
| dd := t.TempDir() |
| called := false |
| - if err := ServeHeldStageRequests(ctx, rec, opt, dd, passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, rec, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| if called || len(rec.calls) != 0 { |
| @@ -180,7 +180,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| } |
| return runThen(then) |
| } |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, authorize); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), authorize); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| gone(t, path) |
| @@ -209,7 +209,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| tc.mutate(&req) |
| path := writeHeldRequest(t, dd, tc.file, req) |
| called := false |
| - if err := ServeHeldStageRequests(ctx, rec, opt, dd, passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, rec, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| gone(t, path) |
| @@ -232,7 +232,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| t.Fatal(err) |
| } |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, passAuth(nil)); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(nil)); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, path) |
| @@ -254,7 +254,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| t.Fatal(err) |
| } |
| called := false |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, link) |
| @@ -272,7 +272,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| req := goodHeldRequest() |
| req.Version = "v9" // never shipped |
| path := writeHeldRequest(t, dd, req.Host+".json", req) |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, passAuth(nil)); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(nil)); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := os.Stat(path); err != nil { |
| @@ -287,7 +287,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| req := goodHeldRequest() |
| path := writeHeldRequest(t, dd, req.Host+".json", req) |
| var seen []HeldStageRequest |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, passAuth(func(r HeldStageRequest) { seen = append(seen, r) })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(func(r HeldStageRequest) { seen = append(seen, r) })); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| if len(seen) != 2 || seen[0] != req || seen[1] != req { |
| @@ -334,7 +334,7 @@ func passAuth(note func(HeldStageRequest)) func(HeldStageRequest, func() error) |
| // refused after staging and before anything is published. |
| func TestMaterializeHeldNeedsAGuard(t *testing.T) { |
| ctx, s, opt := heldFixture(t) |
| - if _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", nil); err == nil { |
| + if _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), nil); err == nil { |
| t.Fatal("MaterializeHeld published with no guard") |
| } |
| gone(t, LiveLink(opt, heldTestHost)) |
| @@ -361,7 +361,7 @@ func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| } |
| return runThen(then) |
| } |
| - _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", func(publish func() error) error { |
| + _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), func(publish func() error) error { |
| return authorize(goodHeldRequest(), func() error { published = true; return publish() }) |
| }) |
| if !errors.Is(err, ErrStaleHeldStage) || published { |
| @@ -372,10 +372,17 @@ func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| staged = false |
| dd := t.TempDir() |
| path := writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, authorize); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), authorize); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, path) |
| gone(t, LiveLink(opt, heldTestHost)) |
| gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| } |
| + |
| +// offGate is a D3 gate on a box with no proof policy: the held stage's |
| +// behaviour is exactly what it was before D3-held. |
| +func offGate(t *testing.T) *ProofGate { |
| + t.Helper() |
| + return NewProofGate(filepath.Join(t.TempDir(), "proof-policy.json"), t.TempDir()) |
| +} |