settings-2-promotion-records
Squashed from settings-2-promotion-records into main.
51 files changed,
+2997
−149
apimode.go+41 −0docs/site/cli.md+3 −1docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json+23 −0docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2-R2.md+18 −0docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2.md+49 −0internal/console/apiauthorize.go+97 −0internal/console/apiauthorize_test.go+164 −0internal/console/authcli.go+5 −0internal/console/claim_invariant_fixtures_test.go+77 −38internal/console/claim_invariant_probes_read_test.go+3 −1internal/console/svcroutes_test.go+8 −2internal/console/testdata/claim-routes.txt+2 −0internal/edge/fleetapi_e2e_test.go+64 −0internal/edge/storedoor.go+28 −1internal/edge/storedoor_test.go+43 −0internal/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/hostops/mutation_classifier.go+16 −0internal/hostops/promotionrecord_classifier_test.go+54 −0internal/provision/plan9_b8_census_test.go+17 −5internal/release/authorize_record.go+61 −0internal/release/authorize_record_test.go+126 −0internal/release/authorize_test.go+5 −0internal/release/claim_app_writes.go+13 −0internal/release/heldauth_test.go+461 −0internal/release/heldverify_test.go+3 −3internal/release/materializeheld.go+145 −20internal/release/materializeheld_claim_test.go+78 −4internal/release/materializeheld_proof_test.go+26 −10internal/release/materializeheld_test.go+39 −19internal/release/promote_record_test.go+120 −0internal/release/promotion.go+184 −0internal/release/promotion_test.go+359 −0internal/release/prune.go+6 −0internal/release/ratchet.go+14 −1internal/release/record_lifecycle_test.go+53 −0internal/release/release.go+18 −0internal/release/stage_reuse_test.go+1 −1internal/release/trash.go+8 −0internal/updatewords/words.go+50 −0internal/updatewords/words_approved_test.go+79 −0internal/updatewords/words_authorize.go+57 −0internal/updatewords/words_test.go+46 −0main.go+8 −3releases_authorize.go+117 −0releases_authorize_test.go+44 −0
CI passed — run details
diff --git a/apimode.go b/apimode.go| index 67ecb8b8b..05cbf26f0 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -40,7 +40,9 @@ import ( |
| "github.com/carlosframework/platform/internal/dbdoor" |
| "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| // serviceSessionLabel stands in for the address in "using X as Y" lines |
| @@ -4753,3 +4755,42 @@ func (s *apiSession) revokeService(ctx context.Context, a apiAcct, name string) |
| } |
| return res.Matched, nil |
| } |
| + |
| +type apiAuthorizePlan struct { |
| + Version string `json:"version"` |
| + ManifestDigest string `json:"manifest_digest"` |
| + Artifacts map[string]release.Artifact `json:"artifacts"` |
| +} |
| + |
| +func (s *apiSession) authorizePlan(ctx context.Context, a apiAcct, app, version string) (apiAuthorizePlan, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize")) |
| + if err != nil { |
| + return apiAuthorizePlan{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiAuthorizePlan{}, apiErrorFromBody(status, body) |
| + } |
| + var p apiAuthorizePlan |
| + if err := json.Unmarshal(body, &p); err != nil || p.ManifestDigest == "" { |
| + return apiAuthorizePlan{}, fmt.Errorf("authorize: unreadable response") |
| + } |
| + return p, nil |
| +} |
| + |
| +func (s *apiSession) authorizeSign(ctx context.Context, a apiAcct, app, version, digest string) (already bool, err error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize"), map[string]string{"manifest_digest": digest}) |
| + if err != nil { |
| + return false, err |
| + } |
| + if status == http.StatusConflict { |
| + return false, errors.New(fmt.Sprintf(updatewords.Text("settings.authorize.changed"), version)) |
| + } |
| + if status != http.StatusOK { |
| + return false, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Result string `json:"result"` |
| + } |
| + _ = json.Unmarshal(body, &res) |
| + return res.Result == "already", nil |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 08bf235a5..aa73211c8 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -568,13 +568,15 @@ to run". |
| carlos releases --app hello |
| ``` |
| -There is one sub-verb. `carlos releases retention --keep 20` sets an ambient |
| +There are two sub-verbs. `carlos releases retention --keep 20` sets an ambient |
| prune policy, and `--dry-run` shows you what it would remove before you commit |
| to it. The safety list always wins over the number: channel pointers, |
| rollback history, tagged releases, anything inside the bake window, and each |
| box's own adopted versions are never pruned, so nothing promoted or recent |
| disappears by policy. `--off` goes back to keeping everything. |
| +`carlos releases authorize <app> <version>` is for operators. It shows a build's manifest and file digests and, when you type yes, signs a promotion record for exactly those bytes. Every promotion writes one; this is for a build promoted before records existed, so an instance can be pinned to it or recovered onto it on a box that does not already have it. |
| + |
| ### carlos version |
| `carlos version` prints the build id of the binary in front of you. It is the |
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json b/docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json| new file mode 100644 |
| index 000000000..0f81b0862 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json |
| @@ -0,0 +1,23 @@ |
| +{ |
| + "approved_by": "Paul", |
| + "approved_on": "2026-10-09", |
| + "entries": [ |
| + {"id": "settings.authorize.usage", "file": "internal/updatewords/words_authorize.go", "text": "usage: carlos releases authorize [--app <a>] [--account <sqid-or-name>] [--yes] <version>"}, |
| + {"id": "settings.authorize.flag-yes", "file": "internal/updatewords/words_authorize.go", "text": "sign without asking"}, |
| + {"id": "settings.authorize.flag-console", "file": "internal/updatewords/words_authorize.go", "text": "console origin to authorize through (API mode only; default: the only logged-in one)"}, |
| + {"id": "settings.authorize.intro", "file": "internal/updatewords/words_authorize.go", "text": "Authorize %s %s with this manifest:"}, |
| + {"id": "settings.authorize.manifest-row", "file": "internal/updatewords/words_authorize.go", "text": " manifest %s"}, |
| + {"id": "settings.authorize.artifact-row", "file": "internal/updatewords/words_authorize.go", "text": " %s %s"}, |
| + {"id": "settings.authorize.prompt", "file": "internal/updatewords/words_authorize.go", "text": "Sign a promotion record for exactly these bytes? Type yes to continue: "}, |
| + {"id": "settings.authorize.done", "file": "internal/updatewords/words_authorize.go", "text": "authorized %s %s: it can now be pinned and recovered on any box"}, |
| + {"id": "settings.authorize.already", "file": "internal/updatewords/words_authorize.go", "text": "%s %s already has a promotion record for these bytes; nothing to sign"}, |
| + {"id": "settings.authorize.aborted", "file": "internal/updatewords/words_authorize.go", "text": "nothing signed"}, |
| + {"id": "settings.authorize.changed", "file": "internal/updatewords/words_authorize.go", "text": "the manifest of %s changed since it was shown. Nothing was signed. Run the command again"}, |
| + {"id": "settings.authorize.root-only", "file": "internal/updatewords/words_authorize.go", "text": "authorizing a build needs the deployment's root key. A scoped key cannot do it"}, |
| + {"id": "settings.authorize.not-shipped", "file": "internal/updatewords/words_authorize.go", "text": "%s has not been shipped, or its manifest cannot be read"}, |
| + {"id": "settings.authorize.no-key", "file": "internal/updatewords/words_authorize.go", "text": "this console has no release key, so it cannot sign a promotion record"}, |
| + {"id": "settings.authorize.write-failed", "file": "internal/updatewords/words_authorize.go", "text": "could not confirm the promotion record was written. Run the command again to check"}, |
| + {"id": "settings.evidence.no-record", "file": "internal/updatewords/words_authorize.go", "text": "no promotion record: pick a newer build, or ask an operator to authorize this one"}, |
| + {"id": "settings.authorize.docs", "file": "internal/updatewords/words_authorize.go", "text": "`carlos releases authorize <app> <version>` is for operators. It shows a build's manifest and file digests and, when you type yes, signs a promotion record for exactly those bytes. Every promotion writes one; this is for a build promoted before records existed, so an instance can be pinned to it or recovered onto it on a box that does not already have it."} |
| + ] |
| +} |
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2-R2.md b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2-R2.md| new file mode 100644 |
| index 000000000..c243619de |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2-R2.md |
| @@ -0,0 +1,18 @@ |
| +Verdict: ACCEPT |
| + |
| +## Round-1 findings |
| + |
| +1. **ADDRESSED** — [internal/release/materializeheld.go:126](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/release/materializeheld.go:126) skips digestless entries on channels carrying digests and continues looking for evidence. The regression at `internal/release/heldauth_test.go:381` checks refusal and no downloads with the flag both off and on. |
| +2. **ADDRESSED** — [internal/release/materializeheld.go:107](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/release/materializeheld.go:107) calls `Admit` before checking whether the pointer names the requested version, following R15. Naming-pointer retry behavior remains intact; `ratchet.go:168` remembers observations before persistence. The two-pass replay regression is at `internal/release/heldauth_test.go:418`. |
| +3. **ADDRESSED** — [internal/edge/storedoor.go:451](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/edge/storedoor.go:451) grants only the exact five-segment promotion-record shape for the owning account, validating app and version. Listing, writes, and deletes remain denied. `internal/edge/fleetapi_e2e_test.go:628` exercises record-only authorization and digest-mismatch refusal through the actual door and fleet client. |
| +4. **ADDRESSED** — [internal/updatewords/words_authorize.go:43](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/updatewords/words_authorize.go:43) now reports an unconfirmed write and advises retrying, without claiming nothing was signed. |
| + |
| +## New findings |
| + |
| +None. No new Critical or Important problem found in the fix diff. |
| + |
| +## Out of scope |
| + |
| +None. |
| + |
| +Read-only source review of `98e3f164..2c3f0ad8`. Reviewed the reported test and mutation evidence; did not rerun tests or modify files. |
| \ No newline at end of file |
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2.md b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2.md| new file mode 100644 |
| index 000000000..be23fc33d |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-2.md |
| @@ -0,0 +1,49 @@ |
| +Verdict: REJECT |
| + |
| +1. **Important — [internal/release/materializeheld.go:108](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/release/materializeheld.go:108): A digested head admits an undigested history target.** |
| + `r.Admit` checks the head’s digest. If that passes but `p.DigestOf(version)` is empty, `HeldAuthorized` still returns success with no digest binding. |
| + |
| + **Failure:** A signed pointer has head `v2` with a digest and legacy history entry `v1` without one. `v1` has no promotion record. Replace its unsigned manifest and artifacts, then request a held stage of `v1`: the replacement bytes pass even with `CARLOS_REQUIRE_POINTER_DIGEST=true`. This bypasses the protection against undigested historical targets in §4.6. |
| + |
| + **Fix:** Reject a digestless history entry when its channel carries digests; continue looking for valid alternative evidence. Add a mixed-head/history regression test that checks both refusal and absence of downloaded artifacts. The existing ratchet test exercises only wholly digestless pointers. |
| + |
| +2. **Important — [internal/release/materializeheld.go:93](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/release/materializeheld.go:93): Verified pointers can escape the ratchet’s memory.** |
| + The version-name check skips `Admit` after successfully verifying a pointer that names another build. §4.6 requires remembering that the box has verified digests for that channel. |
| + |
| + **Failure:** While authorizing `v1` through channel B, the box verifies channel A’s digested pointer for legacy build `v2`, but records no mark. Later, a saved digestless A pointer is replayed alongside substituted `v2` bytes. With the fleet-wide flag off and no promotion record for this legacy build, a held stage of `v2` succeeds. |
| + |
| + **Fix:** Separate observing a verified digested pointer from selecting evidence for this request. Remember every such observation, preserving fail-closed behavior after persistence failures. Replace `TestHeldAuthorizedAdmitsOnlyNamingPointers`’s no-mark assertion with a two-pass replay test; its current assertion protects the wrong behavior. |
| + |
| +3. **Important — [internal/release/promotion.go:124](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/release/promotion.go:124): Fleet boxes cannot read promotion records.** |
| + The new reader requests `releases/<acct>/<app>/promoted/<version>`, but `internal/edge/storedoor.go:425` grants no reads under `releases/`. The fleet client converts the resulting 404 into `os.ErrNotExist`, which becomes `ErrNoPromotionRecord`. |
| + |
| + **Failure:** A validly authorized build outside pointer history cannot be staged on a box using the fleet HTTPS store. Explicitly authorizing it again cannot repair this. If a legacy pointer still names it, the reader instead falls back to weaker evidence despite the existing digest-bound record. |
| + |
| + **Fix:** Grant fleet read access to the exact promotion-record shape within its own account. Keep writes and other accounts denied. Add a test through the actual store door and fleet client, covering record-only authorization and digest-mismatch refusal. |
| + |
| +4. **Minor — [internal/updatewords/words_authorize.go:43](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records/internal/updatewords/words_authorize.go:43): “Nothing was signed” is substantively false after a write error.** |
| + Signing occurs before `Put`, and a failed response does not establish that storage rejected the write. |
| + |
| + **Failure:** Storage commits the authorization but the response times out. The console tells the member nothing was signed, although the build is now authorized. |
| + |
| + **Fix:** Report that authorization could not be confirmed, or verify the stored record before reporting the outcome. This is an accuracy issue, independent of copy style. |
| + |
| +## Rulings |
| + |
| +**R13:** Disagree with checking whether the pointer names the requested version *before* updating the ratchet. That contradicts §4.6’s “once verified” rule and produces finding 2. The distinction between transient failures and definitive refusals should remain. |
| + |
| +## Deferred minors that must be fixed before merge |
| + |
| +None of the ledger’s deferred minors independently warrants blocking merge. Findings 1–3 and their regression coverage do. |
| + |
| +Read-only source review against merge base `412ca787`; no files changed or tests executed. |
| +--- |
| + |
| +**Controller response (fix wave 1, commits 6f79b01d, 0fc74c11, 2c3f0ad8).** All four findings accepted; none rejected. |
| + |
| +- Finding 1 (Ruling R16): a naming pointer whose entry for the version has no digest, on a channel that carries digests, is not held-stage evidence; HeldAuthorized skips it and keeps looking. Cost if wrong: a legacy history build on a now-digested channel needs `carlos releases authorize` before a move can stage it. |
| +- Finding 2 (Ruling R15, revising R13's ordering): HeldAuthorized admits every verified pointer it reads through the ratchet, then selects evidence among those naming the version; R13's retry/skip split stands for naming pointers. Cost if wrong: an unrelated channel's mark is written during a held stage, as adoption would write it. |
| +- Finding 3 (Ruling R17): the fleet store door grants GET of exactly `releases/<sqid>/<app>/promoted/<version>` for the box's own account; no listing, no write, nothing else under `releases/`. Cost if wrong: a box can read its own account's signed build digests (no secret). |
| +- Finding 4 (Ruling R18): the `settings.authorize.write-failed` draft no longer claims nothing was signed. Still a draft under copy review. |
| + |
| +Scoped re-review: CODEX-FINDINGS-SETTINGS-IMPL-2-R2.md (ACCEPT). |
diff --git a/internal/console/apiauthorize.go b/internal/console/apiauthorize.go| new file mode 100644 |
| index 000000000..4f8f9d17d |
| --- /dev/null |
| +++ b/internal/console/apiauthorize.go |
| @@ -0,0 +1,97 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// handleAPIAuthorize is `carlos releases authorize` (settings spec §4.1). GET |
| +// shows the digests; POST signs a promotion record for exactly the digest the |
| +// person confirmed, if the manifest still hashes to it. The console signs |
| +// with its root key. |
| +// |
| +// Operator-only (R19). An authorize record makes a shipped build installable |
| +// on every channel, outside the pipeline's promotions, so it takes the |
| +// platform operator's authority: the same people who roll the platform, an |
| +// owner of the ops account or an address on SystemOperators. The door opens |
| +// with apiSystemRoller, whose refusal is its own bare 404, before any account |
| +// lookup, so a member who is not an operator learns nothing (R20). The |
| +// operator must then also be a member of the app's account (apiAccountApp, |
| +// whose 404 stands); an operator outside that account authorizes with the |
| +// root key against the bucket (direct-bucket `carlos releases authorize`). |
| +// |
| +// Person-only: a credential the app holds must not vouch for builds. The |
| +// POST draws on the write budget, like resign, before any store work; the GET |
| +// is a read and does not. |
| +func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) { |
| + if _, _, ok := s.apiSystemRoller(w, r); !ok { |
| + return |
| + } |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if r.Method == http.MethodPost { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, textTooManyRequests) |
| + return |
| + } |
| + } |
| + version := r.PathValue("version") |
| + if !release.ValidVersion(version) { |
| + jsonError(w, http.StatusBadRequest, "invalid version") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| + defer cancel() |
| + plan, err := release.PlanAuthorize(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| + jsonError(w, http.StatusNotFound, err.Error()) |
| + return |
| + } |
| + if r.Method == http.MethodGet { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "version": plan.Version, "manifest_digest": plan.ManifestDigest, "artifacts": plan.Artifacts, |
| + }) |
| + return |
| + } |
| + var req struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || req.ManifestDigest != plan.ManifestDigest { |
| + jsonError(w, http.StatusConflict, release.ErrAuthorizeChanged.Error()) |
| + return |
| + } |
| + if len(s.cfg.Key) == 0 { |
| + jsonError(w, http.StatusServiceUnavailable, updatewords.Text("settings.authorize.no-key")) |
| + return |
| + } |
| + already, err := release.RootSigner(s.cfg.Key).Authorize(ctx, s.cfg.Releases, plan, addr) |
| + switch { |
| + case errors.Is(err, release.ErrAuthorizeChanged): |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + case err != nil: |
| + log.Printf("console: api authorize %s/%s %s: %v", acct.Sqid, app, version, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.authorize.write-failed")) |
| + return |
| + } |
| + result := "authorized" |
| + if already { |
| + result = "already" |
| + } else { |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "authorize", TargetType: "release", Target: version, |
| + Detail: "signed a promotion record for " + version + " (manifest " + plan.ManifestDigest[:12] + ") (cli)", |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{"result": result}) |
| +} |
diff --git a/internal/console/apiauthorize_test.go b/internal/console/apiauthorize_test.go| new file mode 100644 |
| index 000000000..3dd33d591 |
| --- /dev/null |
| +++ b/internal/console/apiauthorize_test.go |
| @@ -0,0 +1,164 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestAuthorizeDoorShowsThenSigns: an owner of the ops account (paul) reads |
| +// the plan and signs exactly the digest he confirmed (R19). |
| +func TestAuthorizeDoorShowsThenSigns(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.operator) |
| + if st != http.StatusOK { |
| + t.Fatalf("plan: %d %s", st, out) |
| + } |
| + var plan struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| + t.Fatalf("plan body %s: %v", out, err) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": "00"}, fx.operator) |
| + if st != http.StatusConflict { |
| + t.Fatalf("a confirmation for other bytes: %d %s", st, out) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator) |
| + if st != http.StatusOK { |
| + t.Fatalf("sign: %d %s", st, out) |
| + } |
| + if rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); err != nil || !rec.Authorized { |
| + t.Fatalf("record = %+v, %v", rec, err) |
| + } |
| +} |
| + |
| +func TestAuthorizeDoorRefusesServiceCredentials(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.serviceToken) |
| + if st != http.StatusNotFound && st != http.StatusForbidden { |
| + t.Fatalf("a service credential reached authorize: %d", st) |
| + } |
| +} |
| + |
| +// TestAuthorizeDoorSpendsTheWriteBudget: the POST signs a record inside the |
| +// app's exclusive mutation, so it draws on the per-person write budget, like |
| +// resign, before any store work. The GET is a read and stays unbudgeted. |
| +func TestAuthorizeDoorSpendsTheWriteBudget(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| + st, out := getRaw(t, u, fx.operator) |
| + if st != http.StatusOK { |
| + t.Fatalf("plan: %d %s", st, out) |
| + } |
| + var plan struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| + t.Fatalf("plan body %s: %v", out, err) |
| + } |
| + if st, out := postRaw(t, u, map[string]string{"manifest_digest": "00"}, fx.operator); st != http.StatusConflict { |
| + t.Fatalf("first request: %d %s", st, out) |
| + } |
| + if st, out := getRaw(t, u, fx.operator); st != http.StatusOK { |
| + t.Fatalf("a read after the budget is spent: %d %s", st, out) |
| + } |
| + if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests { |
| + t.Fatalf("second request over the budget: %d %s", st, out) |
| + } |
| + if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) { |
| + t.Fatalf("a refused request wrote a record: %v", err) |
| + } |
| +} |
| + |
| +// authorizeDigest is the digest `carlos releases authorize` would show for |
| +// km's v1, read straight from the store so a refused caller's POST can carry |
| +// the right confirmation. |
| +func authorizeDigest(t *testing.T, fx *digestsFixture) string { |
| + t.Helper() |
| + plan, err := release.PlanAuthorize(context.Background(), fx.store, "bab", "km", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return plan.ManifestDigest |
| +} |
| + |
| +// requireAuthorizeRefused: the caller gets the system-operator gate's bare |
| +// 404 on the GET and on a POST carrying the right digest, and no record is |
| +// written. |
| +func requireAuthorizeRefused(t *testing.T, fx *digestsFixture, bearer, who string) { |
| + t.Helper() |
| + u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| + if st, out := getRaw(t, u, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" { |
| + t.Fatalf("%s read the authorize plan: %d %s", who, st, out) |
| + } |
| + if st, out := postRaw(t, u, map[string]string{"manifest_digest": authorizeDigest(t, fx)}, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" { |
| + t.Fatalf("%s signed an authorization: %d %s", who, st, out) |
| + } |
| + if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) { |
| + t.Fatalf("a refused authorize wrote a record: %v", err) |
| + } |
| +} |
| + |
| +// requireAuthorizeSigns: the caller reads the plan and signs it, and the |
| +// record is an authorization of that digest. |
| +func requireAuthorizeSigns(t *testing.T, fx *digestsFixture, bearer, who string) { |
| + t.Helper() |
| + u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| + st, out := getRaw(t, u, bearer) |
| + if st != http.StatusOK { |
| + t.Fatalf("%s: plan: %d %s", who, st, out) |
| + } |
| + var plan struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| + t.Fatalf("%s: plan body %s: %v", who, out, err) |
| + } |
| + if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, bearer); st != http.StatusOK { |
| + t.Fatalf("%s: sign: %d %s", who, st, out) |
| + } |
| + rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1") |
| + if err != nil || !rec.Authorized || rec.ManifestDigest != plan.ManifestDigest { |
| + t.Fatalf("%s: record = %+v, %v", who, rec, err) |
| + } |
| +} |
| + |
| +// TestAuthorizeDoorIsNotAMembersDoor (R19): alice is a member of bab, the |
| +// app's account, but owns nothing in the ops account and is not on |
| +// SystemOperators. An authorize record makes a shipped build installable on |
| +// every channel, so she gets the system gate's bare 404 and writes nothing. |
| +func TestAuthorizeDoorIsNotAMembersDoor(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireAuthorizeRefused(t, fx, fx.member, "a plain member") |
| +} |
| + |
| +// TestAuthorizeDoorOpensToSystemOperators (R19): an address on |
| +// SystemOperators that is a member of bab but no ops owner can read and |
| +// sign; the same address with SystemOperators empty is a plain member again. |
| +func TestAuthorizeDoorOpensToSystemOperators(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.SystemOperators = nil |
| + requireAuthorizeRefused(t, fx, fx.member, "alice off SystemOperators") |
| + fx.srv.cfg.SystemOperators = []string{"alice@keymail.dev"} |
| + requireAuthorizeSigns(t, fx, fx.member, "alice on SystemOperators") |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 0ede8f772..f2012a7a6 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -528,6 +528,11 @@ func (s *Server) registerCLIRoutes() { |
| // manifest digests the console then signs into the channel's pointer, so |
| // no credential reaches it. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign) |
| + // `carlos releases authorize` (settings spec §4.1): GET shows a build's |
| + // digests, POST signs a promotion record for the digest a person |
| + // confirmed. A credential the app holds never vouches for a build. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| // with no version and no config to push — the same write-door class as |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index a4bfabbb2..25609419c 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -21,6 +21,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "net/http" |
| "net/url" |
| "strconv" |
| @@ -238,6 +239,22 @@ func (cx *claimCase) resignPlan(t *testing.T, channel string) { |
| cx.vars["digest"], cx.vars["history_digests"] = plan.Digest, string(hd) |
| } |
| +// authorizePlan deletes the promotion record kass's v1 promotion wrote and |
| +// keeps the authorize plan's digest in vars, as `carlos releases authorize` |
| +// shows it before the person confirms. |
| +func (cx *claimCase) authorizePlan(t *testing.T) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if err := cx.rel.Delete(ctx, release.PromotionRecordKey("bab", "kass", "v1")); err != nil { |
| + t.Fatalf("clear kass v1's promotion record: %v", err) |
| + } |
| + plan, err := release.PlanAuthorize(ctx, cx.rel, "bab", "kass", "v1") |
| + if err != nil { |
| + t.Fatalf("stage an authorize plan: %v", err) |
| + } |
| + cx.vars["manifest_digest"] = plan.ManifestDigest |
| +} |
| + |
| func (cx *claimCase) trash(t *testing.T, app string) { |
| t.Helper() |
| cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/delete", "", http.StatusOK) |
| @@ -682,44 +699,46 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/apps/{account}/{app}/regions": claimRead("reads armed regions"), |
| "route GET /api/cli/apps/{account}/{app}/relay": claimRead("reads relay status and heartbeats"), |
| "route GET /api/cli/apps/{account}/{app}/releases": claimRead("lists releases"), |
| - "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"), |
| - "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"), |
| - "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"), |
| - "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"), |
| - "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"), |
| - "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"), |
| - "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"), |
| - "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"), |
| - "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"), |
| - "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"), |
| - "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"), |
| - "route GET /api/cli/services/{account}": claimRead("lists service credentials"), |
| - "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| - "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| - "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| - "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| - "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| - "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| - "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"), |
| - "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"), |
| - "route GET /api/cli/system/status": claimRead("reads platform release status"), |
| - "route GET /apps": claimRead("legacy redirect into the account"), |
| - "route GET /apps/{app}": claimRead("legacy redirect into the account"), |
| - "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"), |
| - "route GET /auth/cli": claimRead("renders the device-code approval page"), |
| - "route GET /auth/cli/tokens": claimRead("redirects to /settings"), |
| - "route GET /auth/cli/whoami": claimRead("names the bearer's principal"), |
| - "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"), |
| - "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"), |
| - "route GET /fleet": claimRead("operator fleet view"), |
| - "route GET /fleet/economics": claimRead("operator economics view"), |
| - "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"), |
| - "route GET /fleet/metrics": claimRead("operator box metrics"), |
| - "route GET /fleet/store": claimRead("operator entitlement queue page"), |
| - "route GET /healthz": claimRead("answers ok and the build"), |
| - "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"), |
| - "route GET /settings": claimRead("personal settings: the caller's own tokens"), |
| - "route GET /{$}": claimRead("the front door: redirects or renders sign-in"), |
| + "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimRead( |
| + "reads one build's manifest digests"), |
| + "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"), |
| + "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"), |
| + "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"), |
| + "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"), |
| + "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"), |
| + "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"), |
| + "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"), |
| + "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"), |
| + "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"), |
| + "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"), |
| + "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"), |
| + "route GET /api/cli/services/{account}": claimRead("lists service credentials"), |
| + "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| + "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| + "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| + "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| + "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| + "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| + "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"), |
| + "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"), |
| + "route GET /api/cli/system/status": claimRead("reads platform release status"), |
| + "route GET /apps": claimRead("legacy redirect into the account"), |
| + "route GET /apps/{app}": claimRead("legacy redirect into the account"), |
| + "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"), |
| + "route GET /auth/cli": claimRead("renders the device-code approval page"), |
| + "route GET /auth/cli/tokens": claimRead("redirects to /settings"), |
| + "route GET /auth/cli/whoami": claimRead("names the bearer's principal"), |
| + "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"), |
| + "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"), |
| + "route GET /fleet": claimRead("operator fleet view"), |
| + "route GET /fleet/economics": claimRead("operator economics view"), |
| + "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"), |
| + "route GET /fleet/metrics": claimRead("operator box metrics"), |
| + "route GET /fleet/store": claimRead("operator entitlement queue page"), |
| + "route GET /healthz": claimRead("answers ok and the build"), |
| + "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"), |
| + "route GET /settings": claimRead("personal settings: the caller's own tokens"), |
| + "route GET /{$}": claimRead("the front door: redirects or renders sign-in"), |
| // GET that writes: a replica file download streams versioned objects (reads |
| // only) but audits every successful GET as an activity row of the app |
| @@ -1295,6 +1314,26 @@ var claimFixtures = map[string]claimFixture{ |
| return "" |
| }, |
| }}, |
| + "route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize": {door: &claimDoor{ |
| + apps: claimRefs("bab/kass"), |
| + setup: func(t *testing.T, cx *claimCase) { cx.authorizePlan(t) }, |
| + do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/releases/v1/authorize", |
| + `{"manifest_digest":"<manifest_digest>"}`), |
| + check: func(t *testing.T, cx *claimCase, open bool) string { |
| + rec, err := release.ReadPromotionRecord(context.Background(), cx.rel, cx.pub, "bab", "kass", "v1") |
| + switch { |
| + case !open && errors.Is(err, release.ErrNoPromotionRecord): |
| + return "" |
| + case !open && err == nil: |
| + return "kass v1's promotion record was written while kass was foreign-owned" |
| + case err != nil: |
| + return err.Error() |
| + case !rec.Authorized || rec.ManifestDigest != cx.vars["manifest_digest"]: |
| + return "kass v1's promotion record is not an authorization of the confirmed digest" |
| + } |
| + return "" |
| + }, |
| + }}, |
| "route POST /api/cli/apps/{account}/{app}/rollback": {door: &claimDoor{ |
| apps: claimRefs("bab/kass"), |
| setup: func(t *testing.T, cx *claimCase) { |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index 176b3bb2f..c94acb2ac 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -200,7 +200,9 @@ var claimReadProbes = map[string]claimProbe{ |
| "route GET /api/cli/apps/{account}/{app}/regions": claimAPIRead(nil, "/api/cli/apps/bab/titogo/regions", `"regions"`), |
| "route GET /api/cli/apps/{account}/{app}/relay": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) }, |
| "/api/cli/apps/bab/titogo/relay", `"app":"titogo"`, `"enabled":true`), |
| - "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`), |
| + "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`), |
| + "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimAPIRead(nil, |
| + "/api/cli/apps/bab/kass/releases/v1/authorize", `"version":"v1"`, `"manifest_digest":"`), |
| "route GET /api/cli/apps/{account}/{app}/retention": claimAPIRead(nil, "/api/cli/apps/bab/titogo/retention", `"keep"`), |
| "route GET /api/cli/apps/{account}/{app}/routes": claimAPIRead(nil, "/api/cli/apps/bab/kass/routes", `"kass.carlosapp.dev"`), |
| "route GET /api/cli/apps/{account}/{app}/schedules": claimAPIRead(claimWorldSchedule, "/api/cli/apps/bab/titogo/schedules", `"sync"`), |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 3e01db093..61a55de7d 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -228,6 +228,11 @@ var closedDoors = []string{ |
| // `carlos channels resign`: the console signs digests a person has |
| // confirmed. A credential never confirms on a person's behalf. |
| "POST /api/cli/apps/{account}/{app}/channels/resign", |
| + // `carlos releases authorize`: the console signs a promotion record for |
| + // the digest a person confirmed. The GET is closed with it, as the |
| + // pointer-digests read is: a credential has no plan to confirm. |
| + "GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", |
| + "POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", |
| } |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| @@ -894,6 +899,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| "{id}", "deadbeefdeadbeef", |
| "{level}", "0", |
| "{span}", "1-1", |
| + "{version}", "v1", |
| ).Replace(path) |
| if strings.Contains(u, "{") { |
| t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern) |
| @@ -911,8 +917,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 46 { |
| - t.Errorf("walked %d closed doors, want all %d (and 46 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 48 { |
| + t.Errorf("walked %d closed doors, want all %d (and 48 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 85afdee5a..789d2c392 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -65,6 +65,7 @@ route GET /api/cli/apps/{account}/{app}/pipeline |
| route GET /api/cli/apps/{account}/{app}/regions |
| route GET /api/cli/apps/{account}/{app}/relay |
| route GET /api/cli/apps/{account}/{app}/releases |
| +route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize |
| route GET /api/cli/apps/{account}/{app}/retention |
| route GET /api/cli/apps/{account}/{app}/routes |
| route GET /api/cli/apps/{account}/{app}/schedules |
| @@ -185,6 +186,7 @@ route POST /api/cli/apps/{account}/{app}/relay |
| route POST /api/cli/apps/{account}/{app}/relay/adopted |
| route POST /api/cli/apps/{account}/{app}/relay/disable |
| route POST /api/cli/apps/{account}/{app}/releases |
| +route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize |
| route POST /api/cli/apps/{account}/{app}/restart |
| route POST /api/cli/apps/{account}/{app}/restart/scope |
| route POST /api/cli/apps/{account}/{app}/restore |
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go| index 9f6e5e6d6..f6ab8c353 100644 |
| --- a/internal/edge/fleetapi_e2e_test.go |
| +++ b/internal/edge/fleetapi_e2e_test.go |
| @@ -619,3 +619,67 @@ func TestFleetBoxCatchallYieldsToAnotherTenantsClaim(t *testing.T) { |
| t.Fatalf("aliases = %+v, want acme.%s derived for the unclaimed name", aliases, parent) |
| } |
| } |
| + |
| +// Settings spec §4.1, ruling R17: a fleet box reads its own account's |
| +// promotion records through the store door, so a build authorized by record |
| +// alone (no pointer names it) stages on a fleet box, and a manifest that does |
| +// not hash to the record's digest is refused. Another account's record and a |
| +// listing of the records stay out of reach, and a box cannot write one. |
| +func TestFleetBoxReadsItsOwnPromotionRecords(t *testing.T) { |
| + ctx := context.Background() |
| + w := newFleetDoorWorld(t) |
| + const version = "v7" |
| + for _, acct := range []string{w.sqid, w.other} { |
| + if err := release.Ship(ctx, w.store, acct, release.Manifest{ |
| + App: fapiApp, Version: version, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{fapiPlatform: {}}, |
| + }, map[string][]byte{fapiPlatform: []byte("authorized bytes")}); err != nil { |
| + t.Fatalf("Ship(%s): %v", acct, err) |
| + } |
| + _, raw, err := release.GetManifestRaw(ctx, w.store, acct, fapiApp, version) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.RootSigner(w.priv).WritePromotionRecord(ctx, w.store, release.PromotionRecord{ |
| + Account: acct, App: fapiApp, Version: version, ManifestDigest: release.ManifestDigest(raw), Authorized: true, |
| + }); err != nil { |
| + t.Fatalf("WritePromotionRecord(%s): %v", acct, err) |
| + } |
| + } |
| + _, raw, err := release.GetManifestRaw(ctx, w.store, w.sqid, fapiApp, version) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rec, err := release.ReadPromotionRecord(ctx, w.boxA, w.pub, w.sqid, fapiApp, version) |
| + if err != nil || rec.ManifestDigest != release.ManifestDigest(raw) { |
| + t.Fatalf("ReadPromotionRecord through the door = %+v, %v; want the record signing %s", rec, err, release.ManifestDigest(raw)) |
| + } |
| + if m, err := release.AuthorizedManifest(ctx, w.boxA, w.pub, w.sqid, fapiApp, version); err != nil || m.Version != version { |
| + t.Fatalf("AuthorizedManifest through the door = %+v, %v; want the record-only authorized %s", m, err, version) |
| + } |
| + |
| + if _, err := release.ReadPromotionRecord(ctx, w.boxA, w.pub, w.other, fapiApp, version); err == nil { |
| + t.Fatal("a fleet box read another account's promotion record through the door") |
| + } |
| + keys, _ := w.boxA.List(ctx, "releases/"+w.sqid+"/") |
| + for _, k := range keys { |
| + if strings.HasPrefix(k, "releases/") { |
| + t.Fatalf("a fleet box listed promotion records through the door: %v", keys) |
| + } |
| + } |
| + if _, err := w.boxA.Put(ctx, release.PromotionRecordKey(w.sqid, fapiApp, "v8"), []byte("{}"), ""); err == nil { |
| + t.Fatal("a fleet box wrote a promotion record through the door") |
| + } |
| + |
| + _, etag, err := w.store.Get(ctx, release.ManifestKey(w.sqid, fapiApp, version)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := w.store.Put(ctx, release.ManifestKey(w.sqid, fapiApp, version), append(raw, '\n'), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := release.AuthorizedManifest(ctx, w.boxA, w.pub, w.sqid, fapiApp, version); !errors.Is(err, release.ErrManifestDigest) { |
| + t.Fatalf("AuthorizedManifest over a replaced manifest = %v; want ErrManifestDigest", err) |
| + } |
| +} |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index ca7c447d5..e341b9712 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -380,6 +380,9 @@ func canonicalPrefix(raw string) (string, bool) { |
| // the box (knownAccounts -> ParseRecordKey) |
| // apps/<sqid>/** its owning account's whole app estate |
| // fleets/<fleet>/** its own corner |
| +// releases/<sqid>/<app>/promoted/<version> |
| +// a promotion record of its owning account's |
| +// build — see promotionRecordRead |
| // |
| // domains/ is NOT here, and is served by its own two paths instead |
| // (serveClaim, serveClaimListing): a box can list every claimed hostname, |
| @@ -411,6 +414,20 @@ func canonicalPrefix(raw string) (string, bool) { |
| // NAMES are claimed at all, whoever by, so the listing stays whole |
| // (serveClaimListing). Write stays console-only: aclWrite never answers true |
| // for a domains/ key. |
| +// |
| +// A promotion record (settings spec §4.1, release.PromotionRecordKey) is |
| +// granted because a box needs it and it holds nothing secret: it is the |
| +// root-signed digest of one of the account's own builds, readable by anyone |
| +// holding the public key it verifies under. HeldAuthorized reads it before |
| +// staging a held release, and AuthorizedManifest before any install the |
| +// settings add. Without the grant every record reads as absent through the |
| +// door, so a build authorized by record alone could never stage on a fleet |
| +// box, and one a legacy pointer still names would fall back to that |
| +// pointer's weaker word. The grant is one object shape, not the prefix the |
| +// records sit under: releases/ also holds the account's pointer digest |
| +// marker, which a box has no use for. Listing is not granted (aclDir and |
| +// aclListScope are unchanged), and write stays console-only: aclWrite never |
| +// answers true for a releases/ key. |
| func aclRead(sqid, fleet, key string) bool { |
| if sqid == "" || fleet == "" { |
| return false |
| @@ -419,7 +436,7 @@ func aclRead(sqid, fleet, key string) bool { |
| case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(): |
| return true |
| } |
| - if systemReadKey(key) { |
| + if systemReadKey(key) || promotionRecordRead(sqid, key) { |
| return true |
| } |
| return strings.HasPrefix(key, "accounts/"+sqid+"/") || |
| @@ -427,6 +444,16 @@ func aclRead(sqid, fleet, key string) bool { |
| strings.HasPrefix(key, "fleets/"+fleet+"/") |
| } |
| +// promotionRecordRead is the one releases/ shape aclRead grants: exactly |
| +// releases/<sqid>/<app>/promoted/<version>, five segments, the box's own |
| +// account, and a valid app and version name (release.PromotionRecordKey's |
| +// inputs). canonicalKey has already refused any "." or ".." segment. |
| +func promotionRecordRead(sqid, key string) bool { |
| + seg := strings.Split(key, "/") |
| + return len(seg) == 5 && seg[0] == "releases" && seg[1] == sqid && |
| + release.ValidApp(seg[2]) && seg[3] == "promoted" && release.ValidVersion(seg[4]) |
| +} |
| + |
| // systemReadKey is the two shapes the system-update spec (§3) grants every |
| // fleet box: its platform channel pointer and a mirrored platform artifact. |
| // Shapes, not prefixes — exactly three segments each, so nothing else that |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index b1a89f5e2..c67b38de6 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -178,6 +178,24 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"apps/ab12/web/channels/errors/stable.json", opWrite, false, "\"errors\" deeper in the key grants nothing"}, |
| {"apps/ab12/web/errorsx/box-a/x.jsonl.gz", opWrite, false, "the segment is errors/, not a prefix of a longer name"}, |
| + // --- own account's promotion records (settings spec §4.1, ruling R17) --- |
| + // Signed digests of the account's own builds, which HeldAuthorized |
| + // reads on a fleet box. One shape, exactly five segments; nothing else |
| + // under releases/ and never a write. |
| + {"releases/ab12/web/promoted/v1.2.3", opRead, true, "own account's promotion record"}, |
| + {"releases/zz99/web/promoted/v1.2.3", opRead, false, "another account's promotion record"}, |
| + {"releases/ab12x/web/promoted/v1.2.3", opRead, false, "sqid must match exactly, not as a prefix"}, |
| + {"releases/ab12/web/promoted/v1.2.3/x", opRead, false, "a sixth segment"}, |
| + {"releases/ab12/web/promoted", opRead, false, "a missing version"}, |
| + {"releases/ab12/web/promoted/", opRead, false, "an empty version"}, |
| + {"releases/ab12/pointer-digests", opRead, false, "the account's digest marker is not granted"}, |
| + {"releases/ab12/web/other/v1.2.3", opRead, false, "segment 3 is exactly promoted"}, |
| + {"releases/ab12/Web!/promoted/v1.2.3", opRead, false, "an invalid app"}, |
| + {"releases/ab12/web/promoted/v1~bad", opRead, false, "an invalid version"}, |
| + {"releases/ab12/web/promoted/v1.2.3", opWrite, false, "a box never writes a promotion record"}, |
| + {"releases/ab12/web/promoted/v1.2.3", opDelete, false, "nor deletes one"}, |
| + {"releases/ab12/web/promoted/../../zz99/web/promoted/v1", opRead, false, "traversal out of own account"}, |
| + |
| // --- other tenants --- |
| {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"}, |
| {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
| @@ -377,6 +395,31 @@ func TestACLDirIsPerTenant(t *testing.T) { |
| } |
| } |
| +// The promotion-record grant (ruling R17) is a read of one exact object |
| +// shape: no prefix under releases/ maps to a listing scope or shows as a |
| +// directory, the box's own account's included. |
| +func TestPromotionRecordsAreNotListable(t *testing.T) { |
| + for _, prefix := range []string{ |
| + "releases/", |
| + "releases", |
| + "releases/" + testSqid + "/", |
| + "releases/" + testSqid + "/web/", |
| + "releases/" + testSqid + "/web/promoted/", |
| + "releases/" + testSqid + "/web/promoted", |
| + } { |
| + if got, ok := aclListScope(testSqid, testFleet, prefix); ok { |
| + t.Errorf("aclListScope(%q) = (%q, true), want refused", prefix, got) |
| + } |
| + if aclDir(testSqid, testFleet, strings.TrimSuffix(prefix, "/")+"/") { |
| + t.Errorf("aclDir(%q) = true, want false", prefix) |
| + } |
| + } |
| + key := "releases/" + testSqid + "/web/promoted/v1" |
| + if aclRead("", testFleet, key) || aclRead(testSqid, "", key) { |
| + t.Errorf("aclRead(%q) without both identities = true, want false", key) |
| + } |
| +} |
| + |
| // ---- handler tests ------------------------------------------------------ |
| // The credentials every handler test runs with: a fleet channel |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index cab426503..7457ed444 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4294,7 +4294,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 df5457835..4edda2f80 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -361,7 +361,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), stageMarkerRequester(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 ed4541d3e..5abd3a460 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/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index e9694df65..69ae1ce2a 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -148,6 +148,8 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, |
| return classifyGeoip(t) |
| case strings.HasPrefix(t.Key, "store/entitlements/") && len(p) == 3: |
| return Classification{Family: "store-entitlement", NonApp: "operator-set account entitlement; no app state"}, nil |
| + case p[0] == "releases" && len(p) == 5 && p[3] == "promoted": |
| + return classifyPromotionRecord(t, p) |
| case p[0] == "releases": |
| return classifyPointerDigestMarker(t, p) |
| } |
| @@ -179,6 +181,20 @@ func classifyPointerDigestMarker(t MutationTarget, p []string) (Classification, |
| NonApp: "root-signed account switch for pointer digest emission; no app state"}, nil |
| } |
| +// classifyPromotionRecord admits a build's signed promotion record, |
| +// releases/<acct>/<app>/promoted/<version> (settings plan 2, spec §4.1), |
| +// written when a version is promoted or authorized and deleted when the app |
| +// is purged. It is app state, scoped to exactly that app. Anything else |
| +// under releases/<acct>/<app>/ stays unreviewed. |
| +func classifyPromotionRecord(t MutationTarget, p []string) (Classification, error) { |
| + if len(p) != 5 || p[3] != "promoted" || !controlname.ValidAccount(p[1]) || !controlname.ValidApp(p[2]) || |
| + !release.ValidVersion(p[4]) || p[4] == "dev" || t.Key != release.PromotionRecordKey(p[1], p[2], p[4]) || |
| + (t.Method != "Put" && t.Method != "Delete") { |
| + return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) |
| + } |
| + return appScoped("promotion-record", p[1], p[2]) |
| +} |
| + |
| func appScoped(family, account, app string) (Classification, error) { |
| if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { |
| return Classification{}, ErrIdentity |
diff --git a/internal/hostops/promotionrecord_classifier_test.go b/internal/hostops/promotionrecord_classifier_test.go| new file mode 100644 |
| index 000000000..adda9c3dc |
| --- /dev/null |
| +++ b/internal/hostops/promotionrecord_classifier_test.go |
| @@ -0,0 +1,54 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestClassifyPromotionRecordIsExact: a build's signed promotion record, |
| +// releases/<acct>/<app>/promoted/<version> (settings plan 2, spec §4.1), is |
| +// a reviewed family scoped to exactly its app, put and deleted; nothing |
| +// else under releases/<acct>/<app>/ is. |
| +func TestClassifyPromotionRecordIsExact(t *testing.T) { |
| + ctx := context.Background() |
| + key := release.PromotionRecordKey("bab", "km", "v1") |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"record put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}}, |
| + {"record put over an existing one", MutationTarget{Surface: "bucket", Method: "Put", Key: key, ETag: "x", Before: []byte(`{}`), After: []byte(`{}`)}}, |
| + {"record delete", MutationTarget{Surface: "bucket", Method: "Delete", Key: key}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if err != nil { |
| + t.Errorf("%s: %v", c.name, err) |
| + continue |
| + } |
| + if got.Family != "promotion-record" || got.NonApp != "" || got.MailGate || |
| + len(got.Scopes) != 1 || got.Scopes[0] != (AppScope{"bab", "km"}) { |
| + t.Errorf("%s: classified %+v, want family promotion-record scoped to exactly {bab km}", c.name, got) |
| + } |
| + } |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"an invalid account", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/BAB!/km/promoted/v1", After: []byte(`{}`)}}, |
| + {"an invalid app", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/KM!/promoted/v1", After: []byte(`{}`)}}, |
| + {"an empty version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted/", After: []byte(`{}`)}}, |
| + {"a sixth segment", MutationTarget{Surface: "bucket", Method: "Put", Key: key + "/x", After: []byte(`{}`)}}, |
| + {"no version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted", After: []byte(`{}`)}}, |
| + {"another name under the app", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/other/v1", After: []byte(`{}`)}}, |
| + {"the dev version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted/dev", After: []byte(`{}`)}}, |
| + {"a delete-if-match", MutationTarget{Surface: "bucket", Method: "DeleteIfMatch", Key: key, ETag: "x"}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if !errors.Is(err, ErrUnclassified) && !errors.Is(err, ErrIdentity) { |
| + t.Errorf("%s: classified %+v, err %v; want ErrUnclassified or ErrIdentity", c.name, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index ecd3621dd..258957971 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -549,6 +549,9 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "84fcea399a55d77ceee17e35652ef269e5811453ffff62dff43208b68538b1ed"}, |
| // Validates the pointer-digest marker key and Put/Delete method names without writing. |
| {"internal/hostops/mutation_classifier.go", "classifyPointerDigestMarker", "reference", "str:Put", "08111b5db5d98e788d1eef5861ad9c861cf926c5e1d5d54717fd650d2dcf9fa8"}, |
| + // Validates the promotion-record key (releases/<acct>/<app>/promoted/<version>, a release object, not a |
| + // provisioning record) and Put/Delete method names without writing. |
| + {"internal/hostops/mutation_classifier.go", "classifyPromotionRecord", "reference", "key:PromotionRecordKey,str:Put", "211775ccd2f2924f1ca7bb4f0c5cd1711d096c2065f111362130847d8e6daa0a"}, |
| // Renders systemd paths under /data/instances and /run/carlos/instances; performs no store write. |
| {"internal/instancectl/gen.go", "GenDropIn", "key-site", "lit:instances", "f25956c74df80e9a89026ae5a6cf59b1d78ec227ee6cd6ece53bc79d14cc8746"}, |
| // Lists reserved local layout basenames, including instances; contains no writer. |
| @@ -558,7 +561,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Re-signs only the validated channel pointer; the digest-marker helper only reads. |
| {"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"}, |
| // Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers. |
| - {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "81804c8368ee204a8d78b15161fb00bce025c03ae3ee7f920db924ab0cf4fc86"}, |
| + {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "7aff68c454fa65dc19fb8c0cbb08250066aa4aeca629b43df3325b8e7932694d"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
| @@ -589,7 +592,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"}, |
| {"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"}, |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |
| - {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "933a4200e75137c5093528da57cf4b3842fb251ce5cb5ac35fe8224fdde579eb"}, |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "1b93d0d7d358aeec836f88b9f6ee4920fee76a8dad52d347c47affe51557a4dc"}, |
| {"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"}, |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"}, |
| {"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"}, |
| @@ -722,7 +725,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/mutation_boundary_putfrom.go", "conditionalRecordStore.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom", "3c3cb33a601eab5c4d74042a7ca8baf411c0549508b5a8273ef88f4ee0e9636c"}, |
| {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "75df2a3966d0bfe4b4b367617de3ebe28055a38ef4738f352859ba94ddbff241"}, |
| // Classifies mutation keys and method names without executing a store write. |
| - {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "35980690cecc96a442f240db62ab0dad04673ccbe0bea228a0ffdd27a9d15f98"}, |
| + {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "2d22e1d4a99a69ebff6f033a667f59c778855a66828ee5c7646a6b1d18573b65"}, |
| {"internal/hostops/mutation_classifier.go", "ownerUnion", "reference", "str:Put", "0c35233653dfe9ca6cc78bc54bac2c6991f82c9a7c2fd27c228c125f099f489b"}, |
| {"internal/hostops/mutation_classifier.go", "classifyRelayAuthority", "reference", "str:Put", "2887dce5f9f73b637c3a66308fe315f3ee20369263aa462b644d85048cdffc08"}, |
| {"internal/hostops/mutation_classifier.go", "classifyRelayIssuer", "reference", "str:Put", "106e4975e12208e561ac4a021a24db9390ac0ad6e40e76d68886be3275335cc1"}, |
| @@ -860,8 +863,17 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/release/production.go", "claimRawSetProduction", "nonrecord", "sel:Put", "0dd1875a9fcf58da306e8f1a009291b370650b6deeb5fda92afa89f979866ec2"}, |
| {"internal/release/proof.go", "PutProofPending", "nonrecord", "sel:Put", "cea0ebb0d4f6a3ec44df9aec22c9f27eb4440786d5135b6d942059208768016f"}, |
| {"internal/release/release.go", "putImmutable", "nonrecord", "sel:Put", "c1fb19059ffc1a2b5f5c5031b987b86b6a5e7e63a925a3e28e260eb758f60d0e"}, |
| - // Writes a signed channels/<channel>.json pointer under the validated account and app. |
| - {"internal/release/release.go", "Signer.claimRawPromote", "nonrecord", "sel:Put", "e7ac1afb3e931747f052d81bdca6057969ee9ec64b8381aa9a7d46f2b57caae4"}, |
| + // Promotion records are signed release objects at releases/<acct>/<app>/promoted/<version>; the name ends |
| + // in RecordKey, but no provisioning record (apps/<acct>/<app>/instances|sidecars) is built or written. |
| + {"internal/release/promotion.go", "PromotionRecordKey", "key-site", "key:PromotionRecordKey", "7dc4ab60059c02b0eaa1d2200e8b97de3c0f945c851e21ac64e372b82eb2eb8e"}, |
| + {"internal/release/promotion.go", "ReadPromotionRecord", "key-site", "key:PromotionRecordKey", "bcaaef3f8253b1cfe0f0ca4580977f9def67f463ef6d16c05cc54736cf59a6b0"}, |
| + // Puts the signed promotion record (release key grammar, hostops family promotion-record); not a provisioning record. |
| + {"internal/release/promotion.go", "Signer.WritePromotionRecord", "foreign-recordkey", "key:PromotionRecordKey,sel:Put", "ac2d87745d7e8a40e56342dafe8b5b49eeb31c37ed72d8f91b737c3ef77038ff"}, |
| + // Deletes the pruned version's promotion record; no Put and no provisioning record. |
| + {"internal/release/prune.go", "claimRawDeleteVersion", "key-site", "key:PromotionRecordKey", "8781c30f067735b81431df34beddd16f0a7af44776fe01a876a8944af8dbbfb2"}, |
| + // Writes a signed channels/<channel>.json pointer under the validated account and app; a new head's |
| + // promotion record (releases/<acct>/<app>/promoted/<version>) goes through Signer.WritePromotionRecord. |
| + {"internal/release/release.go", "Signer.claimRawPromote", "nonrecord", "sel:Put", "c08a9c111ba6a632ff3b598bf1f7b32c970cbd7fbc5648edeadc498625e5c68a"}, |
| // Writes a signed channels/<channel>.json rollback pointer under the validated account and app. |
| {"internal/release/release.go", "Signer.claimRawRollbackAuthorized", "nonrecord", "sel:Put", "592ab8576c9a81bc2963d8debc4c184903aabf0a7700e3a123090b91fd4359b1"}, |
| // Writes a signed channels/<channel>.json fast-track pointer under the validated account and app. |
diff --git a/internal/release/authorize_record.go b/internal/release/authorize_record.go| new file mode 100644 |
| index 000000000..2425f9067 |
| --- /dev/null |
| +++ b/internal/release/authorize_record.go |
| @@ -0,0 +1,61 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// ErrAuthorizeChanged is an authorize whose version was reshipped between the |
| +// plan and the confirmation; ErrAuthorizeRootOnly a scoped key. |
| +var ( |
| + ErrAuthorizeChanged = errors.New("release: the manifest changed since the authorize plan was made") |
| + ErrAuthorizeRootOnly = errors.New("release: authorizing a build needs the root key") |
| +) |
| + |
| +// AuthorizePlan is what `carlos releases authorize` shows: the digest the |
| +// record will carry and every artifact's sha256 (spec §4.1). |
| +type AuthorizePlan struct { |
| + Account, App, Version, ManifestDigest string |
| + Artifacts map[string]Artifact |
| +} |
| + |
| +// PlanAuthorize reads the version's canonical manifest. It writes nothing. |
| +func PlanAuthorize(ctx context.Context, s Store, acct, app, version string) (*AuthorizePlan, error) { |
| + m, raw, err := GetManifestRaw(ctx, s, acct, app, version) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", fmt.Sprintf(updatewords.Text("settings.authorize.not-shipped"), version), err) |
| + } |
| + return &AuthorizePlan{Account: acct, App: app, Version: version, ManifestDigest: ManifestDigest(raw), Artifacts: m.Artifacts}, nil |
| +} |
| + |
| +// claimRawAuthorize signs a record for exactly the plan's bytes: an explicit |
| +// act by the publisher, never automatic (spec §4.1). The manifest is read |
| +// again and must still hash to the plan's digest. already is true when a |
| +// record for these bytes exists, in which case nothing is written. |
| +func (sg Signer) claimRawAuthorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) (already bool, err error) { |
| + if sg.scope != nil || sg.grant != nil { |
| + return false, fmt.Errorf("%w: %s", ErrAuthorizeRootOnly, updatewords.Text("settings.authorize.root-only")) |
| + } |
| + _, raw, err := GetManifestRaw(ctx, s, plan.Account, plan.App, plan.Version) |
| + if err != nil { |
| + // If the version vanished (os.ErrNotExist), that's a change; keep the cause |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, fmt.Errorf("%w: %s: %w", ErrAuthorizeChanged, fmt.Sprintf(updatewords.Text("settings.authorize.changed"), plan.Version), err) |
| + } |
| + // Any other error is a real failure, not a change |
| + return false, fmt.Errorf("authorize: read the manifest of %s: %w", plan.Version, err) |
| + } |
| + // Read succeeded, check if digest changed |
| + if ManifestDigest(raw) != plan.ManifestDigest { |
| + return false, fmt.Errorf("%w: %s", ErrAuthorizeChanged, fmt.Sprintf(updatewords.Text("settings.authorize.changed"), plan.Version)) |
| + } |
| + if cur, err := ReadPromotionRecord(ctx, s, sg.verifyPub(), plan.Account, plan.App, plan.Version); err == nil && cur.ManifestDigest == plan.ManifestDigest { |
| + return true, nil |
| + } |
| + return false, sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: plan.Account, App: plan.App, Version: plan.Version, |
| + ManifestDigest: plan.ManifestDigest, Authorized: true, Actor: actor}) |
| +} |
diff --git a/internal/release/authorize_record_test.go b/internal/release/authorize_record_test.go| new file mode 100644 |
| index 000000000..5377638e7 |
| --- /dev/null |
| +++ b/internal/release/authorize_record_test.go |
| @@ -0,0 +1,126 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Spec §8 "Evidence": a build promoted before the feature is accepted after |
| +// `carlos releases authorize`. |
| +func TestAuthorizeSignsTheShownDigest(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + plan, err := PlanAuthorize(ctx, s, "bab", "km", "v1") |
| + if err != nil || plan.ManifestDigest != manifestDigestOf(t, s, "bab", "km", "v1") || plan.Artifacts[adoptPlatform].SHA256 == "" { |
| + t.Fatalf("plan = %+v, %v", plan, err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatal("planning wrote a record") |
| + } |
| + already, err := RootSigner(priv).Authorize(ctx, s, plan, "ops@example.com") |
| + if err != nil || already { |
| + t.Fatalf("Authorize = %v, %v", already, err) |
| + } |
| + rec, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if err != nil || !rec.Authorized || rec.Channel != "" { |
| + t.Fatalf("record = %+v, %v", rec, err) |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Review Focus 3. |
| +func TestAuthorizeIsIdempotent(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1") |
| + if _, err := RootSigner(priv).Authorize(ctx, s, plan, "a"); err != nil { |
| + t.Fatal(err) |
| + } |
| + already, err := RootSigner(priv).Authorize(ctx, s, plan, "a") |
| + if err != nil || !already { |
| + t.Fatalf("second authorize = %v, %v; want already", already, err) |
| + } |
| +} |
| + |
| +func TestAuthorizeRefusesAManifestThatChanged(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1") |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("swapped")) |
| + if _, err := RootSigner(priv).Authorize(ctx, s, plan, "a"); !errors.Is(err, ErrAuthorizeChanged) { |
| + t.Fatalf("Authorize = %v; want ErrAuthorizeChanged", err) |
| + } |
| +} |
| + |
| +func TestAuthorizeIsRootOnly(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + root, pub := testKeys(t) |
| + seed, grant, _ := MintKeyGrant(root, "ci", []string{"canary"}, time.Hour) |
| + key, _ := ParseSigningKey(seed, "test") |
| + sg, err := ScopedSigner(key, grant, pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1") |
| + if _, err := sg.Authorize(ctx, s, plan, "ci"); !errors.Is(err, ErrAuthorizeRootOnly) { |
| + t.Fatalf("scoped Authorize = %v; want ErrAuthorizeRootOnly", err) |
| + } |
| +} |
| + |
| +func TestAuthorizeReportsAStoreFailureAsItself(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1") |
| + |
| + // Wrap the store to fail on manifest Get only |
| + failStore := &failOnGetStore{Store: s, failKey: ManifestKey("bab", "km", "v1")} |
| + |
| + // Authorize should report the failure, not ErrAuthorizeChanged |
| + _, err := RootSigner(priv).Authorize(ctx, failStore, plan, "ops") |
| + if err == nil { |
| + t.Fatal("Authorize should fail when store Get fails") |
| + } |
| + if errors.Is(err, ErrAuthorizeChanged) { |
| + t.Fatal("Authorize should NOT wrap store failure as ErrAuthorizeChanged") |
| + } |
| + if !strings.Contains(err.Error(), "bucket unreachable") { |
| + t.Fatalf("error text should contain 'bucket unreachable', got: %v", err) |
| + } |
| + |
| + // Verify no record was written |
| + _, err = ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("record should not exist, got: %v", err) |
| + } |
| +} |
| + |
| +// failOnGetStore wraps a Store and returns an error for a specific key |
| +type failOnGetStore struct { |
| + Store |
| + failKey string |
| +} |
| + |
| +func (fs *failOnGetStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == fs.failKey { |
| + return nil, "", errors.New("bucket unreachable") |
| + } |
| + return fs.Store.Get(ctx, key) |
| +} |
diff --git a/internal/release/authorize_test.go b/internal/release/authorize_test.go| index 5d5b78eba..faadaa20d 100644 |
| --- a/internal/release/authorize_test.go |
| +++ b/internal/release/authorize_test.go |
| @@ -176,6 +176,11 @@ func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) |
| if got := edgeVersion(t, ctx, inner, pub); got != "" { |
| t.Fatalf("edge = %q after a refusal", got) |
| } |
| + // Astra I1 #1: the authoritative callback refused, so nothing may vouch |
| + // for v1 either. |
| + if _, err := ReadPromotionRecord(ctx, inner, pub, "bab", "myapp", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a refused promotion left a record: %v", err) |
| + } |
| } |
| // Astra's round 2 counterexample: an owner promotes under a new pipeline |
diff --git a/internal/release/claim_app_writes.go b/internal/release/claim_app_writes.go| index 50a030f07..326a51248 100644 |
| --- a/internal/release/claim_app_writes.go |
| +++ b/internal/release/claim_app_writes.go |
| @@ -60,3 +60,16 @@ func (sg Signer) ResignAuthorized(ctx context.Context, s Store, plan *ResignPlan |
| return sg.claimRawResign(ctx, s, plan, actor, authorize) |
| }) |
| } |
| + |
| +// Authorize is claimRawAuthorize under one app mutation. |
| +func (sg Signer) Authorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) (already bool, err error) { |
| + if plan == nil { |
| + return false, errors.New("authorize: no plan to sign") |
| + } |
| + err = WithAppMutation(ctx, s, plan.Account, plan.App, "release.Authorize", func(ctx context.Context) error { |
| + var err error |
| + already, err = sg.claimRawAuthorize(ctx, s, plan, actor) |
| + return err |
| + }) |
| + return already, err |
| +} |
diff --git a/internal/release/heldauth_test.go b/internal/release/heldauth_test.go| new file mode 100644 |
| index 000000000..663eceea6 |
| --- /dev/null |
| +++ b/internal/release/heldauth_test.go |
| @@ -0,0 +1,461 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "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) { |
| + t.Fatalf("a reused name under a replayed record = %v; want ErrManifestDigest", 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) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// pointerOnly ships v1 of bab/km, promotes it to each channel in turn (a |
| +// channel listed in digested is promoted after the digest marker is written, |
| +// so its pointer signs v1's digest), and deletes v1's promotion record, so |
| +// the pointers are the only evidence. |
| +func pointerOnly(t *testing.T, s Store, priv ed25519.PrivateKey, legacy, digested []string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| + for _, c := range legacy { |
| + if err := Promote(ctx, s, priv, "bab", "km", c, "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if len(digested) > 0 { |
| + if err := WriteDigestMarker(ctx, s, RootSigner(priv), "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, c := range digested { |
| + if err := Promote(ctx, s, priv, "bab", "km", c, "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Fix round 1, I1: a held stage that cannot read the pointer that may name |
| +// its build cannot tell, so it is not ErrHeldNotAuthorized (a final refusal) |
| +// but an error the root relay keeps the request for. |
| +func TestHeldStageRetriesAPointerItCannotRead(t *testing.T) { |
| + base := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + pointerOnly(t, base, priv, []string{"canary/x"}, nil) |
| + s := failingGetStore{Store: base, key: ChannelKey("bab", "km", "canary/x")} |
| + _, err := StageHeld(ctx, s, pub, nr(t), opt, heldAuthRoute("a.example.com"), "v1") |
| + if err == nil || errors.Is(err, ErrHeldNotAuthorized) || !errors.Is(err, errTransient) { |
| + t.Fatalf("StageHeld over an unreadable pointer = %v; want the read error, not ErrHeldNotAuthorized", err) |
| + } |
| + if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| + t.Fatal("a build nothing could vouch for was staged") |
| + } |
| +} |
| + |
| +// The root relay keeps a request whose authority it could not read. |
| +func TestServeHeldStageRequestsKeepsARequestItCannotAuthorizeYet(t *testing.T) { |
| + ctx, base, opt := heldFixture(t) |
| + if err := base.Delete(ctx, PromotionRecordKey("bab", "console", "v1")); err != nil { |
| + t.Fatal(err) // canary/x's pointer is v1's only evidence |
| + } |
| + s := failingGetStore{Store: base, key: ChannelKey("bab", "console", "canary/x")} |
| + req := goodHeldRequest() |
| + 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) |
| + } |
| + if _, err := os.Stat(path); err != nil { |
| + t.Fatalf("the request was dropped on a transient read error: %v", err) |
| + } |
| + gone(t, LiveLink(opt, heldTestHost)) |
| + gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| +} |
| + |
| +// A ratchet that cannot tell (it could not persist the mark for a pointer |
| +// with digests, or could not read the mark for one without) is retried, not |
| +// taken for a refusal. |
| +func TestHeldStageRetriesARatchetThatCannotTell(t *testing.T) { |
| + t.Run("the mark could not be persisted", func(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("root can write a 0555 directory") |
| + } |
| + s := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + pointerOnly(t, s, priv, nil, []string{"canary/x"}) |
| + ro := filepath.Join(t.TempDir(), "ro") |
| + if err := os.MkdirAll(ro, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err := HeldAuthorized(context.Background(), s, pub, NewDigestRatchet(ro, nil, false), "bab", "km", "v1") |
| + if errors.Is(err, ErrHeldNotAuthorized) || !errors.Is(err, ErrRatchetUnpersisted) { |
| + t.Fatalf("HeldAuthorized = %v; want ErrRatchetUnpersisted, not ErrHeldNotAuthorized", err) |
| + } |
| + }) |
| + t.Run("the mark could not be read", func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + pointerOnly(t, s, priv, []string{"canary/x"}, nil) |
| + dir := t.TempDir() |
| + if err := os.MkdirAll(markPath(dir, "bab", "km", "canary/x"), 0o755); err != nil { |
| + t.Fatal(err) // a directory where the mark belongs: Admit cannot tell |
| + } |
| + _, err := HeldAuthorized(context.Background(), s, pub, NewDigestRatchet(dir, nil, false), "bab", "km", "v1") |
| + if err == nil || errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("HeldAuthorized = %v; want a retryable error, not ErrHeldNotAuthorized", err) |
| + } |
| + }) |
| +} |
| + |
| +// Among pointers that name the build, the one that signs its digest wins, |
| +// whichever channel lists first: binding to "" when a signed digest exists |
| +// would throw away the stronger evidence. |
| +func TestHeldAuthorizedPrefersASignedDigest(t *testing.T) { |
| + for name, chans := range map[string][2][]string{ |
| + "the digestless channel lists first": {{"a"}, {"b"}}, |
| + "the digested channel lists first": {{"b"}, {"a"}}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + pointerOnly(t, s, priv, chans[0], chans[1]) |
| + raw, _, err := s.Get(ctx, ManifestKey("bab", "km", "v1")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want, err := HeldAuthorized(ctx, s, pub, nr(t), "bab", "km", "v1") |
| + if err != nil || want != ManifestDigest(raw) { |
| + t.Fatalf("HeldAuthorized = %q, %v; want the signed digest %q", want, err, ManifestDigest(raw)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Every verified pointer a held stage reads goes through the ratchet (spec |
| +// §4.6, "once verified"; ruling R15): one that does not name the build is not |
| +// evidence, but its digests still mark its channel. Its ratchet outcome never |
| +// changes the answer, so a mark that cannot be written does not hold up a |
| +// build another pointer authorizes. |
| +func TestHeldAuthorizedAdmitsOnlyNamingPointers(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v0", adoptPlatform, []byte("v0")) |
| + pointerOnly(t, s, priv, []string{"b"}, nil) |
| + if err := WriteDigestMarker(ctx, s, RootSigner(priv), "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "km", "a", "v0"); err != nil { |
| + t.Fatal(err) // channel a carries digests and does not name v1 |
| + } |
| + dir := t.TempDir() |
| + if want, err := HeldAuthorized(ctx, s, pub, NewDigestRatchet(dir, nil, false), "bab", "km", "v1"); err != nil || want != "" { |
| + t.Fatalf("HeldAuthorized = %q, %v; want channel b's pointer trust", want, err) |
| + } |
| + if _, err := os.Stat(markPath(dir, "bab", "km", "a")); err != nil { |
| + t.Fatalf("channel a's verified digests left no mark: %v", err) |
| + } |
| + if os.Geteuid() != 0 { |
| + ro := filepath.Join(t.TempDir(), "ro") |
| + if err := os.MkdirAll(ro, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want, err := HeldAuthorized(ctx, s, pub, NewDigestRatchet(ro, nil, false), "bab", "km", "v1"); err != nil || want != "" { |
| + t.Fatalf("HeldAuthorized with channel a's mark unwritable = %q, %v; want channel b's pointer trust", want, err) |
| + } |
| + } |
| +} |
| + |
| +// heldNothingDownloaded fails the test if the held stage read any of |
| +// version's release objects or left anything in the version store. |
| +func heldNothingDownloaded(t *testing.T, rec *heldRecordingStore, opt, version string) { |
| + t.Helper() |
| + rel := ReleasesPrefix("bab", "km") + version + "/" |
| + rec.mu.Lock() |
| + defer rec.mu.Unlock() |
| + for _, c := range rec.calls { |
| + if strings.HasPrefix(c, "get "+rel) { |
| + t.Fatalf("the held stage downloaded %s", strings.TrimPrefix(c, "get ")) |
| + } |
| + } |
| + if _, err := os.Stat(VersionPath(opt, "bab", version, "km")); err == nil { |
| + t.Fatalf("%s was staged", version) |
| + } |
| +} |
| + |
| +// heldSubstitute replaces version's shipped bytes under the same name. |
| +func heldSubstitute(t *testing.T, s Store, version string) { |
| + t.Helper() |
| + if err := DeleteVersion(context.Background(), s, "bab", "km", version); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", version, adoptPlatform, []byte("nobody promoted these")) |
| +} |
| + |
| +// Astra final A1, ruling R16: a pointer whose head signs a digest, naming a |
| +// legacy history entry that signs none, is no evidence for that entry ("never |
| +// a rollback target once its channel has any digest", spec §4.6). With no |
| +// record, its replaced bytes are refused with the flag off and on, and |
| +// nothing is downloaded. |
| +func TestHeldStageRefusesADigestlessEntryOnADigestedChannel(t *testing.T) { |
| + for name, require := range map[string]bool{"flag off": false, "flag on": true} { |
| + t.Run(name, func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + pointerOnly(t, s, priv, []string{"canary/x"}, nil) // v1, digestless |
| + if err := WriteDigestMarker(ctx, s, RootSigner(priv), "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := Channel(ctx, s, pub, "bab", "km", "canary/x") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Version != "v2" || p.Digest == "" || len(p.History) != 1 || p.History[0] != "v1" || p.DigestOf("v1") != "" { |
| + t.Fatalf("pointer = %+v; want a digested head v2 over a digestless history v1", p) |
| + } |
| + heldSubstitute(t, s, "v1") |
| + rec := &heldRecordingStore{Store: s} |
| + if _, err := StageHeld(ctx, rec, pub, NewDigestRatchet(t.TempDir(), nil, require), opt, heldAuthRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("StageHeld of a digestless entry on a digested channel = %v; want ErrHeldNotAuthorized", err) |
| + } |
| + heldNothingDownloaded(t, rec, opt, "v1") |
| + }) |
| + } |
| +} |
| + |
| +// Astra final A2, ruling R15: a held stage remembers every verified pointer |
| +// with digests it reads, naming the build or not. Pass 1 stages v1 on channel |
| +// b's word while channel a's pointer signs digests for v2; pass 2, on the |
| +// same ratchet, is a saved digestless pointer of channel a replayed over |
| +// substituted v2 bytes, and is refused. |
| +func TestHeldStageRemembersEveryVerifiedPointer(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")) |
| + shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2")) |
| + _, v2raw, err := GetManifestRaw(ctx, s, "bab", "km", "v2") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sign := func(p *ChannelPointer) []byte { |
| + t.Helper() |
| + w, err := signPointer(RootSigner(priv), p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| + } |
| + saved := sign(&ChannelPointer{Account: "bab", App: "km", Channel: "a", Version: "v2"}) // signed before digests |
| + put := func(channel string, w []byte) { |
| + t.Helper() |
| + _, etag, err := s.Get(ctx, ChannelKey("bab", "km", channel)) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ChannelKey("bab", "km", channel), w, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put("a", sign(&ChannelPointer{Account: "bab", App: "km", Channel: "a", Version: "v2", Digest: ManifestDigest(v2raw)})) |
| + put("b", sign(&ChannelPointer{Account: "bab", App: "km", Channel: "b", Version: "v1"})) |
| + ratchet := NewDigestRatchet(t.TempDir(), nil, false) |
| + if _, err := StageHeld(ctx, s, pub, ratchet, opt, heldAuthRoute("a.example.com"), "v1"); err != nil { |
| + t.Fatalf("pass 1, v1 on channel b's word: %v", err) |
| + } |
| + put("a", saved) |
| + heldSubstitute(t, s, "v2") |
| + rec := &heldRecordingStore{Store: s} |
| + if _, err := StageHeld(ctx, rec, pub, ratchet, opt, heldAuthRoute("a.example.com"), "v2"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("pass 2, a replayed digestless pointer over substituted v2 = %v; want ErrHeldNotAuthorized", err) |
| + } |
| + heldNothingDownloaded(t, rec, opt, "v2") |
| +} |
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 f83673ad5..b8efb7454 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -2,12 +2,14 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "os" |
| "path/filepath" |
| + "slices" |
| "sort" |
| "strings" |
| "time" |
| @@ -44,14 +46,108 @@ 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, so it is |
| +// returned only when every pointer of the app was read and judged. |
| +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 a naming pointer entry's, preferring one that |
| +// signs a digest ("" only when every admitted naming entry comes from a |
| +// pointer whose channel carries no digests at all, today's pointer trust). |
| +// |
| +// Every pointer that verifies goes through the box's ratchet, whether or not |
| +// it names the version: §4.6 remembers a channel from the moment the box has |
| +// verified digests for it, so a digestless replay later is refused (ruling |
| +// R15). That observation never changes this answer; only a naming pointer's |
| +// ratchet outcome does. |
| +// |
| +// A pointer that is gone, fails verification, does not name the version, or |
| +// that the box's ratchet refuses is not evidence and is skipped. So is a |
| +// naming entry with no digest on a pointer whose channel carries digests: a |
| +// legacy build is never a target once its channel has any digest (§4.6, |
| +// ruling R16). A read that fails any other way, or a ratchet that cannot |
| +// tell about a naming pointer (the mark could not be persisted or read), is |
| +// returned as is: the answer is unknown, not no, so the root relay keeps the |
| +// request for the next pass. |
| +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 |
| + } |
| + named := false // some admitted pointer of a digestless channel names the version |
| + for _, k := range keys { |
| + name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + if !ok || name == "" { |
| + continue |
| + } |
| + raw, _, err := GetQualified(ctx, s, ChannelKey(acct, app, name)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue // gone since the list |
| + } |
| + if err != nil { |
| + return "", fmt.Errorf("release: held stage of %s/%s at %s: reading channel %s: %w", acct, app, version, name, err) |
| + } |
| + p, err := verifyPointer(pub, raw, acct, app, name) |
| + if err != nil { |
| + continue // a pointer that does not verify is not evidence |
| + } |
| + // Observed first, so a verified pointer with digests marks its channel |
| + // whatever it names (§4.6 "once verified"). On a pointer that does not |
| + // name the version the outcome is dropped: a refusal or an unpersisted |
| + // mark there is no evidence either way, and the ratchet's in-memory |
| + // mark keeps that channel refusing digestless pointers regardless. |
| + rerr := r.Admit(acct, p) |
| + if p.Version != version && !slices.Contains(p.History, version) { |
| + 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 != nil { |
| + if errors.Is(rerr, ErrPointerWithoutDigests) && !errors.Is(rerr, errRatchetMarkUnreadable) { |
| + continue // the ratchet's verdict: this pointer is refused |
| + } |
| + return "", fmt.Errorf("release: held stage of %s/%s at %s: channel %s: %w", acct, app, version, name, rerr) |
| + } |
| + if d := p.DigestOf(version); d != "" { |
| + return d, nil |
| + } |
| + if p.ChannelHasDigests() { |
| + continue // a digestless entry on a digested channel is not evidence |
| + } |
| + named = true |
| + } |
| + if named { |
| + return "", 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. |
| @@ -134,11 +230,15 @@ 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 |
| } |
| - return materializeHeld(ctx, s, optDir, rt, version, proof, guard) |
| + return materializeHeld(ctx, s, pub, r, optDir, rt, version, proof, guard) |
| } |
| // MaterializeHeldUnder is MaterializeHeld for a held row under the host |
| @@ -153,7 +253,13 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro |
| // does not consult CARLOS_HOST_CLAIM, since the selection is the authority. |
| // A stage this process cannot write is ErrStageBlocked: there is no root |
| // relay under the claim. |
| -func MaterializeHeldUnder(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error) (*Manifest, error) { |
| +// |
| +// pub and r are §4.1's evidence, as for MaterializeHeld: the stage |
| +// (stageHeld) runs HeldAuthorized first and binds its digest, so a build no |
| +// signed promotion record and no ratchet-admitted pointer of the app |
| +// authorizes is ErrHeldNotAuthorized, before anything is downloaded. The |
| +// claim's selection authorizes the write, not the build. |
| +func MaterializeHeldUnder(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error) (*Manifest, error) { |
| if err := heldShape(rt, version); err != nil { |
| return nil, err |
| } |
| @@ -161,7 +267,7 @@ func MaterializeHeldUnder(ctx context.Context, s Store, optDir string, rt regist |
| return nil, errHeldNoGuard |
| } |
| id := hostwrite.Identity{Release: version} |
| - return materializeHeld(ctx, s, optDir, rt, version, proof, func(publish func() error) error { |
| + return materializeHeld(ctx, s, pub, r, optDir, rt, version, proof, func(publish func() error) error { |
| return work.With(ctx, id, func() error { |
| if guard == nil { |
| return publish() |
| @@ -171,7 +277,9 @@ func MaterializeHeldUnder(ctx context.Context, s Store, optDir string, rt regist |
| }) |
| } |
| -func materializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) (*Manifest, error) { |
| +// materializeHeld is MaterializeHeld and MaterializeHeldUnder after their |
| +// admissions: D3's admit, the authorized stage (stageHeld), then publication. |
| +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 guard == nil { |
| return nil, errHeldNoGuard |
| } |
| @@ -183,7 +291,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 |
| } |
| @@ -197,18 +305,33 @@ 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 |
| } |
| - return stageHeld(ctx, s, optDir, rt, version) |
| + return stageHeld(ctx, s, pub, r, optDir, rt, version) |
| } |
| -func stageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| +// stageHeld is every held stage's one staging step (StageHeld, and |
| +// materializeHeld for MaterializeHeld and MaterializeHeldUnder): §4.1 first |
| +// (HeldAuthorized, under pub and the box's ratchet r), then the stage bound |
| +// to the digest it returned, whose manifest is checked before any artifact |
| +// byte is written (ensureStagedDigest). Authorization lives here, below |
| +// every entry point, so no held path stages a build it has not authorized. |
| +func stageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| if err := heldShape(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) |
| @@ -483,8 +606,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. |
| @@ -496,7 +621,7 @@ const heldStageRequestMax = 4096 |
| // The directory is agent-owned and this runs as root, so every read, write |
| // and removal goes through safefile: no symlink is followed at any |
| // component, and only a regular file with one link 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) |
| dir, err := safefile.OpenDir(reqDir) |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -516,7 +641,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 |
| } |
| } |
| @@ -525,7 +650,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. |
| -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 := safefile.Remove(filepath.Join(reqDir, name)); err != nil { |
| @@ -566,11 +691,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_claim_test.go b/internal/release/materializeheld_claim_test.go| index cf9dc5349..0601dcafe 100644 |
| --- a/internal/release/materializeheld_claim_test.go |
| +++ b/internal/release/materializeheld_claim_test.go |
| @@ -3,6 +3,8 @@ package release |
| import ( |
| "context" |
| "errors" |
| + "os" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| @@ -31,7 +33,7 @@ func TestMaterializeHeldUnderPublishesThroughTheClaimSelection(t *testing.T) { |
| t.Setenv("CARLOS_HOST_CLAIM", "1") |
| work := &heldClaimWork{} |
| locked := 0 |
| - m, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), work, func(publish func() error) error { |
| + m, err := MaterializeHeldUnder(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), work, func(publish func() error) error { |
| locked++ |
| return publish() |
| }) |
| @@ -54,10 +56,10 @@ func TestMaterializeHeldUnderPublishesThroughTheClaimSelection(t *testing.T) { |
| func TestMaterializeHeldUnderRefusals(t *testing.T) { |
| ctx, s, opt := heldFixture(t) |
| t.Setenv("CARLOS_HOST_CLAIM", "1") |
| - if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), nil, nil); !errors.Is(err, errHeldNoGuard) { |
| + if _, err := MaterializeHeldUnder(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), nil, nil); !errors.Is(err, errHeldNoGuard) { |
| t.Fatalf("no selection: %v", err) |
| } |
| - if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), &heldClaimWork{shut: true}, nil); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + if _, err := MaterializeHeldUnder(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), &heldClaimWork{shut: true}, nil); !errors.Is(err, hostwrite.ErrUnavailable) { |
| t.Fatalf("a refused selection: %v", err) |
| } |
| if got, _ := LiveVersion(opt, heldTestHost); got != "" { |
| @@ -65,7 +67,79 @@ func TestMaterializeHeldUnderRefusals(t *testing.T) { |
| } |
| noOp := heldRoute() |
| noOp.HoldOp = "" |
| - if _, err := MaterializeHeldUnder(ctx, s, opt, noOp, "v1", offGate(t), &heldClaimWork{}, nil); err == nil { |
| + if _, err := MaterializeHeldUnder(ctx, s, heldPub, nr(t), opt, noOp, "v1", offGate(t), &heldClaimWork{}, nil); err == nil { |
| t.Fatal("a row no move holds was staged") |
| } |
| } |
| + |
| +// Settings spec rev 11 §4.3a on the claim path: MaterializeHeldUnder stages |
| +// only a build a promotion record or a ratchet-admitted pointer authorizes, |
| +// bound to that digest. A build nothing authorizes, or a replayed record over |
| +// other bytes under the same name, is refused before an artifact byte is read |
| +// or written: nothing staged, no selection entered, live/<host> untouched. |
| +func TestMaterializeHeldUnderStagesOnlyAnAuthorizedBuild(t *testing.T) { |
| + t.Run("nothing authorizes it", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + shipRelease(t, s, "bab", "console", "v3", adoptPlatform, []byte("shipped, never promoted")) |
| + rec := &heldRecordingStore{Store: s} |
| + work := &heldClaimWork{} |
| + if _, err := MaterializeHeldUnder(ctx, rec, heldPub, nr(t), opt, heldRoute(), "v3", offGate(t), work, nil); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("MaterializeHeldUnder of a never-promoted build = %v; want ErrHeldNotAuthorized", err) |
| + } |
| + heldClaimNothingStaged(t, rec, work, opt, "v3", false) |
| + }) |
| + t.Run("a replayed record over other bytes", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + saved, _, err := s.Get(ctx, PromotionRecordKey("bab", "console", "v1")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "console", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("nobody promoted these")) |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "console", "v1"), saved, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := &heldRecordingStore{Store: s} |
| + work := &heldClaimWork{} |
| + if _, err := MaterializeHeldUnder(ctx, rec, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), work, nil); !errors.Is(err, ErrManifestDigest) { |
| + t.Fatalf("MaterializeHeldUnder under a replayed record = %v; want ErrManifestDigest", err) |
| + } |
| + heldClaimNothingStaged(t, rec, work, opt, "v1", true) |
| + }) |
| +} |
| + |
| +// heldClaimNothingStaged fails the test if a claim-path held stage of version |
| +// read its artifact, staged it, entered the selection or pointed live/<host>. |
| +// manifestRead says whether the stage was allowed to read the manifest (to |
| +// check a digest) before refusing. |
| +func heldClaimNothingStaged(t *testing.T, rec *heldRecordingStore, work *heldClaimWork, opt, version string, manifestRead bool) { |
| + t.Helper() |
| + rel := ReleasesPrefix("bab", "console") + version + "/" |
| + manifest := ManifestKey("bab", "console", version) |
| + rec.mu.Lock() |
| + for _, c := range rec.calls { |
| + key, ok := strings.CutPrefix(c, "get ") |
| + if !ok || !strings.HasPrefix(key, rel) { |
| + continue |
| + } |
| + if manifestRead && key == manifest { |
| + continue |
| + } |
| + rec.mu.Unlock() |
| + t.Fatalf("the refused held stage read %s", key) |
| + } |
| + rec.mu.Unlock() |
| + if _, err := os.Stat(VersionPath(opt, "bab", version, "console")); err == nil { |
| + t.Fatalf("%s was staged", version) |
| + } |
| + if len(work.ids) != 0 { |
| + t.Fatalf("the refused held stage entered the selection under %v", work.ids) |
| + } |
| + if got, _ := LiveVersion(opt, heldTestHost); got != "" { |
| + t.Fatalf("live/<host> names %q", got) |
| + } |
| +} |
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 c8a34da37..3fdf931b6 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" |
| @@ -20,16 +22,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() |
| @@ -43,7 +56,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) |
| } |
| @@ -68,10 +81,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) |
| } |
| @@ -92,7 +105,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 { |
| @@ -155,7 +168,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 { |
| @@ -181,7 +194,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) |
| @@ -210,7 +223,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) |
| @@ -233,7 +246,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) |
| @@ -255,7 +268,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) |
| @@ -272,8 +285,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 { |
| @@ -288,7 +308,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 { |
| @@ -335,7 +355,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)) |
| @@ -350,8 +370,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 |
| } |
| @@ -362,7 +382,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 { |
| @@ -373,7 +393,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) |
| @@ -410,7 +430,7 @@ func TestHeldStageHardlinkedRequestIsRefused(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) |
| } |
| if called { |
diff --git a/internal/release/promote_record_test.go b/internal/release/promote_record_test.go| new file mode 100644 |
| index 000000000..cf6e55ae6 |
| --- /dev/null |
| +++ b/internal/release/promote_record_test.go |
| @@ -0,0 +1,120 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPromoteWritesTheRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1", Promotion{Actor: "a@example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if err != nil || rec.Channel != "stable" || rec.Actor != "a@example.com" || rec.ManifestDigest != manifestDigestOf(t, s, "bab", "km", "v1") { |
| + t.Fatalf("record = %+v, %v", rec, err) |
| + } |
| +} |
| + |
| +// Spec §8: a promoted build that has left the pointer's history is accepted |
| +// through its signed promotion record. |
| +func TestABuildOutOfHistoryIsStillAuthorized(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + 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) |
| + } |
| + } |
| + p, _ := Channel(ctx, s, pub, "bab", "km", "canary/x") |
| + for _, h := range append([]string{p.Version}, p.History...) { |
| + if h == "va" { |
| + t.Fatal("va should have fallen out of the pointer") |
| + } |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "va"); err != nil { |
| + t.Fatalf("va left the history and lost its authorization: %v", err) |
| + } |
| +} |
| + |
| +// Review Focus 4. |
| +func TestOneRecordPerVersionAcrossChannels(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + for _, ch := range []string{"canary/x", "stable"} { |
| + if err := Promote(ctx, s, priv, "bab", "km", ch, "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if rec, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); rec.Channel != "canary/x" { |
| + t.Fatalf("record channel %q; want the first promotion's", rec.Channel) |
| + } |
| +} |
| + |
| +// Astra I1 #3: a repeat or a renewal of a legacy head never signs its |
| +// current bytes. Only `carlos releases authorize` does. |
| +func TestARepeatOrRetryNeverCreatesARecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + 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 { // a head promoted before records |
| + t.Fatal(err) |
| + } |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "v1", PromoteOptions{}) |
| + if err != nil || !res.NoOp { |
| + t.Fatalf("repeat = %+v, %v", res, err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a no-op repeat created a record: %v", err) |
| + } |
| + time.Sleep(1100 * time.Millisecond) |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a retry created a record: %v", err) |
| + } |
| +} |
| + |
| +// refusingStore refuses writes under one prefix. |
| +type refusingStore struct { |
| + Store |
| + prefix string |
| +} |
| + |
| +func (r refusingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if strings.HasPrefix(key, r.prefix) { |
| + return "", errors.New("refused") |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// Review Focus 1. |
| +func TestPromotionFailsBeforeThePointerWhenTheRecordCannotBeWritten(t *testing.T) { |
| + base := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, base, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + s := refusingStore{Store: base, prefix: "releases/bab/km/promoted/"} |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err == nil { |
| + t.Fatal("the promotion succeeded without its record") |
| + } |
| + if _, err := Channel(ctx, base, pub, "bab", "km", "stable"); err == nil { |
| + t.Fatal("the pointer moved although the record was not written") |
| + } |
| +} |
diff --git a/internal/release/promotion.go b/internal/release/promotion.go| new file mode 100644 |
| index 000000000..25bdc721e |
| --- /dev/null |
| +++ b/internal/release/promotion.go |
| @@ -0,0 +1,184 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| +) |
| + |
| +// PromotedPrefix is every promotion record of an app: what PurgeApp sweeps. |
| +func PromotedPrefix(acct, app string) string { return "releases/" + acct + "/" + app + "/promoted/" } |
| + |
| +// PromotionRecordKey is the durable record of one promoted build (spec §4.1). |
| +// It sits outside AppPrefix, beside the account's pointer digest marker, so |
| +// PurgeApp sweeps it explicitly. |
| +func PromotionRecordKey(acct, app, version string) string { |
| + return PromotedPrefix(acct, app) + version |
| +} |
| + |
| +// PromotionRecord binds a version name to the exact manifest that was |
| +// promoted (or explicitly authorized). It keeps a build authorized after it |
| +// falls out of the pointer's history, and a reused name cannot inherit it: |
| +// the new name's manifest hashes to another digest. |
| +type PromotionRecord struct { |
| + Schema int `json:"schema"` // 1 |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + ManifestDigest string `json:"manifest_digest"` |
| + Channel string `json:"channel,omitempty"` |
| + Authorized bool `json:"authorized,omitempty"` |
| + Actor string `json:"actor,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// ErrNoPromotionRecord is a build with no record: promoted before records |
| +// existed, or never promoted. It wraps os.ErrNotExist. |
| +var ErrNoPromotionRecord = fmt.Errorf("release: no promotion record: %w", os.ErrNotExist) |
| + |
| +func validateRecord(r *PromotionRecord, acct, app, version string) error { |
| + if r.Schema != 1 { |
| + return fmt.Errorf("unsupported promotion record schema %d", r.Schema) |
| + } |
| + if r.Account != acct || r.App != app || r.Version != version { |
| + return fmt.Errorf("promotion record names %s/%s/%s, expected %s/%s/%s", r.Account, r.App, r.Version, acct, app, version) |
| + } |
| + if !ValidAccount(acct) || !ValidApp(app) || !ValidVersion(version) || version == "dev" { |
| + return fmt.Errorf("promotion record for invalid %s/%s/%s", acct, app, version) |
| + } |
| + if !validDigest(r.ManifestDigest) { |
| + return fmt.Errorf("promotion record has an invalid manifest digest") |
| + } |
| + if r.Channel != "" && !ValidChannel(r.Channel) { |
| + return fmt.Errorf("promotion record has an invalid channel %q", r.Channel) |
| + } |
| + if r.Channel == "" && !r.Authorized { |
| + return fmt.Errorf("promotion record names no channel and is not an authorization") |
| + } |
| + if r.At.IsZero() { |
| + return fmt.Errorf("promotion record has no time") |
| + } |
| + return nil |
| +} |
| + |
| +// WritePromotionRecord signs rec, stamped now, and writes it unless the key |
| +// already holds a verified record for the same digest: one record per |
| +// version, kept from its first promotion. A record for other bytes (a name |
| +// pruned and reshipped) is replaced. A scoped signer may write only a record |
| +// whose channel its grant covers, and never an authorization. |
| +func (sg Signer) WritePromotionRecord(ctx context.Context, s Store, rec PromotionRecord) error { |
| + if err := sg.usable(); err != nil { |
| + return err |
| + } |
| + rec.Schema = 1 |
| + rec.At = time.Now().UTC().Truncate(time.Second) |
| + if err := validateRecord(&rec, rec.Account, rec.App, rec.Version); err != nil { |
| + return err |
| + } |
| + if rec.Channel == "" || rec.Authorized { |
| + if sg.scope != nil { |
| + return fmt.Errorf("promotion record: an authorization needs the deployment's root key") |
| + } |
| + } else if err := sg.allows(rec.Channel, rec.At); err != nil { |
| + return fmt.Errorf("promotion record: %w", err) |
| + } |
| + key := PromotionRecordKey(rec.Account, rec.App, rec.Version) |
| + _, etag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return err |
| + default: |
| + if cur, rerr := ReadPromotionRecord(ctx, s, sg.verifyPub(), rec.Account, rec.App, rec.Version); rerr == nil && cur.ManifestDigest == rec.ManifestDigest { |
| + return nil |
| + } |
| + } |
| + payload, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wire, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), |
| + Grant: sg.grant, |
| + }, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, wire, etag) |
| + return err |
| +} |
| + |
| +// ReadPromotionRecord verifies the record at version's key: root -> grant -> |
| +// record, the chain verifyPointer checks, and the signed payload must name |
| +// this account, app and version. (nil, ErrNoPromotionRecord) when absent; any |
| +// other failure is an error that is never read as "absent". |
| +func ReadPromotionRecord(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*PromotionRecord, error) { |
| + raw, _, err := s.Get(ctx, PromotionRecordKey(acct, app, version)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, fmt.Errorf("%s/%s: %w", app, version, ErrNoPromotionRecord) |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var sw signedWrapper |
| + if err := json.Unmarshal(raw, &sw); err != nil { |
| + return nil, fmt.Errorf("promotion record wrapper: %v", err) |
| + } |
| + signer, scope := pub, (*Grant)(nil) |
| + if sw.Grant != nil { |
| + g, err := verifyGrant(pub, sw.Grant) |
| + if err != nil { |
| + return nil, err |
| + } |
| + gpub, err := ParsePublicKey(g.Pub, "key grant pub") |
| + if err != nil { |
| + return nil, err |
| + } |
| + signer, scope = gpub, g |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| + if err != nil || len(signer) != ed25519.PublicKeySize || !ed25519.Verify(signer, []byte(sw.Payload), sig) { |
| + return nil, fmt.Errorf("promotion record %s/%s: signature verification failed", app, version) |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("promotion record payload: %v", err) |
| + } |
| + var rec PromotionRecord |
| + if err := json.Unmarshal(payload, &rec); err != nil { |
| + return nil, fmt.Errorf("promotion record payload: %v", err) |
| + } |
| + if err := validateRecord(&rec, acct, app, version); err != nil { |
| + return nil, err |
| + } |
| + if scope != nil { |
| + if rec.Channel == "" || rec.Authorized { |
| + return nil, fmt.Errorf("promotion record %s/%s: an authorization signed by a scoped key", app, version) |
| + } |
| + if err := scope.allows(rec.Channel, rec.At); err != nil { |
| + return nil, fmt.Errorf("promotion record %s/%s: %w", app, version, err) |
| + } |
| + } |
| + return &rec, nil |
| +} |
| + |
| +// AuthorizedManifest is §4.1's evidence for every install the settings add: |
| +// the build's signed record, and its canonical manifest hashing to the |
| +// record's digest. The manifest it returns is the one it checked; the caller |
| +// stages from it and never re-reads it. |
| +func AuthorizedManifest(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*Manifest, error) { |
| + rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version) |
| + if err != nil { |
| + return nil, err |
| + } |
| + m, _, err := CheckManifestDigest(ctx, s, acct, app, version, rec.ManifestDigest) |
| + return m, err |
| +} |
diff --git a/internal/release/promotion_test.go b/internal/release/promotion_test.go| new file mode 100644 |
| index 000000000..525b74276 |
| --- /dev/null |
| +++ b/internal/release/promotion_test.go |
| @@ -0,0 +1,359 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func recordFor(t *testing.T, s Store, sg Signer, acct, app, ver, channel string) PromotionRecord { |
| + t.Helper() |
| + raw, _, err := s.Get(context.Background(), ManifestKey(acct, app, ver)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := PromotionRecord{Account: acct, App: app, Version: ver, ManifestDigest: ManifestDigest(raw), Channel: channel, Actor: "test"} |
| + if err := sg.WritePromotionRecord(context.Background(), s, rec); err != nil { |
| + t.Fatalf("WritePromotionRecord: %v", err) |
| + } |
| + return rec |
| +} |
| + |
| +func TestPromotionRecordRoundTrip(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + want := recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + got, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if err != nil || got.ManifestDigest != want.ManifestDigest || got.Channel != "stable" || got.At.IsZero() { |
| + t.Fatalf("ReadPromotionRecord = %+v, %v", got, err) |
| + } |
| + if m, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil || m.Version != "v1" { |
| + t.Fatalf("AuthorizedManifest = %+v, %v", m, err) |
| + } |
| +} |
| + |
| +// Spec §8: a fresh, never-promoted version with a matching manifest is |
| +// refused for a pin. |
| +func TestANeverPromotedVersionHasNoRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + _, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1") |
| + if !errors.Is(err, ErrNoPromotionRecord) || !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("AuthorizedManifest = %v; want ErrNoPromotionRecord wrapping os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +// Spec §8 "Promotion binding": a reused version name with a replayed record |
| +// and a different manifest is refused. |
| +func TestAReplayedRecordDoesNotAuthorizeAReusedName(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("promoted bytes")) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + saved, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these")) |
| + _, etag, gerr := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if errors.Is(gerr, os.ErrNotExist) { |
| + etag = "" |
| + } |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "km", "v1"), saved, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrManifestDigest) { |
| + t.Fatalf("AuthorizedManifest = %v; want ErrManifestDigest", err) |
| + } |
| +} |
| + |
| +// Review Focus 2. |
| +func TestARecordCopiedToAnotherKeyIsRefused(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + raw, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "blog", "v1"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "blog", "v1"); err == nil || errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a copied record = %v; want a refusal that is not 'absent'", err) |
| + } |
| +} |
| + |
| +func TestScopedRecordsFollowTheGrant(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + root, pub := testKeys(t) |
| + seed, grant, err := MintKeyGrant(root, "ci", []string{"canary"}, time.Hour) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, _ := ParseSigningKey(seed, "test") |
| + sg, err := ScopedSigner(key, grant, pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + raw, _, _ := s.Get(ctx, ManifestKey("bab", "km", "v1")) |
| + d := ManifestDigest(raw) |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Channel: "canary/x"}); err != nil { |
| + t.Fatalf("in scope: %v", err) |
| + } |
| + if rec, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err != nil || rec.Channel != "canary/x" { |
| + t.Fatalf("scoped record = %+v, %v", rec, err) |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Channel: "stable"}); err == nil { |
| + t.Fatal("a canary-scoped key signed a stable record") |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Authorized: true}); err == nil { |
| + t.Fatal("a scoped key signed an authorize record") |
| + } |
| +} |
| + |
| +func TestWriteKeepsAnIdenticalRecordAndReplacesAnother(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + first := recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "canary/x") |
| + got1, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + time.Sleep(1100 * time.Millisecond) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + got2, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if !got2.At.Equal(got1.At) || got2.Channel != "canary/x" { |
| + t.Fatalf("an identical digest replaced the record: %+v -> %+v", got1, got2) |
| + } |
| + other := first |
| + other.ManifestDigest = ManifestDigest([]byte("another manifest")) |
| + if err := RootSigner(priv).WritePromotionRecord(ctx, s, other); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got3, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); got3.ManifestDigest != other.ManifestDigest { |
| + t.Fatal("a record for different bytes did not replace the old one") |
| + } |
| +} |
| + |
| +// ---- the reader's own refusals ------------------------------------------------ |
| +// |
| +// WritePromotionRecord refuses every input below before it signs, so these |
| +// tests hand-build the wrapper and Put it directly: the only thing standing |
| +// between each record and a pin is ReadPromotionRecord's own check. |
| + |
| +// recordPayload is rec as the base64 payload string a wrapper signs. |
| +func recordPayload(t *testing.T, rec PromotionRecord) string { |
| + t.Helper() |
| + payload, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(payload) |
| +} |
| + |
| +// putRecordWrapper writes sw at rec's own record key, replacing whatever is |
| +// there. |
| +func putRecordWrapper(t *testing.T, s Store, rec PromotionRecord, sw signedWrapper) { |
| + t.Helper() |
| + ctx := context.Background() |
| + key := PromotionRecordKey(rec.Account, rec.App, rec.Version) |
| + _, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, mustJSON(t, sw), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// putSignedRecord signs rec with key, attaches grant (nil for a root-style |
| +// wrapper) and writes it, with none of WritePromotionRecord's refusals. |
| +func putSignedRecord(t *testing.T, s Store, rec PromotionRecord, key ed25519.PrivateKey, grant *signedGrant) signedWrapper { |
| + t.Helper() |
| + b64 := recordPayload(t, rec) |
| + sw := signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b64))), |
| + Grant: grant, |
| + } |
| + putRecordWrapper(t, s, rec, sw) |
| + return sw |
| +} |
| + |
| +// handBuiltRecord ships bab/km/v1 and returns a record for it that every |
| +// reader check accepts, dated an hour into goodGrant's window. |
| +func handBuiltRecord(t *testing.T) (Store, PromotionRecord) { |
| + t.Helper() |
| + s := NewDirStore(t.TempDir()) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + raw, _, err := s.Get(context.Background(), ManifestKey("bab", "km", "v1")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, PromotionRecord{ |
| + Schema: 1, Account: "bab", App: "km", Version: "v1", |
| + ManifestDigest: ManifestDigest(raw), Channel: "canary/x", Actor: "test", |
| + At: goodGrant(nil).MintedAt.Add(time.Hour), |
| + } |
| +} |
| + |
| +// assertRecordRefused wants ReadPromotionRecord to refuse with an error that |
| +// names want and is never "absent", and AuthorizedManifest to refuse too. |
| +func assertRecordRefused(t *testing.T, s Store, pub ed25519.PublicKey, rec PromotionRecord, want string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + got, err := ReadPromotionRecord(ctx, s, pub, rec.Account, rec.App, rec.Version) |
| + if err == nil || errors.Is(err, ErrNoPromotionRecord) || !strings.Contains(err.Error(), want) { |
| + t.Fatalf("ReadPromotionRecord = %+v, %v; want a refusal naming %q that is not 'absent'", got, err, want) |
| + } |
| + if m, err := AuthorizedManifest(ctx, s, pub, rec.Account, rec.App, rec.Version); err == nil || errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("AuthorizedManifest = %+v, %v; want a refusal that is not 'absent'", m, err) |
| + } |
| +} |
| + |
| +// assertRecordReads is the control: the hand-built wrapper is a real record, |
| +// so each refusal beside it is the reader's check and not a fixture artefact. |
| +func assertRecordReads(t *testing.T, s Store, pub ed25519.PublicKey, rec PromotionRecord) { |
| + t.Helper() |
| + ctx := context.Background() |
| + got, err := ReadPromotionRecord(ctx, s, pub, rec.Account, rec.App, rec.Version) |
| + if err != nil || got.Channel != rec.Channel || got.ManifestDigest != rec.ManifestDigest { |
| + t.Fatalf("control: ReadPromotionRecord = %+v, %v", got, err) |
| + } |
| + if m, err := AuthorizedManifest(ctx, s, pub, rec.Account, rec.App, rec.Version); err != nil || m.Version != rec.Version { |
| + t.Fatalf("control: AuthorizedManifest = %+v, %v", m, err) |
| + } |
| +} |
| + |
| +// The reader's signature check: a payload edited after signing, and a record |
| +// signed by a key that is neither the root nor the key its grant names. |
| +func TestReaderRefusesABadRecordSignature(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + other, otherPub := testKeys(t) |
| + t.Run("control", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, root, nil) |
| + assertRecordReads(t, s, rootPub, rec) |
| + }) |
| + t.Run("tampered payload", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + sw := putSignedRecord(t, s, rec, root, nil) |
| + edited := rec |
| + edited.Channel = "stable" |
| + sw.Payload = recordPayload(t, edited) |
| + putRecordWrapper(t, s, rec, sw) |
| + assertRecordRefused(t, s, rootPub, rec, "signature verification failed") |
| + }) |
| + t.Run("signed by a key that is not the root", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, other, nil) |
| + assertRecordRefused(t, s, rootPub, rec, "signature verification failed") |
| + }) |
| + t.Run("signed by a key its grant does not name", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, root, forgeGrantSub(t, root, goodGrant(otherPub))) |
| + assertRecordRefused(t, s, rootPub, rec, "signature verification failed") |
| + }) |
| +} |
| + |
| +// The reader's verifyGrant call: a grant signed by anything but the pinned |
| +// root makes the record it carries worthless. |
| +func TestReaderRefusesAGrantNotSignedByTheRoot(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + impostor, _ := testKeys(t) |
| + scoped, scopedPub := testKeys(t) |
| + t.Run("control", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, scoped, forgeGrantSub(t, root, goodGrant(scopedPub))) |
| + assertRecordReads(t, s, rootPub, rec) |
| + }) |
| + t.Run("grant signed by another key", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, scoped, forgeGrantSub(t, impostor, goodGrant(scopedPub))) |
| + assertRecordRefused(t, s, rootPub, rec, "key grant signature verification failed") |
| + }) |
| +} |
| + |
| +// The reader's "authorization signed by a scoped key" refusal: a grant |
| +// covers channels, never an authorization, whatever channel rides beside it. |
| +// |
| +// Mutation-mindedness: delete that refusal in ReadPromotionRecord and the |
| +// first case reads as a valid record (canary/x is in scope and in window), |
| +// while the second fails with scope.allows' "not a rung" instead. |
| +func TestReaderRefusesAScopedAuthorization(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + scoped, scopedPub := testKeys(t) |
| + grant := forgeGrantSub(t, root, goodGrant(scopedPub)) |
| + t.Run("control", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + putSignedRecord(t, s, rec, scoped, grant) |
| + assertRecordReads(t, s, rootPub, rec) |
| + }) |
| + for _, tc := range []struct { |
| + name string |
| + channel string |
| + authorized bool |
| + want string |
| + }{ |
| + {"authorized with an in-scope channel", "canary/x", true, "an authorization signed by a scoped key"}, |
| + {"authorized with no channel", "", true, "an authorization signed by a scoped key"}, |
| + {"no channel and not authorized", "", false, "names no channel"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + rec.Channel, rec.Authorized = tc.channel, tc.authorized |
| + putSignedRecord(t, s, rec, scoped, grant) |
| + assertRecordRefused(t, s, rootPub, rec, tc.want) |
| + }) |
| + } |
| +} |
| + |
| +// The reader's scope.allows check: a scoped record must name a channel on a |
| +// rung its grant covers, dated inside the grant's window. |
| +// |
| +// Mutation-mindedness: delete that check in ReadPromotionRecord and every |
| +// case below reads as a valid record. |
| +func TestReaderHoldsAScopedRecordToItsGrant(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + scoped, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) // canary and edge |
| + grant := forgeGrantSub(t, root, g) |
| + t.Run("control", func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + rec.Channel, rec.At = "edge", g.ExpiresAt |
| + putSignedRecord(t, s, rec, scoped, grant) |
| + assertRecordReads(t, s, rootPub, rec) |
| + }) |
| + for _, tc := range []struct { |
| + name string |
| + channel string |
| + at time.Time |
| + want string |
| + }{ |
| + {"stable", "stable", g.MintedAt.Add(time.Hour), "never delegable"}, |
| + {"a rung the grant does not cover", "beta", g.MintedAt.Add(time.Hour), "is on rung beta"}, |
| + {"dated after the grant expired", "canary/x", g.ExpiresAt.Add(time.Second), "expired"}, |
| + {"dated before the grant was minted", "canary/x", g.MintedAt.Add(-time.Second), "predates"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s, rec := handBuiltRecord(t) |
| + rec.Channel, rec.At = tc.channel, tc.at |
| + putSignedRecord(t, s, rec, scoped, grant) |
| + assertRecordRefused(t, s, rootPub, rec, tc.want) |
| + }) |
| + } |
| +} |
diff --git a/internal/release/prune.go b/internal/release/prune.go| index ca41da232..9fe60d0dc 100644 |
| --- a/internal/release/prune.go |
| +++ b/internal/release/prune.go |
| @@ -13,6 +13,7 @@ import ( |
| "crypto/ed25519" |
| "errors" |
| "fmt" |
| + "os" |
| "strings" |
| "time" |
| ) |
| @@ -137,6 +138,11 @@ func claimRawDeleteVersion(ctx context.Context, s Store, acct, app, version stri |
| return err |
| } |
| } |
| + // The version's promotion record goes with it (spec §4.4 keeps only a |
| + // protected build's record). Idempotent like every delete here. |
| + if err := s.Delete(ctx, PromotionRecordKey(acct, app, version)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| return nil |
| } |
| func DeleteVersion(ctx context.Context, s Store, acct, app, version string) (claimResult0 error) { |
diff --git a/internal/release/ratchet.go b/internal/release/ratchet.go| index 2ffcb286e..f88c01a7d 100644 |
| --- a/internal/release/ratchet.go |
| +++ b/internal/release/ratchet.go |
| @@ -30,6 +30,19 @@ var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") |
| // durable, because a restart would otherwise forget the channel (Astra I1 #4). |
| var ErrRatchetUnpersisted = errors.New("release: the pointer digest mark could not be persisted") |
| +// errRatchetMarkUnreadable marks Admit's refusal of a pointer without |
| +// digests because the channel's mark could not be read: unlike the flag or |
| +// a mark that is there, it is not a verdict, so a caller that can retry |
| +// (the held stage) does. markUnreadable carries it without changing the |
| +// error's text. |
| +var errRatchetMarkUnreadable = errors.New("release: the pointer digest mark could not be read") |
| + |
| +type markUnreadable struct{ err error } |
| + |
| +func (e markUnreadable) Error() string { return e.err.Error() } |
| +func (e markUnreadable) Unwrap() error { return e.err } |
| +func (markUnreadable) Is(target error) bool { return target == errRatchetMarkUnreadable } |
| + |
| // RequirePointerDigest reads the flag: 1, true, on or yes in any case. |
| func RequirePointerDigest(getenv func(string) string) bool { |
| switch strings.ToLower(strings.TrimSpace(getenv(RequirePointerDigestEnv))) { |
| @@ -102,7 +115,7 @@ func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error { |
| marked, err := r.marked(acct, p.App, p.Channel) |
| if err != nil { |
| return fmt.Errorf("%w: %s/%s/%s, and this box cannot tell whether it has already verified one with digests for that channel: %w", |
| - ErrPointerWithoutDigests, acct, p.App, p.Channel, err) |
| + ErrPointerWithoutDigests, acct, p.App, p.Channel, markUnreadable{err}) |
| } |
| if marked { |
| return fmt.Errorf("%w: %s/%s/%s, and this box has already verified one with digests for that channel", |
diff --git a/internal/release/record_lifecycle_test.go b/internal/release/record_lifecycle_test.go| new file mode 100644 |
| index 000000000..6729a71b4 |
| --- /dev/null |
| +++ b/internal/release/record_lifecycle_test.go |
| @@ -0,0 +1,53 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Review Focus 5. |
| +func TestPruneThenReshipGetsAFreshRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("the pruned version's record survived: %v", err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one again, other bytes")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/y", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil { |
| + t.Fatalf("the reshipped, promoted name has no valid record: %v", err) |
| + } |
| +} |
| + |
| +func TestPurgeRemovesEveryRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Make the app purgeable: trash it and backdate the tombstone |
| + if err := TrashApp(ctx, s, "bab", "km", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "km", time.Now().Add(31*24*time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, _ := s.List(ctx, PromotedPrefix("bab", "km")) |
| + if len(keys) != 0 { |
| + t.Fatalf("records survived the purge: %v", keys) |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| index 97693744a..d77987961 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -876,6 +876,24 @@ func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channe |
| return &AuthorizationError{Err: aerr} |
| } |
| } |
| + // The durable record of this promotion (spec §4.1), from the bytes the |
| + // publisher shipped: after every check that can refuse the write (the |
| + // retry guard, the authoritative authorize callback), and BEFORE the |
| + // pointer moves, so a refused promotion leaves nothing behind and a |
| + // published head never lacks one. Only a NEW head is recorded: a renewal |
| + // (--retry) keeps the head it found, and signing that head's current |
| + // bytes here would be the automatic backfill §4.1 forbids. A legacy head |
| + // is authorized only by `carlos releases authorize`. |
| + if old.Version != version { |
| + actor := "" |
| + if len(rec) > 0 { |
| + actor = rec[0].Actor |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version, |
| + ManifestDigest: ManifestDigest(shippedRaw), Channel: channel, Actor: actor}); err != nil { |
| + return fmt.Errorf("promote: write the promotion record for %s: %w", version, err) |
| + } |
| + } |
| next := &ChannelPointer{Account: acct, App: app, Channel: channel, Version: version, |
| History: old.History, Audit: old.Audit} |
| if old.Version != "" && old.Version != version { |
diff --git a/internal/release/stage_reuse_test.go b/internal/release/stage_reuse_test.go| index d50c5804a..31ec8093f 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) |
| } |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index 62cbeed62..797f09e60 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -286,6 +286,14 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str |
| return fmt.Errorf("release: list %s: %w", LedgerAppPrefix(acct, app), err) |
| } |
| keys = append(keys, ledgerKeys...) |
| + // The app's promotion records (settings spec §4.1) live outside its |
| + // prefix too, for the same reason as the ledgers: a new app of this name |
| + // must not find them. |
| + promoted, err := s.List(ctx, PromotedPrefix(acct, app)) |
| + if err != nil { |
| + return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err) |
| + } |
| + keys = append(keys, promoted...) |
| for _, key := range keys { |
| if key == marker { |
| continue // last, below |
diff --git a/internal/updatewords/words.go b/internal/updatewords/words.go| new file mode 100644 |
| index 000000000..7f9d5d576 |
| --- /dev/null |
| +++ b/internal/updatewords/words.go |
| @@ -0,0 +1,50 @@ |
| +// Package updatewords holds every member-facing string the instance update |
| +// settings plans add (plans 2 to 5), as drafts with an ID and a context. The |
| +// copy review (plan 5, Task 1) exports All(), Paul rewrites them, and the |
| +// approved text replaces each Text; a test then pins them to the approved |
| +// record. Code reads a string only through Text, so a reviewed string has one |
| +// home. Each plan adds its own words_*.go file, so parallel branches never |
| +// edit the same lines. |
| +package updatewords |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| +) |
| + |
| +// Draft is one string: Context says where it appears and what it does, for |
| +// the reviewer who cannot see the screen. |
| +type Draft struct{ ID, Text, Context string } |
| + |
| +var drafts = map[string]Draft{} |
| + |
| +// add registers drafts from a file's init. A duplicate ID is a programming |
| +// error and panics at start-up, never at the moment a member reads it. |
| +func add(ds ...Draft) { |
| + for _, d := range ds { |
| + if _, dup := drafts[d.ID]; dup { |
| + panic(fmt.Sprintf("updatewords: duplicate id %s", d.ID)) |
| + } |
| + drafts[d.ID] = d |
| + } |
| +} |
| + |
| +// Text is the string for id. An unknown id panics: it can only be a typo in |
| +// code, which the package's tests catch. |
| +func Text(id string) string { |
| + d, ok := drafts[id] |
| + if !ok { |
| + panic(fmt.Sprintf("updatewords: unknown id %s", id)) |
| + } |
| + return d.Text |
| +} |
| + |
| +// All is every draft, sorted by ID. |
| +func All() []Draft { |
| + out := make([]Draft, 0, len(drafts)) |
| + for _, d := range drafts { |
| + out = append(out, d) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) |
| + return out |
| +} |
diff --git a/internal/updatewords/words_approved_test.go b/internal/updatewords/words_approved_test.go| new file mode 100644 |
| index 000000000..b18168ccc |
| --- /dev/null |
| +++ b/internal/updatewords/words_approved_test.go |
| @@ -0,0 +1,79 @@ |
| +package updatewords |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The settings-2 copy Paul approved (2026-10-09). The record must name exactly |
| +// the IDs of All(), each text must be what Text returns byte for byte, and the |
| +// CLI docs page must carry the authorize paragraph verbatim. |
| +func TestApprovedCopyIsApplied(t *testing.T) { |
| + raw, err := os.ReadFile("../../docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rec struct { |
| + ApprovedBy string `json:"approved_by"` |
| + ApprovedOn string `json:"approved_on"` |
| + Entries []struct{ ID, File, Text string } |
| + } |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.ApprovedBy == "" || rec.ApprovedOn == "" { |
| + t.Fatal("the record has no approval") |
| + } |
| + var got, want []string |
| + docs := "" |
| + for _, e := range rec.Entries { |
| + got = append(got, e.ID) |
| + if e.Text == "" { |
| + t.Errorf("%s: the approved text is empty", e.ID) |
| + continue |
| + } |
| + if e.File != "internal/updatewords/words_authorize.go" { |
| + t.Errorf("%s: unexpected file %s", e.ID, e.File) |
| + } |
| + if e.ID == "settings.authorize.docs" { |
| + docs = e.Text |
| + } |
| + if !has(e.ID) { |
| + continue // reported by the ID comparison below |
| + } |
| + if txt := Text(e.ID); txt != e.Text { |
| + t.Errorf("%s: text is %q, approved %q", e.ID, txt, e.Text) |
| + } |
| + } |
| + for _, d := range All() { |
| + want = append(want, d.ID) |
| + } |
| + sort.Strings(got) |
| + sort.Strings(want) |
| + if strings.Join(got, "\n") != strings.Join(want, "\n") { |
| + t.Errorf("record IDs %v, want the IDs of All() %v", got, want) |
| + } |
| + |
| + if docs == "" { |
| + t.Fatal("the record has no settings.authorize.docs entry") |
| + } |
| + page, err := os.ReadFile("../../docs/site/cli.md") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(string(page), docs) { |
| + t.Errorf("docs/site/cli.md does not contain the approved authorize paragraph %q", docs) |
| + } |
| +} |
| + |
| +func has(id string) bool { |
| + for _, d := range All() { |
| + if d.ID == id { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/updatewords/words_authorize.go b/internal/updatewords/words_authorize.go| new file mode 100644 |
| index 000000000..6aefb970d |
| --- /dev/null |
| +++ b/internal/updatewords/words_authorize.go |
| @@ -0,0 +1,57 @@ |
| +package updatewords |
| + |
| +func init() { |
| + add( |
| + Draft{ID: "settings.evidence.no-record", |
| + Text: "no promotion record: pick a newer build, or ask an operator to authorize this one", |
| + Context: "Why a pin, an install request or a recovery onto an older build was refused: the build was promoted before promotion records existed, or never promoted. Shown in carlos instances list, on the instance page, and returned by the console when a pin is set."}, |
| + Draft{ID: "settings.authorize.intro", |
| + Text: "Authorize %s %s with this manifest:", |
| + Context: "First line of carlos releases authorize, above the digests. App and version."}, |
| + Draft{ID: "settings.authorize.manifest-row", |
| + Text: " manifest %s", |
| + Context: "The manifest's sha256, the one digest the signed record will carry."}, |
| + Draft{ID: "settings.authorize.artifact-row", |
| + Text: " %s %s", |
| + Context: "One line per file of the build: its platform (or site) and its sha256, so the person can check them against what they built."}, |
| + Draft{ID: "settings.authorize.prompt", |
| + Text: "Sign a promotion record for exactly these bytes? Type yes to continue: ", |
| + Context: "Asked after the digests. Anything but yes signs nothing. --yes skips it."}, |
| + Draft{ID: "settings.authorize.done", |
| + Text: "authorized %s %s: it can now be pinned and recovered on any box", |
| + Context: "Printed after a record is signed. App and version."}, |
| + Draft{ID: "settings.authorize.already", |
| + Text: "%s %s already has a promotion record for these bytes; nothing to sign", |
| + Context: "Printed when the build already has a record with the same digest."}, |
| + Draft{ID: "settings.authorize.aborted", |
| + Text: "nothing signed", |
| + Context: "Printed when the person did not type yes."}, |
| + Draft{ID: "settings.authorize.changed", |
| + Text: "the manifest of %s changed since it was shown. Nothing was signed. Run the command again", |
| + Context: "Returned when someone reshipped the version name between showing the digests and the confirmation. %s is the version."}, |
| + Draft{ID: "settings.authorize.root-only", |
| + Text: "authorizing a build needs the deployment's root key. A scoped key cannot do it", |
| + Context: "Returned by carlos releases authorize against a bucket directly with a scoped CARLOS_RELEASE_KEY."}, |
| + Draft{ID: "settings.authorize.not-shipped", |
| + Text: "%s has not been shipped, or its manifest cannot be read", |
| + Context: "Returned when the version named does not exist. %s is the version."}, |
| + Draft{ID: "settings.authorize.no-key", |
| + Text: "this console has no release key, so it cannot sign a promotion record", |
| + Context: "Returned by the console's authorize door when the console was started without the deployment's release key. Shown by carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.write-failed", |
| + Text: "could not confirm the promotion record was written. Run the command again to check", |
| + Context: "Returned by the console's authorize door when the record write failed or its outcome is unknown. Running the command again reports \"already\" if the record was written, or signs it. Shown by carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.usage", |
| + Text: "usage: carlos releases authorize [--app <a>] [--account <sqid-or-name>] [--yes] <version>", |
| + Context: "Printed when carlos releases authorize is run with the wrong arguments."}, |
| + Draft{ID: "settings.authorize.flag-yes", |
| + Text: "sign without asking", |
| + Context: "Help text for the --yes flag of carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.flag-console", |
| + Text: "console origin to authorize through (API mode only; default: the only logged-in one)", |
| + Context: "Help text for the --console flag of carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.docs", |
| + Text: "`carlos releases authorize <app> <version>` is for operators. It shows a build's manifest and file digests and, when you type yes, signs a promotion record for exactly those bytes. Every promotion writes one; this is for a build promoted before records existed, so an instance can be pinned to it or recovered onto it on a box that does not already have it.", |
| + Context: "The CLI docs page's paragraph about carlos releases authorize."}, |
| + ) |
| +} |
diff --git a/internal/updatewords/words_test.go b/internal/updatewords/words_test.go| new file mode 100644 |
| index 000000000..0d452269e |
| --- /dev/null |
| +++ b/internal/updatewords/words_test.go |
| @@ -0,0 +1,46 @@ |
| +package updatewords |
| + |
| +import ( |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestDraftsAreWellFormed(t *testing.T) { |
| + all := All() |
| + if len(all) == 0 { |
| + t.Fatal("no drafts registered") |
| + } |
| + if !sort.SliceIsSorted(all, func(i, j int) bool { return all[i].ID < all[j].ID }) { |
| + t.Fatal("All is not sorted by ID") |
| + } |
| + for _, d := range all { |
| + if !strings.HasPrefix(d.ID, "settings.") || d.Text == "" || d.Context == "" { |
| + t.Errorf("%q: every draft needs a settings.* ID, a text and a context", d.ID) |
| + } |
| + if strings.ContainsRune(d.Text, '—') { |
| + t.Errorf("%s: no em dashes in member-facing text", d.ID) |
| + } |
| + if Text(d.ID) != d.Text { |
| + t.Errorf("%s: Text disagrees with All", d.ID) |
| + } |
| + } |
| +} |
| + |
| +func TestUnknownIDPanics(t *testing.T) { |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("an unknown ID did not panic") |
| + } |
| + }() |
| + Text("settings.no.such.string") |
| +} |
| + |
| +func TestDuplicateIDPanics(t *testing.T) { |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("a duplicate ID did not panic") |
| + } |
| + }() |
| + add(Draft{ID: "settings.authorize.done", Text: "x", Context: "y"}) |
| +} |
diff --git a/main.go b/main.go| index 17d8cfcfc..21bff32f4 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -120,13 +120,18 @@ func main() { |
| case "skills": |
| runSkills(args) |
| case "releases": |
| - // One sub-verb, `retention` — the `carlos version target` judgment |
| - // call: flags-only verbs stay flat until a word genuinely names a |
| - // different action, and a policy write is not a listing. |
| + // Two sub-verbs, `retention` and `authorize` — the `carlos version |
| + // target` judgment call: flags-only verbs stay flat until a word |
| + // genuinely names a different action, and a policy write or a |
| + // signature is not a listing. |
| if len(args) > 0 && args[0] == "retention" { |
| runReleasesRetention(args[1:]) |
| return |
| } |
| + if len(args) > 0 && args[0] == "authorize" { |
| + runReleasesAuthorize(args[1:]) |
| + return |
| + } |
| runReleases(args) |
| case "rollback": |
| runRollback(args) |
diff --git a/releases_authorize.go b/releases_authorize.go| new file mode 100644 |
| index 000000000..ebc005164 |
| --- /dev/null |
| +++ b/releases_authorize.go |
| @@ -0,0 +1,117 @@ |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +var authorizeInput io.Reader = os.Stdin |
| + |
| +// runReleasesAuthorize is `carlos releases authorize [--app <a>] <version>` |
| +// (also `<app> <version>`, as the spec writes it): show a build's manifest |
| +// and artifact digests, and on "yes" sign a promotion record for them |
| +// (settings spec §4.1). Never automatic: nothing else backfills a record. |
| +func runReleasesAuthorize(args []string) { |
| + fs := flag.NewFlagSet("releases authorize", flag.ExitOnError) |
| + app := appFlag(fs, "") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + yes := fs.Bool("yes", false, updatewords.Text("settings.authorize.flag-yes")) |
| + consoleFlag := consoleOption(fs, updatewords.Text("settings.authorize.flag-console")) |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args) |
| + usage := updatewords.Text("settings.authorize.usage") |
| + pos := fs.Args() |
| + if len(pos) == 2 && *app == "" { |
| + *app, pos = pos[0], pos[1:] |
| + } |
| + if len(pos) != 1 { |
| + log.Fatal(usage) |
| + } |
| + version := pos[0] |
| + show := func(digest string, arts map[string]release.Artifact) bool { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.intro")+"\n", *app, version) |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.manifest-row")+"\n", digest) |
| + keys := make([]string, 0, len(arts)) |
| + for k := range arts { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.artifact-row")+"\n", k, arts[k].SHA256) |
| + } |
| + if *yes { |
| + return true |
| + } |
| + fmt.Fprint(stdout, updatewords.Text("settings.authorize.prompt")) |
| + line, _ := bufio.NewReader(authorizeInput).ReadString('\n') |
| + if strings.TrimSpace(line) != "yes" { |
| + fmt.Fprintln(stdout, updatewords.Text("settings.authorize.aborted")) |
| + return false |
| + } |
| + return true |
| + } |
| + done := func(already bool) { |
| + if already { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.already")+"\n", *app, version) |
| + return |
| + } |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.done")+"\n", *app, version) |
| + } |
| + |
| + ctx := context.Background() |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil { |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + *app = resolvedApp(sess, acct, *app, usage) |
| + plan, err := sess.authorizePlan(ctx, acct, *app, version) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !show(plan.ManifestDigest, plan.Artifacts) { |
| + return |
| + } |
| + already, err := sess.authorizeSign(ctx, acct, *app, version, plan.ManifestDigest) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + done(already) |
| + return |
| + } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + s, acct := openStore(), acctOf() |
| + plan, err := release.PlanAuthorize(ctx, s, acct, *app, version) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !show(plan.ManifestDigest, plan.Artifacts) { |
| + return |
| + } |
| + already, err := releaseSigner().Authorize(ctx, s, plan, promoteActor()) |
| + if errors.Is(err, release.ErrAuthorizeRootOnly) { |
| + log.Fatal(updatewords.Text("settings.authorize.root-only")) |
| + } |
| + if errors.Is(err, release.ErrAuthorizeChanged) { |
| + log.Fatalf(updatewords.Text("settings.authorize.changed"), version) |
| + } |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + done(already) |
| +} |
diff --git a/releases_authorize_test.go b/releases_authorize_test.go| new file mode 100644 |
| index 000000000..c9c0e6134 |
| --- /dev/null |
| +++ b/releases_authorize_test.go |
| @@ -0,0 +1,44 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +func runAuthorize(t *testing.T, answer string, args ...string) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + prevOut, prevIn := stdout, authorizeInput |
| + stdout, authorizeInput = &out, strings.NewReader(answer) |
| + t.Cleanup(func() { stdout, authorizeInput = prevOut, prevIn }) |
| + runReleasesAuthorize(args) |
| + return out.String() |
| +} |
| + |
| +func TestReleasesAuthorizeAsksThenSigns(t *testing.T) { |
| + s, pub := directDigestStore(t) // plan 1's helper: km v1 shipped and promoted to stable |
| + ctx := context.Background() |
| + if err := s.Delete(ctx, release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := runAuthorize(t, "no\n", "--account-id", "bab", "km", "v1") |
| + if !strings.Contains(out, "linux-amd64") || !strings.Contains(out, updatewords.Text("settings.authorize.aborted")) { |
| + t.Fatalf("answering no:\n%s", out) |
| + } |
| + if _, err := release.ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err == nil { |
| + t.Fatal("answering no wrote a record") |
| + } |
| + runAuthorize(t, "yes\n", "--account-id", "bab", "--app", "km", "v1") |
| + if rec, err := release.ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err != nil || !rec.Authorized { |
| + t.Fatalf("answering yes: %+v, %v", rec, err) |
| + } |
| + out = runAuthorize(t, "", "--account-id", "bab", "--yes", "km", "v1") |
| + if !strings.Contains(out, "already") { |
| + t.Fatalf("a second authorize:\n%s", out) |
| + } |
| +} |