| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // tinyProgram builds a stripped linux/arm64 Go binary whose main package is |
| 16 | // modPath, exactly as the console is built (-s -w), at test time. Nothing is |
| 17 | // checked in. |
| 18 | func tinyProgram(t *testing.T, modPath string) []byte { |
| 19 | t.Helper() |
| 20 | if testing.Short() { |
| 21 | t.Skip("builds a binary") |
| 22 | } |
| 23 | dir := t.TempDir() |
| 24 | must := func(err error) { |
| 25 | t.Helper() |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | } |
| 30 | must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644)) |
| 31 | must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644)) |
| 32 | out := filepath.Join(dir, "bin") |
| 33 | cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".") |
| 34 | cmd.Dir = dir |
| 35 | cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false") |
| 36 | if b, err := cmd.CombinedOutput(); err != nil { |
| 37 | t.Fatalf("build %s: %v\n%s", modPath, err, b) |
| 38 | } |
| 39 | b, err := os.ReadFile(out) |
| 40 | must(err) |
| 41 | return b |
| 42 | } |
| 43 | |
| 44 | func TestArtifactProgram(t *testing.T) { |
| 45 | if got := ArtifactProgram(tinyProgram(t, "example.com/console")); got != "example.com/console" { |
| 46 | t.Fatalf("program = %q", got) |
| 47 | } |
| 48 | if got := ArtifactProgram([]byte("#!/bin/sh\necho hi\n")); got != "" { |
| 49 | t.Fatalf("non-Go program = %q, want empty", got) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestReleaseProgramDisagreement(t *testing.T) { |
| 54 | _, err := ReleaseProgram(map[string][]byte{ |
| 55 | "linux-arm64": tinyProgram(t, "example.com/console"), |
| 56 | "linux-amd64": tinyProgram(t, "example.com/carlos"), |
| 57 | }) |
| 58 | if err == nil || !strings.Contains(err.Error(), "example.com/console") || !strings.Contains(err.Error(), "example.com/carlos") { |
| 59 | t.Fatalf("err = %v, want both programs named", err) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func shipWithProgram(t *testing.T, s Store, ver string, art []byte, allow bool) error { |
| 64 | t.Helper() |
| 65 | files := map[string][]byte{adoptPlatform: art} |
| 66 | m := Manifest{Schema: 1, App: "console", Version: ver, Kind: "binary", |
| 67 | Artifacts: map[string]Artifact{adoptPlatform: {}}} // Ship fills file, sha256, size |
| 68 | if err := CheckShipProgram(context.Background(), s, "bab", &m, files, allow); err != nil { |
| 69 | return err |
| 70 | } |
| 71 | return Ship(context.Background(), s, "bab", m, files) |
| 72 | } |
| 73 | |
| 74 | func TestCheckShipProgram(t *testing.T) { |
| 75 | s := NewDirStore(t.TempDir()) |
| 76 | console := tinyProgram(t, "example.com/console") |
| 77 | cli := tinyProgram(t, "example.com/carlos") |
| 78 | if err := shipWithProgram(t, s, "v1", console, false); err != nil { |
| 79 | t.Fatalf("first release: %v", err) |
| 80 | } |
| 81 | err := shipWithProgram(t, s, "v2", cli, false) |
| 82 | var pm *ProgramMismatchError |
| 83 | if !errors.As(err, &pm) || pm.Got != "example.com/carlos" || pm.Want != "example.com/console" { |
| 84 | t.Fatalf("wrong program ship = %v, want ProgramMismatchError", err) |
| 85 | } |
| 86 | if !strings.Contains(err.Error(), "carlos ship --program-change") { |
| 87 | t.Fatalf("refusal %q does not name the override", err) |
| 88 | } |
| 89 | if err := shipWithProgram(t, s, "v3", cli, true); err != nil { |
| 90 | t.Fatalf("--program-change ship: %v", err) |
| 91 | } |
| 92 | m, err := GetManifest(context.Background(), s, "bab", "console", "v3") |
| 93 | if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange { |
| 94 | t.Fatalf("v3 manifest = %+v, %v; want program recorded and program_change true", m, err) |
| 95 | } |
| 96 | if err := shipWithProgram(t, s, "v4", []byte("#!/bin/sh\n"), false); err != nil { |
| 97 | t.Fatalf("non-Go artifact refused: %v", err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // sameProgram is b with one byte appended: different bytes (a different |
| 102 | // sha256, so a different release) whose build info still names the same |
| 103 | // program. |
| 104 | func sameProgram(b []byte) []byte { |
| 105 | return append(b[:len(b):len(b)], 0) |
| 106 | } |
| 107 | |
| 108 | // Ruling G1: --program-change is recorded whenever it is given, even when the |
| 109 | // newest release already has the same program. Ship A, then B1 with the flag, |
| 110 | // then B2 with the flag: B2 must carry the consent, because a box that missed |
| 111 | // B1 is still on A and decides by its own history. |
| 112 | func TestCheckShipProgramRecordsTheFlagWhenThePredecessorAgrees(t *testing.T) { |
| 113 | s := NewDirStore(t.TempDir()) |
| 114 | a, b1 := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") |
| 115 | b2 := sameProgram(b1) |
| 116 | if err := shipWithProgram(t, s, "va", a, false); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if err := shipWithProgram(t, s, "vb1", b1, true); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | if err := shipWithProgram(t, s, "vb2", b2, true); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | m, err := GetManifest(context.Background(), s, "bab", "console", "vb2") |
| 126 | if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange { |
| 127 | t.Fatalf("vb2 manifest = %+v, %v; want program_change true though vb1 already names the program", m, err) |
| 128 | } |
| 129 | // And without the flag a same-program ship records no change. |
| 130 | if err := shipWithProgram(t, s, "vb3", sameProgram(b2), false); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if m, err := GetManifest(context.Background(), s, "bab", "console", "vb3"); err != nil || m.ProgramChange { |
| 134 | t.Fatalf("vb3 manifest = %+v, %v; want no program_change", m, err) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // Every Go artifact of a release agreeing on one program passes, and the |
| 139 | // manifest names it. |
| 140 | func TestCheckShipProgramAgreeingArtifactsPass(t *testing.T) { |
| 141 | s := NewDirStore(t.TempDir()) |
| 142 | arm := tinyProgram(t, "example.com/console") |
| 143 | files := map[string][]byte{"linux-arm64": arm, "linux-amd64": sameProgram(arm)} |
| 144 | m := Manifest{Schema: 1, App: "console", Version: "v1", Kind: "binary", |
| 145 | Artifacts: map[string]Artifact{"linux-arm64": {}, "linux-amd64": {}}} |
| 146 | if err := CheckShipProgram(context.Background(), s, "bab", &m, files, false); err != nil { |
| 147 | t.Fatalf("agreeing artifacts: %v", err) |
| 148 | } |
| 149 | if m.Program != "example.com/console" { |
| 150 | t.Fatalf("program = %q, want example.com/console", m.Program) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // A static release is never judged or stamped, whatever its bytes are. |
| 155 | func TestCheckShipProgramSkipsStatic(t *testing.T) { |
| 156 | s := NewDirStore(t.TempDir()) |
| 157 | if err := shipWithProgram(t, s, "v1", tinyProgram(t, "example.com/console"), false); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | files := map[string][]byte{"site.tar.gz": tinyProgram(t, "example.com/carlos")} |
| 161 | m := Manifest{Schema: 1, App: "console", Version: "v2", Kind: "static", |
| 162 | Artifacts: map[string]Artifact{"site.tar.gz": {}}} |
| 163 | if err := CheckShipProgram(context.Background(), s, "bab", &m, files, false); err != nil { |
| 164 | t.Fatalf("static release judged: %v", err) |
| 165 | } |
| 166 | if m.Program != "" || m.ProgramChange { |
| 167 | t.Fatalf("static manifest stamped: program %q change %v", m.Program, m.ProgramChange) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // putManifest writes a raw manifest for app console under account bab. |
| 172 | func putManifest(t *testing.T, s Store, ver string, m Manifest) { |
| 173 | t.Helper() |
| 174 | raw, err := json.Marshal(m) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if _, err := s.Put(context.Background(), ManifestKey("bab", "console", ver), raw, ""); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // PreviousProgram picks by built_at, not by the order the store lists |
| 184 | // versions in. "z-new" was built last, but its manifest object is the older |
| 185 | // one (a re-upload, a copy between buckets), so the store's own ranking — |
| 186 | // newest object first, ties by version — lists "a-old" first. |
| 187 | func TestPreviousProgramOrdersByBuiltAt(t *testing.T) { |
| 188 | root := t.TempDir() |
| 189 | s := NewDirStore(root) |
| 190 | art := map[string]Artifact{adoptPlatform: {File: adoptPlatform, SHA256: strings.Repeat("a", 64), Size: 1}} |
| 191 | putManifest(t, s, "z-new", Manifest{Schema: 1, App: "console", Version: "z-new", Kind: "binary", |
| 192 | BuiltAt: "2026-06-01T00:00:00Z", Artifacts: art, Program: "example.com/new"}) |
| 193 | putManifest(t, s, "a-old", Manifest{Schema: 1, App: "console", Version: "a-old", Kind: "binary", |
| 194 | BuiltAt: "2026-01-01T00:00:00Z", Artifacts: art, Program: "example.com/old"}) |
| 195 | older, newer := time.Now().Add(-time.Hour), time.Now() |
| 196 | for ver, mod := range map[string]time.Time{"z-new": older, "a-old": newer} { |
| 197 | p := filepath.Join(root, filepath.FromSlash(ManifestKey("bab", "console", ver))) |
| 198 | if err := os.Chtimes(p, mod, mod); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | } |
| 202 | if vers, _, err := VersionCandidates(context.Background(), s, "bab", "console", 20); err != nil || len(vers) != 2 || vers[0] != "a-old" { |
| 203 | t.Fatalf("listing = %v, %v; the test needs a-old listed first", vers, err) |
| 204 | } |
| 205 | got, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 206 | if err != nil || got != "example.com/new" || ver != "z-new" { |
| 207 | t.Fatalf("PreviousProgram = %q %q %v, want example.com/new from z-new", got, ver, err) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // A manifest that parses but fails validation, and one that names another |
| 212 | // app or version than its key, are skipped; the older valid one answers. |
| 213 | func TestPreviousProgramSkipsInvalidAndMisnamedManifests(t *testing.T) { |
| 214 | s := NewDirStore(t.TempDir()) |
| 215 | if err := shipWithProgram(t, s, "v1", tinyProgram(t, "example.com/console"), false); err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | art := map[string]Artifact{adoptPlatform: {File: adoptPlatform, SHA256: strings.Repeat("a", 64), Size: 1}} |
| 219 | late := "2999-01-01T00:00:00Z" |
| 220 | // Fails validateManifest: schema 2. |
| 221 | putManifest(t, s, "v2", Manifest{Schema: 2, App: "console", Version: "v2", Kind: "binary", |
| 222 | BuiltAt: late, Artifacts: art, Program: "example.com/invalid"}) |
| 223 | // Valid, but declares another app. |
| 224 | putManifest(t, s, "v3", Manifest{Schema: 1, App: "other", Version: "v3", Kind: "binary", |
| 225 | BuiltAt: late, Artifacts: art, Program: "example.com/other-app"}) |
| 226 | // Valid, but declares another version. |
| 227 | putManifest(t, s, "v4", Manifest{Schema: 1, App: "console", Version: "v9", Kind: "binary", |
| 228 | BuiltAt: late, Artifacts: art, Program: "example.com/other-version"}) |
| 229 | got, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 230 | if err != nil || got != "example.com/console" || ver != "v1" { |
| 231 | t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", got, ver, err) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // A release from an old client has no program and must not switch the check |
| 236 | // off for the next ship. |
| 237 | func TestPreviousProgramSkipsReleasesWithoutOne(t *testing.T) { |
| 238 | s := NewDirStore(t.TempDir()) |
| 239 | if err := shipWithProgram(t, s, "v1", tinyProgram(t, "example.com/console"), false); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("old client, no program field")) |
| 243 | got, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 244 | if err != nil || got != "example.com/console" || ver != "v1" { |
| 245 | t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", got, ver, err) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // failingStore wraps a Store and fails Get for a specific key with a non-NotExist error. |
| 250 | type failingStore struct { |
| 251 | Store |
| 252 | failKey string |
| 253 | } |
| 254 | |
| 255 | func (fs *failingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 256 | if key == fs.failKey { |
| 257 | return nil, "", errors.New("simulated store failure") |
| 258 | } |
| 259 | return fs.Store.Get(ctx, key) |
| 260 | } |
| 261 | |
| 262 | // A store error (not just missing manifest) must fail the check closed. |
| 263 | func TestPreviousProgramFailsClosedOnStoreError(t *testing.T) { |
| 264 | base := NewDirStore(t.TempDir()) |
| 265 | console := tinyProgram(t, "example.com/console") |
| 266 | |
| 267 | // Ship v1 with console program, then v2 normally. |
| 268 | if err := shipWithProgram(t, base, "v1", console, false); err != nil { |
| 269 | t.Fatalf("ship v1: %v", err) |
| 270 | } |
| 271 | shipRelease(t, base, "bab", "console", "v2", adoptPlatform, []byte("v2 content")) |
| 272 | |
| 273 | // Wrap the store to fail on v1's manifest key. |
| 274 | manifestKey := ManifestKey("bab", "console", "v1") |
| 275 | s := &failingStore{Store: base, failKey: manifestKey} |
| 276 | |
| 277 | // PreviousProgram must return an error (not silently skip the failure). |
| 278 | prog, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 279 | if err == nil || prog != "" || ver != "" { |
| 280 | t.Fatalf("PreviousProgram = %q %q %v, want non-nil error", prog, ver, err) |
| 281 | } |
| 282 | |
| 283 | // CheckShipProgram must propagate the error. |
| 284 | files := map[string][]byte{adoptPlatform: console} |
| 285 | m := Manifest{Schema: 1, App: "console", Version: "v3", Kind: "binary", |
| 286 | Artifacts: map[string]Artifact{adoptPlatform: {}}} |
| 287 | checkErr := CheckShipProgram(context.Background(), s, "bab", &m, files, false) |
| 288 | if checkErr == nil { |
| 289 | t.Fatalf("CheckShipProgram returned nil, want error") |
| 290 | } |
| 291 | // The error should not be a ProgramMismatchError (which would indicate the check |
| 292 | // wrongly disabled itself and allowed the program change). |
| 293 | var pm *ProgramMismatchError |
| 294 | if errors.As(checkErr, &pm) { |
| 295 | t.Fatalf("CheckShipProgram = ProgramMismatchError, want other error: %v", checkErr) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // An in-flight release with no manifest yet is skipped gracefully. |
| 300 | func TestPreviousProgramSkipsAnInFlightRelease(t *testing.T) { |
| 301 | s := NewDirStore(t.TempDir()) |
| 302 | console := tinyProgram(t, "example.com/console") |
| 303 | |
| 304 | // Ship v1 with console program. |
| 305 | if err := shipWithProgram(t, s, "v1", console, false); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | |
| 309 | // Ship an artifact for v2 without a manifest (in-flight, manifest still being written). |
| 310 | // Write the artifact file directly. |
| 311 | artKey := ArtifactKey("bab", "console", "v2", adoptPlatform) |
| 312 | if _, err := s.Put(context.Background(), artKey, console, ""); err != nil { |
| 313 | t.Fatalf("put artifact: %v", err) |
| 314 | } |
| 315 | |
| 316 | // PreviousProgram should skip v2 (no manifest) and return v1. |
| 317 | prog, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 318 | if err != nil || prog != "example.com/console" || ver != "v1" { |
| 319 | t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", prog, ver, err) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // A corrupt (unparseable) manifest is skipped, and an older valid one is found. |
| 324 | func TestPreviousProgramSkipsACorruptManifest(t *testing.T) { |
| 325 | s := NewDirStore(t.TempDir()) |
| 326 | console := tinyProgram(t, "example.com/console") |
| 327 | |
| 328 | // Ship v1 with console program. |
| 329 | if err := shipWithProgram(t, s, "v1", console, false); err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | |
| 333 | // Write a corrupt manifest for v2 (not valid JSON). |
| 334 | manifestKey := ManifestKey("bab", "console", "v2") |
| 335 | if _, err := s.Put(context.Background(), manifestKey, []byte("not json at all"), ""); err != nil { |
| 336 | t.Fatalf("put corrupt manifest: %v", err) |
| 337 | } |
| 338 | |
| 339 | // PreviousProgram should skip v2 (corrupt manifest) and return v1. |
| 340 | prog, ver, err := PreviousProgram(context.Background(), s, "bab", "console") |
| 341 | if err != nil || prog != "example.com/console" || ver != "v1" { |
| 342 | t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", prog, ver, err) |
| 343 | } |
| 344 | } |
| 345 | |