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 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log"
9 "strings"
10 "time"
11
12 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
13 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
14 "github.com/aws/smithy-go"
15
16 "github.com/carlosframework/platform/internal/clamd"
17)
18
19// scanS3API is the subset of *s3.Client the scanner calls, narrowed the same
20// way s3API is so a test can stand a stub in with no network or credentials.
21//
22// It is deliberately separate from s3API: the provisioner creates and destroys
23// buckets and never reads an object, the scanner reads objects and never
24// touches a bucket. Keeping them apart means neither can quietly grow the
25// other's powers.
26type scanS3API interface {
27 GetObject(ctx context.Context, in *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
28 HeadObject(ctx context.Context, in *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error)
29 GetObjectTagging(ctx context.Context, in *awss3.GetObjectTaggingInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error)
30 PutObjectTagging(ctx context.Context, in *awss3.PutObjectTaggingInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error)
31}
32
33// streamScanner is the engine seam: what clamavScanner needs from a clamd.
34// *clamd.Client satisfies it. The wire protocol is tested in internal/clamd,
35// so tests here fake this interface rather than restaging a socket.
36type streamScanner interface {
37 ScanStream(ctx context.Context, r io.Reader) (clamd.Result, error)
38 Version(ctx context.Context) (string, error)
39}
40
41// clamavScanner scans objects by streaming them past a local clamd and records
42// the verdict as object tags.
43//
44// Nothing about the bucket changes: there is no per-bucket plan to enable, no
45// quota to consume and no per-bucket cost. That is the whole reason ClamAV is
46// the primary engine — the bought alternative caps at 25 protected buckets per
47// account per Region and cannot be raised.
48type clamavScanner struct {
49 s3 scanS3API
50 engine streamScanner
51 maxBytes int64
52 // signer is what makes a tag a verdict. See scantag.go: the app can write
53 // these tags on some gateways whatever the IAM policy says, so authenticity
54 // and not write-permission is the property. Never nil in a running
55 // deployment — the console refuses to construct a scanner without one.
56 signer *TagSigner
57}
58
59var _ Scanner = (*clamavScanner)(nil)
60
61// NewClamAVScanner builds a Scanner against S3 in region and a clamd on
62// socket. maxBytes is the size ceiling above which an object is recorded as
63// skipped without being fetched; it should mirror clamd's own StreamMaxLength.
64//
65// This does NOT ping clamd. An engine that is down at boot — mid signature
66// update, say — must not take the console with it: object scanning is an
67// opt-in feature of one subsystem, and failing the whole control plane closed
68// because a scanner is restarting would be a far worse outage than the one it
69// prevents. An unreachable engine surfaces through Provenance, which is what
70// `carlos store scan status` reports, and fails the pass loudly per tick.
71func NewClamAVScanner(ctx context.Context, region, endpoint, socket string, maxBytes int64, signer *TagSigner) (Scanner, error) {
72 if signer == nil {
73 // A scanner with no signer would write verdicts nothing can verify and
74 // read every forgery as genuine. Refuse at construction, where an
75 // operator sees it, rather than at the first tag.
76 return nil, ErrNoTagKey
77 }
78 s3c, err := S3ClientFor(ctx, region, endpoint)
79 if err != nil {
80 return nil, fmt.Errorf("objstore: clamav scanner: %w", err)
81 }
82 return &clamavScanner{
83 s3: s3c,
84 engine: clamd.New(socket, clamd.Timeouts{}),
85 maxBytes: maxBytes,
86 signer: signer,
87 }, nil
88}
89
90// EnsureEnabled is a no-op. There is nothing on the bucket to configure: the
91// engine is reached from the control plane, not attached to the store.
92func (c *clamavScanner) EnsureEnabled(ctx context.Context, bucket string) error { return nil }
93
94// EnsureDisabled is a no-op for the same reason. Nothing was attached, so
95// nothing leaks when a bucket is destroyed.
96func (c *clamavScanner) EnsureDisabled(ctx context.Context, bucket string) error { return nil }
97
98// Capacity reports no limit. -1 is "unbounded", distinct from 0, which would
99// mean a real engine with nothing left.
100func (c *clamavScanner) Capacity(ctx context.Context) (remaining, capacity int, err error) {
101 return -1, -1, nil
102}
103
104// Provenance names the engine and its signature database — the two facts an
105// audit answer needs beside a count. A scan is only as good as the signatures
106// it ran against, so "clean" without a database version is not evidence.
107func (c *clamavScanner) Provenance(ctx context.Context) (engine, version string, err error) {
108 v, err := c.engine.Version(ctx)
109 if err != nil {
110 return "clamav", "", err
111 }
112 return "clamav", v, nil
113}
114
115// Enforcement is the tag the store user's policy denies on.
116func (c *clamavScanner) Enforcement() DenyTag {
117 return DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}
118}
119
120// Verdict reports what the engine previously said about this exact version of
121// the object.
122//
123// A verdict is bound to the object VERSION it judged — see ObjectVersion: the
124// ETag and the last-modified time together.
125//
126// Without that binding a key that once held something clean is a laundering
127// channel: upload clean, get tagged clean, overwrite with the payload, keep
128// the tag. The ETag alone is not quite enough for it, either. For a
129// single-part upload the ETag is the content MD5, and chosen-prefix MD5
130// collisions are practical on ordinary hardware — so an attacker who controls
131// both uploads could produce a payload with the ETag of the clean object it
132// replaces. Last-modified moves on every overwrite and is not theirs to
133// choose. A tag whose recorded version does not match reads as unknown, and
134// the sweep scans it again.
135func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, version string) (Verdict, error) {
136 verdict, taggedVersion, at, stale, err := c.readSignedTag(ctx, bucket, key)
137 if err != nil {
138 return VerdictUnknown, err
139 }
140 if verdict == VerdictDenied && taggedVersion == "" {
141 // The tag set itself was unreadable. A fact about the object, not a
142 // transport failure, and it must not fail the pass.
143 return VerdictDenied, nil
144 }
145 out := c.interpretForSweep(verdict, taggedVersion, version)
146 if stale && out == verdict && verdict != "" {
147 // A verdict issued under the previous key, still valid, still about
148 // these bytes. Re-sign it so the old key can eventually be dropped —
149 // WITHOUT rescanning, and WITHOUT moving carlos-scan-at, because a
150 // re-sign judges nothing and must not claim a fresh scan.
151 c.resign(ctx, bucket, key, taggedVersion, verdict, at)
152 }
153 return out, nil
154}
155
156// resign rewrites the signature of an existing verdict under the current key.
157//
158// Best effort, deliberately. The verdict is already valid under the previous
159// key, so the cost of failing is that it is re-signed on some later tick;
160// returning an error here would abandon the whole page over a housekeeping
161// write and stall every object behind this one.
162func (c *clamavScanner) resign(ctx context.Context, bucket, key, version string, v Verdict, at string) {
163 if _, err := c.tagWith(ctx, bucket, key, version, v, at); err != nil {
164 log.Printf("objstore: could not re-sign %s/%s under the current tag key (it stays valid under the previous one): %v", bucket, key, err)
165 }
166}
167
168// readTag is the one place tags are read, shared by Verdict and Status so the
169// two cannot drift on what a tag SAYS while differing, deliberately, on what
170// it MEANS.
171//
172// A denied read comes back as (VerdictDenied, "", nil): unreadable tags are a
173// fact about the object.
174func (c *clamavScanner) readTag(ctx context.Context, bucket, key string) (Verdict, string, error) {
175 v, ver, _, _, err := c.readSignedTag(ctx, bucket, key)
176 return v, ver, err
177}
178
179// readSignedTag is readTag with the two extra facts the re-signing path needs:
180// the recorded timestamp, and whether the signature that vouched for all of it
181// was issued under the PREVIOUS key.
182//
183// THIS IS THE TRUST BOUNDARY. Everything above it — Verdict, Status, the
184// sweep, the drain, the status door — is reading a claim that has already been
185// checked against the deployment's key. An unsigned or badly signed tag does
186// not come back as a suspicious verdict to be handled carefully somewhere
187// else; it comes back as no verdict at all, which is the same answer as a bare
188// object and produces the same behaviour: scan it.
189func (c *clamavScanner) readSignedTag(ctx context.Context, bucket, key string) (verdict Verdict, version, at string, stale bool, err error) {
190 out, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
191 Bucket: &bucket,
192 Key: &key,
193 })
194 if err != nil {
195 if isGone(err) {
196 return VerdictUnknown, "", "", false, ErrObjectGone
197 }
198 if isDenied(err) {
199 return VerdictDenied, "", "", false, nil
200 }
201 return VerdictUnknown, "", "", false, fmt.Errorf("objstore: read tags %s/%s: %w", bucket, key, err)
202 }
203
204 var raw, sig string
205 for _, t := range out.TagSet {
206 switch aws2s(t.Key) {
207 case TagVerdict:
208 raw = aws2s(t.Value)
209 case TagVersion:
210 version = aws2s(t.Value)
211 case TagAt:
212 at = aws2s(t.Value)
213 case TagSignature:
214 sig = aws2s(t.Value)
215 }
216 }
217 if raw == "" {
218 return "", "", "", false, nil
219 }
220 ok, stale := c.signer.Verify(scanFacts{
221 bucket: bucket, key: key, version: version, verdict: raw, at: at,
222 }, sig)
223 if !ok {
224 // Somebody else wrote this. On several gateways an app CAN, whatever
225 // the IAM policy says, so this is the expected shape of an attack and
226 // not an anomaly to shout about. Report exactly what a bare object
227 // reports.
228 return "", "", "", false, nil
229 }
230 return Verdict(raw), version, at, stale, nil
231}
232
233// interpretForSweep applies the SWEEP's reading of a tag: unknown when it does
234// not match the current version, and unknown for denied and failed so they are
235// re-issued rather than inherited.
236func (c *clamavScanner) interpretForSweep(verdict Verdict, taggedVersion, version string) Verdict {
237 if verdict == "" || taggedVersion == "" {
238 return VerdictUnknown
239 }
240 // denied and failed are RE-ISSUED every sweep, never carried forward.
241 //
242 // Both are transient by nature — a permissions fix, a Glacier restore, an
243 // engine that was having a bad day — and the tag is an audit trace of what
244 // happened last time, not a verdict to inherit. Reading them back as
245 // answers would make them permanent: the object is not rewritten, so its
246 // version never changes, so it would never be retried and the repair would
247 // never take. An SSE-KMS denial is the case that bites, because the
248 // tagging call needs no KMS access and therefore succeeds.
249 switch verdict {
250 case VerdictDenied, VerdictFailed:
251 return VerdictUnknown
252 }
253 if taggedVersion != version {
254 // The bytes changed under the verdict. Not a failure — just unknown
255 // again, which is exactly what makes the next sweep re-scan it.
256 return VerdictUnknown
257 }
258 return verdict
259}
260
261// Status answers about the CURRENT version of one object, finding that version
262// itself, and reports what the tag SAYS rather than what the sweep should do
263// about it. See the Scanner interface for why those are different questions.
264func (c *clamavScanner) Status(ctx context.Context, bucket, key string) (Verdict, ScanObject, error) {
265 head, err := c.s3.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: &bucket, Key: &key})
266 if err != nil {
267 if isGone(err) {
268 return VerdictUnknown, ScanObject{}, ErrObjectGone
269 }
270 if isDenied(err) {
271 // No version and no size, so a caller must not go on to Scan.
272 return VerdictDenied, ScanObject{}, nil
273 }
274 return VerdictUnknown, ScanObject{}, fmt.Errorf("objstore: head %s/%s: %w", bucket, key, err)
275 }
276 obj := ScanObject{Key: key, ETag: normalizeETag(aws2s(head.ETag))}
277 if head.LastModified != nil {
278 obj.Modified = *head.LastModified
279 }
280 if head.ContentLength != nil {
281 obj.Size = *head.ContentLength
282 }
283
284 verdict, taggedVersion, err := c.readTag(ctx, bucket, key)
285 if err != nil {
286 return VerdictUnknown, obj, err
287 }
288 if verdict == VerdictDenied && taggedVersion == "" {
289 return VerdictDenied, obj, nil
290 }
291 if verdict == "" || taggedVersion == "" || taggedVersion != obj.Version() {
292 // No tag, or a tag about bytes that have since changed. Either way
293 // nothing is known about what is there now.
294 return VerdictUnknown, obj, nil
295 }
296 // Reported as written: denied and failed included. The re-issue rule is
297 // the sweep's, and applying it here would make a failed scan read as
298 // pending forever.
299 return verdict, obj, nil
300}
301
302// Scan streams one object past the engine and records the result.
303func (c *clamavScanner) Scan(ctx context.Context, bucket, key, version string, size int64) (Verdict, bool, error) {
304 // The ceiling is checked BEFORE the fetch. The size is already on the
305 // listing page that got us here, so paying egress to pull bytes clamd has
306 // told us it will refuse is pure waste — and on a bucket full of large
307 // media that waste is the entire bill.
308 if c.maxBytes > 0 && size > c.maxBytes {
309 recorded, err := c.tag(ctx, bucket, key, version, VerdictSkipped)
310 if err != nil {
311 return VerdictUnknown, false, err
312 }
313 return VerdictSkipped, recorded, nil
314 }
315
316 obj, err := c.s3.GetObject(ctx, &awss3.GetObjectInput{Bucket: &bucket, Key: &key})
317 if err != nil {
318 // An object we are not allowed to read is DENIED — a permanent fact
319 // about this object that a permissions fix repairs, not a retry.
320 //
321 // This classification is load-bearing, not tidiness. Returning a plain
322 // error here fails the whole page, unwritten; the next tick re-lists
323 // the same page, meets the same object and fails identically, and the
324 // bucket's sweep never advances past it. One SSE-KMS object the
325 // console's key policy does not cover, or one object owned by another
326 // account in an adopted bucket, would stall that store's coverage
327 // forever while every surface reported a sweep merely "in progress".
328 if isDenied(err) {
329 // Tagging is best effort and its failure is deliberately ignored:
330 // if we may not read the object we may well not be able to tag it,
331 // and the verdict still stands for this sweep's count. The tag is
332 // only an audit trace here — Verdict re-issues denied rather than
333 // reading it back, so nothing depends on this landing.
334 _, _ = c.tag(ctx, bucket, key, version, VerdictDenied)
335 // recorded is false whether or not the tag landed: nothing may
336 // depend on a trace we could not verify.
337 return VerdictDenied, false, nil
338 }
339 // Deleted between the listing and the fetch. Routine on a bucket
340 // anyone is using, and not a failure of anything — the sweep drops it
341 // rather than counting an object that no longer exists.
342 if isGone(err) {
343 return VerdictUnknown, false, ErrObjectGone
344 }
345 return VerdictUnknown, false, fmt.Errorf("objstore: get %s/%s: %w", bucket, key, err)
346 }
347 defer obj.Body.Close()
348
349 // Straight from S3 into the socket. The object is never buffered: a
350 // gigabyte object costs a chunk of memory, not a gigabyte, and the console
351 // holds no copy of tenant data at any point.
352 res, err := c.engine.ScanStream(ctx, obj.Body)
353 if err != nil {
354 // The engine could not be reached or did not answer. That is a fact
355 // about the ENGINE, not about this object, and it must not be written
356 // as a per-object verdict: a clamd that is down for an hour would
357 // otherwise leave thousands of objects permanently tagged failed,
358 // with nothing to distinguish them from objects that genuinely broke
359 // the scanner. Fail the pass instead; the object stays unknown and
360 // the next sweep picks it up.
361 return VerdictUnknown, false, fmt.Errorf("objstore: scan %s/%s: %w", bucket, key, err)
362 }
363
364 v := verdictFor(res.Status)
365 recorded, err := c.tag(ctx, bucket, key, version, v)
366 if err != nil {
367 return VerdictUnknown, false, err
368 }
369 return v, recorded, nil
370}
371
372// verdictFor maps an engine status onto the recorded verdict.
373//
374// StatusSizeExceeded becomes VerdictSkipped, not VerdictFailed. Nothing
375// failed: the object was simply never judged, and an audit that cannot tell
376// "we looked and it was clean" from "we never looked" is not evidence.
377func verdictFor(s clamd.Status) Verdict {
378 switch s {
379 case clamd.StatusClean:
380 return VerdictClean
381 case clamd.StatusInfected:
382 return VerdictInfected
383 case clamd.StatusSizeExceeded:
384 return VerdictSkipped
385 default:
386 return VerdictFailed
387 }
388}
389
390// tag writes the verdict, the ETag it applies to, and when it was reached.
391//
392// PutObjectTagging REPLACES the whole tag set, so anything already on the
393// object is preserved explicitly here rather than assumed absent.
394//
395// An earlier version of this reasoned that nothing could be there to lose: the
396// store user has no s3:PutObjectTagging, and AWS requires that same permission
397// to set tags via x-amz-tagging on upload, so an app cannot tag its own object
398// by either route. That holds for a bucket CARLOS minted. It does not hold for
399// an ADOPTED one — `store grant --bucket` takes over a bucket whose objects
400// may carry tags written long before CARLOS scoped any credentials, by tooling
401// that is still using them for lifecycle rules or cost allocation. Replacing
402// the set blindly would delete them, silently, on first scan.
403func (c *clamavScanner) tag(ctx context.Context, bucket, key, version string, v Verdict) (recorded bool, err error) {
404 // The timestamp is minted HERE, once, and threaded through — it goes in a
405 // tag and into the signature over it, and two calls to time.Now() would
406 // produce a signature over a moment the tag does not record.
407 return c.tagWith(ctx, bucket, key, version, v, time.Now().UTC().Format(time.RFC3339))
408}
409
410// tagWith writes a verdict stamped with a GIVEN time, so that re-signing an
411// existing verdict after a key rotation can keep the moment the bytes were
412// actually judged. tag() supplies now; resign() supplies what was already
413// there.
414func (c *clamavScanner) tagWith(ctx context.Context, bucket, key, version string, v Verdict, at string) (recorded bool, err error) {
415 scanTagSet := []s3types.Tag{
416 {Key: s2aws(TagVerdict), Value: s2aws(string(v))},
417 {Key: s2aws(TagVersion), Value: s2aws(version)},
418 {Key: s2aws(TagAt), Value: s2aws(at)},
419 // The one that makes the other three mean something.
420 {Key: s2aws(TagSignature), Value: s2aws(c.signer.Sign(scanFacts{
421 bucket: bucket, key: key, version: version, verdict: string(v), at: at,
422 }))},
423 }
424 // OURS FIRST, so that truncating to the limit below drops foreign tags
425 // and never one of the three this whole mechanism exists to write.
426 set := append([]s3types.Tag(nil), scanTagSet...)
427 // Read-merge-write. One extra call per object per LIFETIME, not per sweep:
428 // an object whose verdict still matches its version never reaches Scan
429 // again.
430 existing, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
431 Bucket: &bucket,
432 Key: &key,
433 })
434 if err != nil { //nolint:nestif // the three outcomes are the point
435 // A tag set we cannot read must NOT be written over. PutObjectTagging
436 // replaces the whole set, so proceeding here would destroy exactly the
437 // foreign tags this merge exists to protect — silently, and on the one
438 // path where we know least about what is there. The sweep read this
439 // same object's tags moments ago through Verdict, so a failure now is
440 // transient: fail the page and let the next tick retry.
441 if isDenied(err) || isGone(err) {
442 // Nothing we could ever write, and not a pass failure. NOT
443 // recorded, which is what stops a caller believing the object
444 // carries a verdict it does not.
445 return false, nil
446 }
447 return false, fmt.Errorf("objstore: read tags before writing %s/%s: %w", bucket, key, err)
448 }
449 for _, t := range existing.TagSet {
450 if !isScanTag(aws2s(t.Key)) {
451 set = append(set, t)
452 }
453 }
454
455 // S3 caps an object at ten tags, and an object already carrying eight
456 // foreign ones cannot also hold our three. What happens next depends on
457 // the verdict, and the two cases are genuinely different.
458 //
459 // CLEAN (or failed, or skipped): yield. Erroring would abandon the page
460 // unwritten every single tick — the exact permanent stall the denied
461 // classification was added to remove — and the only cost of not recording
462 // is that the object is scanned again next time. A tenant's tags, which on
463 // an ADOPTED bucket may predate CARLOS entirely, are worth more than one
464 // avoided re-scan.
465 //
466 // INFECTED: do not yield. `scan enforce on` writes a Deny keyed on
467 // `s3:ExistingObjectTag/carlos-scan = infected` (aws.go), so the Deny only
468 // ever engages if this tag is WRITTEN. Yielding here means an app that
469 // attaches eight tags of its own to an upload makes its objects
470 // permanently unmarkable — verdict reached, write skipped, Deny never
471 // matches, malware readable through the app's own credential, and nothing
472 // anywhere alarms: the sweep counts the object as reached, the result
473 // object says recorded:false, and the status door reads no tag and answers
474 // unknown.
475 //
476 // So an infected verdict evicts foreign tags to fit. That is a real loss
477 // of a tenant's metadata and it is the right trade against a known-
478 // infected object that enforcement cannot touch. It takes the minimum the
479 // limit forces — our three go in, as many foreign tags as still fit are
480 // kept — because taking more than necessary would be gratuitous.
481 if len(set) > maxObjectTags {
482 if v != VerdictInfected {
483 return false, nil
484 }
485 log.Printf("objstore: %s/%s is infected and carries %d foreign tags — evicting %d of them so the verdict can be recorded and enforcement can see it",
486 bucket, key, len(set)-len(scanTagSet), len(set)-maxObjectTags)
487 // set[:maxObjectTags] keeps OUR tags, which are first, and as many
488 // foreign ones as still fit. The room left for a tenant is
489 // maxObjectTags-len(scanTagSet) — derived, because it changed from
490 // three to four the moment the signature tag landed and a hardcoded
491 // number would have quietly started writing an eleven-tag set.
492 set = set[:maxObjectTags]
493 }
494
495 if _, err := c.s3.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
496 Bucket: &bucket,
497 Key: &key,
498 Tagging: &s3types.Tagging{TagSet: set},
499 }); err != nil {
500 return false, fmt.Errorf("objstore: tag %s/%s: %w", bucket, key, err)
501 }
502 return true, nil
503}
504
505// maxObjectTags is S3's per-object tag limit.
506const maxObjectTags = 10
507
508// isScanTag is the set this scanner owns and may overwrite. Everything else on
509// the object belongs to whoever put it there.
510func isScanTag(key string) bool {
511 return key == TagVerdict || key == TagVersion || key == TagAt || key == TagSignature
512}
513
514// normalizeETag strips the quotes S3 wraps an ETag in. The listing and the tag
515// must compare equal or every object re-scans forever, and only one of the two
516// arrives quoted.
517func normalizeETag(etag string) string {
518 return strings.Trim(strings.TrimSpace(etag), `"`)
519}
520
521func aws2s(p *string) string {
522 if p == nil {
523 return ""
524 }
525 return *p
526}
527
528func s2aws(s string) *string { return &s }
529
530// ErrObjectGone is an object that vanished between the listing page and the
531// fetch. The sweep drops it rather than counting it: it is neither covered nor
532// a gap, it simply is not there any more.
533var ErrObjectGone = errors.New("objstore: object no longer exists")
534
535// deniedCodes are the S3 and KMS answers that mean "not allowed, and a retry
536// will not change that". Matched on the API error code rather than the message
537// so a wording change upstream does not silently reclassify them as failures.
538var deniedCodes = map[string]bool{
539 "AccessDenied": true,
540 "AccessDeniedException": true, // KMS, on an SSE-KMS object
541 "Forbidden": true,
542 "AllAccessDisabled": true,
543 "KMS.AccessDeniedException": true,
544 "InvalidObjectState": true, // GLACIER / DEEP_ARCHIVE: unreadable until restored
545}
546
547func isDenied(err error) bool {
548 var ae smithy.APIError
549 if errors.As(err, &ae) {
550 return deniedCodes[ae.ErrorCode()]
551 }
552 return false
553}
554
555// isGone is an object that is not there — deleted between the listing and the
556// fetch, which happens routinely on a bucket in use.
557func isGone(err error) bool {
558 var nsk *s3types.NoSuchKey
559 if errors.As(err, &nsk) {
560 return true
561 }
562 var ae smithy.APIError
563 if errors.As(err, &ae) {
564 code := ae.ErrorCode()
565 return code == "NoSuchKey" || code == "NotFound"
566 }
567 return false
568}
569