| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "os" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/rootstate" |
| 16 | ) |
| 17 | |
| 18 | const reconstructionKey = "reconstruction.state" |
| 19 | |
| 20 | type HistoryReconstruction struct { |
| 21 | Replaces string `json:"replaces,omitempty"` |
| 22 | Schema int `json:"schema"` |
| 23 | Kind string `json:"kind"` |
| 24 | Identity string `json:"identity"` |
| 25 | Revision uint64 `json:"revision"` |
| 26 | Operation string `json:"operation"` |
| 27 | IssuedAt time.Time `json:"issued_at"` |
| 28 | Account string `json:"account"` |
| 29 | App string `json:"app"` |
| 30 | AppRevision uint64 `json:"app_revision"` |
| 31 | BakeRevision uint64 `json:"bake_revision"` |
| 32 | Hosts []HistoryReconstructionHost `json:"hosts"` |
| 33 | Production bool `json:"production"` |
| 34 | ProductionSetAt time.Time `json:"production_set_at"` |
| 35 | Holds map[string]HistoryReconstructionHold `json:"holds"` |
| 36 | } |
| 37 | |
| 38 | type HistoryReconstructionHost struct { |
| 39 | Host string `json:"host"` |
| 40 | Source string `json:"source"` |
| 41 | Revision uint64 `json:"revision"` |
| 42 | Refusals []Refusal `json:"refusals"` |
| 43 | } |
| 44 | |
| 45 | type HistoryReconstructionHold struct { |
| 46 | Window time.Duration `json:"window"` |
| 47 | SetAt time.Time `json:"set_at"` |
| 48 | } |
| 49 | |
| 50 | type HistoryReconstructionStatus struct { |
| 51 | LastReconstructionDigest string `json:"last_reconstruction_digest,omitempty"` |
| 52 | Identity string `json:"identity"` |
| 53 | Revision uint64 `json:"revision"` |
| 54 | Pending bool `json:"pending"` |
| 55 | } |
| 56 | |
| 57 | type reconstructionImage struct { |
| 58 | Name string `json:"name"` |
| 59 | Before string `json:"before"` |
| 60 | After json.RawMessage `json:"after"` |
| 61 | } |
| 62 | |
| 63 | type reconstructionIntent struct { |
| 64 | Grant HistoryReconstruction `json:"grant"` |
| 65 | Digest string `json:"digest"` |
| 66 | Images []reconstructionImage `json:"images"` |
| 67 | } |
| 68 | |
| 69 | type reconstructionState struct { |
| 70 | Schema int `json:"schema"` |
| 71 | Identity string `json:"identity"` |
| 72 | Revision uint64 `json:"revision"` |
| 73 | Last string `json:"last,omitempty"` |
| 74 | Pending *reconstructionIntent `json:"pending,omitempty"` |
| 75 | } |
| 76 | |
| 77 | func reconstructionDigest(b []byte) string { |
| 78 | s := sha256.Sum256(b) |
| 79 | return hex.EncodeToString(s[:]) |
| 80 | } |
| 81 | |
| 82 | func (h *RootHistory) reconstructionState() (reconstructionState, error) { |
| 83 | var s reconstructionState |
| 84 | if h == nil || h.Store == nil { |
| 85 | return s, os.ErrPermission |
| 86 | } |
| 87 | b, err := h.Store.Read(reconstructionKey) |
| 88 | if err != nil { |
| 89 | return s, err |
| 90 | } |
| 91 | if len(b) > rootstate.MaxRecord || strictCanonicalJSON(b, &s) != nil || s.Schema != 1 || len(s.Identity) != 64 || s.Revision == 0 { |
| 92 | return reconstructionState{}, os.ErrPermission |
| 93 | } |
| 94 | if _, err := hex.DecodeString(s.Identity); err != nil { |
| 95 | return reconstructionState{}, os.ErrPermission |
| 96 | } |
| 97 | if err := h.Store.Write(reconstructionKey, b); err != nil { |
| 98 | return reconstructionState{}, err |
| 99 | } |
| 100 | return s, nil |
| 101 | } |
| 102 | |
| 103 | func (h *RootHistory) writeReconstruction(s reconstructionState) error { |
| 104 | b, err := json.Marshal(s) |
| 105 | if err != nil { |
| 106 | return err |
| 107 | } |
| 108 | if len(b) > rootstate.MaxRecord { |
| 109 | return os.ErrPermission |
| 110 | } |
| 111 | return h.Store.Write(reconstructionKey, b) |
| 112 | } |
| 113 | |
| 114 | func (h *RootHistory) ReconstructionStatus() (HistoryReconstructionStatus, error) { |
| 115 | s, err := h.reconstructionState() |
| 116 | if errors.Is(err, os.ErrNotExist) { |
| 117 | var id [32]byte |
| 118 | if _, err := rand.Read(id[:]); err != nil { |
| 119 | return HistoryReconstructionStatus{}, err |
| 120 | } |
| 121 | s = reconstructionState{Schema: 1, Identity: hex.EncodeToString(id[:]), Revision: 1} |
| 122 | err = h.writeReconstruction(s) |
| 123 | } |
| 124 | if err != nil { |
| 125 | return HistoryReconstructionStatus{}, err |
| 126 | } |
| 127 | return HistoryReconstructionStatus{Identity: s.Identity, Revision: s.Revision, Pending: s.Pending != nil, LastReconstructionDigest: s.Last}, nil |
| 128 | } |
| 129 | |
| 130 | func (h *RootHistory) reconstructionReady() error { |
| 131 | s, err := h.reconstructionState() |
| 132 | if errors.Is(err, os.ErrNotExist) { |
| 133 | return nil |
| 134 | } |
| 135 | if err != nil { |
| 136 | return err |
| 137 | } |
| 138 | if s.Pending != nil { |
| 139 | return os.ErrPermission |
| 140 | } |
| 141 | return nil |
| 142 | } |
| 143 | |
| 144 | func reconstructionSources(g HistoryReconstruction, resolve func(HistoryBinding) (string, error)) error { |
| 145 | if resolve == nil || !ValidAccount(g.Account) || !ValidApp(g.App) || len(g.Hosts) == 0 || len(g.Hosts) > historyLimit { |
| 146 | return os.ErrPermission |
| 147 | } |
| 148 | seen := map[string]bool{} |
| 149 | for _, host := range g.Hosts { |
| 150 | b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} |
| 151 | if !b.valid() || seen[host.Host] || host.Source == "" { |
| 152 | return os.ErrPermission |
| 153 | } |
| 154 | seen[host.Host] = true |
| 155 | source, err := resolve(b) |
| 156 | if err != nil { |
| 157 | return err |
| 158 | } |
| 159 | if source != host.Source { |
| 160 | return os.ErrPermission |
| 161 | } |
| 162 | } |
| 163 | return nil |
| 164 | } |
| 165 | |
| 166 | func (h *RootHistory) Reconstruct(pub ed25519.PublicKey, wire []byte, acct, app string, now time.Time, resolve func(HistoryBinding) (string, error)) error { |
| 167 | if len(wire) > rootstate.MaxRecord || now.IsZero() { |
| 168 | return os.ErrPermission |
| 169 | } |
| 170 | var g HistoryReconstruction |
| 171 | if err := VerifyRootEnvelope(pub, wire, &g); err != nil { |
| 172 | return err |
| 173 | } |
| 174 | if g.Schema != 1 || g.Kind != "carlos-release-history" || g.Operation == "" || len(g.Operation) > 256 || g.Account != acct || g.App != app || |
| 175 | g.IssuedAt.Before(now.Add(-HostRetryGrantTTL)) || g.IssuedAt.After(now.Add(hostRetrySkew)) { |
| 176 | return os.ErrPermission |
| 177 | } |
| 178 | if err := reconstructionSources(g, resolve); err != nil { |
| 179 | return err |
| 180 | } |
| 181 | s, err := h.reconstructionState() |
| 182 | if err != nil { |
| 183 | return err |
| 184 | } |
| 185 | if s.Identity != g.Identity { |
| 186 | return os.ErrPermission |
| 187 | } |
| 188 | raw, err := json.Marshal(g) |
| 189 | if err != nil { |
| 190 | return err |
| 191 | } |
| 192 | digest := reconstructionDigest(raw) |
| 193 | if s.Pending != nil && s.Pending.Digest == digest { |
| 194 | return h.resumeReconstruction(s, resolve) |
| 195 | } |
| 196 | if s.Last == digest { |
| 197 | return nil |
| 198 | } |
| 199 | if s.Revision != g.Revision || s.Revision == ^uint64(0) { |
| 200 | return os.ErrPermission |
| 201 | } |
| 202 | effective := g |
| 203 | if s.Pending != nil { |
| 204 | effective, err = replacementReconstruction(g, s.Pending) |
| 205 | if err != nil { |
| 206 | return err |
| 207 | } |
| 208 | } else if g.Replaces != "" { |
| 209 | return os.ErrPermission |
| 210 | } |
| 211 | images, err := h.reconstructionImages(effective, now) |
| 212 | if err != nil { |
| 213 | return err |
| 214 | } |
| 215 | if err := reconstructionSources(g, resolve); err != nil { |
| 216 | return err |
| 217 | } |
| 218 | s.Pending = &reconstructionIntent{Grant: g, Digest: digest, Images: images} |
| 219 | if err := h.writeReconstruction(s); err != nil { |
| 220 | return err |
| 221 | } |
| 222 | return h.resumeReconstruction(s, resolve) |
| 223 | } |
| 224 | |
| 225 | func (h *RootHistory) ResumeReconstruction(resolve func(HistoryBinding) (string, error)) error { |
| 226 | s, err := h.reconstructionState() |
| 227 | if errors.Is(err, os.ErrNotExist) { |
| 228 | return nil |
| 229 | } |
| 230 | if err != nil { |
| 231 | return err |
| 232 | } |
| 233 | if s.Pending == nil { |
| 234 | return nil |
| 235 | } |
| 236 | return h.resumeReconstruction(s, resolve) |
| 237 | } |
| 238 | |
| 239 | func (h *RootHistory) resumeReconstruction(s reconstructionState, resolve func(HistoryBinding) (string, error)) error { |
| 240 | p := s.Pending |
| 241 | if p == nil || s.Revision == ^uint64(0) || len(p.Images) != len(p.Grant.Hosts)+2 || p.Grant.Identity != s.Identity || p.Grant.Revision != s.Revision { |
| 242 | return os.ErrPermission |
| 243 | } |
| 244 | raw, err := json.Marshal(p.Grant) |
| 245 | if err != nil || reconstructionDigest(raw) != p.Digest { |
| 246 | return os.ErrPermission |
| 247 | } |
| 248 | if err := reconstructionSources(p.Grant, resolve); err != nil { |
| 249 | return err |
| 250 | } |
| 251 | allowed := map[string]bool{rootBakeKey: true, historyAppKey(p.Grant.Account, p.Grant.App): true} |
| 252 | for _, host := range p.Grant.Hosts { |
| 253 | allowed[historyKey(HistoryBinding{Host: host.Host, Account: p.Grant.Account, App: p.Grant.App})] = true |
| 254 | } |
| 255 | for _, im := range p.Images { |
| 256 | if !allowed[im.Name] || len(im.After) == 0 { |
| 257 | return os.ErrPermission |
| 258 | } |
| 259 | delete(allowed, im.Name) |
| 260 | old, err := h.Store.Read(im.Name) |
| 261 | before := "absent" |
| 262 | if err == nil { |
| 263 | before = reconstructionDigest(old) |
| 264 | } else if !errors.Is(err, os.ErrNotExist) { |
| 265 | return err |
| 266 | } |
| 267 | if before != im.Before && before != reconstructionDigest(im.After) { |
| 268 | return os.ErrPermission |
| 269 | } |
| 270 | } |
| 271 | for _, im := range p.Images { |
| 272 | if err := h.Store.Write(im.Name, im.After); err != nil { |
| 273 | return err |
| 274 | } |
| 275 | } |
| 276 | if err := reconstructionSources(p.Grant, resolve); err != nil { |
| 277 | return err |
| 278 | } |
| 279 | s.Revision++ |
| 280 | s.Last = p.Digest |
| 281 | s.Pending = nil |
| 282 | return h.writeReconstruction(s) |
| 283 | } |
| 284 | |
| 285 | func (h *RootHistory) reconstructionImages(g HistoryReconstruction, now time.Time) ([]reconstructionImage, error) { |
| 286 | var images []reconstructionImage |
| 287 | add := func(name string, v any) error { |
| 288 | before := "absent" |
| 289 | old, err := h.Store.Read(name) |
| 290 | if err == nil { |
| 291 | before = reconstructionDigest(old) |
| 292 | } else if !errors.Is(err, os.ErrNotExist) { |
| 293 | return err |
| 294 | } |
| 295 | raw, err := json.Marshal(v) |
| 296 | if err != nil { |
| 297 | return err |
| 298 | } |
| 299 | images = append(images, reconstructionImage{Name: name, Before: before, After: raw}) |
| 300 | return nil |
| 301 | } |
| 302 | a := historyApp{Schema: 1, Account: g.Account, App: g.App, Hosts: []string{}} |
| 303 | if raw, err := h.Store.Read(historyAppKey(g.Account, g.App)); err == nil { |
| 304 | if strictCanonicalJSON(raw, &a) != nil || !validHistoryApp(a) || a.Account != g.Account || a.App != g.App { |
| 305 | return nil, os.ErrPermission |
| 306 | } |
| 307 | } else if !errors.Is(err, os.ErrNotExist) { |
| 308 | return nil, err |
| 309 | } |
| 310 | if a.Revision != g.AppRevision || a.Revision == ^uint64(0) { |
| 311 | return nil, os.ErrPermission |
| 312 | } |
| 313 | names, err := h.Store.Names() |
| 314 | if err != nil { |
| 315 | return nil, err |
| 316 | } |
| 317 | for _, name := range names { |
| 318 | if name == rootBakeKey || !strings.HasSuffix(name, ".json") { |
| 319 | continue |
| 320 | } |
| 321 | raw, err := h.Store.Read(name) |
| 322 | if err != nil { |
| 323 | return nil, err |
| 324 | } |
| 325 | var r historyRecord |
| 326 | if strictCanonicalJSON(raw, &r) != nil || !validHistory(r, r.Binding) || historyKey(r.Binding) != name || r.Attempt != nil { |
| 327 | return nil, os.ErrPermission |
| 328 | } |
| 329 | if r.Binding.Account == g.Account && r.Binding.App == g.App && !slices.Contains(a.Hosts, r.Binding.Host) && !slices.ContainsFunc(g.Hosts, func(host HistoryReconstructionHost) bool { return host.Host == r.Binding.Host }) { |
| 330 | return nil, os.ErrPermission |
| 331 | } |
| 332 | } |
| 333 | for _, host := range g.Hosts { |
| 334 | b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} |
| 335 | r, err := h.readRecord(b) |
| 336 | if errors.Is(err, os.ErrNotExist) { |
| 337 | r = historyRecord{Schema: 1, Binding: b} |
| 338 | } else if err != nil { |
| 339 | return nil, err |
| 340 | } |
| 341 | if r.Revision != host.Revision || r.Attempt != nil || r.Revision == ^uint64(0) { |
| 342 | return nil, os.ErrPermission |
| 343 | } |
| 344 | r.Revision++ |
| 345 | if r.Programs.Revision == 0 { |
| 346 | r.Programs.Revision = r.Revision |
| 347 | } |
| 348 | r.Refusals.Revision, r.Refusals.Complete = r.Revision, true |
| 349 | for _, ref := range host.Refusals { |
| 350 | ref.Recovered = false |
| 351 | if !slices.Contains(r.Refusals.Values, ref) { |
| 352 | r.Refusals.Values = append(r.Refusals.Values, ref) |
| 353 | } |
| 354 | } |
| 355 | if !validHistory(r, b) { |
| 356 | return nil, os.ErrPermission |
| 357 | } |
| 358 | if err := add(historyKey(b), r); err != nil { |
| 359 | return nil, err |
| 360 | } |
| 361 | if !slices.Contains(a.Hosts, b.Host) { |
| 362 | a.Hosts = append(a.Hosts, b.Host) |
| 363 | } |
| 364 | } |
| 365 | a.Revision++ |
| 366 | a.Complete = true |
| 367 | if !validHistoryApp(a) { |
| 368 | return nil, os.ErrPermission |
| 369 | } |
| 370 | if err := add(historyAppKey(g.Account, g.App), a); err != nil { |
| 371 | return nil, err |
| 372 | } |
| 373 | bake := rootBakeRecord{Schema: 1, Complete: map[string]uint64{}, Clock: FirstSeen{Channels: map[string]map[string]time.Time{}}} |
| 374 | if raw, err := h.Store.Read(rootBakeKey); err == nil { |
| 375 | if strictCanonicalJSON(raw, &bake) != nil || !validRootBake(bake) || now.Before(bake.Observed) { |
| 376 | return nil, os.ErrPermission |
| 377 | } |
| 378 | } else if !errors.Is(err, os.ErrNotExist) { |
| 379 | return nil, err |
| 380 | } |
| 381 | if bake.Revision != g.BakeRevision || bake.Revision == ^uint64(0) || len(g.Holds) > historyLimit { |
| 382 | return nil, os.ErrPermission |
| 383 | } |
| 384 | bake.Revision++ |
| 385 | bake.Observed = now.UTC() |
| 386 | bake.Complete[prodKey(g.Account, g.App)] = bake.Revision |
| 387 | for key, versions := range bake.Clock.Channels { |
| 388 | if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { |
| 389 | continue |
| 390 | } |
| 391 | for version := range versions { |
| 392 | versions[version] = now.UTC() |
| 393 | } |
| 394 | } |
| 395 | if bake.Clock.Production == nil { |
| 396 | bake.Clock.Production = map[string]*prodMemory{} |
| 397 | } |
| 398 | if bake.Clock.Holds == nil { |
| 399 | bake.Clock.Holds = map[string]*holdMemory{} |
| 400 | } |
| 401 | pk := prodKey(g.Account, g.App) |
| 402 | prod := bake.Clock.Production[pk] |
| 403 | if prod == nil { |
| 404 | prod = &prodMemory{} |
| 405 | bake.Clock.Production[pk] = prod |
| 406 | } |
| 407 | if !prod.OffFirstSeen.IsZero() { |
| 408 | prod.OffFirstSeen = now.UTC() |
| 409 | } |
| 410 | if g.Production { |
| 411 | prod.WasProduction = true |
| 412 | prod.OffSetAt, prod.OffFirstSeen = time.Time{}, time.Time{} |
| 413 | } |
| 414 | if g.ProductionSetAt.After(prod.NewestSetAt) { |
| 415 | prod.NewestSetAt = g.ProductionSetAt |
| 416 | } |
| 417 | for key, mem := range bake.Clock.Holds { |
| 418 | if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { |
| 419 | continue |
| 420 | } |
| 421 | if !mem.PendingAt.IsZero() { |
| 422 | mem.PendingAt = now.UTC() |
| 423 | } |
| 424 | if !mem.DeletedAt.IsZero() { |
| 425 | mem.DeletedAt = now.UTC() |
| 426 | } |
| 427 | } |
| 428 | for channel, bound := range g.Holds { |
| 429 | if !ValidChannel(channel) || bound.Window < 0 { |
| 430 | return nil, os.ErrPermission |
| 431 | } |
| 432 | key := channelClockKey(g.Account, g.App, channel) |
| 433 | mem := bake.Clock.Holds[key] |
| 434 | if mem == nil { |
| 435 | mem = &holdMemory{} |
| 436 | bake.Clock.Holds[key] = mem |
| 437 | } |
| 438 | if bound.Window > mem.Window { |
| 439 | mem.Window = bound.Window |
| 440 | mem.Pending = 0 |
| 441 | mem.PendingAt = time.Time{} |
| 442 | } |
| 443 | if bound.SetAt.After(mem.SetAt) { |
| 444 | mem.SetAt = bound.SetAt |
| 445 | } |
| 446 | } |
| 447 | if !validRootBake(bake) { |
| 448 | return nil, os.ErrPermission |
| 449 | } |
| 450 | if err := add(rootBakeKey, bake); err != nil { |
| 451 | return nil, err |
| 452 | } |
| 453 | return images, nil |
| 454 | } |
| 455 | |