// The scan result object: apps///scan-results/.json. // // It exists for one narrow case, and the case is not hypothetical. tag() // declines to write a verdict when the object already carries eight foreign // tags (S3 caps an object at ten) or when the pre-write tag read is denied. // Both refusals are deliberate — they stop one object stalling a whole sweep — // and the sweep tolerates them because it re-lists every 24h. // // A queue item does not. The on-demand drain scans, gets a real verdict, and // deletes the item; without somewhere else to put that verdict the status door // would answer "pending" forever while the callback said "clean". The app's // declared source of truth would never arrive, and the two answers it did get // would disagree. // // KEYED BY sha256(key), NOT by a queue id: the door asks by key, and ULID-named // queue items would mean listing the whole prefix on every poll. // // AND VERSION-BOUND, which is the part that has been got wrong three times in // this design's reviews. Without it: v1 scanned, tag not written because the // cap fired, result says clean; the tenant overwrites with v2; the tag is still // absent, the door falls through to the result object, and answers clean for // bytes nobody has read. That is precisely the laundering the verdict tag's own // version binding exists to stop, reintroduced by the mechanism meant to close // a gap in it. package objstore import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) // ScanResultSchema is pinned; an unknown schema refuses rather than guessing. const ScanResultSchema = 1 // ScanResult is a verdict that was reached but could not be written onto the // object it describes. type ScanResult struct { Schema int `json:"schema"` Key string `json:"key"` Verdict string `json:"verdict"` // Version is the object version this verdict judged. A result whose // version does not match the object's current one is not an answer about // the object that is there now. Version string `json:"version"` Engine string `json:"engine,omitempty"` Signature string `json:"signature,omitempty"` At time.Time `json:"at"` } // ScanResultKey is where one key's unrecorded verdict lives. // // Under the APP's prefix, so PurgeApp removes it with everything else and the // name-claim gate can see it. A top-level results prefix would survive app // deletion invisibly, which is the same argument that keeps the queue here. func ScanResultKey(acct, app, objectKey string) string { sum := sha256.Sum256([]byte(objectKey)) return release.AppPrefix(acct, app) + "scan-results/" + hex.EncodeToString(sum[:]) + ".json" } // ScanResultsPrefix is the whole set, for the age sweep and for deletion when // an app stops being scannable. func ScanResultsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "scan-results/" } // WriteScanResult records a verdict the object itself could not carry. // // READ-THEN-CAS, not create-only. release.Store.Put has exactly two modes, and // the second write for a key is a REPLACE: v1 unrecordable, tenant uploads v2, // v2 also unrecordable. A create-only write there returns ErrCASConflict and // silently drops the v2 verdict, leaving the door on "pending" until a sweep // happens to tag it. // // The caller must have re-read store.json immediately before calling this. The // race is the one writeSidecar documents — read the record, scan for seconds, // a teardown and PurgeApp land, and a write under the purged prefix succeeds — // but the consequence here is worse than a stranded sidecar. A stranded queue // item is drained on the next tick because its index entry survives; a stranded // result object is found by nothing, blocks the app-name claim gate, and // because materialize counts any key under an app prefix and fails toward // keeping the row, it RESURRECTS the deleted app on the next console boot. func claimRawWriteScanResult(ctx context.Context, s release.Store, acct, app string, res *ScanResult) error { res.Schema = ScanResultSchema body, err := json.MarshalIndent(res, "", " ") if err != nil { return err } body = append(body, '\n') key := ScanResultKey(acct, app, res.Key) for attempt := 0; attempt < 2; attempt++ { _, etag, err := s.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("objstore: read scan result %s/%s: %w", acct, app, err) } if errors.Is(err, os.ErrNotExist) { etag = "" // create-only } if _, err := s.Put(ctx, key, body, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { continue // somebody moved under us; re-read and try once more } return fmt.Errorf("objstore: write scan result %s/%s: %w", acct, app, err) } return nil } return fmt.Errorf("objstore: write scan result %s/%s: lost the race twice", acct, app) } // ReadScanResult answers only about currentVersion. A result recorded for // different bytes is ABSENT, not stale-but-usable: the comparison lives here so // no door and no app ever handles the version token, and so nobody can forget // to make it. func ReadScanResult(ctx context.Context, s release.Store, acct, app, objectKey, currentVersion string) (*ScanResult, error) { raw, _, err := s.Get(ctx, ScanResultKey(acct, app, objectKey)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var res ScanResult if err := json.Unmarshal(raw, &res); err != nil { return nil, fmt.Errorf("objstore: scan result %s/%s: %w", acct, app, err) } if res.Schema != ScanResultSchema { return nil, fmt.Errorf("objstore: scan result %s/%s: schema %d is not %d", acct, app, res.Schema, ScanResultSchema) } if res.Version != currentVersion { // Recorded about bytes that are no longer there. return nil, nil } return &res, nil } // DeleteScanResult drops one key's result — called when a later scan DOES // persist a verdict onto the object, so the fallback cannot outlive the real // answer. func claimRawDeleteScanResult(ctx context.Context, s release.Store, acct, app, objectKey string) error { err := s.Delete(ctx, ScanResultKey(acct, app, objectKey)) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } // DeleteScanResults drops an app's whole fallback set — called when the app // may no longer be scanned at all. A result object outliving consent is a // verdict no door is allowed to answer from, and it is the orphan that blocks // the app-name claim gate. func claimRawDeleteScanResults(ctx context.Context, s release.Store, acct, app string) error { keys, err := s.List(ctx, ScanResultsPrefix(acct, app)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } for _, k := range keys { if err := s.Delete(ctx, k); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } return nil } // CollectScanResults deletes an app's results older than before, and reports // how many it removed. // // WHY AGE AT ALL. A result exists because a verdict could not be written onto // the object, and the conditions that cause that — a ten-tag object, a denied // tagging call — do not clear themselves. Nothing else deletes these: the // drain only removes one when a LATER scan records a real verdict on the same // key, which for a permanently untaggable object never happens. Without a // collector an app accumulates one small object per such key forever. // // The TTL is the queue's, for the same reason the queue's is what it is: after // a sweep cycle the ordinary walk has had its chance at the object, and the // door's answer falls back to reading the tag itself. // // ModLister is optional (release/store.go:161), exactly as storagePass treats // SizeLister: a store that cannot answer is skipped, not an error — the caller // logs once rather than failing a tick over a capability a test fake lacks. // // The listing and the age filter run outside any operation; the app mutation // objstore.CollectScanResults is entered only when at least one result is // past its TTL. This runs once per visited app per tick and once per app at // boot, and an exclusive operation for an app with nothing to delete would // be most of them. func CollectScanResults(ctx context.Context, s release.Store, acct, app string, before time.Time) (int, error) { expired, err := expiredScanResults(ctx, s, acct, app, before) if err != nil || len(expired) == 0 { return 0, err } revisions := make(map[string]string, len(expired)) for _, key := range expired { _, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return 0, err } revisions[key] = etag } if len(revisions) == 0 { return 0, nil } n := 0 err = release.WithAppMutation(ctx, s, acct, app, "objstore.CollectScanResults", func(ctx context.Context) error { // Recheck age after acquiring authority, and delete only the revision // observed earlier. A replacement also survives with claims disabled. stillExpired, err := expiredScanResults(ctx, s, acct, app, before) if err != nil { return err } for _, k := range stillExpired { etag, observed := revisions[k] if !observed { continue } err := release.DeleteIfMatch(ctx, s, k, etag) if errors.Is(err, release.ErrCASConflict) || errors.Is(err, os.ErrNotExist) { continue } if err != nil { return err } n++ } return nil }) return n, err } // expiredScanResults names an app's results modified before the cut-off. func expiredScanResults(ctx context.Context, s release.Store, acct, app string, before time.Time) ([]string, error) { ml, ok := s.(release.ModLister) if !ok { return nil, ErrNoModLister } entries, err := ml.ListMod(ctx, ScanResultsPrefix(acct, app)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var expired []string for _, e := range entries { if e.Mod.Before(before) { expired = append(expired, e.Key) } } return expired, nil } // ErrNoModLister is a store that cannot rank by modification time. The caller // skips the age sweep and says so once, rather than treating a missing // capability as a failure. var ErrNoModLister = errors.New("objstore: this store cannot list modification times") func DeleteScanResult(ctx context.Context, s release.Store, acct, app, objectKey string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "objstore.DeleteScanResult", func(claimContext context.Context) error { claimResult0 = claimRawDeleteScanResult(claimContext, s, acct, app, objectKey) return claimResult0 }) return } func DeleteScanResults(ctx context.Context, s release.Store, acct, app string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "objstore.DeleteScanResults", func(claimContext context.Context) error { claimResult0 = claimRawDeleteScanResults(claimContext, s, acct, app) return claimResult0 }) return } func WriteScanResult(ctx context.Context, s release.Store, acct, app string, res *ScanResult) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "objstore.WriteScanResult", func(claimContext context.Context) error { claimResult0 = claimRawWriteScanResult(claimContext, s, acct, app, res) return claimResult0 }) return }