package release import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "slices" "strings" "time" "github.com/carlosframework/platform/internal/rootstate" ) const RootHistoryDir = "/var/lib/carlos-release/private/history" const historyLimit = 256 type HistoryBinding struct { Host string `json:"host"` Account string `json:"account"` App string `json:"app"` } type HistoryAttempt struct { ID string `json:"id"` Version string `json:"version"` Source string `json:"source"` Started time.Time `json:"started"` SHA256 string `json:"sha256,omitempty"` Platform string `json:"platform,omitempty"` PromotedAt time.Time `json:"promoted_at,omitempty"` } func (h *RootHistory) Pending(b HistoryBinding) (*HistoryAttempt, error) { r, err := h.read(b) if err != nil { return nil, err } if r.Attempt == nil { return nil, nil } a := *r.Attempt return &a, nil } type historyPrograms struct { Revision uint64 `json:"revision"` Complete bool `json:"complete"` Values []string `json:"values"` } type historyRefusals struct { Revision uint64 `json:"revision"` Complete bool `json:"complete"` Values []Refusal `json:"values"` } type historyRecord struct { Creation string `json:"creation,omitempty"` Fresh bool `json:"fresh,omitempty"` Schema int `json:"schema"` Binding HistoryBinding `json:"binding"` Revision uint64 `json:"revision"` Programs historyPrograms `json:"programs"` Refusals historyRefusals `json:"refusals"` Attempt *HistoryAttempt `json:"attempt,omitempty"` } // Callers hold the root release exclusion across reads, decisions and effects. // A store is supplied only by root construction, never by a service request. type RootHistory struct { Store rootstate.Store } func historyKey(b HistoryBinding) string { sum := sha256.Sum256([]byte(b.Account + "\x00" + b.App + "\x00" + b.Host)) return hex.EncodeToString(sum[:]) + ".json" } func (b HistoryBinding) valid() bool { return ValidAccount(b.Account) && ValidApp(b.App) && b.Host != "" && len(b.Host) <= 253 && !strings.ContainsAny(b.Host, "/\\\x00\n\r") } func validHistory(r historyRecord, b HistoryBinding) bool { if len(r.Creation) > 4096 || (r.Fresh && (r.Creation == "" || !r.Programs.Complete || !r.Refusals.Complete || len(r.Programs.Values) != 0 || len(r.Refusals.Values) != 0 || r.Attempt != nil)) { return false } if !b.valid() || r.Schema != 1 || r.Binding != b || r.Revision == 0 || r.Programs.Revision == 0 || r.Programs.Revision > r.Revision || r.Refusals.Revision == 0 || r.Refusals.Revision > r.Revision || len(r.Programs.Values) > historyLimit || len(r.Refusals.Values) > historyLimit { return false } seen := map[string]bool{} for _, p := range r.Programs.Values { if p == "" || len(p) > 4096 || strings.ContainsAny(p, "\x00\n\r") || seen[p] { return false } seen[p] = true } for _, v := range r.Refusals.Values { if v.Schema != 1 || v.Host != b.Host || v.App != b.App || v.Version == "" || v.RefusedAt.IsZero() || v.MoveOp != "" { return false } if v.Kind != RefusalNotUp && v.Kind != RefusalWrongProgram && v.Kind != RefusalNotProven { return false } } if a := r.Attempt; a != nil && (a.ID == "" || a.Source == "" || a.Version == "" || a.Started.IsZero()) { return false } return true } func (h *RootHistory) read(b HistoryBinding) (historyRecord, error) { var r historyRecord if err := h.reconstructionReady(); err != nil { return r, err } return h.readRecord(b) } func (h *RootHistory) readRecord(b HistoryBinding) (historyRecord, error) { var r historyRecord if h == nil || h.Store == nil || !b.valid() { return r, os.ErrPermission } raw, err := h.Store.Read(historyKey(b)) if err != nil { return r, err } if len(raw) > rootstate.MaxRecord { return r, os.ErrPermission } if strictCanonicalJSON(raw, &r) != nil || !validHistory(r, b) { return historyRecord{}, os.ErrPermission } // A preceding process may have seen rename succeed and directory sync fail. // Recommit the exact bytes before using either absence or an exception. if err := h.Store.Write(historyKey(b), raw); err != nil { return historyRecord{}, err } return r, nil } func (h *RootHistory) write(r historyRecord) error { if h == nil || h.Store == nil || !validHistory(r, r.Binding) { return os.ErrPermission } raw, err := json.Marshal(r) if err != nil { return err } if len(raw) > rootstate.MaxRecord { return os.ErrPermission } return h.Store.Write(historyKey(r.Binding), raw) } func (h *RootHistory) Programs(b HistoryBinding) ([]string, error) { r, err := h.read(b) if err != nil { return nil, err } if !r.Programs.Complete { return nil, os.ErrPermission } return slices.Clone(r.Programs.Values), nil } func (h *RootHistory) RecordInstalledProgram(b HistoryBinding, program string) error { r, err := h.read(b) if errors.Is(err, os.ErrNotExist) && h != nil && h.Store != nil && b.valid() { r = historyRecord{Schema: 1, Binding: b, Revision: 1, Programs: historyPrograms{Revision: 1}, Refusals: historyRefusals{Revision: 1}} } else if err != nil { return err } if !r.Fresh && r.Programs.Complete && (program == "" || slices.Contains(r.Programs.Values, program)) { return nil } if r.Revision == ^uint64(0) { return os.ErrPermission } if program != "" && !slices.Contains(r.Programs.Values, program) { r.Programs.Values = append(r.Programs.Values, program) } r.Revision++ r.Fresh = false r.Programs.Complete, r.Programs.Revision = true, r.Revision return h.write(r) } func (h *RootHistory) Refusals(b HistoryBinding) ([]Refusal, error) { r, err := h.read(b) if err != nil { return nil, err } if !r.Refusals.Complete || r.Attempt != nil { return nil, os.ErrPermission } outcomes, err := h.recoveryOutcomes() if err != nil { return nil, err } refs, err := h.recoveryRefusals(outcomes, b) if err != nil { return nil, err } refs = append(slices.Clone(r.Refusals.Values), refs...) if len(refs) > historyLimit { return nil, os.ErrPermission } return refs, nil } func (h *RootHistory) AddRefusal(b HistoryBinding, v Refusal) error { r, err := h.read(b) if errors.Is(err, os.ErrNotExist) && h != nil && h.Store != nil && b.valid() { r = historyRecord{Schema: 1, Binding: b, Revision: 1, Programs: historyPrograms{Revision: 1}, Refusals: historyRefusals{Revision: 1}} } else if err != nil { return err } v.Schema = 1 for _, old := range r.Refusals.Values { if old.RefusedAt.Equal(v.RefusedAt) && old.PointerPromotedAt.Equal(v.PointerPromotedAt) { old.RefusedAt, old.PointerPromotedAt = v.RefusedAt, v.PointerPromotedAt } if old == v { return nil } } if r.Revision == ^uint64(0) { return os.ErrPermission } r.Revision++ r.Fresh = false r.Refusals.Revision = r.Revision next := r next.Refusals.Values = append(slices.Clone(r.Refusals.Values), v) raw, marshalErr := json.Marshal(next) if marshalErr != nil { return marshalErr } if len(next.Refusals.Values) > historyLimit || len(raw) > rootstate.MaxRecord { r.Refusals.Complete = false return errors.Join(os.ErrPermission, h.write(r)) } return h.write(next) } // The caller has independently verified the signed target. Missing or corrupt // history cannot grant minting, but it does not prevent authorized recovery. func (h *RootHistory) Blocks(b HistoryBinding, p *ChannelPointer) (bool, error) { if !b.valid() || p == nil || p.Version == "" { return true, os.ErrPermission } for _, v := range h.recoveryDenials(b) { if v.Version == p.Version && (v.Kind != RefusalNotUp || v.Blocks(p)) { return true, nil } } return false, nil } func (h *RootHistory) Begin(b HistoryBinding, a HistoryAttempt) error { r, err := h.read(b) if err != nil { return err } if !r.Refusals.Complete || !r.Programs.Complete { return os.ErrPermission } if r.Attempt != nil { return os.ErrPermission } if r.Revision == ^uint64(0) { return os.ErrPermission } r.Revision++ r.Fresh = false r.Attempt = &a return h.write(r) } // Only independently checked completion may settle an intent. Callers cannot // infer completion from a live link or registry stamp. func (h *RootHistory) Settle(b HistoryBinding, id string, refusal *Refusal) error { r, err := h.read(b) if err != nil { return err } if r.Attempt == nil || r.Attempt.ID != id { return os.ErrPermission } if refusal != nil { v := *refusal v.Schema = 1 if v.Version != r.Attempt.Version { return os.ErrPermission } r.Refusals.Values = append(r.Refusals.Values, v) } if r.Revision == ^uint64(0) { return os.ErrPermission } r.Revision++ r.Refusals.Revision = r.Revision r.Attempt = nil return h.write(r) }