objstore: the sweep walk, resumable and safe against teardown
A reconciler, not a pipeline. Nothing in CARLOS sees a PUT, so there is no upload to hook — but what a walk can do that a pipeline cannot is count what it has NOT seen. A pipeline's misses are invisible by construction; a walk that lists the bucket knows exactly how many objects carry no verdict. That number is the whole deliverable. The pre-write re-read is not belt-and-braces. A fresh sweep's first write is create-only by construction, so the CAS retry guard cannot see this case at all: the sweep reads an enabled store.json, spends seconds listing and scanning, a teardown purges the whole prefix, and the create-only write then SUCCEEDS. The orphan that leaves is expensive — the app-name claim gate refuses any name whose prefix still holds a key, rename is refused, and box-side auto-purge never reaches it because the tombstone went last. The app name is gone for good. Verified by disabling the re-read: the test writes the orphan. On a lost CAS the guard looks before retrying. A conditional write against a deleted key also reads as ErrCASConflict, so a blind retry would fall back to create-only and resurrect the sidecar teardown just removed. Skipped counts as unscanned, infected counts as scanned. An object over the size ceiling was never judged; an infected one we know exactly what it is. Getting that backwards would make the audit sentence false in precisely the direction that matters. StartAfter, not ContinuationToken: a token's cross-process lifetime is unspecified and, on Ceph, unmeasured. A key resumes after a console restart or a change of S3 vendor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
3 files changed,
+688
−2
internal/objstore/memscanner.go+19 −2internal/objstore/sweep.go+319 −0internal/objstore/sweep_test.go+350 −0
diff --git a/internal/objstore/memscanner.go b/internal/objstore/memscanner.go| index 3c19340f4..550cb2dd4 100644 |
| --- a/internal/objstore/memscanner.go |
| +++ b/internal/objstore/memscanner.go |
| @@ -13,6 +13,7 @@ type MemScanner struct { |
| enabled map[string]bool |
| verdicts map[string]scanRecord |
| failures map[string]error |
| + scanAs map[string]Verdict |
| } |
| // scanRecord is one object's verdict AND the etag it was judged against. The |
| @@ -103,8 +104,24 @@ func (s *MemScanner) Scan(ctx context.Context, bucket, key, etag string, size in |
| if err := s.takeFailure("Scan"); err != nil { |
| return VerdictUnknown, err |
| } |
| - s.verdicts[memScanKey(bucket, key)] = scanRecord{etag: etag, verdict: VerdictClean} |
| - return VerdictClean, nil |
| + v := VerdictClean |
| + if got, ok := s.scanAs[key]; ok { |
| + v = got |
| + } |
| + s.verdicts[memScanKey(bucket, key)] = scanRecord{etag: etag, verdict: v} |
| + return v, nil |
| +} |
| + |
| +// ScanAs makes Scan answer v for key rather than clean, so a test can stage a |
| +// skipped or infected object without a real engine. Skipped in particular is |
| +// not a hypothetical: it is every object over the size ceiling. |
| +func (s *MemScanner) ScanAs(key string, v Verdict) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + if s.scanAs == nil { |
| + s.scanAs = map[string]Verdict{} |
| + } |
| + s.scanAs[key] = v |
| } |
| func (s *MemScanner) Enforcement() DenyTag { |
diff --git a/internal/objstore/sweep.go b/internal/objstore/sweep.go| new file mode 100644 |
| index 000000000..edce0db1b |
| --- /dev/null |
| +++ b/internal/objstore/sweep.go |
| @@ -0,0 +1,319 @@ |
| +// The sweep (spec §7): a resumable reconciliation pass over one bucket. |
| +// |
| +// It is a reconciler, not a pipeline. Nothing in CARLOS sees a PUT — the |
| +// control plane hands an app its own credentials and then never touches the |
| +// data plane — so there is no upload to hook. What a reconciler can do that a |
| +// pipeline cannot is COUNT WHAT IT HAS NOT SEEN: a pipeline's misses are |
| +// invisible by construction, while a walk that lists the bucket knows exactly |
| +// how many objects carry no verdict. That number is the whole point. An audit |
| +// answer needs two sentences, and the second one is the honest one. |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// sweepPageSize is how many keys one listing page asks for. The page is the |
| +// unit of durable progress: everything on it is scanned, then the sidecar is |
| +// written once. Smaller pages mean more sidecar writes; larger ones mean more |
| +// work lost when a tick's budget runs out mid-page. |
| +const sweepPageSize = 1000 |
| + |
| +// ScanObject is one key as the listing reports it. Size and ETag come free on |
| +// the listing page, which is why the sweep never needs a HeadObject: the size |
| +// decides the ceiling before any fetch, and the ETag binds the verdict. |
| +type ScanObject struct { |
| + Key string |
| + ETag string |
| + Size int64 |
| +} |
| + |
| +// ObjectLister is the one listing call the sweep makes, narrowed so the walk |
| +// is testable without S3 or credentials. |
| +type ObjectLister interface { |
| + // ListPage returns up to limit keys ordered after startAfter, and whether |
| + // more remain. |
| + ListPage(ctx context.Context, bucket, startAfter string, limit int32) (objs []ScanObject, truncated bool, err error) |
| +} |
| + |
| +// s3Lister adapts an S3 client to ObjectLister. |
| +type s3Lister struct{ s3 listPageAPI } |
| + |
| +type listPageAPI interface { |
| + ListObjectsV2(ctx context.Context, in *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) |
| +} |
| + |
| +// NewObjectLister adapts an S3 client to the sweep's listing seam. |
| +func NewObjectLister(c listPageAPI) ObjectLister { return &s3Lister{s3: c} } |
| + |
| +func (l *s3Lister) ListPage(ctx context.Context, bucket, startAfter string, limit int32) ([]ScanObject, bool, error) { |
| + in := &awss3.ListObjectsV2Input{Bucket: &bucket, MaxKeys: &limit} |
| + // StartAfter, not ContinuationToken. A token's lifetime across processes |
| + // is unspecified and, on Ceph, unmeasured; a key is a portable resume |
| + // point that survives a console restart and a change of S3 vendor. |
| + if startAfter != "" { |
| + in.StartAfter = &startAfter |
| + } |
| + out, err := l.s3.ListObjectsV2(ctx, in) |
| + if err != nil { |
| + return nil, false, fmt.Errorf("objstore: list %s: %w", bucket, err) |
| + } |
| + objs := make([]ScanObject, 0, len(out.Contents)) |
| + for _, o := range out.Contents { |
| + obj := ScanObject{Key: aws2s(o.Key), ETag: normalizeETag(aws2s(o.ETag))} |
| + if o.Size != nil { |
| + obj.Size = *o.Size |
| + } |
| + objs = append(objs, obj) |
| + } |
| + truncated := out.IsTruncated != nil && *out.IsTruncated |
| + return objs, truncated, nil |
| +} |
| + |
| +// errStandDown is not a failure. It is this sweep noticing that someone else — |
| +// another driver, or a teardown — is ahead of it, and that the right move is |
| +// to stop writing. RunSweep converts it to a nil error. |
| +var errStandDown = errors.New("objstore: sweep standing down") |
| + |
| +// RunSweep advances one bucket's coverage by up to budget of work and returns |
| +// the sidecar as it now stands. |
| +// |
| +// A nil sidecar with a nil error means there was nothing to do: no store, no |
| +// scanning enabled, or an app in the trash. |
| +func RunSweep(ctx context.Context, s release.Store, lister ObjectLister, sc Scanner, |
| + acct, app string, budget time.Duration) (*ScanSidecar, error) { |
| + |
| + rec, _, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Provisioned == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil { |
| + return nil, nil |
| + } |
| + |
| + // A trashed store is destroyed within 30 days. Scanning it bills for |
| + // egress on data that is on its way out, and reports coverage for an app |
| + // nobody can reach. |
| + tomb, err := release.ReadAppTombstone(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if tomb != nil { |
| + return nil, nil |
| + } |
| + |
| + // A scanner cannot read ciphertext. Sweeping a client-encrypted store |
| + // would report every object clean, which is worse than reporting nothing: |
| + // it is a true-looking sentence about bytes nobody examined. Mark it and |
| + // stop. |
| + if rec.Scanning.Requested != nil && rec.Scanning.Requested.Encrypted { |
| + return markNotApplicable(ctx, s, acct, app) |
| + } |
| + |
| + if sc == nil { |
| + return nil, fmt.Errorf("objstore: %s/%s has scanning enabled but no engine is configured "+ |
| + "(set CARLOS_STORE_SCANNER)", acct, app) |
| + } |
| + |
| + engine, signature, err := sc.Provenance(ctx) |
| + if err != nil { |
| + return nil, fmt.Errorf("objstore: %s/%s: engine unreachable: %w", acct, app, err) |
| + } |
| + // Engine drift refuses rather than guesses. Verdicts already in the bucket |
| + // were written by a different engine under its own conventions, and |
| + // recounting them as unscanned would destroy the coverage history. An |
| + // operator changes engine by revoking and re-granting, deliberately. |
| + if rec.Scanning.Enabled.Engine != engine { |
| + return nil, fmt.Errorf("objstore: %s/%s: record says engine %q, configured engine is %q", |
| + acct, app, rec.Scanning.Enabled.Engine, engine) |
| + } |
| + |
| + side, etag, err := GetSidecar(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if side == nil { |
| + side = &ScanSidecar{} |
| + } |
| + side.NotApplicable = false |
| + if side.Current == nil { |
| + side.Current = &Sweep{StartedAt: time.Now().UTC()} |
| + } |
| + side.Current.Engine = engine |
| + side.Current.Signature = signature |
| + |
| + bucket := rec.Provisioned.Bucket |
| + deadline := time.Now().Add(budget) |
| + |
| + for { |
| + pageStart := side.Current.After |
| + objs, truncated, err := lister.ListPage(ctx, bucket, pageStart, sweepPageSize) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + for _, o := range objs { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + v, err := sc.Verdict(ctx, bucket, o.Key, o.ETag) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if v == VerdictUnknown { |
| + // A scan error here is the ENGINE failing, not this object: |
| + // the next object would fail identically. Abandon the page |
| + // without writing. Nothing is lost but the listing — every |
| + // verdict already reached is a tag in the bucket, so the |
| + // re-run reads them back without re-scanning a byte. |
| + if v, err = sc.Scan(ctx, bucket, o.Key, o.ETag, o.Size); err != nil { |
| + return nil, err |
| + } |
| + } |
| + side.Current.count(v, o.Size) |
| + } |
| + |
| + side.Current.At = time.Now().UTC() |
| + if len(objs) > 0 { |
| + side.Current.After = objs[len(objs)-1].Key |
| + } |
| + |
| + last := !truncated |
| + if last { |
| + // The completed sweep becomes the answer, and Current clears. A |
| + // single field would have the next tick overwrite the compliance |
| + // artifact with page-one partials. |
| + side.Current.After = "" |
| + side.LastComplete = side.Current |
| + side.Current = nil |
| + } |
| + |
| + newETag, err := writeSidecar(ctx, s, acct, app, side, etag, pageStart) |
| + if errors.Is(err, errStandDown) { |
| + return side, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + etag = newETag |
| + |
| + if last || time.Now().After(deadline) { |
| + return side, nil |
| + } |
| + } |
| +} |
| + |
| +// count folds one object's verdict into the rollup. |
| +// |
| +// Scanned and Unscanned are not complements, and the arithmetic here is the |
| +// reason the audit answer needs two sentences. An infected object WAS scanned |
| +// — we know what it is. A skipped one was not judged at all. |
| +func (sw *Sweep) count(v Verdict, size int64) { |
| + sw.Objects++ |
| + sw.Bytes += size |
| + switch v { |
| + case VerdictClean: |
| + sw.Scanned++ |
| + case VerdictInfected: |
| + sw.Scanned++ |
| + sw.Infected++ |
| + case VerdictSkipped: |
| + sw.Skipped++ |
| + sw.Unscanned++ |
| + case VerdictDenied: |
| + sw.Denied++ |
| + sw.Unscanned++ |
| + case VerdictFailed: |
| + sw.Failed++ |
| + sw.Unscanned++ |
| + default: |
| + sw.Unscanned++ |
| + } |
| +} |
| + |
| +// markNotApplicable records that no coverage is claimed for this store, and |
| +// does no listing at all. |
| +func markNotApplicable(ctx context.Context, s release.Store, acct, app string) (*ScanSidecar, error) { |
| + side, etag, err := GetSidecar(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if side != nil && side.NotApplicable && side.Current == nil { |
| + return side, nil // already recorded; no write, no CAS churn |
| + } |
| + if side == nil { |
| + side = &ScanSidecar{} |
| + } |
| + side.NotApplicable = true |
| + side.Current = nil |
| + if _, err := writeSidecar(ctx, s, acct, app, side, etag, ""); err != nil { |
| + if errors.Is(err, errStandDown) { |
| + return side, nil |
| + } |
| + return nil, err |
| + } |
| + return side, nil |
| +} |
| + |
| +// writeSidecar performs the pre-write re-read and the CAS retry guard. |
| +// |
| +// The re-read closes a hole the retry guard alone cannot. A FRESH sweep's |
| +// first write is create-only by construction — no sidecar exists, so the etag |
| +// is "". Sequence: the sweep reads an enabled store.json, spends seconds |
| +// listing and scanning a page, a teardown runs and PurgeApp deletes the whole |
| +// prefix, and the sweep then writes scan.json create-only, which SUCCEEDS. The |
| +// result is an orphan under a purged app, and that orphan is expensive: the |
| +// app-name claim gate refuses any name whose prefix still holds a key, rename |
| +// is refused, and box-side auto-purge never reaches it because the tombstone |
| +// was written last. The app name becomes permanently unclaimable. |
| +// |
| +// The one-round-trip race this still leaves is the same one |
| +// deleteRecordIfUnchanged already accepts. |
| +func writeSidecar(ctx context.Context, s release.Store, acct, app string, |
| + side *ScanSidecar, etag, pageStart string) (string, error) { |
| + |
| + rec, _, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return "", err |
| + } |
| + if rec == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil { |
| + return "", errStandDown |
| + } |
| + |
| + newETag, err := PutSidecar(ctx, s, acct, app, side, etag) |
| + if err == nil { |
| + return newETag, nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return "", err |
| + } |
| + |
| + // Someone else wrote. A conditional write against a DELETED key also |
| + // reads as ErrCASConflict, so a blind retry would fall back to a |
| + // create-only write and resurrect a sidecar teardown just removed. Look |
| + // before retrying. |
| + current, freshETag, gerr := GetSidecar(ctx, s, acct, app) |
| + if gerr != nil { |
| + return "", gerr |
| + } |
| + if current == nil || current.Current == nil || current.Current.After != pageStart { |
| + // Deleted, completed by another driver, or advanced past us. In every |
| + // case this sweep is no longer the one making progress. |
| + return "", errStandDown |
| + } |
| + newETag, err = PutSidecar(ctx, s, acct, app, side, freshETag) |
| + if err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return "", errStandDown |
| + } |
| + return "", err |
| + } |
| + return newETag, nil |
| +} |
diff --git a/internal/objstore/sweep_test.go b/internal/objstore/sweep_test.go| new file mode 100644 |
| index 000000000..4d86bbd2c |
| --- /dev/null |
| +++ b/internal/objstore/sweep_test.go |
| @@ -0,0 +1,350 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "crypto/md5" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// memLister lists a MemProvisioner bucket, paginated, with real content-hash |
| +// ETags so the etag binding in Verdict is exercised rather than stubbed away. |
| +type memLister struct { |
| + p *MemProvisioner |
| + pageSize int |
| + calls int |
| + // before runs at the top of each ListPage, so a test can make the world |
| + // change underneath a sweep at the one moment that matters. |
| + before func() |
| +} |
| + |
| +func (l *memLister) ListPage(_ context.Context, bucket, startAfter string, _ int32) ([]ScanObject, bool, error) { |
| + l.calls++ |
| + if l.before != nil { |
| + l.before() |
| + } |
| + keys := make([]string, 0, len(l.p.Buckets[bucket])) |
| + for k := range l.p.Buckets[bucket] { |
| + if k > startAfter { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + |
| + size := l.pageSize |
| + if size <= 0 { |
| + size = sweepPageSize |
| + } |
| + truncated := len(keys) > size |
| + if truncated { |
| + keys = keys[:size] |
| + } |
| + objs := make([]ScanObject, 0, len(keys)) |
| + for _, k := range keys { |
| + body := l.p.Buckets[bucket][k] |
| + sum := md5.Sum(body) |
| + objs = append(objs, ScanObject{ |
| + Key: k, |
| + ETag: hex.EncodeToString(sum[:]), |
| + Size: int64(len(body)), |
| + }) |
| + } |
| + return objs, truncated, nil |
| +} |
| + |
| +// refuseLister fails the test if it is ever asked for a page — for the cases |
| +// where the right amount of work is none. |
| +type refuseLister struct{ t *testing.T } |
| + |
| +func (l *refuseLister) ListPage(context.Context, string, string, int32) ([]ScanObject, bool, error) { |
| + l.t.Fatal("listed a bucket that should never have been swept") |
| + return nil, false, nil |
| +} |
| + |
| +// sweepFixture stands up a provisioned, scanning-enabled store with n objects. |
| +func sweepFixture(t *testing.T, n int) (release.Store, *MemProvisioner, *MemScanner, string) { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + rec := rotateFixture(t, s, pub, p) |
| + enableScanning(t, s, "bab", "titogo") |
| + |
| + bucket := rec.Provisioned.Bucket |
| + for i := 0; i < n; i++ { |
| + p.Buckets[bucket][objectName(i)] = []byte(strings.Repeat("x", i+1)) |
| + } |
| + return s, p, NewMemScanner(), bucket |
| +} |
| + |
| +func objectName(i int) string { |
| + return "uploads/" + string(rune('a'+i)) + ".txt" |
| +} |
| + |
| +// sweepToCompletion drives RunSweep until a sweep finishes, so a paginated |
| +// test does not have to guess how many ticks that takes. It bounds the loop: |
| +// a sweep that never completes is a bug, not a slow test. |
| +func sweepToCompletion(t *testing.T, s release.Store, l ObjectLister, sc Scanner) *ScanSidecar { |
| + t.Helper() |
| + var side *ScanSidecar |
| + for i := 0; i < 50; i++ { |
| + var err error |
| + side, err = RunSweep(context.Background(), s, l, sc, "bab", "titogo", 0) |
| + if err != nil { |
| + t.Fatalf("sweep %d: %v", i, err) |
| + } |
| + if side != nil && side.LastComplete != nil { |
| + return side |
| + } |
| + } |
| + t.Fatal("sweep never completed in 50 ticks") |
| + return nil |
| +} |
| + |
| +// A synchronous engine's FIRST pass reports objects as scanned. Counting them |
| +// unscanned because "the next sweep reads the result" is GuardDuty's shape, |
| +// and applying it to ClamAV would make the audit sentence false for a full |
| +// re-sweep interval. |
| +func TestSweepCountsSynchronousVerdicts(t *testing.T) { |
| + s, p, sc, _ := sweepFixture(t, 3) |
| + |
| + side, err := RunSweep(context.Background(), s, &memLister{p: p}, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if side.LastComplete == nil { |
| + t.Fatal("a sweep that listed every object in one page did not complete") |
| + } |
| + got := side.LastComplete |
| + if got.Objects != 3 || got.Scanned != 3 || got.Unscanned != 0 { |
| + t.Fatalf("objects=%d scanned=%d unscanned=%d, want 3/3/0", got.Objects, got.Scanned, got.Unscanned) |
| + } |
| + if got.Engine != "clamav" || got.Signature == "" { |
| + t.Fatalf("no provenance recorded: engine=%q signature=%q", got.Engine, got.Signature) |
| + } |
| + if side.Current != nil { |
| + t.Fatal("a completed sweep left Current set") |
| + } |
| +} |
| + |
| +// LastComplete survives the tick after completion. A single Sweep field would |
| +// overwrite the audit answer with page-one partials every cycle — the |
| +// compliance artifact destroyed on a timer. |
| +func TestSweepLastCompleteSurvivesNextTick(t *testing.T) { |
| + s, p, sc, _ := sweepFixture(t, 5) |
| + l := &memLister{p: p, pageSize: 2} |
| + |
| + side := sweepToCompletion(t, s, l, sc) |
| + if side.LastComplete.Objects != 5 { |
| + t.Fatalf("completed sweep counted %d objects, want 5", side.LastComplete.Objects) |
| + } |
| + |
| + // One more tick: a fresh sweep starts and gets exactly one page in. |
| + next, err := RunSweep(context.Background(), s, l, sc, "bab", "titogo", 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if next.Current == nil || next.Current.Objects != 2 { |
| + t.Fatalf("the next tick did not start a fresh partial sweep: %+v", next.Current) |
| + } |
| + if next.LastComplete == nil || next.LastComplete.Objects != 5 { |
| + t.Fatalf("page-one partials overwrote the audit answer: %+v", next.LastComplete) |
| + } |
| +} |
| + |
| +// Interrupted mid-bucket and re-run, a sweep reaches the same rollup without |
| +// double-counting what it already saw. |
| +func TestSweepResumesWithoutDoubleCounting(t *testing.T) { |
| + s, p, sc, _ := sweepFixture(t, 5) |
| + l := &memLister{p: p, pageSize: 2} |
| + |
| + side := sweepToCompletion(t, s, l, sc) |
| + if l.calls < 3 { |
| + t.Fatalf("the fixture did not actually paginate: %d listing calls", l.calls) |
| + } |
| + if side.LastComplete.Objects != 5 || side.LastComplete.Scanned != 5 { |
| + t.Fatalf("resumed sweep counted objects=%d scanned=%d, want 5/5", |
| + side.LastComplete.Objects, side.LastComplete.Scanned) |
| + } |
| +} |
| + |
| +// A fresh sweep over a bucket whose app is torn down between the listing and |
| +// the write must leave NOTHING behind: an orphan scan.json under a purged |
| +// prefix makes the app name permanently unclaimable, because the claim gate |
| +// refuses any name whose prefix still holds a key and box-side auto-purge |
| +// never reaches it. |
| +// |
| +// The first write of a fresh sweep is create-only by construction, so the CAS |
| +// retry guard cannot catch this on its own — only the pre-write re-read can. |
| +func TestSweepDoesNotResurrectTornDownApp(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, sc, _ := sweepFixture(t, 3) |
| + |
| + l := &memLister{p: p} |
| + l.before = func() { |
| + // Teardown lands while this sweep is listing. |
| + if err := s.Delete(ctx, Key("bab", "titogo")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + side, err := RunSweep(ctx, s, l, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatalf("standing down is not an error: %v", err) |
| + } |
| + _ = side |
| + if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the sweep wrote an orphan sidecar under a purged app: %v", err) |
| + } |
| +} |
| + |
| +// A client-encrypted store is marked not_applicable and does no work. A |
| +// scanner cannot read ciphertext, and reporting every object clean would be a |
| +// true-looking sentence about bytes nobody examined. |
| +func TestSweepSkipsEncryptedStore(t *testing.T) { |
| + ctx := context.Background() |
| + s, _, sc, _ := sweepFixture(t, 3) |
| + |
| + rec, etag, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Scanning.Requested = &ScanRequested{At: time.Now().UTC(), By: "m@example.com", Encrypted: true} |
| + if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + side, err := RunSweep(ctx, s, &refuseLister{t: t}, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if side == nil || !side.NotApplicable { |
| + t.Fatalf("an encrypted store was not marked not_applicable: %+v", side) |
| + } |
| + if side.LastComplete != nil { |
| + t.Fatal("coverage was claimed for a store nobody could read") |
| + } |
| +} |
| + |
| +// A trashed app is not swept: it is destroyed within 30 days and scanning it |
| +// bills for egress on data on its way out. |
| +func TestSweepSkipsTrashedApp(t *testing.T) { |
| + ctx := context.Background() |
| + s, _, sc, _ := sweepFixture(t, 3) |
| + if err := release.TrashApp(ctx, s, "bab", "titogo", "op@example.com", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + side, err := RunSweep(ctx, s, &refuseLister{t: t}, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if side != nil { |
| + t.Fatalf("a trashed app was swept: %+v", side) |
| + } |
| +} |
| + |
| +// Skipped is not clean, and both are visible separately. An object over the |
| +// size ceiling was never judged; folding it into Scanned would make the audit |
| +// sentence false in exactly the direction that matters. |
| +func TestSweepCountsSkippedSeparately(t *testing.T) { |
| + s, p, sc, _ := sweepFixture(t, 3) |
| + sc.ScanAs(objectName(1), VerdictSkipped) |
| + |
| + side, err := RunSweep(context.Background(), s, &memLister{p: p}, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := side.LastComplete |
| + if got.Objects != 3 || got.Scanned != 2 || got.Skipped != 1 { |
| + t.Fatalf("objects=%d scanned=%d skipped=%d, want 3/2/1", got.Objects, got.Scanned, got.Skipped) |
| + } |
| + if got.Unscanned != 1 { |
| + t.Fatalf("unscanned=%d, want 1 — a skipped object is not covered", got.Unscanned) |
| + } |
| +} |
| + |
| +// An infected object WAS scanned. We know exactly what it is; it is coverage, |
| +// not a gap. This is the one place the two counts could plausibly have been |
| +// wired the other way round. |
| +func TestSweepCountsInfectedAsScanned(t *testing.T) { |
| + s, p, sc, _ := sweepFixture(t, 2) |
| + sc.ScanAs(objectName(0), VerdictInfected) |
| + |
| + side, err := RunSweep(context.Background(), s, &memLister{p: p}, sc, "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := side.LastComplete |
| + if got.Infected != 1 || got.Scanned != 2 || got.Unscanned != 0 { |
| + t.Fatalf("infected=%d scanned=%d unscanned=%d, want 1/2/0", |
| + got.Infected, got.Scanned, got.Unscanned) |
| + } |
| +} |
| + |
| +// A record naming another engine refuses rather than guessing. Verdicts in the |
| +// bucket were written under conventions this engine does not read, and |
| +// recounting them as unscanned would destroy the coverage history. |
| +func TestSweepRefusesOnEngineDrift(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, sc, _ := sweepFixture(t, 1) |
| + |
| + rec, etag, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Scanning.Enabled.Engine = "guardduty" |
| + if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + _, err = RunSweep(ctx, s, &memLister{p: p}, sc, "bab", "titogo", time.Minute) |
| + if err == nil { |
| + t.Fatal("a sweep ran against a record naming a different engine") |
| + } |
| + if !strings.Contains(err.Error(), "guardduty") { |
| + t.Fatalf("the refusal does not name the engine on the record: %v", err) |
| + } |
| +} |
| + |
| +// An engine that goes away mid-page fails the pass and writes NOTHING. Every |
| +// verdict already reached is a tag in the bucket, so the re-run reads them |
| +// back without re-scanning a byte — but a half-written rollup would be a |
| +// number nobody could interpret. |
| +func TestSweepEngineFailureWritesNoRollup(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, sc, _ := sweepFixture(t, 3) |
| + sc.Fail("Scan", errors.New("dial unix /run/clamav/clamd.ctl: connection refused")) |
| + |
| + if _, err := RunSweep(ctx, s, &memLister{p: p}, sc, "bab", "titogo", time.Minute); err == nil { |
| + t.Fatal("an unreachable engine reported a successful sweep") |
| + } |
| + if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a failed sweep left a rollup behind: %v", err) |
| + } |
| +} |
| + |
| +// A store with no scanning enabled is not swept at all — which is every store |
| +// on a deployment that has not opted in. |
| +func TestSweepIgnoresStoreWithoutScanning(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + rotateFixture(t, s, pub, p) |
| + |
| + side, err := RunSweep(context.Background(), s, &refuseLister{t: t}, NewMemScanner(), |
| + "bab", "titogo", time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if side != nil { |
| + t.Fatalf("a store that never opted in was swept: %+v", side) |
| + } |
| +} |