| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/base64" |
| 8 | "encoding/json" |
| 9 | "reflect" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | // The wire shape of a channel pointer, pinned. Every deployment bucket in |
| 17 | // production holds objects that look EXACTLY like this right now, written by |
| 18 | // binaries that predate whatever is being changed today; they have to keep |
| 19 | // parsing and keep verifying, byte for byte, forever. So this fixture is not |
| 20 | // regenerated when the shape changes — a diff here means live pointers stopped |
| 21 | // verifying, which is a fleet that cannot resolve a channel. |
| 22 | // |
| 23 | // Captured from the signing path on f1cc914 with a deterministic seed |
| 24 | // (bytes 1..32) and a frozen promoted_at, over a payload carrying every |
| 25 | // optional field a real pointer has ever had: history and an audit trail. |
| 26 | const goldenRootSignedWrapper = `{ |
| 27 | "payload": "ewogICJzY2hlbWEiOiAxLAogICJhcHAiOiAia2FzcyIsCiAgImNoYW5uZWwiOiAiY2FuYXJ5L3BsYXRmb3JtLWFkb3B0aW9uIiwKICAidmVyc2lvbiI6ICI5ZjNjMWFkIiwKICAicHJvbW90ZWRfYXQiOiAiMjAyNi0wNy0zMFQxMTowNDoxN1oiLAogICJoaXN0b3J5IjogWwogICAgIjFiMmU0YzciLAogICAgIjBhYzkxZGUiCiAgXSwKICAiYXVkaXQiOiBbCiAgICB7CiAgICAgICJ2ZXJzaW9uIjogIjlmM2MxYWQiLAogICAgICAiZnJvbSI6ICJlZGdlIiwKICAgICAgImFjdG9yIjogIm93bmVyQGV4YW1wbGUudGVzdCIsCiAgICAgICJhdCI6ICIyMDI2LTA3LTMwVDExOjA0OjE3WiIKICAgIH0KICBdCn0=", |
| 28 | "sig": "NZ8OU/xsAUtv5uSDzlTRJnktVXG2KD8T0ko5JogagJHdVLxC19tucSiul9nVKDNsldGdL4t09AC5nznBicuuDA==" |
| 29 | }` |
| 30 | |
| 31 | // goldenRootPub is the public half of the fixture's signer. |
| 32 | const goldenRootPub = "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=" |
| 33 | |
| 34 | // A pointer written by an older binary — no grant field at all — verifies |
| 35 | // against the root public key and decodes to exactly what it said. |
| 36 | // |
| 37 | // Mutation-mindedness: if grant handling were ever made mandatory (require a |
| 38 | // grant, or reject a wrapper without one), this test goes red immediately, |
| 39 | // because the entire live fleet is on wrappers shaped like this one. |
| 40 | func TestGoldenRootSignedWrapperStillVerifies(t *testing.T) { |
| 41 | pub, err := ParsePublicKey(goldenRootPub, "golden") |
| 42 | if err != nil { |
| 43 | t.Fatalf("ParsePublicKey: %v", err) |
| 44 | } |
| 45 | p, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), "bab", "kass", "canary/platform-adoption") |
| 46 | if err != nil { |
| 47 | t.Fatalf("golden root-signed pointer no longer verifies: %v", err) |
| 48 | } |
| 49 | want := &ChannelPointer{ |
| 50 | Schema: 1, |
| 51 | App: "kass", |
| 52 | Channel: "canary/platform-adoption", |
| 53 | Version: "9f3c1ad", |
| 54 | PromotedAt: time.Date(2026, 7, 30, 11, 4, 17, 0, time.UTC), |
| 55 | History: []string{"1b2e4c7", "0ac91de"}, |
| 56 | Audit: []Promotion{{ |
| 57 | Version: "9f3c1ad", |
| 58 | From: "edge", |
| 59 | Actor: "owner@example.test", |
| 60 | At: time.Date(2026, 7, 30, 11, 4, 17, 0, time.UTC), |
| 61 | }}, |
| 62 | } |
| 63 | if !reflect.DeepEqual(p, want) { |
| 64 | t.Fatalf("golden pointer decoded as\n%+v\nwant\n%+v", p, want) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // The wrapper a root key writes TODAY has exactly two keys. A new wrapper |
| 69 | // field that is not omitted when empty would change every pointer the fleet |
| 70 | // writes and break old readers' strictness assumptions; this is the assertion |
| 71 | // that catches a forgotten `omitempty`. |
| 72 | // |
| 73 | // Mutation-mindedness: drop `,omitempty` from any wrapper field added later |
| 74 | // and this fails with the extra key named. |
| 75 | func TestRootSignedWrapperHasOnlyPayloadAndSig(t *testing.T) { |
| 76 | for _, tc := range []struct { |
| 77 | name string |
| 78 | wrapper []byte |
| 79 | }{ |
| 80 | {"golden fixture", []byte(goldenRootSignedWrapper)}, |
| 81 | {"freshly signed", freshRootWrapper(t)}, |
| 82 | } { |
| 83 | var keys map[string]json.RawMessage |
| 84 | if err := json.Unmarshal(tc.wrapper, &keys); err != nil { |
| 85 | t.Fatalf("%s: %v", tc.name, err) |
| 86 | } |
| 87 | got := make([]string, 0, len(keys)) |
| 88 | for k := range keys { |
| 89 | got = append(got, k) |
| 90 | } |
| 91 | sort.Strings(got) |
| 92 | if want := []string{"payload", "sig"}; !reflect.DeepEqual(got, want) { |
| 93 | t.Errorf("%s: wrapper keys = %v, want %v", tc.name, got, want) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // freshRootWrapper promotes with a root key through the real store path and |
| 99 | // returns the bytes that landed in the bucket — the shape a fleet reads. |
| 100 | func freshRootWrapper(t *testing.T) []byte { |
| 101 | t.Helper() |
| 102 | s := NewDirStore(t.TempDir()) |
| 103 | ctx := context.Background() |
| 104 | priv, _ := testKeys(t) |
| 105 | shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("bytes")) |
| 106 | if err := Promote(ctx, s, priv, "bab", "kass", "canary/platform-adoption", "9f3c1ad"); err != nil { |
| 107 | t.Fatalf("Promote: %v", err) |
| 108 | } |
| 109 | raw, _, err := s.Get(ctx, ChannelKey("bab", "kass", "canary/platform-adoption")) |
| 110 | if err != nil { |
| 111 | t.Fatalf("read back pointer: %v", err) |
| 112 | } |
| 113 | return raw |
| 114 | } |
| 115 | |
| 116 | // The signature covers the base64 payload STRING, not the decoded JSON, so no |
| 117 | // canonicalisation question can ever arise. Pinned because a refactor to |
| 118 | // "verify the decoded bytes" would silently invalidate every live pointer. |
| 119 | // |
| 120 | // Mutation-mindedness: change signPointer/verifyPointer to sign the decoded |
| 121 | // payload and the golden fixture stops verifying; this test says why. |
| 122 | func TestSignatureCoversTheBase64PayloadString(t *testing.T) { |
| 123 | pub, err := ParsePublicKey(goldenRootPub, "golden") |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | var sw signedWrapper |
| 128 | if err := json.Unmarshal([]byte(goldenRootSignedWrapper), &sw); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| 136 | t.Fatal("golden signature does not cover the base64 payload string") |
| 137 | } |
| 138 | decoded, err := base64.StdEncoding.DecodeString(sw.Payload) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if ed25519.Verify(pub, decoded, sig) { |
| 143 | t.Fatal("golden signature covers the DECODED payload — the scheme changed") |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // The manifest shape as it exists in every production bucket TODAY: schema 1, |
| 148 | // no "capabilities" key, written by binaries that predate the field. It joins |
| 149 | // the byte-identical guarantees above for the same reason they exist — these |
| 150 | // objects are immutable at their bucket paths, so they can never be rewritten |
| 151 | // into a newer shape, and a binary that stopped parsing them would be a fleet |
| 152 | // that cannot stage a single running version. |
| 153 | // |
| 154 | // Mutation-mindedness: make Capabilities a non-pointer, make "v" required at |
| 155 | // the manifest level, or put DisallowUnknownFields anywhere on the manifest |
| 156 | // read path, and this test goes red. |
| 157 | func TestYesterdaysManifestParsesWithNoCapabilities(t *testing.T) { |
| 158 | // A var, not a const: strings.Repeat is not a constant expression. |
| 159 | yesterday := `{ |
| 160 | "schema": 1, |
| 161 | "app": "kass", |
| 162 | "version": "9f3c1ad", |
| 163 | "kind": "binary", |
| 164 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } } |
| 165 | }` |
| 166 | var m Manifest |
| 167 | if err := json.Unmarshal([]byte(yesterday), &m); err != nil { |
| 168 | t.Fatalf("a manifest already in every production bucket stopped parsing: %v", err) |
| 169 | } |
| 170 | if m.Capabilities != nil { |
| 171 | t.Fatalf("Capabilities = %+v, want nil (absent means none)", m.Capabilities) |
| 172 | } |
| 173 | if err := validateManifest(&m); err != nil { |
| 174 | t.Fatalf("validate: %v", err) |
| 175 | } |
| 176 | // And a capability-free manifest still marshals with NO capabilities key — |
| 177 | // which is what keeps Ship's putImmutable idempotent against bytes an |
| 178 | // older binary already wrote. |
| 179 | raw, err := json.Marshal(&m) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if bytes.Contains(raw, []byte("capabilities")) { |
| 184 | t.Fatalf("a capability-free manifest grew a key: %s", raw) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // The other direction of the same forward-compatibility contract: a manifest |
| 189 | // carrying keys THIS binary has never heard of at the TOP level still parses, |
| 190 | // because encoding/json ignores unknown fields and nothing on the manifest |
| 191 | // read path turns that off. That is what lets an old binary keep running |
| 192 | // against a bucket a newer one has shipped into, and it is why the pinned |
| 193 | // deploy order (platform binaries first, capability-bearing releases second) |
| 194 | // is belt-and-braces rather than load-bearing. |
| 195 | func TestAManifestWithFutureTopLevelKeysStillParses(t *testing.T) { |
| 196 | tomorrow := `{ |
| 197 | "schema": 1, |
| 198 | "app": "kass", |
| 199 | "version": "9f3c1ad", |
| 200 | "kind": "binary", |
| 201 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }, |
| 202 | "capabilities": { "v": 1, "daemon": true }, |
| 203 | "some_future_key": { "nested": [1, 2, 3] } |
| 204 | }` |
| 205 | var m Manifest |
| 206 | if err := json.Unmarshal([]byte(tomorrow), &m); err != nil { |
| 207 | t.Fatalf("unknown TOP-LEVEL keys must be ignored, not refused: %v", err) |
| 208 | } |
| 209 | if err := validateManifest(&m); err != nil { |
| 210 | t.Fatalf("validate: %v", err) |
| 211 | } |
| 212 | if m.Capabilities == nil || !m.Capabilities.Daemon { |
| 213 | t.Fatalf("Capabilities = %+v, want the daemon block decoded", m.Capabilities) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // A manifest from BEFORE the release versioning spec (2026-08-08): no |
| 218 | // "label" or "notes" keys at all, same as every manifest already sitting in |
| 219 | // a production bucket today. It has to keep parsing, and Label/Notes have to |
| 220 | // come back empty rather than erroring — that is the wire back-compat lock |
| 221 | // for the new fields, the same guarantee TestYesterdaysManifestParsesWithNoCapabilities |
| 222 | // gives Capabilities. |
| 223 | // |
| 224 | // Mutation-mindedness: make Label or Notes required, or reject a manifest |
| 225 | // missing either key, and this test goes red — which would mean every |
| 226 | // manifest shipped before today's change stopped parsing. |
| 227 | func TestYesterdaysManifestParsesWithNoLabelOrNotes(t *testing.T) { |
| 228 | yesterday := `{ |
| 229 | "schema": 1, |
| 230 | "app": "kass", |
| 231 | "version": "9f3c1ad", |
| 232 | "kind": "binary", |
| 233 | "commit": "9f3c1ad", |
| 234 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } } |
| 235 | }` |
| 236 | var m Manifest |
| 237 | if err := json.Unmarshal([]byte(yesterday), &m); err != nil { |
| 238 | t.Fatalf("a manifest already in every production bucket stopped parsing: %v", err) |
| 239 | } |
| 240 | if m.Label != "" || m.Notes != "" { |
| 241 | t.Fatalf("Label=%q Notes=%q, want both empty — absent means none", m.Label, m.Notes) |
| 242 | } |
| 243 | if err := validateManifest(&m); err != nil { |
| 244 | t.Fatalf("validate: %v", err) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // goldenLabeledManifest is a manifest carrying the release-versioning spec's |
| 249 | // two new fields: Label (a short one-line intent) and Notes (a changelog |
| 250 | // fragment), both written at ship time. Captured from json.MarshalIndent |
| 251 | // on schema 1 with every field the ship path populates set. |
| 252 | // |
| 253 | // Mutation-mindedness: reorder the Manifest struct's fields, drop |
| 254 | // `,omitempty` from Label or Notes, or rename either json tag, and the |
| 255 | // marshal half of this test fails; corrupt the decoder and the unmarshal |
| 256 | // half does. |
| 257 | // |
| 258 | // A var, not a const: strings.Repeat is not a constant expression (same |
| 259 | // reason TestYesterdaysManifestParsesWithNoCapabilities's "yesterday" is a |
| 260 | // var too). |
| 261 | var goldenLabeledManifest = `{ |
| 262 | "schema": 1, |
| 263 | "app": "kass", |
| 264 | "version": "9f3c1ad", |
| 265 | "kind": "binary", |
| 266 | "commit": "9f3c1ad", |
| 267 | "built_by": "ci@carlosframework.com", |
| 268 | "built_at": "2026-08-08T09:15:00Z", |
| 269 | "label": "fixing modals", |
| 270 | "notes": "- focus trap fixed\n- escape closes on nested dialogs", |
| 271 | "artifacts": { |
| 272 | "linux-arm64": { |
| 273 | "file": "linux-arm64", |
| 274 | "sha256": "` + strings.Repeat("ab", 32) + `", |
| 275 | "size": 12 |
| 276 | } |
| 277 | } |
| 278 | }` |
| 279 | |
| 280 | func goldenLabeledManifestValue() *Manifest { |
| 281 | return &Manifest{ |
| 282 | Schema: 1, |
| 283 | App: "kass", |
| 284 | Version: "9f3c1ad", |
| 285 | Kind: "binary", |
| 286 | Commit: "9f3c1ad", |
| 287 | BuiltBy: "ci@carlosframework.com", |
| 288 | BuiltAt: "2026-08-08T09:15:00Z", |
| 289 | Label: "fixing modals", |
| 290 | Notes: "- focus trap fixed\n- escape closes on nested dialogs", |
| 291 | Artifacts: map[string]Artifact{ |
| 292 | "linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 12}, |
| 293 | }, |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | // TestGoldenLabeledManifestRoundTrips is the labeled-manifest half of the |
| 298 | // wire-shape lock: the Go type marshals to exactly the golden bytes, and the |
| 299 | // golden bytes decode to exactly the Go value — both directions, so a |
| 300 | // regression in either the encoder or the decoder is caught here rather than |
| 301 | // downstream in Ship or console rendering. |
| 302 | func TestGoldenLabeledManifestRoundTrips(t *testing.T) { |
| 303 | want := goldenLabeledManifestValue() |
| 304 | raw, err := json.MarshalIndent(want, "", " ") |
| 305 | if err != nil { |
| 306 | t.Fatalf("marshal: %v", err) |
| 307 | } |
| 308 | if string(raw) != goldenLabeledManifest { |
| 309 | t.Fatalf("marshaled manifest =\n%s\nwant golden\n%s", raw, goldenLabeledManifest) |
| 310 | } |
| 311 | var got Manifest |
| 312 | if err := json.Unmarshal([]byte(goldenLabeledManifest), &got); err != nil { |
| 313 | t.Fatalf("unmarshal: %v", err) |
| 314 | } |
| 315 | if !reflect.DeepEqual(&got, want) { |
| 316 | t.Fatalf("golden manifest decoded as\n%+v\nwant\n%+v", got, want) |
| 317 | } |
| 318 | if err := validateManifest(&got); err != nil { |
| 319 | t.Fatalf("validate: %v", err) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // goldenVersionTarget is the version-target.json bucket object (release |
| 324 | // versioning spec, 2026-08-08; see target.go's TargetKey) — the per-app |
| 325 | // "what we're building toward" that SetTarget writes and GetTarget reads. |
| 326 | // Schema 1, every field set. |
| 327 | // |
| 328 | // Mutation-mindedness: reorder VersionTarget's fields, rename a json tag, or |
| 329 | // drop `,omitempty` from SetBy, and the marshal half fails; break the |
| 330 | // decoder's handling of set_at as RFC3339 and the unmarshal half does. |
| 331 | const goldenVersionTarget = `{ |
| 332 | "schema": 1, |
| 333 | "target": "0.5.0", |
| 334 | "set_by": "paul@example.com", |
| 335 | "set_at": "2026-08-08T09:15:00Z" |
| 336 | }` |
| 337 | |
| 338 | func goldenVersionTargetValue() *VersionTarget { |
| 339 | return &VersionTarget{ |
| 340 | Schema: 1, |
| 341 | Target: "0.5.0", |
| 342 | SetBy: "paul@example.com", |
| 343 | SetAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC), |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | // TestGoldenVersionTargetRoundTrips locks the version-target.json wire shape |
| 348 | // down in both directions, matching the pattern every other object in this |
| 349 | // file follows. |
| 350 | func TestGoldenVersionTargetRoundTrips(t *testing.T) { |
| 351 | want := goldenVersionTargetValue() |
| 352 | raw, err := json.MarshalIndent(want, "", " ") |
| 353 | if err != nil { |
| 354 | t.Fatalf("marshal: %v", err) |
| 355 | } |
| 356 | if string(raw) != goldenVersionTarget { |
| 357 | t.Fatalf("marshaled version target =\n%s\nwant golden\n%s", raw, goldenVersionTarget) |
| 358 | } |
| 359 | var got VersionTarget |
| 360 | if err := json.Unmarshal([]byte(goldenVersionTarget), &got); err != nil { |
| 361 | t.Fatalf("unmarshal: %v", err) |
| 362 | } |
| 363 | if !reflect.DeepEqual(&got, want) { |
| 364 | t.Fatalf("golden version target decoded as\n%+v\nwant\n%+v", got, want) |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | // goldenTagWithChangelog is a Tag as written by EnsureTag at promote-to-stable |
| 369 | // time: every field populated, including the compiled changelog spanning |
| 370 | // more than one iteration. Tags are unsigned read-side sugar (tag.go's |
| 371 | // package doc), so unlike a channel pointer this shape has no signature to |
| 372 | // pin — just the JSON. |
| 373 | // |
| 374 | // Mutation-mindedness: reorder Tag's fields, rename a json tag, or drop |
| 375 | // `,omitempty` from Commit/Actor/Changelog, and the marshal half fails. |
| 376 | const goldenTagWithChangelog = `{ |
| 377 | "schema": 1, |
| 378 | "version": "0.5.0", |
| 379 | "release": "0.5.0-3", |
| 380 | "commit": "9f3c1ad", |
| 381 | "tagged_at": "2026-08-08T09:15:00Z", |
| 382 | "actor": "paul@example.com", |
| 383 | "changelog": "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n" |
| 384 | }` |
| 385 | |
| 386 | func goldenTagWithChangelogValue() *Tag { |
| 387 | return &Tag{ |
| 388 | Schema: 1, |
| 389 | Version: "0.5.0", |
| 390 | Release: "0.5.0-3", |
| 391 | Commit: "9f3c1ad", |
| 392 | TaggedAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC), |
| 393 | Actor: "paul@example.com", |
| 394 | Changelog: "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n", |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | // TestGoldenTagWithChangelogRoundTrips locks the tag wire shape down in both |
| 399 | // directions. |
| 400 | func TestGoldenTagWithChangelogRoundTrips(t *testing.T) { |
| 401 | want := goldenTagWithChangelogValue() |
| 402 | raw, err := json.MarshalIndent(want, "", " ") |
| 403 | if err != nil { |
| 404 | t.Fatalf("marshal: %v", err) |
| 405 | } |
| 406 | if string(raw) != goldenTagWithChangelog { |
| 407 | t.Fatalf("marshaled tag =\n%s\nwant golden\n%s", raw, goldenTagWithChangelog) |
| 408 | } |
| 409 | var got Tag |
| 410 | if err := json.Unmarshal([]byte(goldenTagWithChangelog), &got); err != nil { |
| 411 | t.Fatalf("unmarshal: %v", err) |
| 412 | } |
| 413 | if !reflect.DeepEqual(&got, want) { |
| 414 | t.Fatalf("golden tag decoded as\n%+v\nwant\n%+v", got, want) |
| 415 | } |
| 416 | } |
| 417 | |