objstore: the scan sidecar, with schema and account refusals
The rollup gets its own object rather than a block in store.json. The sweep writes once per page over minutes against a record the create, grant and rotate doors also write, so sharing one object would put every page-write in a CAS race it normally loses. Its own object with a single writer reduces the whole story to "another sweep is ahead, stand down". Current and LastComplete are separate fields because one field would have to mean both "in progress" and "the answer", and the tick after a sweep finished would overwrite the audit answer with page-one partials. The reader refuses two things rather than guessing: an unknown schema, and a body whose account disagrees with its path. The second is not hypothetical — accounts migrate copies every key under an app's prefix, so a sidecar can arrive under a new account still holding rollups for a bucket named after the old one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+254
−0
internal/objstore/sidecar.go+129 −0internal/objstore/sidecar_test.go+125 −0
diff --git a/internal/objstore/sidecar.go b/internal/objstore/sidecar.go| new file mode 100644 |
| index 000000000..2655433c1 |
| --- /dev/null |
| +++ b/internal/objstore/sidecar.go |
| @@ -0,0 +1,129 @@ |
| +// The scan sidecar (spec §5): apps/<acct>/<app>/scan.json, the coverage |
| +// rollup, and the object an audit answer is quoted from. |
| +// |
| +// It is a SEPARATE OBJECT from store.json on purpose. The sweep writes once |
| +// per page over minutes; the create, grant and rotate doors write store.json |
| +// too. Sharing one object would put a page-write in a compare-and-swap race |
| +// with every repair walk, and losing that race is the normal case rather than |
| +// the exception. With its own object and a single writer, the whole CAS story |
| +// shrinks to "another sweep is ahead of me, stand down". |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const SidecarSchema = 1 |
| + |
| +// ErrSidecarMismatch is a sidecar whose body disagrees with the path it was |
| +// read from. |
| +var ErrSidecarMismatch = errors.New("objstore: scan sidecar names another app") |
| + |
| +func ScanKey(acct, app string) string { return release.AppPrefix(acct, app) + "scan.json" } |
| + |
| +// Sweep is one reconciliation pass over one bucket. |
| +// |
| +// Scanned and Unscanned are NOT complements. Skipped, Denied and Failed are |
| +// recorded verdicts that are not coverage — an object too large to stream was |
| +// never judged, and calling it clean would be a lie an auditor is entitled to |
| +// catch. That is why the audit sentence names two numbers. |
| +type Sweep struct { |
| + StartedAt time.Time `json:"started_at"` |
| + At time.Time `json:"at"` |
| + Engine string `json:"engine"` |
| + // Signature is the engine's signature-database version, verbatim. A clean |
| + // verdict from three-month-old signatures should not be indistinguishable |
| + // from a fresh one. |
| + Signature string `json:"signature,omitempty"` |
| + Objects int64 `json:"objects"` |
| + Scanned int64 `json:"scanned"` |
| + Infected int64 `json:"infected"` |
| + Skipped int64 `json:"skipped"` |
| + Denied int64 `json:"denied"` |
| + Failed int64 `json:"failed"` |
| + Unscanned int64 `json:"unscanned"` |
| + Bytes int64 `json:"bytes"` |
| + // After is the last key of the last written page — the resume point. |
| + // A key, not a ListObjectsV2 continuation token: a token's lifetime across |
| + // processes is unspecified and, on Ceph, unmeasured, while a key is |
| + // portable to any S3-compatible store. |
| + After string `json:"after,omitempty"` |
| +} |
| + |
| +// ScanSidecar holds the in-flight sweep and the last completed one. |
| +// |
| +// TWO SWEEPS, NOT ONE. A single field would have to mean both "in progress" |
| +// and "the answer", and the tick after a sweep completed would overwrite the |
| +// completed counts with page-one partials — destroying the compliance artifact |
| +// on every cycle. Current accumulates; on its last page it moves to |
| +// LastComplete, and LastComplete is the only thing an audit answer quotes. |
| +type ScanSidecar struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + // NotApplicable is a store whose member attested client-side encryption. |
| + // A scanner cannot read ciphertext, so no sweep runs and no coverage is |
| + // claimed — reporting "all clean" for such a store would be worse than |
| + // reporting nothing. |
| + NotApplicable bool `json:"not_applicable,omitempty"` |
| + Current *Sweep `json:"current,omitempty"` |
| + LastComplete *Sweep `json:"last_complete,omitempty"` |
| +} |
| + |
| +// GetSidecar reads the sidecar. Absent is (nil, "", nil) — no sidecar is a |
| +// state, not an error, the same idiom Get uses for an absent record. |
| +func GetSidecar(ctx context.Context, s release.Store, acct, app string) (*ScanSidecar, string, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, "", fmt.Errorf("objstore: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return nil, "", fmt.Errorf("objstore: invalid app %q", app) |
| + } |
| + raw, etag, err := s.Get(ctx, ScanKey(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var sc ScanSidecar |
| + if err := json.Unmarshal(raw, &sc); err != nil { |
| + return nil, "", fmt.Errorf("objstore: %s: %w", ScanKey(acct, app), err) |
| + } |
| + // An unknown schema is refused rather than guessed at — the rule |
| + // release.ReadAppTombstone already applies to its own shape. |
| + if sc.Schema != SidecarSchema { |
| + return nil, "", fmt.Errorf("objstore: %s: schema %d is not %d", |
| + ScanKey(acct, app), sc.Schema, SidecarSchema) |
| + } |
| + // `accounts migrate` copies every key under an app's prefix, so a sidecar |
| + // can arrive under an account it was never written for, carrying rollups |
| + // for a bucket named after the old one. Refusing beats reporting another |
| + // account's coverage as this one's. |
| + if sc.Account != acct || sc.App != app { |
| + return nil, "", fmt.Errorf("%w: body says %s/%s, path says %s/%s", |
| + ErrSidecarMismatch, sc.Account, sc.App, acct, app) |
| + } |
| + return &sc, etag, nil |
| +} |
| + |
| +// PutSidecar writes with the caller's CAS position; etag "" is create-only. |
| +// It stamps Schema, Account and App the way Put stamps Schema and App, so a |
| +// caller cannot write a body that disagrees with its own path. |
| +func PutSidecar(ctx context.Context, s release.Store, acct, app string, sc *ScanSidecar, etag string) (string, error) { |
| + sc.Schema = SidecarSchema |
| + sc.Account = acct |
| + sc.App = app |
| + body, err := json.MarshalIndent(sc, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, ScanKey(acct, app), append(body, '\n'), etag) |
| +} |
diff --git a/internal/objstore/sidecar_test.go b/internal/objstore/sidecar_test.go| new file mode 100644 |
| index 000000000..de523d42e |
| --- /dev/null |
| +++ b/internal/objstore/sidecar_test.go |
| @@ -0,0 +1,125 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestScanKey(t *testing.T) { |
| + if got := ScanKey("bab", "titogo"); got != "apps/bab/titogo/scan.json" { |
| + t.Fatalf("ScanKey = %q", got) |
| + } |
| +} |
| + |
| +func TestSidecarRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + sc := &ScanSidecar{ |
| + LastComplete: &Sweep{ |
| + Objects: 12481, Scanned: 12479, Skipped: 2, Engine: "clamav", |
| + Signature: "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026", |
| + }, |
| + } |
| + if _, err := PutSidecar(ctx, s, "bab", "titogo", sc, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + back, etag, err := GetSidecar(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if etag == "" { |
| + t.Fatal("GetSidecar returned no etag, so no caller can CAS against it") |
| + } |
| + if back.LastComplete == nil || back.LastComplete.Objects != 12481 { |
| + t.Fatalf("rollup did not round-trip: %+v", back) |
| + } |
| + if back.LastComplete.Skipped != 2 { |
| + t.Fatalf("skipped = %d, want 2 — skipped must never be folded into scanned", |
| + back.LastComplete.Skipped) |
| + } |
| + if back.Schema != SidecarSchema || back.Account != "bab" || back.App != "titogo" { |
| + t.Fatalf("PutSidecar did not stamp identity: %+v", back) |
| + } |
| +} |
| + |
| +// Absent is (nil, "", nil): a store nobody has swept has no sidecar, and that |
| +// is a state rather than an error. |
| +func TestSidecarAbsentIsNotAnError(t *testing.T) { |
| + sc, etag, err := GetSidecar(context.Background(), |
| + release.NewDirStore(t.TempDir()), "bab", "titogo") |
| + if err != nil || sc != nil || etag != "" { |
| + t.Fatalf("absent sidecar = (%v, %q, %v), want (nil, \"\", nil)", sc, etag, err) |
| + } |
| +} |
| + |
| +// accounts migrate copies every key under an app's prefix, so a sidecar can |
| +// arrive under an account it was never written for, still carrying rollups for |
| +// a bucket named after the old one. Refusing beats reporting one account's |
| +// coverage as another's. |
| +func TestSidecarRefusesAccountMismatch(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := PutSidecar(ctx, s, "bab", "titogo", &ScanSidecar{}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := s.Get(ctx, ScanKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Exactly what a migration does: the same bytes under a new prefix. |
| + if _, err := s.Put(ctx, ScanKey("zed", "titogo"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := GetSidecar(ctx, s, "zed", "titogo"); !errors.Is(err, ErrSidecarMismatch) { |
| + t.Fatalf("migrated sidecar error = %v, want ErrSidecarMismatch", err) |
| + } |
| +} |
| + |
| +// An unknown schema is refused, not guessed at. |
| +func TestSidecarRefusesUnknownSchema(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, ScanKey("bab", "titogo"), |
| + []byte(`{"schema":99,"account":"bab","app":"titogo"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := GetSidecar(ctx, s, "bab", "titogo"); err == nil { |
| + t.Fatal("an unknown sidecar schema must be refused") |
| + } |
| +} |
| + |
| +// Current and LastComplete are separate fields so a finished sweep survives |
| +// the start of the next one. With a single field the tick after completion |
| +// would overwrite the audit answer with page-one partials. |
| +func TestSidecarKeepsLastCompleteWhileCurrentRuns(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + done := &Sweep{Objects: 100, Scanned: 100, Engine: "clamav"} |
| + if _, err := PutSidecar(ctx, s, "bab", "titogo", |
| + &ScanSidecar{LastComplete: done}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + stored, etag, err := GetSidecar(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // A fresh sweep begins: Current starts at page one, LastComplete must not |
| + // move. |
| + stored.Current = &Sweep{Objects: 7, Scanned: 7, Engine: "clamav", After: "g.txt"} |
| + if _, err := PutSidecar(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + back, _, err := GetSidecar(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if back.LastComplete == nil || back.LastComplete.Objects != 100 { |
| + t.Fatalf("a running sweep destroyed the last completed one: %+v", back.LastComplete) |
| + } |
| + if back.Current == nil || back.Current.After != "g.txt" { |
| + t.Fatalf("current sweep did not round-trip: %+v", back.Current) |
| + } |
| +} |