fix: a damaged entry truncates the archive instead of failing it

a read error while copying one entry (a zip CRC mismatch, a corrupt deflate stream) was returned from `add`. That aborted the walk, and `Extract` threw away every entry already stored. So a large war with one damaged, irrelevant entry produced zero packages with the feature on, while java's legacy path (which reads only the entries it needs) still cataloged it.

- a read error on one entry now drops that entry and marks the resolver truncated (`entry "x" could not be read: ...`), and the walk continues
- if the stream itself breaks partway (a truncated `.tar.gz`), whatever was read before the break is kept and the archive is truncated. With nothing read it is still an error, as before
- disk limit and decompression budget errors still stop the walk

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-24 13:28:53 -04:00
parent 14bae1a0d9
commit 0152e5b59c
4 changed files with 44 additions and 22 deletions
+14 -19
View File
@@ -221,34 +221,29 @@ func Test_archiveCataloger_truncationStillCatalogs(t *testing.T) {
assert.Contains(t, reasons[0], ArchiveCatalogerTaskName, "the unknown names the task that recorded it")
}
func Test_archiveCataloger_failureIsRecordedAndSkipsSubPipeline(t *testing.T) {
func Test_archiveCataloger_aDamagedEntryCostsOnlyThatEntry(t *testing.T) {
rootDir := t.TempDir()
// entry data overwritten but header and end-of-central-directory intact: detected as an archive,
// fails when the entry is read
corrupt := makeZip(t, map[string][]byte{"data.bin": incompressible(4096)})
// entry data overwritten but headers and end-of-central-directory intact: detected as an archive,
// and data.bin fails when it is read. ok.txt comes after it and is intact.
corrupt := makeZip(t, map[string][]byte{"data.bin": incompressible(4096), "ok.txt": []byte("ok")})
require.Greater(t, len(corrupt), 1024)
for i := 100; i < 600; i++ {
corrupt[i] ^= 0xff
}
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "corrupt.zip"), corrupt, 0o600))
goodZip := makeZip(t, map[string][]byte{"ok.txt": []byte("ok")})
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "good.zip"), goodZip, 0o600))
var seen []string
fileCounts := map[string]int{}
tsk := newTestTask(t, cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), capturingTask(t, &seen, &fileCounts))
var ran int
tsk := newTestTask(t, cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), countingTask(&ran))
s := newTestSBOM()
require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)))
err := tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(newTestSBOM()))
// a coordinate error is what executor.go pulls out into sbom.Artifacts.Unknowns; a plain error would
// fail the scan instead
require.Error(t, err)
coordErrs, remaining := unknown.ExtractCoordinateErrors(err)
assert.NoError(t, remaining, "nothing may escape as a non-coordinate error")
require.Len(t, coordErrs, 1)
assert.Equal(t, "/corrupt.zip", coordErrs[0].Coordinates.RealPath)
assert.Equal(t, 1, ran, "the sub-pipeline must still run for the archive that extracted cleanly")
assert.Equal(t, []string{"/corrupt.zip"}, seen, "the archive is still cataloged")
assert.Equal(t, 1, fileCounts["/corrupt.zip"], "from the entries that could be read")
reasons := s.Artifacts.Unknowns[file.Coordinates{RealPath: "/corrupt.zip"}]
require.Len(t, reasons, 1)
assert.Contains(t, reasons[0], `entry "data.bin" could not be read`)
}
func Test_NewArchiveCatalogerTask_gating(t *testing.T) {