| 1 | // The wrong-program check — D2 of the console release defences (spec |
| 2 | // 2026-10-06-console-release-defences-design.md §3). |
| 3 | // |
| 4 | // On 2026-10-06 the public carlos CLI binary was shipped as the console. Its |
| 5 | // architecture and its version stamp were both right, so nothing objected; |
| 6 | // what was wrong was the program. A Go binary records its main package path |
| 7 | // in its build info, readable from stripped bytes for any architecture |
| 8 | // without running them, so a ship can compare it with the app's previous |
| 9 | // release and a box can compare it with what its route has run. |
| 10 | package release |
| 11 | |
| 12 | import ( |
| 13 | "bytes" |
| 14 | "context" |
| 15 | "debug/buildinfo" |
| 16 | "encoding/json" |
| 17 | "errors" |
| 18 | "fmt" |
| 19 | "log" |
| 20 | "os" |
| 21 | "sort" |
| 22 | "strings" |
| 23 | ) |
| 24 | |
| 25 | // ArtifactProgram extracts the Go main package path from a binary's debug/buildinfo, |
| 26 | // returning "" if the bytes are not a Go binary or buildinfo is unreadable. |
| 27 | func ArtifactProgram(b []byte) string { |
| 28 | bi, err := buildinfo.Read(bytes.NewReader(b)) |
| 29 | if err != nil { |
| 30 | return "" |
| 31 | } |
| 32 | return bi.Path |
| 33 | } |
| 34 | |
| 35 | // BuildStampFlag is the linker flag that stamps a carlos build's version |
| 36 | // (internal/version.Build). Its value is a release build's tag. |
| 37 | const BuildStampFlag = "github.com/carlosframework/platform/internal/version.Build" |
| 38 | |
| 39 | // ArtifactBuild is the version.Build stamp a linux-arm64 Go binary's build |
| 40 | // info records: the value of the one "-X <BuildStampFlag>=<v>" in its |
| 41 | // recorded -ldflags. It is "" when the bytes fail CheckELFBounds, have no |
| 42 | // build info, or record the flag absent, more than once or in any other |
| 43 | // form, so a caller that needs a stamp refuses all of those alike. |
| 44 | // |
| 45 | // It reads the RECORDED flag, not the linked value of version.Build: a |
| 46 | // release binary is stripped (-s -w), so there is no symbol table to read |
| 47 | // the variable from, and a build's recorded flags could be edited without |
| 48 | // changing its linked value. Its caller (claims publish-identity) relies on |
| 49 | // the release key's authentication of the artefact: the signed pointer, |
| 50 | // manifest digest and sha256 only the operator can produce. Owed before |
| 51 | // production cohorts: trusted build attestation tied to the artefact digest |
| 52 | // (controller ruling F2, accepted as a trial gap). |
| 53 | func ArtifactBuild(b []byte) string { |
| 54 | if CheckELFBounds(b) != nil { |
| 55 | return "" |
| 56 | } |
| 57 | bi, err := buildinfo.Read(bytes.NewReader(b)) |
| 58 | if err != nil { |
| 59 | return "" |
| 60 | } |
| 61 | ldflags := "" |
| 62 | for _, s := range bi.Settings { |
| 63 | if s.Key == "-ldflags" { |
| 64 | ldflags = s.Value |
| 65 | } |
| 66 | } |
| 67 | fields := strings.Fields(ldflags) |
| 68 | stamp, seen := "", 0 |
| 69 | for i, f := range fields { |
| 70 | if strings.Contains(f, BuildStampFlag) { |
| 71 | seen++ |
| 72 | if i > 0 && fields[i-1] == "-X" && strings.HasPrefix(f, BuildStampFlag+"=") { |
| 73 | stamp = strings.TrimPrefix(f, BuildStampFlag+"=") |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | if seen != 1 { |
| 78 | return "" |
| 79 | } |
| 80 | return stamp |
| 81 | } |
| 82 | |
| 83 | // FileProgram extracts the Go main package path from a file at path, |
| 84 | // returning "" if the file is not a Go binary or buildinfo is unreadable. |
| 85 | func FileProgram(path string) string { |
| 86 | bi, err := buildinfo.ReadFile(path) |
| 87 | if err != nil { |
| 88 | return "" |
| 89 | } |
| 90 | return bi.Path |
| 91 | } |
| 92 | |
| 93 | // ReleaseProgram is the one program every Go artifact in files reports, "" |
| 94 | // when none is Go. |
| 95 | func ReleaseProgram(files map[string][]byte) (string, error) { |
| 96 | seen := map[string][]string{} |
| 97 | for name, b := range files { |
| 98 | if p := ArtifactProgram(b); p != "" { |
| 99 | seen[p] = append(seen[p], name) |
| 100 | } |
| 101 | } |
| 102 | if len(seen) > 1 { |
| 103 | var parts []string |
| 104 | for p, names := range seen { |
| 105 | sort.Strings(names) |
| 106 | parts = append(parts, fmt.Sprintf("%s is %s", strings.Join(names, ", "), p)) |
| 107 | } |
| 108 | sort.Strings(parts) |
| 109 | return "", fmt.Errorf("this release's artifacts are different programs: %s", strings.Join(parts, "; ")) |
| 110 | } |
| 111 | for p := range seen { |
| 112 | return p, nil |
| 113 | } |
| 114 | return "", nil |
| 115 | } |
| 116 | |
| 117 | // PreviousProgram is the program of the newest release (among the 20 newest) |
| 118 | // whose manifest declares one, and that release's version. It skips missing |
| 119 | // manifests (os.ErrNotExist) and manifests that don't parse or validate |
| 120 | // (logging each), returning store errors to fail the check closed. |
| 121 | func PreviousProgram(ctx context.Context, s Store, acct, app string) (string, string, error) { |
| 122 | vers, _, err := VersionCandidates(ctx, s, acct, app, 20) |
| 123 | if err != nil { |
| 124 | return "", "", err |
| 125 | } |
| 126 | var best *Manifest |
| 127 | for _, v := range vers { |
| 128 | // Read the manifest using GetQualified, exactly as GetManifest does. |
| 129 | raw, _, err := GetQualified(ctx, s, ManifestKey(acct, app, v)) |
| 130 | if err != nil { |
| 131 | if errors.Is(err, os.ErrNotExist) { |
| 132 | // In-flight release with no manifest yet; skip it. |
| 133 | continue |
| 134 | } |
| 135 | // Store error; fail closed. |
| 136 | return "", "", err |
| 137 | } |
| 138 | |
| 139 | // Decode and validate the manifest ourselves, separating parse errors |
| 140 | // (which we skip) from store errors (which fail closed). |
| 141 | var m Manifest |
| 142 | if err := json.Unmarshal(raw, &m); err != nil { |
| 143 | log.Printf("program check: manifest %s/%s: decode error: %v", app, v, err) |
| 144 | continue |
| 145 | } |
| 146 | if err := validateManifest(&m); err != nil { |
| 147 | log.Printf("program check: manifest %s/%s: validation error: %v", app, v, err) |
| 148 | continue |
| 149 | } |
| 150 | if m.App != app || m.Version != v { |
| 151 | log.Printf("program check: manifest %s/%s: declares wrong app/version", app, v) |
| 152 | continue |
| 153 | } |
| 154 | |
| 155 | if m.Program == "" { |
| 156 | continue |
| 157 | } |
| 158 | if best == nil || m.BuiltAt > best.BuiltAt { |
| 159 | best = &m |
| 160 | } |
| 161 | } |
| 162 | if best == nil { |
| 163 | return "", "", nil |
| 164 | } |
| 165 | return best.Program, best.Version, nil |
| 166 | } |
| 167 | |
| 168 | // ProgramMismatchError is returned when a release attempts to ship a different program |
| 169 | // than the app's previous releases without the --program-change override. |
| 170 | // |
| 171 | // The message names `carlos ship` because `carlos deploy` and canary meet |
| 172 | // this refusal too and do not take the flag. Nothing was written, so the |
| 173 | // same version can be shipped again. |
| 174 | type ProgramMismatchError struct{ App, Got, Want, WantVersion string } |
| 175 | |
| 176 | func (e *ProgramMismatchError) Error() string { |
| 177 | return fmt.Sprintf("this artifact is %s; %s's releases are %s (%s). If the app really is changing program, ship again with carlos ship --program-change", |
| 178 | e.Got, e.App, e.Want, e.WantVersion) |
| 179 | } |
| 180 | |
| 181 | // StampProgram records a binary release's Go program on m, and the operator's |
| 182 | // --program-change as m.ProgramChange. A static release, or one with no Go |
| 183 | // artifact, is left unstamped. It errors only when the artifacts are |
| 184 | // different programs. |
| 185 | // |
| 186 | // The flag is recorded whenever it was given, not only when the release |
| 187 | // differs from the newest one: a box decides by its own route's history, and a |
| 188 | // box that missed the release which changed the program (ship A, ship B1 with |
| 189 | // the flag, a box still on A) must find the consent on B2 too. |
| 190 | // |
| 191 | // It is the half of CheckShipProgram that does not read the store, so the |
| 192 | // direct-bucket door can stamp a re-ship of an existing version exactly as |
| 193 | // its first ship was stamped (the version's manifest is compared byte for |
| 194 | // byte by release.Ship) without judging it against the history again. |
| 195 | func StampProgram(m *Manifest, files map[string][]byte, allowChange bool) error { |
| 196 | if m.Kind != "binary" { |
| 197 | return nil |
| 198 | } |
| 199 | prog, err := ReleaseProgram(files) |
| 200 | if err != nil || prog == "" { |
| 201 | return err |
| 202 | } |
| 203 | m.Program = prog |
| 204 | m.ProgramChange = allowChange |
| 205 | return nil |
| 206 | } |
| 207 | |
| 208 | // CheckShipProgram is the one ship-side rule both ship doors call before |
| 209 | // release.Ship for a new version. It stamps m (StampProgram), then refuses a |
| 210 | // release whose program differs from the app's previous release unless |
| 211 | // allowChange: the comparison only decides whether a ship WITHOUT the flag |
| 212 | // refuses. |
| 213 | func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error { |
| 214 | if err := StampProgram(m, files, allowChange); err != nil || m.Program == "" { |
| 215 | return err |
| 216 | } |
| 217 | prog := m.Program |
| 218 | prev, prevVer, err := PreviousProgram(ctx, s, acct, m.App) |
| 219 | if err != nil { |
| 220 | return fmt.Errorf("reading %s's previous releases: %w", m.App, err) |
| 221 | } |
| 222 | if prev == "" || prev == prog || allowChange { |
| 223 | return nil |
| 224 | } |
| 225 | return &ProgramMismatchError{App: m.App, Got: prog, Want: prev, WantVersion: prevVer} |
| 226 | } |
| 227 | |