fix: charge the decompression budget at the decoder, not per entry

Draining skipped entries still left a hole: a tar entry whose header doesn't read as a regular file
(a socket or directory per its mode bits, say) can carry a body, and the tar reader decompresses it
to reach the next header without anything being charged or ctx being checked.

- compressed tars are now read through a reader that charges the budget for every byte the decoder
  produces and checks ctx per read, so no skip path inside the tar reader gets around it
- entry content from that stream isn't charged a second time, and the per-entry drain is gone
- zip and plain tar are unchanged

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-21 11:45:34 -04:00
parent c4c0907d4e
commit 36d417d52a
4 changed files with 65 additions and 40 deletions
+19 -22
View File
@@ -11,6 +11,7 @@ import (
"github.com/mholt/archives"
"github.com/anchore/syft/internal"
intFile "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/file"
@@ -139,21 +140,26 @@ func (r *Resolver) extract(ctx context.Context, format archives.Extractor, conte
if _, err := content.Seek(0, io.SeekStart); err != nil {
return err
}
// a compressed tar decompresses whatever an entry leaves unread to reach the next header, so there the
// bytes of skipped entries cost as much as kept ones. A zip or a plain tar seeks past them for free
_, drains := format.(archives.CompressedArchive)
err := format.Extract(ctx, content, func(_ context.Context, f archives.FileInfo) error {
hdr, ok := r.entryHeader(f)
skip := !ok || exclusions.Excludes(hdr.Name)
if !f.Mode().IsRegular() {
if skip {
return nil
}
return r.add(hdr, nil)
var source io.Reader = content
if ca, ok := format.(archives.CompressedArchive); ok && ca.Compression != nil {
// a compressed tar decompresses everything to reach each header, including entries skipped whole
// and whatever an entry leaves unread, so the budget is charged for every byte the decoder
// produces rather than for what is kept. A zip or a plain tar seeks past what it skips
rc, err := ca.OpenReader(content)
if err != nil {
return err
}
if skip && !drains {
defer internal.CloseAndLogError(rc, "decompressor")
source, format, r.metered = &meteredReader{ctx: ctx, reader: rc, budget: r.budget}, ca.Extraction, true
}
err := format.Extract(ctx, source, func(_ context.Context, f archives.FileInfo) error {
hdr, ok := r.entryHeader(f)
if !ok || exclusions.Excludes(hdr.Name) {
return nil
}
if !f.Mode().IsRegular() {
return r.add(hdr, nil)
}
entry, err := f.Open()
if err != nil {
log.WithFields("entry", hdr.Name, "error", err).Trace("unable to open archive entry, skipping it")
@@ -164,16 +170,7 @@ func (r *Resolver) extract(ctx context.Context, format archives.Extractor, conte
log.WithFields("entry", hdr.Name, "error", err).Trace("unable to close archive entry")
}
}()
if !skip {
if err := r.add(hdr, entry); err != nil {
return err
}
}
if !drains {
return nil
}
// what add left unread (the rest of an entry over the size cap, or one skipped whole) is charged here
return r.drain(ctx, entry)
return r.add(hdr, entry)
})
r.finish()
if errors.Is(err, ErrDiskLimitReached) {