fix: recover from panics while extracting an archive

sub-pipeline catalogers were already run under `runTaskSafely`, but extraction wasn't: the zip, tar and compressor decoders run on untrusted bytes, and a panic in one unwound the whole walk and failed `CreateSBOM` with "failed to run tasks".

- `processArchive` recovers and returns the panic (with its stack) as an error at the archive's coordinates, so it becomes an unknown and the sibling archives are still cataloged
- `Extract` now defers `Cleanup` on failure instead of calling it on each error path, so a panic no longer leaks the limiter charge or the spill file

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-24 11:28:26 -04:00
parent f5bac4a026
commit c9b4f9c085
4 changed files with 85 additions and 4 deletions
+9 -3
View File
@@ -28,9 +28,16 @@ func Extract(ctx context.Context, r io.Reader, fileSystemID, archivePath string,
charge := limiter.charge()
resolver := newResolver(ctx, fileSystemID, archivePath, charge)
// deferred so that a panic in a decoder still gives back the charge and removes any spill file
succeeded := false
defer func() {
if !succeeded {
resolver.Cleanup()
}
}()
content, release, err := resolver.acquire(r)
if err != nil {
resolver.Cleanup()
return nil, err
}
// the archive's own bytes are needed only until they are extracted and digested
@@ -38,15 +45,14 @@ func Extract(ctx context.Context, r io.Reader, fileSystemID, archivePath string,
format := identifyFormat(ctx, archiveFileName(archivePath), content)
if format == nil {
resolver.Cleanup()
return nil, nil
}
if err := resolver.extract(ctx, format, content, exclusions); err != nil {
resolver.Cleanup()
return nil, fmt.Errorf("unable to extract archive %q: %w", archivePath, err)
}
resolver.Digests = digestsOf(ctx, content, archivePath)
succeeded = true
return resolver, nil
}
+28
View File
@@ -339,6 +339,34 @@ func TestExtract_heldContentWrittenToDiskStaysUntilCleanup(t *testing.T) {
assert.Zero(t, disk)
}
func TestExtract_aPanicStillReleasesEverything(t *testing.T) {
// a decoder panicking on hostile bytes must not leak the charge or the spill file
ctx, root := scanContext(t)
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
require.Panics(t, func() {
_, _ = Extract(ctx, &panicsAfter{n: 4096}, "", "app.zip", limiter, nil)
})
mem, disk := limiter.InUse()
assert.Zero(t, mem)
assert.Zero(t, disk)
assert.Empty(t, filesIn(t, root))
}
// panicsAfter hands out n zero bytes, then panics.
type panicsAfter struct{ n int }
func (p *panicsAfter) Read(b []byte) (int, error) {
if p.n <= 0 {
panic("decoder blew up")
}
n := min(len(b), p.n)
clear(b[:n])
p.n -= n
return n, nil
}
func TestExtract_archiveBytesThatCannotBePlacedAreSkipped(t *testing.T) {
data := zipBytes(t, sampleFiles)
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0})