carlos / platform Public

objstore: the ClamAV scanner, with etag-bound verdicts

A verdict is bound to the ETag it judged. Without that, a key that once
held something clean is a laundry: upload clean, get tagged clean,
overwrite with the payload, keep the tag. A tag whose recorded ETag no
longer matches reads as unknown, so the next sweep scans it again.

Two errors that look alike and must not be recorded alike. "The engine
is unreachable" is a fact about the engine and fails the pass; a clamd
down for an hour would otherwise leave thousands of objects permanently
tagged failed, indistinguishable from objects that genuinely broke the
scanner. "The engine read this object and failed on it" is a fact about
the object and is written as a tag.

The size ceiling is checked before the fetch, not after: the size is
already on the listing page, so pulling bytes clamd has told us it will
refuse is pure egress spend, and on a bucket of large media it is the
whole bill. A size refusal records as skipped, never failed — an audit
that cannot tell "we looked and it was clean" from "we never looked" is
not evidence.

Construction does NOT ping clamd. An engine down mid signature update
must not take the console with it: failing the whole control plane
closed because an opt-in scanner is restarting is a worse outage than
the one it prevents. Unreachability surfaces through Provenance, which
is what scan status reports.

The scanner's S3 seam is separate from the provisioner's: one creates
and destroys buckets and never reads an object, the other reads objects
and never touches a bucket. Neither can quietly grow the other's
powers. Tests fake the engine interface rather than restaging a socket
— the wire protocol has its own tests in internal/clamd.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
Paul Campbell pushed by paul@keymail.dev 829c8f1387d0313136e2d47b75347aed5cffcccc parent 4561908
2 files changed, +590 −0
  • internal/objstore/clamav.go +240 −0
  • internal/objstore/clamav_test.go +350 −0
diff --git a/internal/objstore/clamav.go b/internal/objstore/clamav.go
new file mode 100644
index 000000000..fc4fd6e75
--- /dev/null
+++ b/internal/objstore/clamav.go
@@ -0,0 +1,240 @@
+package objstore
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+
+ "github.com/carlosframework/platform/internal/clamd"
+)
+
+// scanS3API is the subset of *s3.Client the scanner calls, narrowed the same
+// way s3API is so a test can stand a stub in with no network or credentials.
+//
+// It is deliberately separate from s3API: the provisioner creates and destroys
+// buckets and never reads an object, the scanner reads objects and never
+// touches a bucket. Keeping them apart means neither can quietly grow the
+// other's powers.
+type scanS3API interface {
+ GetObject(ctx context.Context, in *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
+ GetObjectTagging(ctx context.Context, in *awss3.GetObjectTaggingInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error)
+ PutObjectTagging(ctx context.Context, in *awss3.PutObjectTaggingInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error)
+}
+
+// streamScanner is the engine seam: what clamavScanner needs from a clamd.
+// *clamd.Client satisfies it. The wire protocol is tested in internal/clamd,
+// so tests here fake this interface rather than restaging a socket.
+type streamScanner interface {
+ ScanStream(ctx context.Context, r io.Reader) (clamd.Result, error)
+ Version(ctx context.Context) (string, error)
+}
+
+// clamavScanner scans objects by streaming them past a local clamd and records
+// the verdict as object tags.
+//
+// Nothing about the bucket changes: there is no per-bucket plan to enable, no
+// quota to consume and no per-bucket cost. That is the whole reason ClamAV is
+// the primary engine — the bought alternative caps at 25 protected buckets per
+// account per Region and cannot be raised.
+type clamavScanner struct {
+ s3 scanS3API
+ engine streamScanner
+ maxBytes int64
+}
+
+var _ Scanner = (*clamavScanner)(nil)
+
+// NewClamAVScanner builds a Scanner against S3 in region and a clamd on
+// socket. maxBytes is the size ceiling above which an object is recorded as
+// skipped without being fetched; it should mirror clamd's own StreamMaxLength.
+//
+// This does NOT ping clamd. An engine that is down at boot — mid signature
+// update, say — must not take the console with it: object scanning is an
+// opt-in feature of one subsystem, and failing the whole control plane closed
+// because a scanner is restarting would be a far worse outage than the one it
+// prevents. An unreachable engine surfaces through Provenance, which is what
+// `carlos store scan status` reports, and fails the pass loudly per tick.
+func NewClamAVScanner(ctx context.Context, region, endpoint, socket string, maxBytes int64) (Scanner, error) {
+ s3c, err := S3ClientFor(ctx, region, endpoint)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: clamav scanner: %w", err)
+ }
+ return &clamavScanner{
+ s3: s3c,
+ engine: clamd.New(socket, clamd.Timeouts{}),
+ maxBytes: maxBytes,
+ }, nil
+}
+
+// EnsureEnabled is a no-op. There is nothing on the bucket to configure: the
+// engine is reached from the control plane, not attached to the store.
+func (c *clamavScanner) EnsureEnabled(ctx context.Context, bucket string) error { return nil }
+
+// EnsureDisabled is a no-op for the same reason. Nothing was attached, so
+// nothing leaks when a bucket is destroyed.
+func (c *clamavScanner) EnsureDisabled(ctx context.Context, bucket string) error { return nil }
+
+// Capacity reports no limit. -1 is "unbounded", distinct from 0, which would
+// mean a real engine with nothing left.
+func (c *clamavScanner) Capacity(ctx context.Context) (remaining, capacity int, err error) {
+ return -1, -1, nil
+}
+
+// Provenance names the engine and its signature database — the two facts an
+// audit answer needs beside a count. A scan is only as good as the signatures
+// it ran against, so "clean" without a database version is not evidence.
+func (c *clamavScanner) Provenance(ctx context.Context) (engine, version string, err error) {
+ v, err := c.engine.Version(ctx)
+ if err != nil {
+ return "clamav", "", err
+ }
+ return "clamav", v, nil
+}
+
+// Enforcement is the tag the store user's policy denies on.
+func (c *clamavScanner) Enforcement() DenyTag {
+ return DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}
+}
+
+// Verdict reports what the engine previously said about this exact version of
+// the object.
+//
+// A verdict is bound to the ETag it judged. An object rewritten under the same
+// key is a different object, and inheriting the old verdict would let anyone
+// launder a payload through a key that once held something clean — upload
+// clean, get tagged clean, overwrite with the payload, keep the tag. So a tag
+// whose recorded ETag does not match the caller's reads as unknown, and the
+// sweep scans it again.
+func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, etag string) (Verdict, error) {
+ out, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
+ Bucket: &bucket,
+ Key: &key,
+ })
+ if err != nil {
+ return VerdictUnknown, fmt.Errorf("objstore: read tags %s/%s: %w", bucket, key, err)
+ }
+
+ var verdict, taggedETag string
+ for _, t := range out.TagSet {
+ switch aws2s(t.Key) {
+ case TagVerdict:
+ verdict = aws2s(t.Value)
+ case TagETag:
+ taggedETag = aws2s(t.Value)
+ }
+ }
+ if verdict == "" || taggedETag == "" {
+ return VerdictUnknown, nil
+ }
+ if normalizeETag(taggedETag) != normalizeETag(etag) {
+ // The bytes changed under the verdict. Not a failure — just unknown
+ // again, which is exactly what makes the next sweep re-scan it.
+ return VerdictUnknown, nil
+ }
+ return Verdict(verdict), nil
+}
+
+// Scan streams one object past the engine and records the result.
+func (c *clamavScanner) Scan(ctx context.Context, bucket, key, etag string, size int64) (Verdict, error) {
+ // The ceiling is checked BEFORE the fetch. The size is already on the
+ // listing page that got us here, so paying egress to pull bytes clamd has
+ // told us it will refuse is pure waste — and on a bucket full of large
+ // media that waste is the entire bill.
+ if c.maxBytes > 0 && size > c.maxBytes {
+ if err := c.tag(ctx, bucket, key, etag, VerdictSkipped); err != nil {
+ return VerdictUnknown, err
+ }
+ return VerdictSkipped, nil
+ }
+
+ obj, err := c.s3.GetObject(ctx, &awss3.GetObjectInput{Bucket: &bucket, Key: &key})
+ if err != nil {
+ return VerdictUnknown, fmt.Errorf("objstore: get %s/%s: %w", bucket, key, err)
+ }
+ defer obj.Body.Close()
+
+ // Straight from S3 into the socket. The object is never buffered: a
+ // gigabyte object costs a chunk of memory, not a gigabyte, and the console
+ // holds no copy of tenant data at any point.
+ res, err := c.engine.ScanStream(ctx, obj.Body)
+ if err != nil {
+ // The engine could not be reached or did not answer. That is a fact
+ // about the ENGINE, not about this object, and it must not be written
+ // as a per-object verdict: a clamd that is down for an hour would
+ // otherwise leave thousands of objects permanently tagged failed,
+ // with nothing to distinguish them from objects that genuinely broke
+ // the scanner. Fail the pass instead; the object stays unknown and
+ // the next sweep picks it up.
+ return VerdictUnknown, fmt.Errorf("objstore: scan %s/%s: %w", bucket, key, err)
+ }
+
+ v := verdictFor(res.Status)
+ if err := c.tag(ctx, bucket, key, etag, v); err != nil {
+ return VerdictUnknown, err
+ }
+ return v, nil
+}
+
+// verdictFor maps an engine status onto the recorded verdict.
+//
+// StatusSizeExceeded becomes VerdictSkipped, not VerdictFailed. Nothing
+// failed: the object was simply never judged, and an audit that cannot tell
+// "we looked and it was clean" from "we never looked" is not evidence.
+func verdictFor(s clamd.Status) Verdict {
+ switch s {
+ case clamd.StatusClean:
+ return VerdictClean
+ case clamd.StatusInfected:
+ return VerdictInfected
+ case clamd.StatusSizeExceeded:
+ return VerdictSkipped
+ default:
+ return VerdictFailed
+ }
+}
+
+// tag writes the verdict, the ETag it applies to, and when it was reached.
+//
+// PutObjectTagging replaces the whole tag set, which is safe here precisely
+// because of the permission the anti-forgery property already depends on: the
+// store user has no s3:PutObjectTagging, and AWS requires that same permission
+// to set tags through x-amz-tagging on upload. So an app cannot have put a tag
+// on its own object by either route, and there is nothing of the tenant's to
+// destroy. One permission, two properties.
+func (c *clamavScanner) tag(ctx context.Context, bucket, key, etag string, v Verdict) error {
+ set := []s3types.Tag{
+ {Key: s2aws(TagVerdict), Value: s2aws(string(v))},
+ {Key: s2aws(TagETag), Value: s2aws(normalizeETag(etag))},
+ {Key: s2aws(TagAt), Value: s2aws(time.Now().UTC().Format(time.RFC3339))},
+ }
+ _, err := c.s3.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
+ Bucket: &bucket,
+ Key: &key,
+ Tagging: &s3types.Tagging{TagSet: set},
+ })
+ if err != nil {
+ return fmt.Errorf("objstore: tag %s/%s: %w", bucket, key, err)
+ }
+ return nil
+}
+
+// normalizeETag strips the quotes S3 wraps an ETag in. The listing and the tag
+// must compare equal or every object re-scans forever, and only one of the two
+// arrives quoted.
+func normalizeETag(etag string) string {
+ return strings.Trim(strings.TrimSpace(etag), `"`)
+}
+
+func aws2s(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
+
+func s2aws(s string) *string { return &s }
diff --git a/internal/objstore/clamav_test.go b/internal/objstore/clamav_test.go
new file mode 100644
index 000000000..2a1cd44e6
--- /dev/null
+++ b/internal/objstore/clamav_test.go
@@ -0,0 +1,350 @@
+package objstore
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+
+ "github.com/carlosframework/platform/internal/clamd"
+)
+
+// stubScanS3 implements the narrow scanS3API seam with per-method hooks, in
+// the same style as stubS3. An un-set hook fails the test loudly rather than
+// panicking on a nil func — several tests here exist precisely to assert that
+// a call did NOT happen, and that only works if an unexpected call says so.
+type stubScanS3 struct {
+ t *testing.T
+
+ getObject func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error)
+ getTagging func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error)
+ putTagging func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error)
+}
+
+func (s *stubScanS3) GetObject(_ context.Context, in *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
+ if s.getObject == nil {
+ s.t.Fatal("unexpected GetObject call — bytes were fetched that should not have been")
+ }
+ return s.getObject(in)
+}
+
+func (s *stubScanS3) GetObjectTagging(_ context.Context, in *awss3.GetObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error) {
+ if s.getTagging == nil {
+ s.t.Fatal("unexpected GetObjectTagging call")
+ }
+ return s.getTagging(in)
+}
+
+func (s *stubScanS3) PutObjectTagging(_ context.Context, in *awss3.PutObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error) {
+ if s.putTagging == nil {
+ s.t.Fatal("unexpected PutObjectTagging call — a verdict was written that should not have been")
+ }
+ return s.putTagging(in)
+}
+
+// fakeEngine stands in for clamd. The wire protocol has its own tests in
+// internal/clamd; what matters here is how the scanner reacts to each answer.
+type fakeEngine struct {
+ res clamd.Result
+ err error
+ version string
+
+ calls int
+ read []byte
+}
+
+func (f *fakeEngine) ScanStream(_ context.Context, r io.Reader) (clamd.Result, error) {
+ f.calls++
+ f.read, _ = io.ReadAll(r)
+ if f.err != nil {
+ return clamd.Result{}, f.err
+ }
+ return f.res, nil
+}
+
+func (f *fakeEngine) Version(_ context.Context) (string, error) {
+ if f.err != nil {
+ return "", f.err
+ }
+ return f.version, nil
+}
+
+func objectBody(s string) *awss3.GetObjectOutput {
+ return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(s))}
+}
+
+func tagsOf(in *awss3.PutObjectTaggingInput) map[string]string {
+ m := map[string]string{}
+ if in.Tagging == nil {
+ return m
+ }
+ for _, t := range in.Tagging.TagSet {
+ m[aws2s(t.Key)] = aws2s(t.Value)
+ }
+ return m
+}
+
+// An object above the ceiling is tagged skipped WITHOUT a GetObject: paying
+// egress to fetch bytes clamd will refuse is pure waste, and the size is
+// already on the listing page.
+func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) {
+ var wrote map[string]string
+ s3 := &stubScanS3{t: t} // getObject deliberately unset: calling it fails the test
+ s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
+ wrote = tagsOf(in)
+ return &awss3.PutObjectTaggingOutput{}, nil
+ }
+ eng := &fakeEngine{}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 10}
+
+ v, err := c.Scan(context.Background(), "b", "big.iso", "abc", 11)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictSkipped {
+ t.Fatalf("verdict = %q, want skipped", v)
+ }
+ if eng.calls != 0 {
+ t.Fatal("an oversize object was streamed to the engine anyway")
+ }
+ if wrote[TagVerdict] != string(VerdictSkipped) {
+ t.Fatalf("tags = %v", wrote)
+ }
+}
+
+// A verdict is written with the etag it judged, so a later rewrite reads as
+// unknown rather than inheriting the old verdict.
+func TestClamAVTagsVerdictWithETag(t *testing.T) {
+ var wrote map[string]string
+ s3 := &stubScanS3{t: t}
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
+ wrote = tagsOf(in)
+ return &awss3.PutObjectTaggingOutput{}, nil
+ }
+ eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20}
+
+ v, err := c.Scan(context.Background(), "b", "k", `"d41d8cd9"`, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictClean {
+ t.Fatalf("verdict = %q", v)
+ }
+ if wrote[TagETag] != "d41d8cd9" {
+ t.Fatalf("verdict is not bound to the etag it judged: %v", wrote)
+ }
+ if wrote[TagAt] == "" {
+ t.Fatalf("no scan time recorded: %v", wrote)
+ }
+ if !bytes.Equal(eng.read, []byte("hello")) {
+ t.Fatalf("engine saw %q", eng.read)
+ }
+}
+
+// clamd being unreachable is a PASS error, not thousands of failed tags.
+// "The engine is unreachable" and "the engine read this object and failed on
+// it" are different facts and only the second belongs in a tag.
+func TestClamAVDialFailureWritesNoVerdict(t *testing.T) {
+ s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ eng := &fakeEngine{err: errors.New("dial unix /run/clamav/clamd.ctl: connection refused")}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20}
+
+ v, err := c.Scan(context.Background(), "b", "k", "e", 5)
+ if err == nil {
+ t.Fatal("an unreachable engine reported success")
+ }
+ if v != VerdictUnknown {
+ t.Fatalf("verdict = %q, want unknown", v)
+ }
+}
+
+// A mid-stream size refusal maps to skipped, not failed.
+func TestClamAVSizeExceededMapsToSkipped(t *testing.T) {
+ var wrote map[string]string
+ s3 := &stubScanS3{t: t}
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
+ wrote = tagsOf(in)
+ return &awss3.PutObjectTaggingOutput{}, nil
+ }
+ eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusSizeExceeded}}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 0} // no local ceiling: the engine's own
+
+ v, err := c.Scan(context.Background(), "b", "k", "e", 999)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictSkipped {
+ t.Fatalf("verdict = %q, want skipped", v)
+ }
+ if wrote[TagVerdict] != string(VerdictSkipped) {
+ t.Fatalf("tags = %v", wrote)
+ }
+}
+
+// An engine ERROR on an object it did read IS a per-object fact, and does get
+// recorded — the mirror of the unreachable case above.
+func TestClamAVEngineErrorIsRecordedAsFailed(t *testing.T) {
+ var wrote map[string]string
+ s3 := &stubScanS3{t: t}
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
+ wrote = tagsOf(in)
+ return &awss3.PutObjectTaggingOutput{}, nil
+ }
+ eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusError, Raw: "stream: broken ERROR"}}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20}
+
+ v, err := c.Scan(context.Background(), "b", "k", "e", 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictFailed || wrote[TagVerdict] != string(VerdictFailed) {
+ t.Fatalf("verdict = %q, tags = %v", v, wrote)
+ }
+}
+
+// An infected object carries the verdict the store policy denies on. If these
+// two strings ever drift apart, enforcement silently stops matching anything.
+func TestClamAVInfectedVerdictMatchesEnforcementTag(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("x5O!P%"), nil
+ }
+ var wrote map[string]string
+ s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
+ wrote = tagsOf(in)
+ return &awss3.PutObjectTaggingOutput{}, nil
+ }
+ eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Signature"}}
+ c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20}
+
+ if _, err := c.Scan(context.Background(), "b", "k", "e", 6); err != nil {
+ t.Fatal(err)
+ }
+ deny := c.Enforcement()
+ if wrote[deny.Key] != deny.Value {
+ t.Fatalf("the tag written (%v) does not match the tag denied on (%+v)", wrote, deny)
+ }
+}
+
+func taggingOutput(pairs ...string) *awss3.GetObjectTaggingOutput {
+ var set []s3types.Tag
+ for i := 0; i+1 < len(pairs); i += 2 {
+ k, v := pairs[i], pairs[i+1]
+ set = append(set, s3types.Tag{Key: &k, Value: &v})
+ }
+ return &awss3.GetObjectTaggingOutput{TagSet: set}
+}
+
+// A verdict is bound to the bytes it judged. An object rewritten under the
+// same key reads as unknown, so the sweep scans it again — otherwise anyone
+// could launder a payload through a key that once held something clean.
+func TestClamAVVerdictIgnoresStaleETag(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return taggingOutput(TagVerdict, "clean", TagETag, "old-etag"), nil
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{}}
+
+ v, err := c.Verdict(context.Background(), "b", "k", "new-etag")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictUnknown {
+ t.Fatalf("a rewritten object inherited its old verdict: %q", v)
+ }
+}
+
+// The listing gives a quoted ETag and the tag holds an unquoted one. If those
+// never compare equal, every object re-scans on every sweep forever — an
+// invisible bug that costs money rather than correctness.
+func TestClamAVVerdictMatchesAcrossETagQuoting(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return taggingOutput(TagVerdict, "clean", TagETag, "d41d8cd9"), nil
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{}}
+
+ v, err := c.Verdict(context.Background(), "b", "k", `"d41d8cd9"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictClean {
+ t.Fatalf("verdict = %q, want clean — quoting broke the comparison", v)
+ }
+}
+
+// An untagged object is unknown, not clean. Defaulting the other way would
+// report a bucket nobody has ever scanned as fully clean.
+func TestClamAVUntaggedObjectIsUnknown(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return taggingOutput(), nil
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{}}
+
+ v, err := c.Verdict(context.Background(), "b", "k", "e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictUnknown {
+ t.Fatalf("an object nobody scanned reported %q", v)
+ }
+}
+
+// Nothing is attached to the bucket, so nothing is consumed and nothing leaks.
+// This is the whole reason ClamAV is the primary engine rather than the bought
+// alternative, whose per-bucket plans cap at 25 per account per Region.
+func TestClamAVAttachesNothingToTheBucket(t *testing.T) {
+ ctx := context.Background()
+ c := &clamavScanner{s3: &stubScanS3{t: t}, engine: &fakeEngine{}}
+
+ if err := c.EnsureEnabled(ctx, "b"); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.EnsureDisabled(ctx, "b"); err != nil {
+ t.Fatal(err)
+ }
+ remaining, capacity, err := c.Capacity(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if remaining != -1 || capacity != -1 {
+ t.Fatalf("capacity = %d/%d, want -1/-1 (unbounded)", remaining, capacity)
+ }
+}
+
+// Provenance carries the signature database, not just the engine name. "Clean"
+// without a database version is not evidence of anything.
+func TestClamAVProvenanceCarriesSignatureVersion(t *testing.T) {
+ eng := &fakeEngine{version: "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026"}
+ c := &clamavScanner{s3: &stubScanS3{t: t}, engine: eng}
+
+ engine, version, err := c.Provenance(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if engine != "clamav" {
+ t.Fatalf("engine = %q", engine)
+ }
+ if !strings.Contains(version, "27500") {
+ t.Fatalf("version = %q, want the signature database in it", version)
+ }
+}