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
+28
View File
@@ -314,6 +314,34 @@ func TestResolver_extract_skippedCompressedTarBytesSpendTheBudget(t *testing.T)
}
}
// a body behind a header that doesn't read as a regular file (here a socket per its mode bits) is never
// opened, but the tar reader still decompresses it to reach the next header
func TestResolver_extract_bodiesOfNonRegularTarEntriesSpendTheBudget(t *testing.T) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
body := strings.Repeat("\x00", 4<<20)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "sock", Typeflag: tar.TypeReg, Mode: 0o140644, Size: int64(len(body))}))
_, err := tw.Write([]byte(body))
require.NoError(t, err)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "after.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 5}))
_, err = tw.Write([]byte("after"))
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
r, _ := resolverIn(t, memCharge(1<<30))
r.budget = NewBudget(1 << 20)
content := bytes.NewReader(buf.Bytes())
format, err := identifyFormat(context.Background(), "test.tar.gz", content)
require.NoError(t, err)
require.NoError(t, r.extract(context.Background(), format, content, nil))
assert.ErrorIs(t, r.Truncated, errBudgetSpent)
assert.Empty(t, storedNames(t, r))
assert.Zero(t, r.budget.remaining)
}
// a zip or a plain tar seeks past what it skips, so an excluded entry there costs nothing
func TestResolver_extract_skippedSeekableBytesAreFree(t *testing.T) {
entries := []testEntry{{name: "bomb.bin", body: strings.Repeat("\x00", 4<<20)}, {name: "after.txt", body: "after"}}