carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/aws/smithy-go"
13
14 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
15 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
16
17 "github.com/carlosframework/platform/internal/clamd"
18)
19
20// stubScanS3 implements the narrow scanS3API seam with per-method hooks, in
21// the same style as stubS3. An un-set hook fails the test loudly rather than
22// panicking on a nil func — several tests here exist precisely to assert that
23// a call did NOT happen, and that only works if an unexpected call says so.
24type stubScanS3 struct {
25 t *testing.T
26
27 getObject func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error)
28 headObject func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error)
29 getTagging func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error)
30 putTagging func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error)
31}
32
33func (s *stubScanS3) GetObject(_ context.Context, in *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
34 if s.getObject == nil {
35 s.t.Fatal("unexpected GetObject call — bytes were fetched that should not have been")
36 }
37 return s.getObject(in)
38}
39
40func (s *stubScanS3) HeadObject(_ context.Context, in *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
41 if s.headObject == nil {
42 s.t.Fatal("unexpected HeadObject call")
43 }
44 return s.headObject(in)
45}
46
47// headOK makes HeadObject describe an object of the given shape, which is what
48// Status needs in order to know the CURRENT version.
49func (s *stubScanS3) headOK(etag string, modified time.Time, size int64) {
50 s.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
51 return &awss3.HeadObjectOutput{ETag: &etag, LastModified: &modified, ContentLength: &size}, nil
52 }
53}
54
55func (s *stubScanS3) GetObjectTagging(_ context.Context, in *awss3.GetObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error) {
56 if s.getTagging == nil {
57 s.t.Fatal("unexpected GetObjectTagging call")
58 }
59 return s.getTagging(in)
60}
61
62func (s *stubScanS3) PutObjectTagging(_ context.Context, in *awss3.PutObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error) {
63 if s.putTagging == nil {
64 s.t.Fatal("unexpected PutObjectTagging call — a verdict was written that should not have been")
65 }
66 return s.putTagging(in)
67}
68
69// fakeEngine stands in for clamd. The wire protocol has its own tests in
70// internal/clamd; what matters here is how the scanner reacts to each answer.
71type fakeEngine struct {
72 res clamd.Result
73 err error
74 version string
75
76 calls int
77 read []byte
78}
79
80func (f *fakeEngine) ScanStream(_ context.Context, r io.Reader) (clamd.Result, error) {
81 f.calls++
82 f.read, _ = io.ReadAll(r)
83 if f.err != nil {
84 return clamd.Result{}, f.err
85 }
86 return f.res, nil
87}
88
89func (f *fakeEngine) Version(_ context.Context) (string, error) {
90 if f.err != nil {
91 return "", f.err
92 }
93 return f.version, nil
94}
95
96// allowTagRead lets tag()'s read-merge-write see an empty tag set. Scan now
97// reads the existing tags before replacing them, so that a bucket CARLOS
98// adopted does not lose tags someone else's tooling put there.
99func (s *stubScanS3) allowTagRead(tags ...string) {
100 s.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
101 return taggingOutput(tags...), nil
102 }
103}
104
105func objectBody(s string) *awss3.GetObjectOutput {
106 return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(s))}
107}
108
109func tagsOf(in *awss3.PutObjectTaggingInput) map[string]string {
110 m := map[string]string{}
111 if in.Tagging == nil {
112 return m
113 }
114 for _, t := range in.Tagging.TagSet {
115 m[aws2s(t.Key)] = aws2s(t.Value)
116 }
117 return m
118}
119
120// An object above the ceiling is tagged skipped WITHOUT a GetObject: paying
121// egress to fetch bytes clamd will refuse is pure waste, and the size is
122// already on the listing page.
123func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) {
124 var wrote map[string]string
125 s3 := &stubScanS3{t: t} // getObject deliberately unset: calling it fails the test
126 s3.allowTagRead()
127 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
128 wrote = tagsOf(in)
129 return &awss3.PutObjectTaggingOutput{}, nil
130 }
131 eng := &fakeEngine{}
132 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 10}
133
134 v, _, err := c.Scan(context.Background(), "b", "big.iso", "abc", 11)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if v != VerdictSkipped {
139 t.Fatalf("verdict = %q, want skipped", v)
140 }
141 if eng.calls != 0 {
142 t.Fatal("an oversize object was streamed to the engine anyway")
143 }
144 if wrote[TagVerdict] != string(VerdictSkipped) {
145 t.Fatalf("tags = %v", wrote)
146 }
147}
148
149// A verdict is written with the etag it judged, so a later rewrite reads as
150// unknown rather than inheriting the old verdict.
151func TestClamAVTagsVerdictWithETag(t *testing.T) {
152 var wrote map[string]string
153 s3 := &stubScanS3{t: t}
154 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
155 return objectBody("hello"), nil
156 }
157 s3.allowTagRead()
158 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
159 wrote = tagsOf(in)
160 return &awss3.PutObjectTaggingOutput{}, nil
161 }
162 eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}
163 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20}
164
165 v, _, err := c.Scan(context.Background(), "b", "k", "d41d8cd9@2026-09-01T10:00:00Z", 5)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if v != VerdictClean {
170 t.Fatalf("verdict = %q", v)
171 }
172 if wrote[TagVersion] != "d41d8cd9@2026-09-01T10:00:00Z" {
173 t.Fatalf("verdict is not bound to the object version it judged: %v", wrote)
174 }
175 if wrote[TagAt] == "" {
176 t.Fatalf("no scan time recorded: %v", wrote)
177 }
178 if !bytes.Equal(eng.read, []byte("hello")) {
179 t.Fatalf("engine saw %q", eng.read)
180 }
181}
182
183// clamd being unreachable is a PASS error, not thousands of failed tags.
184// "The engine is unreachable" and "the engine read this object and failed on
185// it" are different facts and only the second belongs in a tag.
186func TestClamAVDialFailureWritesNoVerdict(t *testing.T) {
187 s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test
188 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
189 return objectBody("hello"), nil
190 }
191 eng := &fakeEngine{err: errors.New("dial unix /run/clamav/clamd.ctl: connection refused")}
192 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20}
193
194 v, _, err := c.Scan(context.Background(), "b", "k", "e", 5)
195 if err == nil {
196 t.Fatal("an unreachable engine reported success")
197 }
198 if v != VerdictUnknown {
199 t.Fatalf("verdict = %q, want unknown", v)
200 }
201}
202
203// A mid-stream size refusal maps to skipped, not failed.
204func TestClamAVSizeExceededMapsToSkipped(t *testing.T) {
205 var wrote map[string]string
206 s3 := &stubScanS3{t: t}
207 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
208 return objectBody("hello"), nil
209 }
210 s3.allowTagRead()
211 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
212 wrote = tagsOf(in)
213 return &awss3.PutObjectTaggingOutput{}, nil
214 }
215 eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusSizeExceeded}}
216 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 0} // no local ceiling: the engine's own
217
218 v, _, err := c.Scan(context.Background(), "b", "k", "e", 999)
219 if err != nil {
220 t.Fatal(err)
221 }
222 if v != VerdictSkipped {
223 t.Fatalf("verdict = %q, want skipped", v)
224 }
225 if wrote[TagVerdict] != string(VerdictSkipped) {
226 t.Fatalf("tags = %v", wrote)
227 }
228}
229
230// An engine ERROR on an object it did read IS a per-object fact, and does get
231// recorded — the mirror of the unreachable case above.
232func TestClamAVEngineErrorIsRecordedAsFailed(t *testing.T) {
233 var wrote map[string]string
234 s3 := &stubScanS3{t: t}
235 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
236 return objectBody("hello"), nil
237 }
238 s3.allowTagRead()
239 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
240 wrote = tagsOf(in)
241 return &awss3.PutObjectTaggingOutput{}, nil
242 }
243 eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusError, Raw: "stream: broken ERROR"}}
244 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20}
245
246 v, _, err := c.Scan(context.Background(), "b", "k", "e", 5)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if v != VerdictFailed || wrote[TagVerdict] != string(VerdictFailed) {
251 t.Fatalf("verdict = %q, tags = %v", v, wrote)
252 }
253}
254
255// An infected object carries the verdict the store policy denies on. If these
256// two strings ever drift apart, enforcement silently stops matching anything.
257func TestClamAVInfectedVerdictMatchesEnforcementTag(t *testing.T) {
258 s3 := &stubScanS3{t: t}
259 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
260 return objectBody("x5O!P%"), nil
261 }
262 s3.allowTagRead()
263 var wrote map[string]string
264 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
265 wrote = tagsOf(in)
266 return &awss3.PutObjectTaggingOutput{}, nil
267 }
268 eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Signature"}}
269 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20}
270
271 if _, _, err := c.Scan(context.Background(), "b", "k", "e", 6); err != nil {
272 t.Fatal(err)
273 }
274 deny := c.Enforcement()
275 if wrote[deny.Key] != deny.Value {
276 t.Fatalf("the tag written (%v) does not match the tag denied on (%+v)", wrote, deny)
277 }
278}
279
280func taggingOutput(pairs ...string) *awss3.GetObjectTaggingOutput {
281 var set []s3types.Tag
282 for i := 0; i+1 < len(pairs); i += 2 {
283 k, v := pairs[i], pairs[i+1]
284 set = append(set, s3types.Tag{Key: &k, Value: &v})
285 }
286 return &awss3.GetObjectTaggingOutput{TagSet: set}
287}
288
289// A verdict is bound to the bytes it judged. An object rewritten under the
290// same key reads as unknown, so the sweep scans it again — otherwise anyone
291// could launder a payload through a key that once held something clean.
292func TestClamAVVerdictIgnoresStaleETag(t *testing.T) {
293 s3 := &stubScanS3{t: t}
294 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
295 return taggingOutput(TagVerdict, "clean", TagVersion, "old-etag"), nil
296 }
297 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
298
299 v, err := c.Verdict(context.Background(), "b", "k", "new-etag")
300 if err != nil {
301 t.Fatal(err)
302 }
303 if v != VerdictUnknown {
304 t.Fatalf("a rewritten object inherited its old verdict: %q", v)
305 }
306}
307
308// S3 hands back a QUOTED ETag. Normalisation happens once, where the version
309// token is built, so a stored tag and a fresh listing compare equal. If they
310// never did, every object would re-scan on every sweep forever — an invisible
311// bug that costs money rather than correctness.
312func TestScanObjectVersionNormalisesQuoting(t *testing.T) {
313 at := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
314 quoted := ScanObject{Key: "k", ETag: `"d41d8cd9"`, Modified: at}
315 bare := ScanObject{Key: "k", ETag: "d41d8cd9", Modified: at}
316 if quoted.Version() != bare.Version() {
317 t.Fatalf("quoting changed the version token: %q vs %q", quoted.Version(), bare.Version())
318 }
319}
320
321// The version moves when the object is overwritten, even if the bytes hash to
322// the same ETag. That is the whole point of folding last-modified in: a
323// single-part upload's ETag is the content MD5, and chosen-prefix MD5
324// collisions are practical — an attacker controlling both uploads could give a
325// payload the ETag of the clean object it replaces and inherit its verdict.
326// Last-modified is not theirs to choose.
327func TestScanObjectVersionChangesOnOverwriteWithCollidingETag(t *testing.T) {
328 etag := "d41d8cd9" // same hash, as a collision would give
329 before := ScanObject{Key: "k", ETag: etag, Modified: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)}
330 after := ScanObject{Key: "k", ETag: etag, Modified: time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC)}
331 if before.Version() == after.Version() {
332 t.Fatalf("an overwrite kept the same version token (%q) — the old verdict would be inherited",
333 before.Version())
334 }
335}
336
337// An untagged object is unknown, not clean. Defaulting the other way would
338// report a bucket nobody has ever scanned as fully clean.
339func TestClamAVUntaggedObjectIsUnknown(t *testing.T) {
340 s3 := &stubScanS3{t: t}
341 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
342 return taggingOutput(), nil
343 }
344 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
345
346 v, err := c.Verdict(context.Background(), "b", "k", "e")
347 if err != nil {
348 t.Fatal(err)
349 }
350 if v != VerdictUnknown {
351 t.Fatalf("an object nobody scanned reported %q", v)
352 }
353}
354
355// Nothing is attached to the bucket, so nothing is consumed and nothing leaks.
356// This is the whole reason ClamAV is the primary engine rather than the bought
357// alternative, whose per-bucket plans cap at 25 per account per Region.
358func TestClamAVAttachesNothingToTheBucket(t *testing.T) {
359 ctx := context.Background()
360 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: &fakeEngine{}}
361
362 if err := c.EnsureEnabled(ctx, "b"); err != nil {
363 t.Fatal(err)
364 }
365 if err := c.EnsureDisabled(ctx, "b"); err != nil {
366 t.Fatal(err)
367 }
368 remaining, capacity, err := c.Capacity(ctx)
369 if err != nil {
370 t.Fatal(err)
371 }
372 if remaining != -1 || capacity != -1 {
373 t.Fatalf("capacity = %d/%d, want -1/-1 (unbounded)", remaining, capacity)
374 }
375}
376
377// Provenance carries the signature database, not just the engine name. "Clean"
378// without a database version is not evidence of anything.
379func TestClamAVProvenanceCarriesSignatureVersion(t *testing.T) {
380 eng := &fakeEngine{version: "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026"}
381 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: eng}
382
383 engine, version, err := c.Provenance(context.Background())
384 if err != nil {
385 t.Fatal(err)
386 }
387 if engine != "clamav" {
388 t.Fatalf("engine = %q", engine)
389 }
390 if !strings.Contains(version, "27500") {
391 t.Fatalf("version = %q, want the signature database in it", version)
392 }
393}
394
395// apiErr is an AWS API error with a given code, which is what isDenied and
396// isGone classify on.
397func apiErr(code string) error {
398 return &smithy.GenericAPIError{Code: code, Message: code}
399}
400
401// An object we are not allowed to read is DENIED, and the sweep goes on.
402//
403// This is not tidiness. A plain error here fails the whole page unwritten; the
404// next tick re-lists the same page, meets the same object and fails
405// identically, and the bucket's sweep never advances past it. One SSE-KMS
406// object outside the console's key policy would stall a store's coverage
407// forever while every surface said "in progress".
408func TestClamAVUnreadableObjectIsDeniedNotAPassFailure(t *testing.T) {
409 for _, code := range []string{"AccessDenied", "AccessDeniedException", "Forbidden", "InvalidObjectState"} {
410 t.Run(code, func(t *testing.T) {
411 var wrote map[string]string
412 s3 := &stubScanS3{t: t}
413 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
414 return nil, apiErr(code)
415 }
416 s3.allowTagRead()
417 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
418 wrote = tagsOf(in)
419 return &awss3.PutObjectTaggingOutput{}, nil
420 }
421 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20}
422
423 v, _, err := c.Scan(context.Background(), "b", "k", "e", 5)
424 if err != nil {
425 t.Fatalf("an unreadable object failed the pass: %v", err)
426 }
427 if v != VerdictDenied {
428 t.Fatalf("verdict = %q, want denied", v)
429 }
430 // The tag is an audit trace, not an answer: the next sweep
431 // re-issues denied rather than reading it back. See
432 // TestClamAVDeniedAndFailedAreReIssuedNotInherited.
433 if wrote[TagVerdict] != string(VerdictDenied) {
434 t.Fatalf("tags = %v", wrote)
435 }
436 })
437 }
438}
439
440// An object deleted between the listing and the fetch is GONE — neither
441// covered nor a gap. Routine on any bucket in use, and it must not stall the
442// page either.
443func TestClamAVDeletedObjectIsGone(t *testing.T) {
444 s3 := &stubScanS3{t: t} // putTagging unset: nothing may be written
445 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
446 return nil, apiErr("NoSuchKey")
447 }
448 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20}
449
450 if _, _, err := c.Scan(context.Background(), "b", "k", "e", 5); !errors.Is(err, ErrObjectGone) {
451 t.Fatalf("err = %v, want ErrObjectGone", err)
452 }
453}
454
455// Unreadable TAGS are denied too, by the same argument: Verdict is called for
456// every object on every page, so an error there stalls just as hard.
457func TestClamAVUnreadableTagsAreDenied(t *testing.T) {
458 s3 := &stubScanS3{t: t}
459 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
460 return nil, apiErr("AccessDenied")
461 }
462 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
463
464 v, err := c.Verdict(context.Background(), "b", "k", "e")
465 if err != nil {
466 t.Fatalf("an unreadable tag set failed the pass: %v", err)
467 }
468 if v != VerdictDenied {
469 t.Fatalf("verdict = %q, want denied", v)
470 }
471}
472
473// A genuine transport failure is still a pass failure — the classification
474// must not swallow everything.
475func TestClamAVTransportErrorStillFailsThePass(t *testing.T) {
476 s3 := &stubScanS3{t: t}
477 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
478 return nil, apiErr("InternalError")
479 }
480 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20}
481
482 v, _, err := c.Scan(context.Background(), "b", "k", "e", 5)
483 if err == nil {
484 t.Fatal("a transport failure was recorded as a verdict")
485 }
486 if v != VerdictUnknown {
487 t.Fatalf("verdict = %q, want unknown", v)
488 }
489}
490
491// An adopted bucket's objects can carry tags written long before CARLOS ever
492// scoped credentials to it — lifecycle rules, cost allocation. PutObjectTagging
493// replaces the whole set, so those have to be carried across explicitly.
494// Deleting a tenant's tags silently on first scan would be a real loss with no
495// trace of what went.
496func TestClamAVPreservesForeignTags(t *testing.T) {
497 var wrote map[string]string
498 s3 := &stubScanS3{t: t}
499 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
500 return objectBody("hello"), nil
501 }
502 s3.allowTagRead("cost-centre", "research", TagVerdict, "clean", TagVersion, "stale")
503 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
504 wrote = tagsOf(in)
505 return &awss3.PutObjectTaggingOutput{}, nil
506 }
507 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
508
509 if _, _, err := c.Scan(context.Background(), "b", "k", "fresh", 5); err != nil {
510 t.Fatal(err)
511 }
512 if wrote["cost-centre"] != "research" {
513 t.Fatalf("a tag this scanner does not own was destroyed: %v", wrote)
514 }
515 // Its own tags ARE replaced, not duplicated.
516 if wrote[TagVersion] != "fresh" {
517 t.Fatalf("the scanner did not overwrite its own stale etag tag: %v", wrote)
518 }
519}
520
521// denied and failed are RE-ISSUED every sweep, never carried forward.
522//
523// Both are transient — a permissions fix, a Glacier restore, an engine having
524// a bad day. Reading them back as answers would make them permanent: the
525// object is not rewritten, so its version never changes, so it would never be
526// retried and the repair would never take. The SSE-KMS case is the one that
527// bites, because tagging needs no KMS access and so the tag does land.
528func TestClamAVDeniedAndFailedAreReIssuedNotInherited(t *testing.T) {
529 for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} {
530 t.Run(string(stuck), func(t *testing.T) {
531 sg := testSigner(t, "test-key", "")
532 s3 := &stubScanS3{t: t}
533 s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", stuck)...)
534 c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}}
535
536 v, err := c.Verdict(context.Background(), "b", "k", "v1")
537 if err != nil {
538 t.Fatal(err)
539 }
540 if v != VerdictUnknown {
541 t.Fatalf("verdict = %q, want unknown — %q must be retried, not inherited", v, stuck)
542 }
543 })
544 }
545
546 // clean and infected ARE carried forward: they are answers about bytes
547 // that have not changed.
548 for _, keep := range []Verdict{VerdictClean, VerdictInfected, VerdictSkipped} {
549 t.Run(string(keep), func(t *testing.T) {
550 sg := testSigner(t, "test-key", "")
551 s3 := &stubScanS3{t: t}
552 s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", keep)...)
553 c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}}
554 v, err := c.Verdict(context.Background(), "b", "k", "v1")
555 if err != nil {
556 t.Fatal(err)
557 }
558 if v != keep {
559 t.Fatalf("verdict = %q, want %q carried forward", v, keep)
560 }
561 })
562 }
563}
564
565// A tag set we cannot READ must never be written over. PutObjectTagging
566// replaces the whole set, so writing blind here destroys exactly the foreign
567// tags the merge exists to protect — on the one path where we know least about
568// what is there.
569func TestClamAVDoesNotBlindWriteWhenTagReadFails(t *testing.T) {
570 s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test
571 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
572 return objectBody("hello"), nil
573 }
574 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
575 return nil, apiErr("SlowDown")
576 }
577 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
578
579 if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err == nil {
580 t.Fatal("an unreadable tag set was written over anyway")
581 }
582}
583
584// An object already carrying eight foreign tags cannot also hold our three —
585// S3 caps an object at ten. Erroring there would abandon the page unwritten
586// every tick forever, which is the exact permanent stall the denied
587// classification was added to remove. Record the verdict, skip the write.
588func TestClamAVSkipsTagWriteRatherThanStallAtTheTagLimit(t *testing.T) {
589 var foreign []string
590 for i := 0; i < 8; i++ {
591 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
592 }
593 s3 := &stubScanS3{t: t} // putTagging unset: an 11-tag write fails the test
594 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
595 return objectBody("hello"), nil
596 }
597 s3.allowTagRead(foreign...)
598 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
599
600 v, _, err := c.Scan(context.Background(), "b", "k", "v1", 5)
601 if err != nil {
602 t.Fatalf("a heavily tagged object stalled the sweep: %v", err)
603 }
604 if v != VerdictClean {
605 t.Fatalf("verdict = %q, want clean — it was scanned, just not recorded", v)
606 }
607}
608
609// Status finds the CURRENT version itself and hands back the object, so a
610// caller holding only a key can go on to Scan without a HeadObject of its own.
611//
612// That second return is the whole point: Scan needs a version and a size, the
613// on-demand drain has no listing page, and asking it to reconstruct the token
614// would leak an internal format and hand it the laundering bug as a
615// correctness bug.
616func TestStatusReturnsObjectSoCallerCanScan(t *testing.T) {
617 mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
618 s3 := &stubScanS3{t: t}
619 s3.headOK(`"d41d8cd9"`, mod, 4096)
620 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
621 return taggingOutput(), nil // no tag yet
622 }
623 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
624
625 v, obj, err := c.Status(context.Background(), "b", "k")
626 if err != nil {
627 t.Fatal(err)
628 }
629 if v != VerdictUnknown {
630 t.Fatalf("verdict = %q, want unknown for an untagged object", v)
631 }
632 if obj.Size != 4096 || obj.ETag != "d41d8cd9" || !obj.Modified.Equal(mod) {
633 t.Fatalf("object = %+v — a caller cannot Scan without these", obj)
634 }
635 if obj.Version() == "" {
636 t.Fatal("no version token to scan against")
637 }
638}
639
640// Status reports what the tag SAYS. Verdict decides what the SWEEP does about
641// it. Conflating them makes a failed scan read as pending forever: the drain
642// deletes the item as scanned, and nothing would ever change the answer.
643func TestStatusDoesNotApplyTheReIssueRule(t *testing.T) {
644 mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
645 version := ScanObject{ETag: "abc", Modified: mod}.Version()
646
647 for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} {
648 t.Run(string(stuck), func(t *testing.T) {
649 sg := testSigner(t, "test-key", "")
650 s3 := &stubScanS3{t: t}
651 s3.headOK("abc", mod, 10)
652 s3.allowTagRead(signedTagPairs(t, sg, "b", "k", version, stuck)...)
653 c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}}
654
655 // Status: as written.
656 got, _, err := c.Status(context.Background(), "b", "k")
657 if err != nil {
658 t.Fatal(err)
659 }
660 if got != stuck {
661 t.Fatalf("Status = %q, want %q reported as itself", got, stuck)
662 }
663
664 // Verdict: re-issued, so the sweep retries it.
665 sweepView, err := c.Verdict(context.Background(), "b", "k", version)
666 if err != nil {
667 t.Fatal(err)
668 }
669 if sweepView != VerdictUnknown {
670 t.Fatalf("Verdict = %q, want unknown so the sweep re-scans", sweepView)
671 }
672 })
673 }
674}
675
676// A tag about bytes that have since changed is not an answer about the bytes
677// that are there now.
678func TestStatusIgnoresStaleTag(t *testing.T) {
679 mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
680 s3 := &stubScanS3{t: t}
681 s3.headOK("new-etag", mod, 10)
682 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
683 return taggingOutput(TagVerdict, "clean",
684 TagVersion, ScanObject{ETag: "old-etag", Modified: mod}.Version()), nil
685 }
686 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
687
688 v, _, err := c.Status(context.Background(), "b", "k")
689 if err != nil {
690 t.Fatal(err)
691 }
692 if v != VerdictUnknown {
693 t.Fatalf("a rewritten object reported %q from its predecessor's tag", v)
694 }
695
696 // THE case the version binding exists for, and the one the check above
697 // does not make: the ETag is IDENTICAL and only the timestamp moved.
698 // Single-part ETag is the content MD5, and chosen-prefix MD5 collisions
699 // are practical — so an attacker who can get one file scanned can upload a
700 // second with the same ETag and inherit its clean verdict. Comparing only
701 // the half before the "@" passes the first assertion and fails this one.
702 s3.headOK("same-etag", mod.Add(time.Hour), 10)
703 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
704 return taggingOutput(TagVerdict, "clean",
705 TagVersion, ScanObject{ETag: "same-etag", Modified: mod}.Version()), nil
706 }
707 v, _, err = c.Status(context.Background(), "b", "k")
708 if err != nil {
709 t.Fatal(err)
710 }
711 if v != VerdictUnknown {
712 t.Fatalf("an object rewritten with a colliding ETag reported %q — the timestamp half of the version is not being compared", v)
713 }
714}
715
716// A denied HeadObject leaves no version and no size, so the caller must not go
717// on to Scan — the zero ScanObject is how it knows.
718func TestStatusOnDeniedHead(t *testing.T) {
719 s3 := &stubScanS3{t: t}
720 s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
721 return nil, apiErr("AccessDenied")
722 }
723 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
724
725 v, obj, err := c.Status(context.Background(), "b", "k")
726 if err != nil {
727 t.Fatal(err)
728 }
729 if v != VerdictDenied {
730 t.Fatalf("verdict = %q, want denied", v)
731 }
732 if obj != (ScanObject{}) {
733 t.Fatalf("object = %+v, want the zero value so nobody tries to scan it", obj)
734 }
735}
736
737func TestStatusOnMissingObject(t *testing.T) {
738 s3 := &stubScanS3{t: t}
739 s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
740 return nil, apiErr("NotFound")
741 }
742 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}}
743
744 if _, _, err := c.Status(context.Background(), "b", "k"); !errors.Is(err, ErrObjectGone) {
745 t.Fatalf("err = %v, want ErrObjectGone", err)
746 }
747}
748
749// A verdict reached but not persisted says so. Without the flag the on-demand
750// drain would report clean, delete the item, and the status door would answer
751// pending forever — the app's source of truth never arriving while a callback
752// said otherwise.
753func TestScanReportsUnrecordedAtTheTagLimit(t *testing.T) {
754 var foreign []string
755 for i := 0; i < 8; i++ {
756 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
757 }
758 s3 := &stubScanS3{t: t} // putTagging unset: an 11-tag write fails the test
759 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
760 return objectBody("hello"), nil
761 }
762 s3.allowTagRead(foreign...)
763 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
764
765 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
766 if err != nil {
767 t.Fatal(err)
768 }
769 if v != VerdictClean {
770 t.Fatalf("verdict = %q, want clean — it WAS scanned", v)
771 }
772 if recorded {
773 t.Fatal("recorded = true, but nothing was written to the object")
774 }
775}
776
777// The ordinary path records, so `recorded` is not simply always false.
778func TestScanReportsRecordedOnTheOrdinaryPath(t *testing.T) {
779 s3 := &stubScanS3{t: t}
780 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
781 return objectBody("hello"), nil
782 }
783 s3.allowTagRead()
784 s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
785 return &awss3.PutObjectTaggingOutput{}, nil
786 }
787 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
788
789 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
790 if err != nil || v != VerdictClean || !recorded {
791 t.Fatalf("= %q, %v, %v", v, recorded, err)
792 }
793}
794
795// A denied fetch is never "recorded", whether or not the best-effort tag
796// landed: nothing may depend on a trace we could not verify.
797func TestScanDeniedIsNeverRecorded(t *testing.T) {
798 s3 := &stubScanS3{t: t}
799 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
800 return nil, apiErr("AccessDenied")
801 }
802 s3.allowTagRead()
803 s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
804 return &awss3.PutObjectTaggingOutput{}, nil
805 }
806 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20}
807
808 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
809 if err != nil || v != VerdictDenied {
810 t.Fatalf("= %q, %v", v, err)
811 }
812 if recorded {
813 t.Fatal("a denied object reported its verdict as recorded")
814 }
815}
816
817// AN INFECTED VERDICT MUST ALWAYS BE RECORDABLE, AND FOREIGN TAGS DO NOT
818// OUTRANK IT.
819//
820// The tag limit tolerance above is right for a clean verdict: the object is
821// simply re-scanned next tick, and an app's own tags — which on an ADOPTED
822// bucket may predate CARLOS entirely — are worth more than one avoided
823// re-scan. For infected it is a bypass of the whole enforcement mechanism.
824//
825// `scan enforce on` writes a Deny keyed on `s3:ExistingObjectTag/carlos-scan =
826// infected` (aws.go). That Deny only ever engages if the tag gets WRITTEN. An
827// app that attaches eight tags of its own to an upload makes its object
828// permanently unmarkable: the verdict is reached, the write is skipped, the
829// Deny never matches, and the malware stays readable through the app's own
830// credential. Nothing alarms — the sweep counts the object as reached, the
831// result object says recorded:false, and the status door reads no tag and
832// answers unknown.
833//
834// So: for infected, foreign tags are evicted to make room. That is a real
835// cost to a tenant's metadata and it is the right trade, because the
836// alternative is a known-infected object that enforcement cannot touch.
837func TestInfectedIsRecordedEvenWhenForeignTagsCrowdItOut(t *testing.T) {
838 var foreign []string
839 for i := 0; i < 8; i++ {
840 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
841 }
842 var written []s3types.Tag
843 s3 := &stubScanS3{t: t}
844 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
845 return objectBody("hello"), nil
846 }
847 s3.allowTagRead(foreign...)
848 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
849 written = in.Tagging.TagSet
850 return &awss3.PutObjectTaggingOutput{}, nil
851 }
852 c := &clamavScanner{signer: testSigner(t, "test-key", ""),
853 s3: s3,
854 engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}},
855 maxBytes: 1 << 20,
856 }
857
858 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
859 if err != nil {
860 t.Fatal(err)
861 }
862 if v != VerdictInfected {
863 t.Fatalf("verdict = %q, want infected", v)
864 }
865 if !recorded {
866 t.Fatal("an infected verdict went unrecorded — `scan enforce on` denies on the TAG, so an object that cannot be tagged cannot be quarantined")
867 }
868 if len(written) == 0 {
869 t.Fatal("nothing was written to the object at all")
870 }
871 if len(written) > maxObjectTags {
872 t.Fatalf("wrote %d tags, over S3's limit of %d — the write would be rejected", len(written), maxObjectTags)
873 }
874 var gotVerdict string
875 for _, tg := range written {
876 if aws2s(tg.Key) == TagVerdict {
877 gotVerdict = aws2s(tg.Value)
878 }
879 }
880 if gotVerdict != string(VerdictInfected) {
881 t.Fatalf("the written set carries carlos-scan=%q, want infected:\n%v", gotVerdict, written)
882 }
883}
884
885// The eviction is only as large as it needs to be: our three go in, and as
886// many foreign tags as still fit are kept. Dropping all of them would be a
887// simpler implementation and a gratuitous one — the object is a tenant's, and
888// this takes the minimum the limit forces.
889func TestInfectedEvictsOnlyAsManyForeignTagsAsItMustNot(t *testing.T) {
890 var foreign []string
891 for i := 0; i < 8; i++ {
892 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
893 }
894 var written []s3types.Tag
895 s3 := &stubScanS3{t: t}
896 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
897 return objectBody("hello"), nil
898 }
899 s3.allowTagRead(foreign...)
900 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
901 written = in.Tagging.TagSet
902 return &awss3.PutObjectTaggingOutput{}, nil
903 }
904 c := &clamavScanner{signer: testSigner(t, "test-key", ""),
905 s3: s3,
906 engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}},
907 maxBytes: 1 << 20,
908 }
909 if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err != nil {
910 t.Fatal(err)
911 }
912
913 var kept int
914 for _, tg := range written {
915 if !isScanTag(aws2s(tg.Key)) {
916 kept++
917 }
918 }
919 // Ten minus however many WE own — derived, because that number went from
920 // three to four when the signature tag landed and a literal would have
921 // silently started asserting an eleven-tag write.
922 var owned int
923 for _, tg := range written {
924 if isScanTag(aws2s(tg.Key)) {
925 owned++
926 }
927 }
928 if want := maxObjectTags - owned; kept != want {
929 t.Fatalf("kept %d foreign tags, want %d — evict the minimum the limit forces, not all of them:\n%v", kept, want, written)
930 }
931}
932
933// And a CLEAN verdict still yields, which is the behaviour the two tests above
934// this one pin. The split is deliberate: clean costs one re-scan, infected
935// costs the enforcement Deny never engaging.
936func TestCleanStillYieldsToForeignTagsAtTheLimit(t *testing.T) {
937 var foreign []string
938 for i := 0; i < 8; i++ {
939 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
940 }
941 s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test
942 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
943 return objectBody("hello"), nil
944 }
945 s3.allowTagRead(foreign...)
946 c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
947
948 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
949 if err != nil {
950 t.Fatal(err)
951 }
952 if v != VerdictClean || recorded {
953 t.Fatalf("clean = %q recorded=%v, want clean and unrecorded — a tenant's tags outrank one avoided re-scan", v, recorded)
954 }
955}
956
957// THE FORGERY THE IAM DENYS CANNOT REACH, refused at the only place that
958// matters: the read.
959//
960// A POST-policy form upload sets carlos-scan freely on MinIO with every Deny
961// in place, because no IAM condition key describes a form upload. So the app
962// CAN write these three tags. What it cannot do is make them mean anything:
963// without a signature this deployment issued, readTag reports no verdict and
964// the object is scanned like any other.
965//
966// This is the test that would have caught all four of the tagging defects at
967// once, and it does not care which route the tag arrived by.
968func TestForgedTagWithoutASignatureIsNotAVerdict(t *testing.T) {
969 s3 := &stubScanS3{t: t}
970 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
971 return objectBody("hello"), nil
972 }
973 s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
974 return &awss3.HeadObjectOutput{ETag: s2aws(`"etag1"`)}, nil
975 }
976 // Exactly what an app can write for itself, and nothing else.
977 s3.allowTagRead(TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, "2026-09-05T00:00:00Z")
978 c := &clamavScanner{
979 s3: s3,
980 engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}},
981 maxBytes: 1 << 20,
982 signer: testSigner(t, "deployment-key", ""),
983 }
984
985 v, err := c.Verdict(context.Background(), "b", "k", "v1")
986 if err != nil {
987 t.Fatal(err)
988 }
989 if v != VerdictUnknown {
990 t.Fatalf("verdict = %q for an unsigned tag, want unknown — an app just marked its own object clean", v)
991 }
992
993 // And Status, the door's read, must agree. Two readers of one tag that
994 // disagree about whether it is a verdict is the drift readTag exists to
995 // prevent.
996 st, _, err := c.Status(context.Background(), "b", "k")
997 if err != nil {
998 t.Fatal(err)
999 }
1000 if st != VerdictUnknown {
1001 t.Fatalf("Status = %q for an unsigned tag, want unknown", st)
1002 }
1003}
1004
1005// A signature that is present but not ours — a random value, or one lifted
1006// from a different object — is the same answer.
1007func TestForgedSignatureIsNotAVerdict(t *testing.T) {
1008 signer := testSigner(t, "deployment-key", "")
1009 // A signature legitimately issued for a DIFFERENT object, copied across.
1010 lifted := signer.Sign(scanFacts{
1011 bucket: "b", key: "other", version: "v1",
1012 verdict: string(VerdictClean), at: "2026-09-05T00:00:00Z",
1013 })
1014
1015 for _, tc := range []struct{ name, sig string }{
1016 {"rubbish", "not-a-signature"},
1017 {"another object's", lifted},
1018 {"an empty value", ""},
1019 } {
1020 t.Run(tc.name, func(t *testing.T) {
1021 s3 := &stubScanS3{t: t}
1022 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
1023 return objectBody("hello"), nil
1024 }
1025 s3.allowTagRead(
1026 TagVerdict, string(VerdictClean),
1027 TagVersion, "v1",
1028 TagAt, "2026-09-05T00:00:00Z",
1029 TagSignature, tc.sig,
1030 )
1031 c := &clamavScanner{
1032 s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}},
1033 maxBytes: 1 << 20, signer: signer,
1034 }
1035 v, err := c.Verdict(context.Background(), "b", "k", "v1")
1036 if err != nil {
1037 t.Fatal(err)
1038 }
1039 if v != VerdictUnknown {
1040 t.Fatalf("verdict = %q, want unknown", v)
1041 }
1042 })
1043 }
1044}
1045
1046// The positive half: a verdict this scanner wrote is read back and trusted, so
1047// the whole mechanism does not simply refuse everything. Without this the two
1048// tests above are satisfied by a readTag that always returns unknown.
1049func TestAVerdictThisScannerWroteIsTrusted(t *testing.T) {
1050 signer := testSigner(t, "deployment-key", "")
1051 var written []s3types.Tag
1052 s3 := &stubScanS3{t: t}
1053 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
1054 return objectBody("hello"), nil
1055 }
1056 s3.allowTagRead()
1057 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
1058 written = in.Tagging.TagSet
1059 return &awss3.PutObjectTaggingOutput{}, nil
1060 }
1061 c := &clamavScanner{
1062 s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}},
1063 maxBytes: 1 << 20, signer: signer,
1064 }
1065
1066 v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5)
1067 if err != nil || v != VerdictClean || !recorded {
1068 t.Fatalf("Scan = %q recorded=%v err=%v", v, recorded, err)
1069 }
1070 if len(written) != 4 {
1071 t.Fatalf("wrote %d tags, want four — the verdict, the version, the time and the signature over them:\n%v", len(written), written)
1072 }
1073
1074 // Read it back through the real path: what was written IS what the next
1075 // read sees, which is the round trip the whole mechanism depends on.
1076 s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
1077 return &awss3.GetObjectTaggingOutput{TagSet: written}, nil
1078 }
1079 got, err := c.Verdict(context.Background(), "b", "k", "v1")
1080 if err != nil {
1081 t.Fatal(err)
1082 }
1083 if got != VerdictClean {
1084 t.Fatalf("reading back a verdict this scanner just wrote = %q, want clean", got)
1085 }
1086}
1087
1088// ROTATION. A verdict signed under the previous key is still a verdict — the
1089// alternative is that rotating the key rescans every object in every store at
1090// once — and the sweep re-signs it under the current key so the old one can
1091// eventually be dropped.
1092//
1093// The re-sign must NOT move carlos-scan-at. That timestamp is when the bytes
1094// were judged, and a rotation judges nothing; moving it would silently make
1095// every verdict look freshly scanned and destroy the one field an auditor
1096// reads to ask "how old is this answer".
1097func TestRotationResignsWithoutRescanningOrMovingTheTimestamp(t *testing.T) {
1098 old := testSigner(t, "old-key", "")
1099 rotated := testSigner(t, "new-key", "old-key")
1100
1101 const at = "2026-09-05T00:00:00Z"
1102 stale := old.Sign(scanFacts{
1103 bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at,
1104 })
1105
1106 var written []s3types.Tag
1107 s3 := &stubScanS3{t: t}
1108 s3.allowTagRead(
1109 TagVerdict, string(VerdictClean),
1110 TagVersion, "v1",
1111 TagAt, at,
1112 TagSignature, stale,
1113 )
1114 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
1115 written = in.Tagging.TagSet
1116 return &awss3.PutObjectTaggingOutput{}, nil
1117 }
1118 // getObject is deliberately unset: reaching the engine at all fails the
1119 // test, which is how "without rescanning" is asserted rather than assumed.
1120 c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated}
1121
1122 v, err := c.Verdict(context.Background(), "b", "k", "v1")
1123 if err != nil {
1124 t.Fatal(err)
1125 }
1126 if v != VerdictClean {
1127 t.Fatalf("verdict = %q under the previous key, want clean — a rotation must not invalidate stored verdicts", v)
1128 }
1129 if written == nil {
1130 t.Fatal("nothing was re-signed, so the previous key could never be dropped")
1131 }
1132
1133 var gotAt, gotSig string
1134 for _, tg := range written {
1135 switch aws2s(tg.Key) {
1136 case TagAt:
1137 gotAt = aws2s(tg.Value)
1138 case TagSignature:
1139 gotSig = aws2s(tg.Value)
1140 }
1141 }
1142 if gotAt != at {
1143 t.Fatalf("carlos-scan-at moved to %q — a re-sign judges nothing and must not claim a fresh scan", gotAt)
1144 }
1145 if gotSig == stale {
1146 t.Fatal("the signature was rewritten unchanged, still under the old key")
1147 }
1148 if ok, wasStale := rotated.Verify(scanFacts{
1149 bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at,
1150 }, gotSig); !ok || wasStale {
1151 t.Fatalf("the re-signed value does not verify under the current key (ok=%v stale=%v)", ok, wasStale)
1152 }
1153}
1154
1155// A re-sign that cannot be written must not fail the sweep. The verdict is
1156// still valid under the previous key; the worst case is that it is re-signed
1157// on a later tick, and failing the page over it would stall every object
1158// behind this one.
1159func TestAFailedResignDoesNotFailTheSweep(t *testing.T) {
1160 old := testSigner(t, "old-key", "")
1161 rotated := testSigner(t, "new-key", "old-key")
1162 const at = "2026-09-05T00:00:00Z"
1163
1164 s3 := &stubScanS3{t: t}
1165 s3.allowTagRead(
1166 TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, at,
1167 TagSignature, old.Sign(scanFacts{bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at}),
1168 )
1169 s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
1170 return nil, errors.New("the bucket had a bad moment")
1171 }
1172 c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated}
1173
1174 v, err := c.Verdict(context.Background(), "b", "k", "v1")
1175 if err != nil {
1176 t.Fatalf("a failed re-sign failed the sweep: %v", err)
1177 }
1178 if v != VerdictClean {
1179 t.Fatalf("verdict = %q, want clean", v)
1180 }
1181}
1182
1183// Four owned tags, not three, so the room left for a tenant's own is six.
1184// 63e80cc computed this from a hardcoded 3; a fourth tag silently made that
1185// wrong in the direction of writing an eleven-tag set S3 would reject.
1186func TestInfectedEvictionAccountsForTheSignatureTag(t *testing.T) {
1187 var foreign []string
1188 for i := 0; i < 8; i++ {
1189 foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
1190 }
1191 var written []s3types.Tag
1192 s3 := &stubScanS3{t: t}
1193 s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
1194 return objectBody("hello"), nil
1195 }
1196 s3.allowTagRead(foreign...)
1197 s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) {
1198 written = in.Tagging.TagSet
1199 return &awss3.PutObjectTaggingOutput{}, nil
1200 }
1201 c := &clamavScanner{
1202 s3: s3,
1203 engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}},
1204 maxBytes: 1 << 20,
1205 signer: testSigner(t, "deployment-key", ""),
1206 }
1207 if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err != nil {
1208 t.Fatal(err)
1209 }
1210 if len(written) > maxObjectTags {
1211 t.Fatalf("wrote %d tags, over S3's limit of %d", len(written), maxObjectTags)
1212 }
1213 var kept int
1214 for _, tg := range written {
1215 if !isScanTag(aws2s(tg.Key)) {
1216 kept++
1217 }
1218 }
1219 if want := maxObjectTags - 4; kept != want {
1220 t.Fatalf("kept %d foreign tags, want %d — four owned tags now, not three:\n%v", kept, want, written)
1221 }
1222}
1223
1224// The signature tag is the scanner's own, so a re-scan replaces it rather than
1225// treating it as a tenant's and preserving it. A stale signature left beside a
1226// fresh verdict is a verdict that will not verify.
1227func TestSignatureTagIsOwnedByTheScanner(t *testing.T) {
1228 if !isScanTag(TagSignature) {
1229 t.Fatal("the signature tag is not in isScanTag — a re-scan would preserve the old one beside a new verdict")
1230 }
1231}
1232
1233// signedTagPairs is a tag set as the scanner itself would have written it:
1234// verdict, version, time, and a signature over all of it.
1235//
1236// Every test that asserts on how a verdict READS has to build one of these
1237// now. An unsigned set is not a weaker verdict, it is not a verdict — which is
1238// the entire point of the mechanism, and is why hand-written tag sets stopped
1239// working the moment signing landed.
1240func signedTagPairs(t *testing.T, sg *TagSigner, bucket, key, version string, v Verdict) []string {
1241 t.Helper()
1242 const at = "2026-09-05T00:00:00Z"
1243 return []string{
1244 TagVerdict, string(v),
1245 TagVersion, version,
1246 TagAt, at,
1247 TagSignature, sg.Sign(scanFacts{
1248 bucket: bucket, key: key, version: version, verdict: string(v), at: at,
1249 }),
1250 }
1251}
1252