package release import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "slices" "strings" "time" "github.com/carlosframework/platform/internal/rootstate" ) const reconstructionKey = "reconstruction.state" type HistoryReconstruction struct { Replaces string `json:"replaces,omitempty"` Schema int `json:"schema"` Kind string `json:"kind"` Identity string `json:"identity"` Revision uint64 `json:"revision"` Operation string `json:"operation"` IssuedAt time.Time `json:"issued_at"` Account string `json:"account"` App string `json:"app"` AppRevision uint64 `json:"app_revision"` BakeRevision uint64 `json:"bake_revision"` Hosts []HistoryReconstructionHost `json:"hosts"` Production bool `json:"production"` ProductionSetAt time.Time `json:"production_set_at"` Holds map[string]HistoryReconstructionHold `json:"holds"` } type HistoryReconstructionHost struct { Host string `json:"host"` Source string `json:"source"` Revision uint64 `json:"revision"` Refusals []Refusal `json:"refusals"` } type HistoryReconstructionHold struct { Window time.Duration `json:"window"` SetAt time.Time `json:"set_at"` } type HistoryReconstructionStatus struct { LastReconstructionDigest string `json:"last_reconstruction_digest,omitempty"` Identity string `json:"identity"` Revision uint64 `json:"revision"` Pending bool `json:"pending"` } type reconstructionImage struct { Name string `json:"name"` Before string `json:"before"` After json.RawMessage `json:"after"` } type reconstructionIntent struct { Grant HistoryReconstruction `json:"grant"` Digest string `json:"digest"` Images []reconstructionImage `json:"images"` } type reconstructionState struct { Schema int `json:"schema"` Identity string `json:"identity"` Revision uint64 `json:"revision"` Last string `json:"last,omitempty"` Pending *reconstructionIntent `json:"pending,omitempty"` } func reconstructionDigest(b []byte) string { s := sha256.Sum256(b) return hex.EncodeToString(s[:]) } func (h *RootHistory) reconstructionState() (reconstructionState, error) { var s reconstructionState if h == nil || h.Store == nil { return s, os.ErrPermission } b, err := h.Store.Read(reconstructionKey) if err != nil { return s, err } if len(b) > rootstate.MaxRecord || strictCanonicalJSON(b, &s) != nil || s.Schema != 1 || len(s.Identity) != 64 || s.Revision == 0 { return reconstructionState{}, os.ErrPermission } if _, err := hex.DecodeString(s.Identity); err != nil { return reconstructionState{}, os.ErrPermission } if err := h.Store.Write(reconstructionKey, b); err != nil { return reconstructionState{}, err } return s, nil } func (h *RootHistory) writeReconstruction(s reconstructionState) error { b, err := json.Marshal(s) if err != nil { return err } if len(b) > rootstate.MaxRecord { return os.ErrPermission } return h.Store.Write(reconstructionKey, b) } func (h *RootHistory) ReconstructionStatus() (HistoryReconstructionStatus, error) { s, err := h.reconstructionState() if errors.Is(err, os.ErrNotExist) { var id [32]byte if _, err := rand.Read(id[:]); err != nil { return HistoryReconstructionStatus{}, err } s = reconstructionState{Schema: 1, Identity: hex.EncodeToString(id[:]), Revision: 1} err = h.writeReconstruction(s) } if err != nil { return HistoryReconstructionStatus{}, err } return HistoryReconstructionStatus{Identity: s.Identity, Revision: s.Revision, Pending: s.Pending != nil, LastReconstructionDigest: s.Last}, nil } func (h *RootHistory) reconstructionReady() error { s, err := h.reconstructionState() if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if s.Pending != nil { return os.ErrPermission } return nil } func reconstructionSources(g HistoryReconstruction, resolve func(HistoryBinding) (string, error)) error { if resolve == nil || !ValidAccount(g.Account) || !ValidApp(g.App) || len(g.Hosts) == 0 || len(g.Hosts) > historyLimit { return os.ErrPermission } seen := map[string]bool{} for _, host := range g.Hosts { b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} if !b.valid() || seen[host.Host] || host.Source == "" { return os.ErrPermission } seen[host.Host] = true source, err := resolve(b) if err != nil { return err } if source != host.Source { return os.ErrPermission } } return nil } func (h *RootHistory) Reconstruct(pub ed25519.PublicKey, wire []byte, acct, app string, now time.Time, resolve func(HistoryBinding) (string, error)) error { if len(wire) > rootstate.MaxRecord || now.IsZero() { return os.ErrPermission } var g HistoryReconstruction if err := VerifyRootEnvelope(pub, wire, &g); err != nil { return err } if g.Schema != 1 || g.Kind != "carlos-release-history" || g.Operation == "" || len(g.Operation) > 256 || g.Account != acct || g.App != app || g.IssuedAt.Before(now.Add(-HostRetryGrantTTL)) || g.IssuedAt.After(now.Add(hostRetrySkew)) { return os.ErrPermission } if err := reconstructionSources(g, resolve); err != nil { return err } s, err := h.reconstructionState() if err != nil { return err } if s.Identity != g.Identity { return os.ErrPermission } raw, err := json.Marshal(g) if err != nil { return err } digest := reconstructionDigest(raw) if s.Pending != nil && s.Pending.Digest == digest { return h.resumeReconstruction(s, resolve) } if s.Last == digest { return nil } if s.Revision != g.Revision || s.Revision == ^uint64(0) { return os.ErrPermission } effective := g if s.Pending != nil { effective, err = replacementReconstruction(g, s.Pending) if err != nil { return err } } else if g.Replaces != "" { return os.ErrPermission } images, err := h.reconstructionImages(effective, now) if err != nil { return err } if err := reconstructionSources(g, resolve); err != nil { return err } s.Pending = &reconstructionIntent{Grant: g, Digest: digest, Images: images} if err := h.writeReconstruction(s); err != nil { return err } return h.resumeReconstruction(s, resolve) } func (h *RootHistory) ResumeReconstruction(resolve func(HistoryBinding) (string, error)) error { s, err := h.reconstructionState() if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if s.Pending == nil { return nil } return h.resumeReconstruction(s, resolve) } func (h *RootHistory) resumeReconstruction(s reconstructionState, resolve func(HistoryBinding) (string, error)) error { p := s.Pending 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 { return os.ErrPermission } raw, err := json.Marshal(p.Grant) if err != nil || reconstructionDigest(raw) != p.Digest { return os.ErrPermission } if err := reconstructionSources(p.Grant, resolve); err != nil { return err } allowed := map[string]bool{rootBakeKey: true, historyAppKey(p.Grant.Account, p.Grant.App): true} for _, host := range p.Grant.Hosts { allowed[historyKey(HistoryBinding{Host: host.Host, Account: p.Grant.Account, App: p.Grant.App})] = true } for _, im := range p.Images { if !allowed[im.Name] || len(im.After) == 0 { return os.ErrPermission } delete(allowed, im.Name) old, err := h.Store.Read(im.Name) before := "absent" if err == nil { before = reconstructionDigest(old) } else if !errors.Is(err, os.ErrNotExist) { return err } if before != im.Before && before != reconstructionDigest(im.After) { return os.ErrPermission } } for _, im := range p.Images { if err := h.Store.Write(im.Name, im.After); err != nil { return err } } if err := reconstructionSources(p.Grant, resolve); err != nil { return err } s.Revision++ s.Last = p.Digest s.Pending = nil return h.writeReconstruction(s) } func (h *RootHistory) reconstructionImages(g HistoryReconstruction, now time.Time) ([]reconstructionImage, error) { var images []reconstructionImage add := func(name string, v any) error { before := "absent" old, err := h.Store.Read(name) if err == nil { before = reconstructionDigest(old) } else if !errors.Is(err, os.ErrNotExist) { return err } raw, err := json.Marshal(v) if err != nil { return err } images = append(images, reconstructionImage{Name: name, Before: before, After: raw}) return nil } a := historyApp{Schema: 1, Account: g.Account, App: g.App, Hosts: []string{}} if raw, err := h.Store.Read(historyAppKey(g.Account, g.App)); err == nil { if strictCanonicalJSON(raw, &a) != nil || !validHistoryApp(a) || a.Account != g.Account || a.App != g.App { return nil, os.ErrPermission } } else if !errors.Is(err, os.ErrNotExist) { return nil, err } if a.Revision != g.AppRevision || a.Revision == ^uint64(0) { return nil, os.ErrPermission } names, err := h.Store.Names() if err != nil { return nil, err } for _, name := range names { if name == rootBakeKey || !strings.HasSuffix(name, ".json") { continue } raw, err := h.Store.Read(name) if err != nil { return nil, err } var r historyRecord if strictCanonicalJSON(raw, &r) != nil || !validHistory(r, r.Binding) || historyKey(r.Binding) != name || r.Attempt != nil { return nil, os.ErrPermission } 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 }) { return nil, os.ErrPermission } } for _, host := range g.Hosts { b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} r, err := h.readRecord(b) if errors.Is(err, os.ErrNotExist) { r = historyRecord{Schema: 1, Binding: b} } else if err != nil { return nil, err } if r.Revision != host.Revision || r.Attempt != nil || r.Revision == ^uint64(0) { return nil, os.ErrPermission } r.Revision++ if r.Programs.Revision == 0 { r.Programs.Revision = r.Revision } r.Refusals.Revision, r.Refusals.Complete = r.Revision, true for _, ref := range host.Refusals { ref.Recovered = false if !slices.Contains(r.Refusals.Values, ref) { r.Refusals.Values = append(r.Refusals.Values, ref) } } if !validHistory(r, b) { return nil, os.ErrPermission } if err := add(historyKey(b), r); err != nil { return nil, err } if !slices.Contains(a.Hosts, b.Host) { a.Hosts = append(a.Hosts, b.Host) } } a.Revision++ a.Complete = true if !validHistoryApp(a) { return nil, os.ErrPermission } if err := add(historyAppKey(g.Account, g.App), a); err != nil { return nil, err } bake := rootBakeRecord{Schema: 1, Complete: map[string]uint64{}, Clock: FirstSeen{Channels: map[string]map[string]time.Time{}}} if raw, err := h.Store.Read(rootBakeKey); err == nil { if strictCanonicalJSON(raw, &bake) != nil || !validRootBake(bake) || now.Before(bake.Observed) { return nil, os.ErrPermission } } else if !errors.Is(err, os.ErrNotExist) { return nil, err } if bake.Revision != g.BakeRevision || bake.Revision == ^uint64(0) || len(g.Holds) > historyLimit { return nil, os.ErrPermission } bake.Revision++ bake.Observed = now.UTC() bake.Complete[prodKey(g.Account, g.App)] = bake.Revision for key, versions := range bake.Clock.Channels { if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { continue } for version := range versions { versions[version] = now.UTC() } } if bake.Clock.Production == nil { bake.Clock.Production = map[string]*prodMemory{} } if bake.Clock.Holds == nil { bake.Clock.Holds = map[string]*holdMemory{} } pk := prodKey(g.Account, g.App) prod := bake.Clock.Production[pk] if prod == nil { prod = &prodMemory{} bake.Clock.Production[pk] = prod } if !prod.OffFirstSeen.IsZero() { prod.OffFirstSeen = now.UTC() } if g.Production { prod.WasProduction = true prod.OffSetAt, prod.OffFirstSeen = time.Time{}, time.Time{} } if g.ProductionSetAt.After(prod.NewestSetAt) { prod.NewestSetAt = g.ProductionSetAt } for key, mem := range bake.Clock.Holds { if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { continue } if !mem.PendingAt.IsZero() { mem.PendingAt = now.UTC() } if !mem.DeletedAt.IsZero() { mem.DeletedAt = now.UTC() } } for channel, bound := range g.Holds { if !ValidChannel(channel) || bound.Window < 0 { return nil, os.ErrPermission } key := channelClockKey(g.Account, g.App, channel) mem := bake.Clock.Holds[key] if mem == nil { mem = &holdMemory{} bake.Clock.Holds[key] = mem } if bound.Window > mem.Window { mem.Window = bound.Window mem.Pending = 0 mem.PendingAt = time.Time{} } if bound.SetAt.After(mem.SetAt) { mem.SetAt = bound.SetAt } } if !validRootBake(bake) { return nil, os.ErrPermission } if err := add(rootBakeKey, bake); err != nil { return nil, err } return images, nil }