fix: charge the budget for compressed tar bytes that are skipped, not just kept

The tree decompression budget was only spent as entry content was stored. A compressed tar reader
still decompresses whatever an entry leaves unread to reach the next header, so an excluded entry, a
file/directory clash, or the rest of one over the 2 GB per-entry cap decompressed with nothing charged
and no ctx check. A small `.tar.zst` could force hundreds of GB of work past the budget.

- in compressed tars the rest of every regular entry is now drained through the budget, with ctx
  checked per chunk
- zip and plain tar are unchanged: they seek past skipped entries, so those cost nothing

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-21 11:41:32 -04:00
parent 2fa7282abc
commit 432f9b0803
3 changed files with 102 additions and 4 deletions
+22
View File
@@ -2,6 +2,7 @@ package archive
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -61,6 +62,27 @@ func (r *Resolver) put(b *blob, content io.Reader) error {
return nil
}
// drain reads the rest of a streaming entry without keeping it, charging the decompression budget for
// every byte, so skipping an entry is not a way around the budget. A read error ends the drain quietly;
// the archive reader reports a broken stream at the next entry.
func (r *Resolver) drain(ctx context.Context, content io.Reader) error {
if r.chunk == nil {
r.chunk = make([]byte, copyChunkSize)
}
for {
if err := ctx.Err(); err != nil {
return err
}
n, err := content.Read(r.chunk)
if n > 0 && !r.budget.take(int64(n)) {
return errBudgetSpent
}
if err != nil {
return nil
}
}
}
// write appends p to b: into memory while the memory limit admits it, otherwise into the spill file
// after everything held in memory is moved there first.
func (r *Resolver) write(b *blob, p []byte) error {