| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestResignSignsTheDigestsTheOperatorSaw(t *testing.T) { |
| 16 | s := NewDirStore(t.TempDir()) |
| 17 | ctx := context.Background() |
| 18 | priv, pub := testKeys(t) |
| 19 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 20 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| 21 | for _, v := range []string{"v1", "v2"} { |
| 22 | if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | } |
| 26 | markerOn(t, s, priv, "bab") // every writer upgraded; the old pointer still has no digests |
| 27 | sg := RootSigner(priv) |
| 28 | plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if plan.Digest != manifestDigestOf(t, s, "bab", "km", "v2") || plan.HistoryDigests[0] != manifestDigestOf(t, s, "bab", "km", "v1") || |
| 33 | !plan.Computed[0] || !plan.Computed[1] { |
| 34 | t.Fatalf("plan = %+v", plan) |
| 35 | } |
| 36 | if p := readPtr(t, s, pub, "stable"); p.Digest != "" { |
| 37 | t.Fatal("PlanResign wrote something") |
| 38 | } |
| 39 | if err := sg.Resign(ctx, s, plan, "ops@example.com"); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | p := readPtr(t, s, pub, "stable") |
| 43 | if p.Version != "v2" || p.Digest != plan.Digest || p.HistoryDigests[0] != plan.HistoryDigests[0] { |
| 44 | t.Fatalf("resigned pointer = %+v", p) |
| 45 | } |
| 46 | // Once resigned, the rollback the spec refused is allowed. |
| 47 | if err := Rollback(ctx, s, priv, "bab", "km", "stable"); err != nil { |
| 48 | t.Fatalf("rollback after resign: %v", err) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestResignCopiesSignedDigestsAndLeavesGoneBuildsEmpty(t *testing.T) { |
| 53 | s := NewDirStore(t.TempDir()) |
| 54 | ctx := context.Background() |
| 55 | priv, _ := testKeys(t) |
| 56 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 57 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| 58 | shipRelease(t, s, "bab", "km", "v3", adoptPlatform, []byte("three")) |
| 59 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | markerOn(t, s, priv, "bab") |
| 66 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v3"); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | plan, err := RootSigner(priv).PlanResign(ctx, s, "bab", "km", "stable") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | // head v3 signed, v2 inherited "" (computed now), v1 gone. |
| 77 | if plan.Computed[0] || !plan.Computed[1] || plan.HistoryDigests[1] != "" || plan.Computed[2] { |
| 78 | t.Fatalf("plan = %+v", plan) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestResignRefusesAChannelThatMoved(t *testing.T) { |
| 83 | s := NewDirStore(t.TempDir()) |
| 84 | ctx := context.Background() |
| 85 | priv, _ := testKeys(t) |
| 86 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 87 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| 88 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | markerOn(t, s, priv, "bab") |
| 92 | sg := RootSigner(priv) |
| 93 | plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if err := sg.Resign(ctx, s, plan, "ops"); !errors.Is(err, ErrResignChanged) { |
| 101 | t.Fatalf("resign over a moved channel = %v, want ErrResignChanged", err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestResignRefusesAHeadWithNoManifest(t *testing.T) { |
| 106 | s := NewDirStore(t.TempDir()) |
| 107 | ctx := context.Background() |
| 108 | priv, _ := testKeys(t) |
| 109 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 110 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | markerOn(t, s, priv, "bab") |
| 117 | if _, err := RootSigner(priv).PlanResign(ctx, s, "bab", "km", "stable"); err == nil || err.Error() != fmt.Sprintf(TextResignUnreadable, "v1") { |
| 118 | t.Fatalf("a head with no manifest was planned: %v", err) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // Astra I3 #4: resign arms every box's ratchet for the channel, so it needs |
| 123 | // the verified marker (every writer upgraded), at plan time and at signing. |
| 124 | func TestResignRequiresTheVerifiedMarker(t *testing.T) { |
| 125 | s := NewDirStore(t.TempDir()) |
| 126 | ctx := context.Background() |
| 127 | priv, pub := testKeys(t) |
| 128 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 129 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | sg := RootSigner(priv) |
| 133 | if _, err := sg.PlanResign(ctx, s, "bab", "km", "stable"); err == nil || err.Error() != fmt.Sprintf(TextResignNoMarker, "bab", "bab") { |
| 134 | t.Fatalf("PlanResign with no marker = %v, want the no-marker refusal", err) |
| 135 | } |
| 136 | markerOn(t, s, priv, "bab") |
| 137 | plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if err := RemoveDigestMarker(ctx, s, "bab"); err != nil { // turned off between plan and confirmation |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | if err := sg.Resign(ctx, s, plan, "ops"); err == nil || err.Error() != fmt.Sprintf(TextResignNoMarker, "bab", "bab") { |
| 145 | t.Fatalf("Resign with the marker gone = %v, want the no-marker refusal", err) |
| 146 | } |
| 147 | foreign, _ := testKeys(t) |
| 148 | markerOn(t, s, foreign, "bab") // present, but not root-signed |
| 149 | if err := sg.Resign(ctx, s, plan, "ops"); err == nil || !strings.Contains(err.Error(), "marker") { |
| 150 | t.Fatalf("Resign with an unverifiable marker = %v", err) |
| 151 | } |
| 152 | if p := readPtr(t, s, pub, "stable"); p.Digest != "" { |
| 153 | t.Fatal("a refused resign wrote digests") |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // unbindPointer rewrites a channel's pointer as one signed before the account |
| 158 | // binding existed: same fields, no account, root-signed. |
| 159 | func unbindPointer(t *testing.T, s Store, priv ed25519.PrivateKey, acct, app, channel string) { |
| 160 | t.Helper() |
| 161 | ctx := context.Background() |
| 162 | key := ChannelKey(acct, app, channel) |
| 163 | body, etag, err := GetQualified(ctx, s, key) |
| 164 | if err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | var w signedWrapper |
| 168 | if err := json.Unmarshal(body, &w); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | payload, err := base64.StdEncoding.DecodeString(w.Payload) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | var p ChannelPointer |
| 176 | if err := json.Unmarshal(payload, &p); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | p.Account = "" |
| 180 | payload, err = json.MarshalIndent(&p, "", " ") |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 185 | wire, err := json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}, "", " ") |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | if _, err := s.Put(ctx, key, wire, etag); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // A channel not promoted since the account binding has no account in its |
| 195 | // pointer. Resign targets exactly those, and must bind the account. |
| 196 | func TestResignBindsTheAccountOnAPreBindingPointer(t *testing.T) { |
| 197 | s := NewDirStore(t.TempDir()) |
| 198 | ctx := context.Background() |
| 199 | priv, pub := testKeys(t) |
| 200 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 201 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| 202 | for _, v := range []string{"v1", "v2"} { |
| 203 | if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | } |
| 207 | unbindPointer(t, s, priv, "bab", "km", "stable") |
| 208 | if p := readPtr(t, s, pub, "stable"); p.Account != "" { |
| 209 | t.Fatalf("test setup: pointer still bound to %q", p.Account) |
| 210 | } |
| 211 | markerOn(t, s, priv, "bab") |
| 212 | sg := RootSigner(priv) |
| 213 | plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | if err := sg.Resign(ctx, s, plan, "ops"); err != nil { |
| 218 | t.Fatalf("resign of a pre-binding pointer: %v", err) |
| 219 | } |
| 220 | p := readPtr(t, s, pub, "stable") |
| 221 | if p.Account != "bab" || p.Digest != plan.Digest || p.HistoryDigests[0] != plan.HistoryDigests[0] { |
| 222 | t.Fatalf("resigned pointer = %+v", p) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestResignRefusesAnInvalidPlan(t *testing.T) { |
| 227 | priv, _ := testKeys(t) |
| 228 | if err := RootSigner(priv).claimRawResign(context.Background(), NewDirStore(t.TempDir()), &ResignPlan{Account: "bab", App: "km", Channel: "../x"}, "ops", nil); err == nil { |
| 229 | t.Fatal("a plan with an invalid channel was signed") |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestResignRefusesANilPlan(t *testing.T) { |
| 234 | priv, _ := testKeys(t) |
| 235 | if err := RootSigner(priv).Resign(context.Background(), NewDirStore(t.TempDir()), nil, "ops"); err == nil { |
| 236 | t.Fatal("a nil plan was signed") |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestDigestStateOf(t *testing.T) { |
| 241 | at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 242 | m := &DigestMarker{Schema: 1, Account: "bab", At: at} |
| 243 | legacyBefore := &ChannelPointer{Channel: "a", Version: "v1", PromotedAt: at.Add(-time.Hour), History: []string{"v0"}} |
| 244 | legacyAfter := &ChannelPointer{Channel: "b", Version: "v1", PromotedAt: at.Add(time.Hour)} |
| 245 | signed := &ChannelPointer{Channel: "c", Version: "v2", Digest: dA, PromotedAt: at.Add(time.Hour), History: []string{"v1"}, HistoryDigests: []string{""}} |
| 246 | if st := DigestStateOf(legacyBefore, m); st.HeadDigest || st.Straggler || st.HistoryMissing != 1 { |
| 247 | t.Fatalf("before the marker: %+v", st) |
| 248 | } |
| 249 | if st := DigestStateOf(legacyAfter, m); !st.Straggler { |
| 250 | t.Fatalf("written after the marker without digests: %+v", st) |
| 251 | } |
| 252 | if st := DigestStateOf(legacyAfter, nil); st.Straggler { |
| 253 | t.Fatal("no marker, no stragglers") |
| 254 | } |
| 255 | if st := DigestStateOf(signed, m); !st.HeadDigest || st.Straggler || st.HistoryMissing != 1 { |
| 256 | t.Fatalf("signed: %+v", st) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | func TestDigestReportListsEveryChannel(t *testing.T) { |
| 261 | s := NewDirStore(t.TempDir()) |
| 262 | ctx := context.Background() |
| 263 | priv, pub := testKeys(t) |
| 264 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 265 | if err := Promote(ctx, s, priv, "bab", "km", "beta", "v1"); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | markerOn(t, s, priv, "bab") |
| 269 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | rows, marker, err := DigestReport(ctx, s, pub, "bab", "km") |
| 273 | if err != nil || marker == nil || len(rows) != 2 { |
| 274 | t.Fatalf("report = %+v, %+v, %v", rows, marker, err) |
| 275 | } |
| 276 | got := map[string]bool{} |
| 277 | for _, r := range rows { |
| 278 | got[r.Channel] = r.HeadDigest |
| 279 | } |
| 280 | if got["beta"] || !got["canary/x"] { |
| 281 | t.Fatalf("report = %+v", rows) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // Astra r1 #2: ResignAuthorized asks the caller's authority of the pipeline |
| 286 | // read inside the mutation, after the pointer; a refusal is an |
| 287 | // AuthorizationError and the pointer is untouched. With no hook (Resign) the |
| 288 | // same plan signs. |
| 289 | func TestResignAuthorizedAsksTheHookAndWritesNothingOnRefusal(t *testing.T) { |
| 290 | s := NewDirStore(t.TempDir()) |
| 291 | ctx := context.Background() |
| 292 | priv, pub := testKeys(t) |
| 293 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| 294 | if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if _, err := SetPipeline(ctx, s, priv, "bab", "km", []PipelineChannel{{Name: "stable", Rules: ChannelRules{Passkey: true}}}, "t"); err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | markerOn(t, s, priv, "bab") |
| 301 | sg := RootSigner(priv) |
| 302 | plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| 303 | if err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | refusal := errors.New("needs a passkey") |
| 307 | var saw *Pipeline |
| 308 | err = sg.ResignAuthorized(ctx, s, plan, "ops@example.com", func(p *Pipeline) error { |
| 309 | saw = p |
| 310 | return refusal |
| 311 | }) |
| 312 | var ae *AuthorizationError |
| 313 | if !errors.As(err, &ae) || !errors.Is(ae.Err, refusal) { |
| 314 | t.Fatalf("ResignAuthorized = %v, want the hook's refusal as an AuthorizationError", err) |
| 315 | } |
| 316 | if saw == nil || len(saw.Channels) != 1 || !saw.Channels[0].Rules.Passkey { |
| 317 | t.Fatalf("the hook saw pipeline %+v, want the app's", saw) |
| 318 | } |
| 319 | if p := readPtr(t, s, pub, "stable"); p.Digest != "" { |
| 320 | t.Fatalf("a refused resign wrote %+v", p) |
| 321 | } |
| 322 | if err := sg.ResignAuthorized(ctx, s, plan, "ops@example.com", func(*Pipeline) error { return nil }); err != nil { |
| 323 | t.Fatalf("an allowing hook: %v", err) |
| 324 | } |
| 325 | if p := readPtr(t, s, pub, "stable"); p.Digest != plan.Digest { |
| 326 | t.Fatalf("after an allowed resign = %+v", p) |
| 327 | } |
| 328 | } |
| 329 | |