settings-2 task 7: a move's held stage installs only §4.1-authorized builds
StageHeld asks HeldAuthorized first: a signed promotion record (its digest bound at staging), else a verified, ratchet-admitted pointer of the app that names the build. Anything else is ErrHeldNotAuthorized before a byte is downloaded; the root relay drops it (and ErrManifestDigest) for good. pub and root's ratchet reach the root pass; the agent's own held path (heldReleaseReady, ruling R5) gets the parsed release key and its ratchet, failing closed on a key that does not parse.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+497
−86
internal/host/agent.go+1 −1internal/host/heldstage.go+8 −2internal/host/heldstage_test.go+112 −8internal/host/movespass.go+12 −1internal/host/movesreport_test.go+12 −20internal/host/movesstage.go+10 −2internal/host/movesstage_proof_test.go+9 −6internal/release/heldauth_test.go+184 −0internal/release/heldverify_test.go+3 −3internal/release/materializeheld.go+81 −14internal/release/materializeheld_proof_test.go+26 −10internal/release/materializeheld_test.go+38 −18internal/release/stage_reuse_test.go+1 −1
diff --git a/internal/host/agent.go b/internal/host/agent.go| index c3afd0949..fabde926a 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4206,7 +4206,7 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu |
| // release adoption, on -stage-only and on the hourly pass (its floor). |
| // Reads nothing and writes nothing unless a request exists, and none |
| // exist while CARLOS_MOVES_PHASE1 is off. |
| - heldStagePass(ctx, dataDir(), release.OptDir(), reg, store, BoxLabel()) |
| + heldStagePass(ctx, dataDir(), release.OptDir(), pub, pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv), reg, store, BoxLabel()) |
| // Step 4: the advisories. The two capability advisories that fall out of |
| // READING manifests (declared-but-ungranted, and capabilities on an |
diff --git a/internal/host/heldstage.go b/internal/host/heldstage.go| index c7173cd8d..a7abb7f52 100644 |
| --- a/internal/host/heldstage.go |
| +++ b/internal/host/heldstage.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -194,9 +195,14 @@ func heldStageClaim(obs hostclaim.Observed, req release.HeldStageRequest) error |
| // 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) { |
| +// |
| +// Settings spec rev 11 §4.3a: each request stages only a build §4.1 |
| +// authorizes, a signed promotion record or a pointer of the app verified |
| +// under pub and admitted by r, root's pointer digest ratchet (the one |
| +// adoption uses). Anything else is dropped before a byte is downloaded. |
| +func heldStagePass(ctx context.Context, dataDir, optDir string, pub ed25519.PublicKey, r *release.DigestRatchet, reg *registry.Registry, store release.Store, box string) { |
| proof := release.NewProofGate(release.ProofPolicyPath, dataDir) |
| - if err := release.ServeHeldStageRequests(ctx, store, optDir, dataDir, proof, heldStageAuthority(reg, store, box, dataDir)); err != nil { |
| + if err := release.ServeHeldStageRequests(ctx, store, pub, r, optDir, dataDir, proof, heldStageAuthority(reg, store, box, dataDir)); err != nil { |
| log.Printf("adopt: held stage requests: %v", err) |
| } |
| retireEndedHeldRefusals(ctx, store, dataDir, box) |
diff --git a/internal/host/heldstage_test.go b/internal/host/heldstage_test.go| index 88aa68140..7dc1d46ab 100644 |
| --- a/internal/host/heldstage_test.go |
| +++ b/internal/host/heldstage_test.go |
| @@ -1,7 +1,9 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "os" |
| @@ -26,6 +28,28 @@ const ( |
| hsBox = "uk-box" |
| ) |
| +// hsPriv signs the held-stage tests' pointers and promotion records, and |
| +// hsPub verifies them: the release key a held stage is handed. |
| +var ( |
| + hsPriv = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize)) |
| + hsPub = hsPriv.Public().(ed25519.PublicKey) |
| +) |
| + |
| +// hsRatchet is a fresh, unarmed pointer digest ratchet: today's admission. |
| +func hsRatchet(t *testing.T) *release.DigestRatchet { |
| + return release.NewDigestRatchet(t.TempDir(), nil, false) |
| +} |
| + |
| +// promoteHeld promotes bab/app's version to canary/x with hsPriv, writing |
| +// its signed promotion record: a held stage installs only a build §4.1 |
| +// authorizes (settings spec rev 11 §4.3a). |
| +func promoteHeld(t *testing.T, s release.Store, app, version string) { |
| + t.Helper() |
| + if err := release.Promote(context.Background(), s, hsPriv, "bab", app, "canary/x", version); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| // hsClaim writes host's claim: held by bab/titogo, op as given. |
| func hsClaim(t *testing.T, s release.Store, op hostclaim.Op) { |
| t.Helper() |
| @@ -239,7 +263,7 @@ func (c *hsCountingStore) Put(ctx context.Context, key string, body []byte, ifMa |
| func TestHeldStagePassIsANoOpWithoutRequests(t *testing.T) { |
| dd, opt := t.TempDir(), t.TempDir() |
| s := &hsCountingStore{Store: release.NewDirStore(t.TempDir())} |
| - heldStagePass(context.Background(), dd, opt, testRegistry(t), s, hsBox) |
| + heldStagePass(context.Background(), dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| if s.n != 0 { |
| t.Fatalf("the pass made %d store calls", s.n) |
| } |
| @@ -257,10 +281,7 @@ func TestHeldStagePassServesAnAuthorizedRequest(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| hsClaim(t, s, movingOp()) |
| hsReport(t, s, hsOp, "v7") |
| - m := release.Manifest{App: "titogo", Version: "v7", Kind: "binary", Artifacts: map[string]release.Artifact{runtime.GOOS + "-" + runtime.GOARCH: {}}} |
| - if err := release.Ship(ctx, s, "bab", m, map[string][]byte{runtime.GOOS + "-" + runtime.GOARCH: []byte("pinned")}); err != nil { |
| - t.Fatal(err) |
| - } |
| + shipV7(t, s) |
| dd, opt := t.TempDir(), t.TempDir() |
| reqDir := release.HeldStageDir(dd) |
| if err := os.MkdirAll(reqDir, 0o755); err != nil { |
| @@ -277,7 +298,7 @@ func TestHeldStagePassServesAnAuthorizedRequest(t *testing.T) { |
| if err := os.WriteFile(filepath.Join(reqDir, stale.Host+".json"), raw, 0o644); err != nil { |
| t.Fatal(err) |
| } |
| - heldStagePass(ctx, dd, opt, testRegistry(t), s, hsBox) |
| + heldStagePass(ctx, dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| if got, err := release.LiveVersion(opt, hsHost); err != nil || got != "v7" { |
| t.Fatalf("LiveVersion = %q, %v", got, err) |
| } |
| @@ -293,7 +314,8 @@ func TestHeldStagePassServesAnAuthorizedRequest(t *testing.T) { |
| } |
| } |
| -// shipV7 ships bab/titogo v7 for this platform. |
| +// shipV7 ships bab/titogo v7 for this platform and promotes it to |
| +// canary/x, so a held stage of it is authorized. |
| func shipV7(t *testing.T, s release.Store) { |
| t.Helper() |
| plat := runtime.GOOS + "-" + runtime.GOARCH |
| @@ -301,6 +323,7 @@ func shipV7(t *testing.T, s release.Store) { |
| if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{plat: []byte("pinned")}); err != nil { |
| t.Fatal(err) |
| } |
| + promoteHeld(t, s, "titogo", "v7") |
| } |
| // TestHeldPublicationIsGuardedByTheAuthority: the authority that admitted a |
| @@ -356,7 +379,7 @@ func TestHeldPublicationIsGuardedByTheAuthority(t *testing.T) { |
| t.Fatalf("authority before staging: %v", err) |
| } |
| rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op} |
| - if _, err := release.StageHeld(ctx, s, opt, rt, req.Version); err != nil { |
| + if _, err := release.StageHeld(ctx, s, hsPub, hsRatchet(t), opt, rt, req.Version); err != nil { |
| t.Fatalf("StageHeld: %v", err) |
| } |
| tc.during(t, reg, s) |
| @@ -453,3 +476,84 @@ func TestHeldPublicationRechecksInsideTheFreeze(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// shipUnpromoted ships bab/app's version for this platform and promotes it |
| +// nowhere: no promotion record and no pointer authorizes it. |
| +func shipUnpromoted(t *testing.T, s release.Store, app, version string) { |
| + t.Helper() |
| + plat := runtime.GOOS + "-" + runtime.GOARCH |
| + m := release.Manifest{App: app, Version: version, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| + if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{plat: []byte("pinned")}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Settings spec rev 11 §4.3a, root's side: a request the move's authority |
| +// accepts, for a build no promotion record and no pointer authorizes, is |
| +// dropped before anything is downloaded, and live/<host> is never written. |
| +func TestHeldStagePassDropsAnUnauthorizedBuild(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + hsClaim(t, s, movingOp()) |
| + hsReport(t, s, hsOp, "v7") |
| + shipUnpromoted(t, s, "titogo", "v7") |
| + dd, opt := t.TempDir(), t.TempDir() |
| + reqDir := release.HeldStageDir(dd) |
| + if err := os.MkdirAll(reqDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(hsRequest()) |
| + if err := os.WriteFile(filepath.Join(reqDir, hsHost+".json"), raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + heldStagePass(ctx, dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| + if got, _ := release.LiveVersion(opt, hsHost); got != "" { |
| + t.Fatalf("an unauthorized build went live: %q", got) |
| + } |
| + if _, err := os.Stat(filepath.Join(opt, "versions")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an unauthorized build was staged: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(reqDir, hsHost+".json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the refused request was kept for a retry: %v", err) |
| + } |
| +} |
| + |
| +// Settings spec rev 11 §4.3a, the agent's own path (ruling R5): T7 stages |
| +// only a build §4.1 authorizes. A shipped, never-promoted build, or any |
| +// build when the release key did not parse (a nil pub), fails T7 with |
| +// nothing staged and nothing relayed to the root pass. |
| +func TestHeldReleaseReadyRefusesAnUnauthorizedBuild(t *testing.T) { |
| + ctx := context.Background() |
| + for name, tc := range map[string]struct { |
| + promote bool |
| + pub ed25519.PublicKey |
| + }{ |
| + "never promoted": {false, hsPub}, |
| + "no release key": {true, nil}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + rec := h.start(t, "manual") |
| + shipUnpromoted(t, h.s, "app", "v7") |
| + if tc.promote { |
| + promoteHeld(t, h.s, "app", "v7") |
| + } |
| + h.pass(t) |
| + data, opt := t.TempDir(), t.TempDir() |
| + relayed := false |
| + ready := heldReleaseReady(testRegistry(t), opt, data, "uk-box", tc.pub, hsRatchet(t), offProofGate(t), func(string, string, error) { relayed = true }) |
| + if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| + t.Fatalf("T7 = %+v, want %q", c, t7Failed) |
| + } |
| + if _, err := os.Stat(filepath.Join(opt, "versions")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an unauthorized build was staged: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(release.HeldStageDir(data), p1Host+".json")); !errors.Is(err, os.ErrNotExist) || relayed { |
| + t.Fatalf("an unauthorized build was relayed to the root pass (%v, %v)", err, relayed) |
| + } |
| + if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| + t.Fatalf("live = %q, want no live link", v) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 12f4ba48b..f744420b5 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -349,7 +349,18 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| // 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, |
| + // |
| + // Settings spec rev 11 §4.3a: the held stage installs only a build a |
| + // promotion record or a pointer verified under the release key, and |
| + // admitted by the agent's pointer digest ratchet, authorizes. A key |
| + // that does not parse leaves pub nil, and every held stage is |
| + // refused: fail closed. |
| + pub, perr := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if perr != nil { |
| + log.Printf("moves: held stages are refused: %v", perr) |
| + pub = nil |
| + } |
| + cfg.ReleaseReady = heldReleaseReady(reg, cfg.OptDir, dataDir, cfg.Box, pub, pointerRatchet(false, cfg.OptDir, dataDir, getenv), |
| release.NewProofGate(release.ProofPolicyPath, dataDir), stageRequester(dataDir)) |
| if svc != nil { |
| // S1 asks the instance itself, woken through the activator. |
diff --git a/internal/host/movesreport_test.go b/internal/host/movesreport_test.go| index 49f0109c5..b1c5ccc7a 100644 |
| --- a/internal/host/movesreport_test.go |
| +++ b/internal/host/movesreport_test.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/binary" |
| "encoding/json" |
| "errors" |
| @@ -11,7 +12,6 @@ import ( |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| - "runtime" |
| "strings" |
| "syscall" |
| "testing" |
| @@ -258,23 +258,19 @@ func TestPreparingRelaysHeldStageOnAHardenedBox(t *testing.T) { |
| ctx := context.Background() |
| h := newPhase1Harness(t) |
| h.start(t, "manual") |
| - plat := runtime.GOOS + "-" + runtime.GOARCH |
| - if err := release.Ship(ctx, h.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) |
| - } |
| + shipPinnedV7(t, h.s) |
| h.pass(t) // the source report names Release v7 |
| reg := testRegistry(t) |
| targetData, targetOpt := t.TempDir(), t.TempDir() |
| prev := heldMaterialize |
| t.Cleanup(func() { heldMaterialize = prev }) |
| - heldMaterialize = func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) { |
| + heldMaterialize = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, 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", offProofGate(t), stageRequester(targetData)) |
| + tc.ReleaseReady = heldReleaseReady(reg, targetOpt, targetData, "uk-box", hsPub, hsRatchet(t), 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 +289,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, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box", targetData)); err != nil { |
| + if err := release.ServeHeldStageRequests(ctx, h.s, hsPub, hsRatchet(t), targetOpt, targetData, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box", targetData)); err != nil { |
| t.Fatal(err) |
| } |
| if v, err := release.LiveVersion(targetOpt, p1Host); err != nil || v != "v7" { |
| @@ -313,7 +309,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, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box", targetData)); err != nil { |
| + if err := release.ServeHeldStageRequests(ctx, h.s, hsPub, hsRatchet(t), opt, targetData, offProofGate(t), heldStageAuthority(reg, h.s, "uk-box", targetData)); err != nil { |
| t.Fatal(err) |
| } |
| if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| @@ -412,15 +408,11 @@ func TestHeldReleaseReadyOnAWritableBox(t *testing.T) { |
| ctx := context.Background() |
| h := newPhase1Harness(t) |
| rec := h.start(t, "manual") |
| - plat := runtime.GOOS + "-" + runtime.GOARCH |
| - if err := release.Ship(ctx, h.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) |
| - } |
| + shipPinnedV7(t, h.s) |
| h.pass(t) |
| reg := testRegistry(t) |
| data, opt := t.TempDir(), t.TempDir() |
| - ready := heldReleaseReady(reg, opt, data, "uk-box", offProofGate(t), nil) |
| + ready := heldReleaseReady(reg, opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil) |
| if c := ready(ctx, h.s, rec, "v7"); !c.OK { |
| t.Fatalf("T7 = %+v", c) |
| } |
| @@ -431,12 +423,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, proof *release.ProofGate, guard func(func() error) error) (*release.Manifest, error) { |
| + heldMaterialize = func(ctx context.Context, s release.Store, pub ed25519.PublicKey, r *release.DigestRatchet, 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, proof, guard) |
| + return prev(ctx, s, pub, r, optDir, rt, v, proof, guard) |
| } |
| opt2 := t.TempDir() |
| - if c := heldReleaseReady(reg, opt2, data, "uk-box", offProofGate(t), nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| + if c := heldReleaseReady(reg, opt2, data, "uk-box", hsPub, hsRatchet(t), 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 +437,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", offProofGate(t), nil)(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| + if c := heldReleaseReady(reg, opt3, data, "uk-box", hsPub, hsRatchet(t), 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 cd053b20d..38c994733 100644 |
| --- a/internal/host/movesstage.go |
| +++ b/internal/host/movesstage.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "log" |
| @@ -44,7 +45,14 @@ var heldMaterialize = release.MaterializeHeld |
| // 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 { |
| +// |
| +// pub and r are §4.1's evidence (settings spec rev 11 §4.3a): the stage |
| +// installs only a build a promotion record or a pointer of the app, |
| +// verified under pub and admitted by r (the agent's pointer digest |
| +// ratchet), authorizes. Anything else is release.ErrHeldNotAuthorized, |
| +// before anything is downloaded or relayed, and T7 fails (t7Failed). A nil |
| +// pub verifies nothing, so every held stage is refused. |
| +func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, pub ed25519.PublicKey, r *release.DigestRatchet, 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 { |
| req := release.HeldStageRequest{Account: rec.Account, App: rec.App, Host: rec.Host, Version: version, Op: rec.Move.ID, Box: box} |
| auth := heldStageAuthority(reg, s, box, dataDir) |
| @@ -72,7 +80,7 @@ func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, proof |
| if c, done := verified(); done { |
| return c |
| } |
| - _, err := heldMaterialize(ctx, s, optDir, rt, version, proof, func(publish func() error) error { return auth(req, publish) }) |
| + _, err := heldMaterialize(ctx, s, pub, r, 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) |
diff --git a/internal/host/movesstage_proof_test.go b/internal/host/movesstage_proof_test.go| index ec532547b..e6c7b5614 100644 |
| --- a/internal/host/movesstage_proof_test.go |
| +++ b/internal/host/movesstage_proof_test.go |
| @@ -72,6 +72,8 @@ func (b *heldProofBox) prove(t *testing.T, s release.Store, app string, body []b |
| } |
| } |
| +// shipPinnedV7 ships bab/app v7 for this platform and promotes it to |
| +// canary/x, so a held stage of it is authorized. |
| func shipPinnedV7(t *testing.T, s release.Store) { |
| t.Helper() |
| plat := runtime.GOOS + "-" + runtime.GOARCH |
| @@ -79,6 +81,7 @@ func shipPinnedV7(t *testing.T, s release.Store) { |
| map[string][]byte{plat: []byte("pinned")}); err != nil { |
| t.Fatal(err) |
| } |
| + promoteHeld(t, s, "app", "v7") |
| } |
| // D3-held, through the target's report: on a box whose policy lists the |
| @@ -99,7 +102,7 @@ func TestHeldReleaseReadyAppliesTheProofGate(t *testing.T) { |
| 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)) |
| + tc.ReleaseReady = heldReleaseReady(testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), 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) |
| @@ -124,7 +127,7 @@ func TestHeldReleaseReadyAppliesTheProofGate(t *testing.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 { |
| + if c := heldReleaseReady(testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), box.gate(), nil)(ctx, h.s, rec, "v7"); !c.OK { |
| t.Fatalf("T7 = %+v", c) |
| } |
| if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| @@ -140,7 +143,7 @@ func TestHeldReleaseReadyAppliesTheProofGate(t *testing.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 { |
| + if c := heldReleaseReady(testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), box.gate(), nil)(ctx, h.s, rec, "v7"); !c.OK { |
| t.Fatalf("T7 = %+v", c) |
| } |
| if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| @@ -265,7 +268,7 @@ func TestT7RechecksAnExistingLink(t *testing.T) { |
| box.prove(t, h.s, "app", []byte("pinned")) |
| reg, data, opt := testRegistry(t), t.TempDir(), t.TempDir() |
| t7 := func() moves.Check { |
| - return heldReleaseReady(reg, opt, data, "uk-box", box.gate(), nil)(ctx, h.s, rec, "v7") |
| + return heldReleaseReady(reg, opt, data, "uk-box", hsPub, hsRatchet(t), box.gate(), nil)(ctx, h.s, rec, "v7") |
| } |
| if c := t7(); !c.OK { |
| t.Fatalf("T7 = %+v", c) |
| @@ -323,7 +326,7 @@ func TestT7WaitsOutAPublicationsRollback(t *testing.T) { |
| } |
| prev := heldMaterialize |
| t.Cleanup(func() { heldMaterialize = prev }) |
| - heldMaterialize = func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) { |
| + heldMaterialize = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) { |
| return nil, fmt.Errorf("%w: read-only", release.ErrStageBlocked) |
| } |
| swapped, rolledBack := make(chan struct{}), make(chan time.Time, 1) |
| @@ -343,7 +346,7 @@ func TestT7WaitsOutAPublicationsRollback(t *testing.T) { |
| } |
| }() |
| <-swapped |
| - c := heldReleaseReady(reg, opt, data, "uk-box", box.gate(), stageRequester(data))(ctx, h.s, rec, "v7") |
| + c := heldReleaseReady(reg, opt, data, "uk-box", hsPub, hsRatchet(t), box.gate(), stageRequester(data))(ctx, h.s, rec, "v7") |
| answered := time.Now() |
| if c.OK { |
| t.Fatalf("T7 passed on a link the publication rolled back: %+v", c) |
diff --git a/internal/release/heldauth_test.go b/internal/release/heldauth_test.go| new file mode 100644 |
| index 000000000..6f63d229b |
| --- /dev/null |
| +++ b/internal/release/heldauth_test.go |
| @@ -0,0 +1,184 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func heldAuthRoute(host string) registry.Route { |
| + return registry.Route{Host: host, Account: "bab", App: "km", HoldOp: "op-1"} |
| +} |
| + |
| +// nr is a fresh, unarmed ratchet: today's admission. |
| +func nr(t *testing.T) *DigestRatchet { return NewDigestRatchet(t.TempDir(), nil, false) } |
| + |
| +// Astra I1 #2: a pointer the box's ratchet refuses is not held-stage evidence. |
| +func TestHeldStageAppliesTheDigestRatchet(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { // a legacy, digestless pointer |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // so the pointer is the only evidence |
| + t.Fatal(err) |
| + } |
| + armed := NewDigestRatchet(t.TempDir(), nil, false) |
| + if err := armed.Admit("bab", &ChannelPointer{App: "km", Channel: "canary/x", Version: "v0", Digest: ManifestDigest([]byte("m"))}); err != nil { |
| + t.Fatal(err) // this box has seen canary/x with digests |
| + } |
| + if _, err := StageHeld(ctx, s, pub, armed, opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("a ratcheted channel's legacy pointer authorized a held stage: %v", err) |
| + } |
| + required := NewDigestRatchet(t.TempDir(), nil, true) |
| + if _, err := StageHeld(ctx, s, pub, required, opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("CARLOS_REQUIRE_POINTER_DIGEST did not apply to a held stage: %v", err) |
| + } |
| + if _, err := StageHeld(ctx, s, pub, nil, opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("a nil ratchet admitted pointer evidence: %v", err) |
| + } |
| +} |
| + |
| +func TestHeldStageRefusesABuildNothingAuthorizes(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + _, pub := testKeys(t) |
| + opt := t.TempDir() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("shipped, never promoted")) |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("StageHeld = %v; want ErrHeldNotAuthorized", err) |
| + } |
| + if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| + t.Fatal("an unauthorized build was staged") |
| + } |
| +} |
| + |
| +func TestHeldStageAcceptsARecordedBuildOutOfHistory(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + for i := 0; i < historyMax+2; i++ { |
| + v := "v" + string(rune('a'+i)) |
| + shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldAuthRoute("a.example.com"), "va"); err != nil { |
| + t.Fatalf("a recorded build out of the pointer's history: %v", err) |
| + } |
| +} |
| + |
| +func TestHeldStageAcceptsAPointerNamedBuildWithoutARecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // promoted before records |
| + t.Fatal(err) |
| + } |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldAuthRoute("a.example.com"), "v1"); err != nil { |
| + t.Fatalf("a build the signed pointer names: %v", err) |
| + } |
| +} |
| + |
| +func TestHeldStageBindsTheRecordDigest(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("promoted")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + for i := 0; i <= historyMax; i++ { // push v1 out of the pointer, so the record is the only evidence |
| + v := "w" + string(rune('a'+i)) |
| + shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these")) |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "km", "v1"), saved, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrManifestDigest) && !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("a reused name under a replayed record = %v", err) |
| + } |
| + if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| + t.Fatal("the reused name's bytes were staged") |
| + } |
| +} |
| + |
| +// Under a promotion record the held stage binds the record's digest (spec |
| +// §4.6): a copy staged while the canonical manifest cannot be read is |
| +// refused at staging, never published and never overwritten, where pointer |
| +// trust would have used it as staged and left it to D3's bind. |
| +func TestHeldStageRecordRefusesAStaleCopyItCannotCheck(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + stale := []byte("not the shipped bytes") |
| + down := stageStaleCopyKept(t, ctx, s, opt, "bab", "console", "v1", stale) |
| + _, err := MaterializeHeld(ctx, down, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), openGuard) |
| + if err == nil || errors.Is(err, ErrStageBlocked) { |
| + t.Fatalf("MaterializeHeld = %v; want the record-bound stage to refuse the copy", err) |
| + } |
| + gone(t, LiveLink(opt, heldTestHost)) |
| + if b, rerr := os.ReadFile(VersionPath(opt, "bab", "v1", "console")); rerr != nil || string(b) != string(stale) { |
| + t.Fatalf("staged v1 = %q, %v; want it untouched", b, rerr) |
| + } |
| +} |
| + |
| +// §4.1 refusals are final: the root relay removes a request for a build |
| +// nothing authorizes, or whose manifest does not hash to the digest its |
| +// record signs, writes no ack and never repoints live/<host>. |
| +func TestServeHeldStageRequestsDropsUnauthorizedBuilds(t *testing.T) { |
| + for name, setup := range map[string]func(t *testing.T, ctx context.Context, s Store) string{ |
| + "nothing authorizes it": func(t *testing.T, _ context.Context, s Store) string { |
| + shipRelease(t, s, "bab", "console", "v3", adoptPlatform, []byte("shipped, never promoted")) |
| + return "v3" |
| + }, |
| + "its manifest is not the recorded one": func(t *testing.T, ctx context.Context, s Store) string { |
| + raw, etag, err := s.Get(ctx, ManifestKey("bab", "console", "v1")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ManifestKey("bab", "console", "v1"), append(raw, '\n'), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + return "v1" |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + req := goodHeldRequest() |
| + req.Version = setup(t, ctx, s) |
| + dd := t.TempDir() |
| + path := writeHeldRequest(t, dd, req.Host+".json", req) |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + gone(t, path) |
| + gone(t, LiveLink(opt, heldTestHost)) |
| + gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| + if _, err := os.Stat(VersionPath(opt, "bab", req.Version, "console")); err == nil { |
| + t.Fatalf("%s was staged", req.Version) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/heldverify_test.go b/internal/release/heldverify_test.go| index 5d076faf9..62d214438 100644 |
| --- a/internal/release/heldverify_test.go |
| +++ b/internal/release/heldverify_test.go |
| @@ -83,7 +83,7 @@ func TestVerifyHeldLiveWaitsOutAFailedPublication(t *testing.T) { |
| during = nil |
| } |
| } |
| - _, err := MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, lock.guard) |
| + _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, lock.guard) |
| if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| t.Fatalf("the publication: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| } |
| @@ -116,7 +116,7 @@ func TestVerifyHeldLiveRefusesTheSwappedBytes(t *testing.T) { |
| } |
| during = VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", agent, passLock) |
| } |
| - if _, err := MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, openGuard); !errors.Is(err, ErrHeldNotProven) { |
| + if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, openGuard); !errors.Is(err, ErrHeldNotProven) { |
| t.Fatalf("the publication: %v", err) |
| } |
| if !errors.Is(during, ErrHeldNotProven) { |
| @@ -132,7 +132,7 @@ func TestVerifyHeldLiveRechecksAnExistingLink(t *testing.T) { |
| e := newHeldProofEnv(t) |
| e.require(t, "console") |
| e.prove(t, e.pinnedSHA) |
| - if _, err := MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, openGuard); err != nil || e.live() != "v1" { |
| + if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, openGuard); err != nil || e.live() != "v1" { |
| t.Fatalf("publish v1: %v, live %q", err, e.live()) |
| } |
| verify := func() error { |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index c36d618a0..01c3c1f50 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -2,11 +2,13 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "time" |
| @@ -42,14 +44,64 @@ var ErrHeldClaimWired = errors.New("release: held rows are not staged while CARL |
| // Returned before anything is published, so live/<host> is untouched. |
| var ErrHeldNotProven = errors.New("release: the held release is not proven for this box") |
| +// ErrHeldNotAuthorized is a held stage of a build §4.1 does not authorize: |
| +// no promotion record, and no verified pointer of the app names it (settings |
| +// spec rev 11 §4.3a). The request is dropped, never retried. |
| +var ErrHeldNotAuthorized = errors.New("release: the held release is not authorized: no promotion record and no signed pointer names it") |
| + |
| +// HeldAuthorized is §4.1 for a move's held stage. want is the digest staging |
| +// must bind: the record's, else the naming pointer entry's ("" for an entry |
| +// signed before digests, today's pointer trust). |
| +func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, acct, app, version string) (string, error) { |
| + rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version) |
| + if err == nil { |
| + return rec.ManifestDigest, nil |
| + } |
| + if !errors.Is(err, ErrNoPromotionRecord) { |
| + return "", err |
| + } |
| + prefix := ChannelsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return "", err |
| + } |
| + for _, k := range keys { |
| + name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + if !ok || name == "" { |
| + continue |
| + } |
| + p, err := Channel(ctx, s, pub, acct, app, name) |
| + if err != nil { |
| + continue |
| + } |
| + // The box's ratchet and CARLOS_REQUIRE_POINTER_DIGEST apply here as |
| + // they do to adoption (§4.6): a pointer the ratchet refuses is not |
| + // evidence. A nil ratchet would admit everything, so it is refused. |
| + if r == nil { |
| + return "", fmt.Errorf("%w: no pointer digest ratchet", ErrHeldNotAuthorized) |
| + } |
| + if rerr := r.Admit(acct, p); rerr != nil { |
| + continue |
| + } |
| + if p.Version == version || slices.Contains(p.History, version) { |
| + return p.DigestOf(version), nil |
| + } |
| + } |
| + return "", fmt.Errorf("%w: %s/%s at %s", ErrHeldNotAuthorized, acct, app, version) |
| +} |
| + |
| 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 |
| +// materializeHeldStage is MaterializeHeld's staging step, ensureStagedDigest |
| +// bound to the digest HeldAuthorized returned (want "" is ensureStaged |
| +// exactly), as a variable so a test can make it fail the way a read-only |
| +// mount does. |
| +var materializeHeldStage = func(ctx context.Context, s Store, optDir, acct, app, ver, host, want string) (*Manifest, error) { |
| + return ensureStagedDigest(ctx, s, optDir, acct, app, ver, host, want, stageCheck{}) |
| +} |
| // heldBeforeSwap runs inside the publication between bind and the link |
| // swap. A test seam: the window a replaced staged file lands in. |
| @@ -132,7 +184,11 @@ func VerifyHeldLive(ctx context.Context, s Store, optDir string, rt registry.Rou |
| // 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) { |
| +// |
| +// pub and r are §4.1's evidence for the stage (StageHeld): a build no |
| +// signed promotion record and no ratchet-admitted pointer of the app |
| +// authorizes is ErrHeldNotAuthorized, before anything is downloaded. |
| +func MaterializeHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, 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 |
| } |
| @@ -147,7 +203,7 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro |
| if err != nil { |
| return nil, err |
| } |
| - m, err := StageHeld(ctx, s, optDir, rt, version) |
| + m, err := StageHeld(ctx, s, pub, r, optDir, rt, version) |
| if err != nil { |
| return nil, err |
| } |
| @@ -161,11 +217,20 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro |
| // published: the version lands in the version store and live/<host> is |
| // left alone. It refuses while CARLOS_HOST_CLAIM is on, and a refused write |
| // is ErrStageBlocked wrapping the cause. |
| -func StageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| +// |
| +// It stages only a build §4.1 authorizes (settings spec rev 11 §4.3a): |
| +// HeldAuthorized, under pub and the box's ratchet r, answers first, and the |
| +// staging binds the digest it returns. A build nothing authorizes is |
| +// ErrHeldNotAuthorized, and nothing is downloaded. |
| +func StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| if err := heldStageable(rt, version); err != nil { |
| return nil, err |
| } |
| - m, err := materializeHeldStage(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host) |
| + want, err := HeldAuthorized(ctx, s, pub, r, RouteAccount(rt), rt.App, version) |
| + if err != nil { |
| + return nil, err |
| + } |
| + m, err := materializeHeldStage(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host, want) |
| if err != nil { |
| if StageBlocked(err) { |
| return nil, fmt.Errorf("%w: %w", ErrStageBlocked, err) |
| @@ -432,8 +497,10 @@ const heldStageRequestMax = 4096 |
| // 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, that authorize refuses at |
| -// either point with ErrStaleHeldStage, or that D3 refuses (proof, as |
| -// MaterializeHeld applies it: ErrHeldNotProven) is removed with a log line, |
| +// either point with ErrStaleHeldStage, that D3 refuses (proof, as |
| +// MaterializeHeld applies it: ErrHeldNotProven), or that §4.1 does not |
| +// authorize (ErrHeldNotAuthorized, or a build that does not hash to its |
| +// authorizing digest: ErrManifestDigest) 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. |
| @@ -445,7 +512,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, proof *ProofGate, authorize func(req HeldStageRequest, then func() error) error) error { |
| +func ServeHeldStageRequests(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, 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) { |
| @@ -459,7 +526,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, proof, authorize); err != nil { |
| + if err := serveHeldStageRequest(ctx, s, pub, r, optDir, dataDir, reqDir, name, proof, authorize); err != nil { |
| return err |
| } |
| } |
| @@ -468,7 +535,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, proof *ProofGate, authorize func(HeldStageRequest, func() error) error) error { |
| +func serveHeldStageRequest(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, 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 { |
| @@ -498,11 +565,11 @@ func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir |
| } |
| rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op} |
| guard := func(publish func() error) error { return authorize(req, publish) } |
| - if _, err := MaterializeHeld(ctx, s, optDir, rt, req.Version, proof, guard); err != nil { |
| + if _, err := MaterializeHeld(ctx, s, pub, r, 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) { |
| + if errors.Is(err, ErrHeldNotProven) || errors.Is(err, ErrHeldNotAuthorized) || errors.Is(err, ErrManifestDigest) { |
| 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) |
diff --git a/internal/release/materializeheld_proof_test.go b/internal/release/materializeheld_proof_test.go| index e5a71a059..1c7fdbd61 100644 |
| --- a/internal/release/materializeheld_proof_test.go |
| +++ b/internal/release/materializeheld_proof_test.go |
| @@ -78,6 +78,22 @@ func (e *heldProofEnv) prove(t *testing.T, sha string) { |
| } |
| } |
| +// staleCopyDown is stageStaleCopyKept for v1: a copy of other bytes staged |
| +// while v1's canonical manifest cannot be read, so only D3's bind can catch |
| +// it. A held stage reaches bind on such a copy only on pointer trust (want |
| +// "", a build promoted before records, settings spec §4.1): under v1's |
| +// promotion record the stage binds the record's digest and refuses the copy |
| +// before bind (TestHeldStageRecordRefusesAStaleCopyItCannotCheck). So v1's |
| +// record is removed first, and the digestless canary/x pointer is the |
| +// stage's evidence. |
| +func (e *heldProofEnv) staleCopyDown(t *testing.T, stale []byte) Store { |
| + t.Helper() |
| + if err := e.store.Delete(e.ctx, PromotionRecordKey("bab", "console", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + return stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", stale) |
| +} |
| + |
| func (e *heldProofEnv) live() string { v, _ := LiveVersion(e.opt, heldTestHost); return v } |
| // materialize runs MaterializeHeld with a guard that records whether it |
| @@ -91,7 +107,7 @@ func (e *heldProofEnv) materialize(t *testing.T, s Store) (error, bool, string) |
| out bytes.Buffer |
| ) |
| withCapturedStdout(t, &out, func() { |
| - _, err = MaterializeHeld(e.ctx, s, e.opt, heldRoute(), "v1", e.gate, func(publish func() error) error { |
| + _, err = MaterializeHeld(e.ctx, s, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, func(publish func() error) error { |
| guarded = true |
| return publish() |
| }) |
| @@ -215,7 +231,7 @@ func TestHeldStageProofBindingRefusesOtherStagedBytes(t *testing.T) { |
| e.require(t, "console") |
| e.prove(t, e.pinnedSHA) |
| stale := []byte("not the shipped bytes") |
| - down := stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", stale) |
| + down := e.staleCopyDown(t, stale) |
| // bind runs inside the publication (fix round 1), so the guard is asked |
| // and the refusal comes from within it. |
| err, _, out := e.materialize(t, down) |
| @@ -234,7 +250,7 @@ func TestHeldStageProofBindingRefusesOtherStagedBytes(t *testing.T) { |
| // 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 { |
| + if _, err := StageHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1"); err != nil { |
| t.Fatal(err) |
| } |
| e.require(t, "console") |
| @@ -251,7 +267,7 @@ func TestHeldStageProofPublishHeldChecksToo(t *testing.T) { |
| // 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 { |
| + if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), 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 { |
| @@ -274,7 +290,7 @@ func TestServeHeldStageRequestsAppliesTheProofGate(t *testing.T) { |
| 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 { |
| + if err := ServeHeldStageRequests(e.ctx, e.store, heldPub, nr(t), e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| }) |
| @@ -291,7 +307,7 @@ func TestServeHeldStageRequestsAppliesTheProofGate(t *testing.T) { |
| 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 { |
| + if err := ServeHeldStageRequests(e.ctx, e.store, heldPub, nr(t), e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| }) |
| @@ -308,7 +324,7 @@ func (e *heldProofEnv) materializeWith(t *testing.T, guard func(publish func() e |
| out bytes.Buffer |
| ) |
| withCapturedStdout(t, &out, func() { |
| - _, err = MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, guard) |
| + _, err = MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, guard) |
| }) |
| return err, out.String() |
| } |
| @@ -376,7 +392,7 @@ func TestHeldStageProofRefusalCarriesTheMove(t *testing.T) { |
| e := newHeldProofEnv(t) |
| e.require(t, "console") |
| e.prove(t, e.pinnedSHA) |
| - down := stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", []byte("not the shipped bytes")) |
| + down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| e.materialize(t, down) |
| r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost) |
| if r == nil || r.MoveOp != heldTestOp || r.Kind != RefusalNotProven { |
| @@ -401,7 +417,7 @@ func TestHeldStageProofLeavesAnAdoptionRefusal(t *testing.T) { |
| t.Fatal(err) |
| } |
| before, _ := os.ReadFile(filepath.Join(RefusalDir(e.bake), heldTestHost+".json")) |
| - down := stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", []byte("not the shipped bytes")) |
| + down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| err, _, _ := e.materialize(t, down) |
| if !errors.Is(err, ErrHeldNotProven) { |
| t.Fatalf("MaterializeHeld: %v, want ErrHeldNotProven", err) |
| @@ -421,7 +437,7 @@ func TestHeldStageProofSuccessRetiresTheMovesRefusal(t *testing.T) { |
| e := newHeldProofEnv(t) |
| e.require(t, "console") |
| e.prove(t, e.pinnedSHA) |
| - down := stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", []byte("not the shipped bytes")) |
| + down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| e.materialize(t, down) |
| if r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost); r == nil { |
| t.Fatal("no refusal to retire") |
diff --git a/internal/release/materializeheld_test.go b/internal/release/materializeheld_test.go| index 25c7e45df..30f4fde86 100644 |
| --- a/internal/release/materializeheld_test.go |
| +++ b/internal/release/materializeheld_test.go |
| @@ -1,7 +1,9 @@ |
| package release |
| import ( |
| + "bytes" |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -19,16 +21,27 @@ const ( |
| heldTestOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| ) |
| +// heldPriv signs every held fixture's pointers and promotion records, and |
| +// heldPub verifies them: the release key a held stage is handed. |
| +var ( |
| + heldPriv = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)) |
| + heldPub = heldPriv.Public().(ed25519.PublicKey) |
| +) |
| + |
| // heldFixture ships two builds of bab/console and moves the stable channel |
| // to the newer one: the pinned release (v1) is deliberately NOT the head. |
| +// v1 was promoted to canary/x before that, so it has a promotion record and |
| +// a held stage of it is §4.1-authorized (settings spec rev 11 §4.3a). |
| func heldFixture(t *testing.T) (context.Context, Store, string) { |
| t.Helper() |
| ctx := context.Background() |
| s := NewDirStore(t.TempDir()) |
| - priv, _ := testKeys(t) |
| shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("pinned build")) |
| shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("channel head")) |
| - if err := Promote(ctx, s, priv, "bab", "console", "stable", "v2"); err != nil { |
| + if err := Promote(ctx, s, heldPriv, "bab", "console", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + if err := Promote(ctx, s, heldPriv, "bab", "console", "stable", "v2"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| return ctx, s, t.TempDir() |
| @@ -42,7 +55,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", offGate(t), openGuard) |
| + m, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), openGuard) |
| if err != nil { |
| t.Fatalf("MaterializeHeld: %v", err) |
| } |
| @@ -67,10 +80,10 @@ func TestMaterializeHeldStageBlocked(t *testing.T) { |
| ctx, s, opt := heldFixture(t) |
| orig := materializeHeldStage |
| t.Cleanup(func() { materializeHeldStage = orig }) |
| - materializeHeldStage = func(context.Context, Store, string, string, string, string, string) (*Manifest, error) { |
| + materializeHeldStage = func(context.Context, Store, string, 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", offGate(t), openGuard) |
| + _, err := MaterializeHeld(ctx, s, heldPub, nr(t), 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 +104,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", offGate(t), openGuard); !errors.Is(err, ErrHeldClaimWired) { |
| + if _, err := MaterializeHeld(ctx, s, heldPub, nr(t), 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 +167,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| rec := &heldRecordingStore{Store: s} |
| dd := t.TempDir() |
| called := false |
| - if err := ServeHeldStageRequests(ctx, rec, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, rec, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| if called || len(rec.calls) != 0 { |
| @@ -180,7 +193,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| } |
| return runThen(then) |
| } |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), authorize); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), authorize); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| gone(t, path) |
| @@ -209,7 +222,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, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, rec, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatalf("ServeHeldStageRequests: %v", err) |
| } |
| gone(t, path) |
| @@ -232,7 +245,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, offGate(t), passAuth(nil)); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(nil)); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, path) |
| @@ -254,7 +267,7 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| t.Fatal(err) |
| } |
| called := false |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, link) |
| @@ -271,8 +284,15 @@ func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| dd := t.TempDir() |
| req := goodHeldRequest() |
| req.Version = "v9" // never shipped |
| + // ...but authorized (a signed record), so the stage fails after |
| + // §4.1 lets it through, not at it: an unauthorized build is |
| + // dropped (TestServeHeldStageRequestsDropsUnauthorizedBuilds). |
| + if err := RootSigner(heldPriv).WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "console", Version: "v9", |
| + ManifestDigest: ManifestDigest([]byte("v9")), Channel: "canary/x"}); err != nil { |
| + t.Fatal(err) |
| + } |
| path := writeHeldRequest(t, dd, req.Host+".json", req) |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(nil)); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(nil)); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := os.Stat(path); err != nil { |
| @@ -287,7 +307,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, offGate(t), passAuth(func(r HeldStageRequest) { seen = append(seen, r) })); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), 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 +354,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", offGate(t), nil); err == nil { |
| + if _, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), nil); err == nil { |
| t.Fatal("MaterializeHeld published with no guard") |
| } |
| gone(t, LiveLink(opt, heldTestHost)) |
| @@ -349,8 +369,8 @@ func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| orig := materializeHeldStage |
| t.Cleanup(func() { materializeHeldStage = orig }) |
| staged := false |
| - materializeHeldStage = func(ctx context.Context, s Store, optDir, acct, app, ver, host string) (*Manifest, error) { |
| - m, err := orig(ctx, s, optDir, acct, app, ver, host) |
| + materializeHeldStage = func(ctx context.Context, s Store, optDir, acct, app, ver, host, want string) (*Manifest, error) { |
| + m, err := orig(ctx, s, optDir, acct, app, ver, host, want) |
| staged = true // the move is replaced while this runs |
| return m, err |
| } |
| @@ -361,7 +381,7 @@ func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| } |
| return runThen(then) |
| } |
| - _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1", offGate(t), func(publish func() error) error { |
| + _, err := MaterializeHeld(ctx, s, heldPub, nr(t), 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,7 +392,7 @@ func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| staged = false |
| dd := t.TempDir() |
| path := writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| - if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), authorize); err != nil { |
| + if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), authorize); err != nil { |
| t.Fatal(err) |
| } |
| gone(t, path) |
diff --git a/internal/release/stage_reuse_test.go b/internal/release/stage_reuse_test.go| index 3d45f7fa2..f0c60c6e2 100644 |
| --- a/internal/release/stage_reuse_test.go |
| +++ b/internal/release/stage_reuse_test.go |
| @@ -302,7 +302,7 @@ func TestStageHeldNeverRestagesADifferingCopy(t *testing.T) { |
| if err := StageArtifact(opt, "bab", "v1", "console", []byte("old v1")); err != nil { |
| t.Fatal(err) |
| } |
| - m, err := StageHeld(ctx, s, opt, heldRoute(), "v1") |
| + m, err := StageHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1") |
| if err == nil || m != nil { |
| t.Fatalf("StageHeld = %+v, %v; want a refusal", m, err) |
| } |