package host import ( "bytes" "context" "encoding/json" "errors" "io" "os" "path/filepath" "reflect" "sort" "strings" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/rootstate" ) // A completed root retirement changes where an obligation is checked. It never // deletes the payload, its requirements, or a generation reservation. func generationRetirements(ctx context.Context, snapshot generationInventory) (map[int]string, error) { transfers, err := generationTransfers(ctx, snapshot) if err != nil { return nil, err } retired := map[int]string{} for _, j := range snapshot.Retirements { if err := ctx.Err(); err != nil { return nil, err } canonical, err := hostbox.NewRetirementJournal(j.ID, j.Inventory) if err != nil || j.V != 1 || !j.Complete || j.Target == nil || j.ID != j.Target.FenceID || !reflect.DeepEqual(j.Inventory, canonical.Inventory) || len(j.Steps) != len(canonical.Steps) { return nil, os.ErrPermission } for i, step := range j.Steps { if !step.Done || step.Artifact != canonical.Steps[i].Artifact { return nil, os.ErrPermission } } t := *j.Target if t.Incarnation == "." || t.Incarnation == ".." || strings.ContainsAny(t.Incarnation, "/\\\x00") { return nil, os.ErrPermission } index := -1 for i, p := range snapshot.Payloads { in := p.Inventory if in.Host == t.Host && in.Incarnation == t.Incarnation && in.InstallationStamp == t.Stamp { if index != -1 || in.Box != t.Box || in.Enrollment != t.Enrollment || in.DirectoryRevision != t.DirectoryRevision { return nil, os.ErrPermission } index = i } } if index < 0 || retired[index] != "" || j.Inventory.Host != t.Host || j.Inventory.Captured.Incarnation != t.Incarnation || j.Inventory.Captured.Stamp != t.Stamp { return nil, os.ErrPermission } receipt := false for _, view := range snapshot.Installations { if view.Host != t.Host { continue } for _, r := range view.Receipts { if r.Target == t && bytes.Equal(r.Bytes, j.StopReceipt) && verifyRetirementReceipt(ctx, r) == nil { receipt = true } } } if !receipt { return nil, os.ErrPermission } expected := map[hostbox.Artifact]bool{} for _, e := range snapshot.Payloads[index].Inventory.Entries { if e.Ownership != j.Inventory.Ownership || (e.Materialization != "materialized" && !(e.Kind == "timer" && e.Materialization == "intended")) { return nil, os.ErrPermission } a := hostbox.Artifact{AllowAbsent: e.Kind == "timer" && (e.TimerRetired || e.Materialization == "intended"), Kind: hostbox.ArtifactKind(e.Kind), Root: e.ManagedRoot, Name: e.RelativePath, Owner: e.Ownership, RowID: e.RowIdentity, Identity: e.Identity, Dev: e.Device, Ino: e.Inode, Type: e.Type} if expected[a] { return nil, os.ErrPermission } expected[a] = true } matched := map[hostbox.Artifact]bool{} for _, a := range j.Inventory.Artifacts { // The retirement producer also captures the timer registration. Root's // inventory establishes that registration as the timer's exact row ID. if a.Kind == hostbox.ArtifactTimer { row, registration, ok := strings.Cut(a.RowID, "~") if ok { if row != registration { return nil, os.ErrPermission } a.RowID = row } } if !expected[a] { return nil, os.ErrPermission } matched[a] = true } if len(matched) != len(expected) { return nil, os.ErrPermission } retired[index] = j.ID } for prior := range transfers { next := prior for steps := 0; ; steps++ { if steps > len(transfers) { return nil, os.ErrPermission } successor, ok := transfers[next] if !ok { break } next = successor } if id := retired[next]; id != "" { if retired[prior] != "" { return nil, os.ErrPermission } retired[prior] = id } } return retired, nil } func generationPhysicalPayloads(ctx context.Context, snapshot generationInventory) ([]hostbox.GenerationPayload, error) { retired, err := generationRetirements(ctx, snapshot) if err != nil { return nil, err } for i, payload := range snapshot.Payloads { if retired[i] != "" { continue } for _, entry := range payload.Inventory.Entries { if entry.Materialization != "materialized" { return nil, os.ErrPermission } } } payloads := append([]hostbox.GenerationPayload(nil), snapshot.Payloads...) transfers, err := generationTransfers(ctx, snapshot) if err != nil { return nil, err } for prior := range transfers { next := transfers[prior] for steps := 0; ; steps++ { if steps > len(transfers) { return nil, os.ErrPermission } successor, ok := transfers[next] if !ok { break } next = successor } in := &payloads[prior].Inventory in.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) for i, entry := range in.Entries { if entry.Kind != "path" { continue } for _, current := range snapshot.Payloads[next].Inventory.Entries { if current.Kind == "path" && current.ManagedRoot == entry.ManagedRoot && current.RelativePath == entry.RelativePath { in.Entries[i].Identity, in.Entries[i].Device = current.Identity, current.Device } } } } for i, id := range retired { in := &payloads[i].Inventory in.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) for n, e := range in.Entries { if e.Kind == "path" { if !filepath.IsLocal(e.RelativePath) || filepath.Clean(e.RelativePath) != e.RelativePath { return nil, os.ErrPermission } roots := map[string]string{"data": dataDir()} parker := hostbox.TypedParker{CapturePersistent: true, Roots: roots, ParkedRoots: roots} parkRoot, err := parker.ParkedArtifactRoot(hostbox.Artifact{Root: e.ManagedRoot, Name: e.RelativePath, Identity: e.Identity}) if err != nil { return nil, err } rel, err := filepath.Rel(dataDir(), parkRoot) if err != nil || !filepath.IsLocal(rel) { return nil, os.ErrPermission } in.Entries[n].RelativePath = filepath.Join(rel, ".parked", in.Incarnation, in.Host, id, e.ManagedRoot, e.RelativePath) } } } return payloads, nil } func generationRetirementRecords(ctx context.Context, store rootstate.Store) ([]hostbox.RetirementJournal, error) { names, err := store.Names() if err != nil { return nil, err } if len(names) > 8192 { return nil, os.ErrPermission } sort.Strings(names) var out []hostbox.RetirementJournal for _, name := range names { if err := ctx.Err(); err != nil { return nil, err } // Cold handover retains its own payload inventory. It is never parking // authority here. Temporary writes cannot establish completed retirement. if name == "cold" || name == "serving" || strings.HasPrefix(name, ".retire-") { continue } if !strings.HasSuffix(name, ".json") { return nil, os.ErrPermission } raw, err := store.Read(name) if err != nil { return nil, err } var j hostbox.RetirementJournal dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if dec.Decode(&j) != nil || dec.Decode(new(any)) != io.EOF || j.ID+".json" != name { return nil, os.ErrPermission } out = append(out, j) } return out, nil } func productionGenerationRetirements(ctx context.Context) ([]hostbox.RetirementJournal, error) { return generationReadRetirements(ctx, claimRetireJournalDir("", ""), func(path string) (rootstate.Store, error) { return rootstate.Open(path, false, false) }, func(path string) error { directory, err := os.Open(path) if err != nil { return err } defer directory.Close() return directory.Sync() }) } func generationReadRetirements(ctx context.Context, path string, open func(string) (rootstate.Store, error), sync func(string) error) ([]hostbox.RetirementJournal, error) { store, err := open(path) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } defer store.Close() journals, err := generationRetirementRecords(ctx, store) if err != nil { return nil, err } // Sync the completion rename without rewriting concurrent progress. if err := sync(path); err != nil { return nil, err } return journals, nil }