From def9bc2e16b834198abc1d33c5a40f362a3882bd Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 13 Aug 2026 15:07:05 -0400 Subject: [PATCH] fix(java): bound user-controlled reads in the graalvm native-image cataloger Sizes, offsets and lengths in a native-image binary all come from the file being parsed, and several were acted on directly. The PE export directory sized a `make([]byte, Size)` from a uint32 in the optional header, so an 8KB file claiming 4GB reserved 4GB before reading a byte. It now reads what the file actually holds, bounded by the declared size, and still requires the whole directory to be present. This also fixes a latent bug: `unionreader.readerAtAdapter.ReadAt` can return a short read with a nil error, and the old code discarded the count, so squashfs-sourced binaries could parse zero padding as export data. The bounds checks in `decompressSbom` and the PE export walk validated only the end of a range, computed by adding to a value out of the file. Those sums wrap, and a wrapped sum compares as in range while the slice that follows it panics. They now subtract from the known-good length instead. The three `address - sectionBase` subtractions are unsigned and underflowed on an address below the base; they share one guarded helper now. The embedded SBOM decompresses through an `io.LimitedReader`, since the compressed bytes are bounded by the file but what they expand to is not. The limit is checked before the decoder's own error, which would otherwise report a size problem as "not a cyclonedx json document". Hitting it is logged, since the caller reports parse failures at trace level and a dropped SBOM would otherwise go unnoticed. Signed-off-by: Alex Goodman --- .../java/graalvm_native_image_cataloger.go | 173 ++++++++++------ .../graalvm_native_image_cataloger_test.go | 189 ++++++++++++++++++ 2 files changed, 301 insertions(+), 61 deletions(-) diff --git a/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go b/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go index c9611b9da..6db6059a2 100644 --- a/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go +++ b/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go @@ -82,6 +82,17 @@ const nativeImageMissingSymbolsError = "one or more symbols are missing from the const nativeImageInvalidIndexError = "parsing the executable file generated an invalid index" const nativeImageMissingExportedDataDirectoryError = "exported data directory is missing" +// nativeImageMaxDecompressedSbomSize bounds how far the embedded SBOM may decompress. +// +// A native-image SBOM enumerating thousands of Java dependencies comes to single-digit megabytes (20k +// components is ~4MB, see TestDecompressSbom_AcceptsLargeSbom), so this is a ~20x margin over anything +// real and should never truncate a genuine SBOM. Truncating one silently drops packages, so the margin +// is deliberate, but the ceiling is not free either: the decoder buffers the whole stream before it can +// identify it, and io.ReadAll's growth makes the real peak roughly double this. It has to bound the +// decompressed stream rather than the compressed bytes, since gzip will happily turn a few kilobytes +// into gigabytes. Hitting it is logged, because the packages are dropped either way. +const nativeImageMaxDecompressedSbomSize = 100 * 1024 * 1024 + // NewNativeImageCataloger returns a new Native Image cataloger object. func NewNativeImageCataloger() pkg.Cataloger { return &nativeImageCataloger{} @@ -94,34 +105,48 @@ func (c *nativeImageCataloger) Name() string { // decompressSbom returns the packages given within a native image executable's SBOM. func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg.Package, []artifact.Relationship, error) { - lengthEnd := lengthStart + 8 - bufLen := len(dataBuf) - if lengthEnd > uint64(bufLen) { + return decompressSbomWithLimit(dataBuf, sbomStart, lengthStart, nativeImageMaxDecompressedSbomSize) +} + +// decompressSbomWithLimit is decompressSbom with the decompressed-size bound passed in, so that a test +// can exercise the bound without first having to produce the hundreds of megabytes it takes to trip the +// real one. Production always goes through decompressSbom. +// +// Both offsets and the stored length come from the binary being parsed, so every bound below subtracts +// from the known-good buffer length rather than adding to a user-controlled value: `start+n` wraps, and a +// wrapped sum compares as in-range while the slice that follows it panics. +func decompressSbomWithLimit(dataBuf []byte, sbomStart, lengthStart uint64, maxDecompressed int64) ([]pkg.Package, []artifact.Relationship, error) { + bufLen := uint64(len(dataBuf)) + if lengthStart > bufLen || bufLen-lengthStart < 8 { return nil, nil, errors.New("the 'sbom_length' symbol overflows the binary") } - length := dataBuf[lengthStart:lengthEnd] - p := bytes.NewBuffer(length) - var storedLength uint64 - err := binary.Read(p, binary.LittleEndian, &storedLength) - if err != nil { - return nil, nil, fmt.Errorf("could not read from binary file: %w", err) - } + storedLength := binary.LittleEndian.Uint64(dataBuf[lengthStart : lengthStart+8]) log.WithFields("len", storedLength).Trace("found java native-image SBOM") - sbomEnd := sbomStart + storedLength - if sbomEnd > uint64(bufLen) { + if sbomStart > bufLen || storedLength > bufLen-sbomStart { return nil, nil, errors.New("the sbom symbol overflows the binary") } - sbomCompressed := dataBuf[sbomStart:sbomEnd] - p = bytes.NewBuffer(sbomCompressed) - gzreader, err := gzip.NewReader(p) + sbomCompressed := dataBuf[sbomStart : sbomStart+storedLength] + gzreader, err := gzip.NewReader(bytes.NewBuffer(sbomCompressed)) if err != nil { return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err) } - sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(gzreader) + // sbomCompressed is bounded by the file, but the decompressed stream is not, so bound it too. The + // decoder buffers everything it is handed before it can even tell whether the payload is CycloneDX, + // so a payload that is not an SBOM at all still costs whatever we allow here. + limited := &io.LimitedReader{R: gzreader, N: maxDecompressed + 1} + + sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(limited) + // checked before err, since hitting the bound surfaces as an unhelpful "not a cyclonedx json + // document" from the decoder rather than as anything about size + if limited.N == 0 { + log.WithFields("limit", maxDecompressed, "compressed", len(sbomCompressed)). + Debug("java native-image SBOM decompresses past the size limit; skipping it") + return nil, nil, fmt.Errorf("the java native-image SBOM decompresses past %d bytes", maxDecompressed) + } if err != nil { return nil, nil, fmt.Errorf("could not unmarshal the java native-image SBOM: %w", err) } @@ -132,6 +157,16 @@ func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg return pkgs, sbom.Relationships, nil } +// symbolOffsets converts the SBOM symbol addresses into offsets within the section data that should hold +// them. Both addresses come from the binary, and the subtraction is unsigned, so an address below the +// section base underflows into a huge offset instead of a negative one. +func symbolOffsets(sbomAddr, lengthAddr, sectionBase uint64) (sbomOffset uint64, lengthOffset uint64, err error) { + if sbomAddr < sectionBase || lengthAddr < sectionBase { + return 0, 0, errors.New("an SBOM symbol precedes the section that should contain it") + } + return sbomAddr - sectionBase, lengthAddr - sectionBase, nil +} + // fileError logs an error message when an executable cannot be read. func fileError(filename string, err error) (nativeImage, error) { // We could not read the file as a binary for the desired platform, but it may still be a native-image executable. @@ -210,11 +245,22 @@ func newPE(filename string, r io.ReaderAt) (nativeImage, error) { return fileError(filename, errors.New(nativeImageMissingExportedDataDirectoryError)) } exportSymbolsOffset := uint64(exportSymbolsDataDirectory.VirtualAddress) - exports := make([]byte, exportSymbolsDataDirectory.Size) - _, err = r.ReadAt(exports, int64(exportSymbolsOffset)) + // Size is a user-controlled uint32 from the PE optional header, so sizing the buffer from it up + // front lets a small file reserve up to 4GB. Reading grows the buffer to what the file actually holds + // instead, bounded by the declared size, and the length check below still requires the whole directory + // to be present rather than accepting a truncated read. The io.LimitReader is redundant with the + // SectionReader, which already stops at exportSize, but the ruleguard rule in test/rules/rules.go + // matches on the text of the io.ReadAll argument and cannot see that; removing it fails lint. + exportSize := int64(exportSymbolsDataDirectory.Size) + sectionReader := io.NewSectionReader(r, int64(exportSymbolsOffset), exportSize) + exports, err := io.ReadAll(io.LimitReader(sectionReader, exportSize)) if err != nil { return fileError(filename, fmt.Errorf("could not read the exported symbols data directory: %w", err)) } + if int64(len(exports)) != exportSize { + return fileError(filename, fmt.Errorf("exported symbols data directory is truncated: got %d of %d bytes", + len(exports), exportSize)) + } return nativeImagePE{ file: bi, reader: r, @@ -273,15 +319,16 @@ func (ni nativeImageElf) fetchPkgs() (pkgs []pkg.Package, relationships []artifa } dataSection := bi.Section(".data") if dataSection == nil { - return nil, nil, fmt.Errorf("no .data section found in binary: %w", err) + return nil, nil, errors.New("no .data section found in binary") } - dataSectionBase := dataSection.Addr data, err := dataSection.Data() if err != nil { return nil, nil, fmt.Errorf("cannot read the .data section: %w", err) } - sbomLocation := sbom.Value - dataSectionBase - lengthLocation := sbomLength.Value - dataSectionBase + sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSection.Addr) + if err != nil { + return nil, nil, err + } return decompressSbom(data, sbomLocation, lengthLocation) } @@ -352,48 +399,39 @@ func (ni nativeImageMachO) fetchPkgs() (pkgs []pkg.Package, relationships []arti log.Tracef("cannot obtain buffer from data segment") return nil, nil, nil } - sbomLocation := sbom.Value - dataSegment.Addr - lengthLocation := sbomLength.Value - dataSegment.Addr + sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSegment.Addr) + if err != nil { + return nil, nil, err + } return decompressSbom(dataBuf, sbomLocation, lengthLocation) } // fetchExportAttribute obtains an attribute from the exported symbols directory entry. func (ni nativeImagePE) fetchExportAttribute(i int) (uint32, error) { - var attribute uint32 n := len(ni.exports) + // i is only ever 0-3, so this arithmetic cannot overflow; the bound is > rather than >= because an + // attribute ending flush with the directory is still entirely present j := int(unsafe.Sizeof(ni.header)) + i*int(unsafe.Sizeof(ni.t.headerAttribute)) - if j+4 >= n { + if j+4 > n { log.Tracef("invalid index to export directory entry attribute: %v", j) return uint32(0), errors.New(nativeImageInvalidIndexError) } - p := bytes.NewBuffer(ni.exports[j : j+4]) - err := binary.Read(p, binary.LittleEndian, &attribute) - if err != nil { - log.Tracef("error fetching export directory entry attribute: %v", err) - return uint32(0), err - } - return attribute, nil + return binary.LittleEndian.Uint32(ni.exports[j : j+4]), nil } // fetchExportFunctionPointer obtains a function pointer from the exported symbols directory entry. func (ni nativeImagePE) fetchExportFunctionPointer(functionsBase uint32, i uint32) (uint32, error) { - var pointer uint32 - - n := uint32(len(ni.exports)) - sz := uint32(unsafe.Sizeof(ni.t.functionPointer)) - j := functionsBase + i*sz - if j+sz >= n { + // functionsBase derives from a file-controlled RVA, so widen to uint64 before indexing: in uint32 the + // sum wraps to a small value that passes the bound and then panics on the slice + n := uint64(len(ni.exports)) + sz := uint64(unsafe.Sizeof(ni.t.functionPointer)) + j := uint64(functionsBase) + uint64(i)*sz + if j > n || n-j < sz { log.Tracef("invalid index to exported function: %v", j) return uint32(0), errors.New(nativeImageInvalidIndexError) } - p := bytes.NewBuffer(ni.exports[j : j+sz]) - err := binary.Read(p, binary.LittleEndian, &pointer) - if err != nil { - log.Tracef("error fetching exported function: %v", err) - return uint32(0), err - } - return pointer, nil + return binary.LittleEndian.Uint32(ni.exports[j : j+sz]), nil } // fetchExportContent obtains the content of the export directory entry relevant to the SBOM. @@ -425,27 +463,31 @@ func (ni nativeImagePE) fetchSbomSymbols(content *exportContentPE) { sbomBytes := []byte(nativeImageSbomSymbol + "\x00") sbomLengthBytes := []byte(nativeImageSbomLengthSymbol + "\x00") svmVersionInfoBytes := []byte(nativeImageSbomVersionSymbol + "\x00") - n := uint32(len(ni.exports)) + n := uint64(len(ni.exports)) + sz := uint64(unsafe.Sizeof(ni.t.namePointer)) + + // the name array must start inside the directory we read; an RVA below it would underflow the + // subtraction into a huge offset that then wraps back into range on the bound below + if content.addressOfNames < ni.exportSymbols.VirtualAddress { + log.Tracef("exported name array precedes the export directory: %v", content.addressOfNames) + return + } + addressBase := uint64(content.addressOfNames - ni.exportSymbols.VirtualAddress) // Find SBOM, SBOM Length, and SVM Version Symbol for i := uint32(0); i < content.numberOfNames; i++ { - j := i * uint32(unsafe.Sizeof(ni.t.namePointer)) - addressBase := content.addressOfNames - ni.exportSymbols.VirtualAddress - k := addressBase + j - sz := uint32(unsafe.Sizeof(ni.t.namePointer)) - if k+sz >= n { + k := addressBase + uint64(i)*sz + if k > n || n-k < sz { log.Tracef("invalid index to exported function: %v", k) // If we are at the end of exports, stop looking return } - var symbolAddress uint32 - p := bytes.NewBuffer(ni.exports[k : k+sz]) - err := binary.Read(p, binary.LittleEndian, &symbolAddress) - if err != nil { - log.Tracef("error fetching address of symbol %v", err) + symbolAddress := binary.LittleEndian.Uint32(ni.exports[k : k+sz]) + if symbolAddress < ni.exportSymbols.VirtualAddress { + log.Tracef("exported symbol precedes the export directory: %v", symbolAddress) return } - symbolBase := symbolAddress - ni.exportSymbols.VirtualAddress + symbolBase := uint64(symbolAddress - ni.exportSymbols.VirtualAddress) if symbolBase >= n { log.Tracef("invalid index to exported symbol: %v", symbolBase) return @@ -480,6 +522,9 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac if content.addressOfSbom == uint32(0) || content.addressOfSbomLength == uint32(0) || content.addressOfSvmVersion == uint32(0) { return nil, nil, errors.New(nativeImageMissingSymbolsError) } + if content.addressOfFunctions < ni.exportSymbols.VirtualAddress { + return nil, nil, errors.New("exported function array precedes the export directory") + } functionsBase := content.addressOfFunctions - ni.exportSymbols.VirtualAddress sbomOffset := content.addressOfSbom sbomAddress, err := ni.fetchExportFunctionPointer(functionsBase, sbomOffset) @@ -501,10 +546,13 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac log.Tracef("cannot obtain buffer from the java native-image .data section") return nil, nil, nil } - sbomLocation := sbomAddress - dataSection.VirtualAddress - lengthLocation := sbomLengthAddress - dataSection.VirtualAddress + sbomLocation, lengthLocation, err := symbolOffsets(uint64(sbomAddress), uint64(sbomLengthAddress), + uint64(dataSection.VirtualAddress)) + if err != nil { + return nil, nil, err + } - return decompressSbom(dataBuf, uint64(sbomLocation), uint64(lengthLocation)) + return decompressSbom(dataBuf, sbomLocation, lengthLocation) } // fetchPkgs provides the packages available in a UnionReader. @@ -525,6 +573,9 @@ func fetchPkgs(reader unionreader.UnionReader, location file.Location) ([]pkg.Pa for _, makeNativeImage := range imageFormats { ni, err := makeNativeImage(filename, r) if err != nil { + // this covers both "not this format" and a real rejection of a file that is one, and the + // latter drops every package the binary carries, so it cannot go unrecorded + log.WithFields("file", filename, "error", err).Trace("unable to read possible java native-image") continue } if ni == nil { diff --git a/syft/pkg/cataloger/java/graalvm_native_image_cataloger_test.go b/syft/pkg/cataloger/java/graalvm_native_image_cataloger_test.go index 504b6f16b..ef5cd366f 100644 --- a/syft/pkg/cataloger/java/graalvm_native_image_cataloger_test.go +++ b/syft/pkg/cataloger/java/graalvm_native_image_cataloger_test.go @@ -4,11 +4,13 @@ import ( "bufio" "bytes" "compress/gzip" + "crypto/sha256" "encoding/binary" "fmt" "io" "os" "path" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -265,3 +267,190 @@ func verifyRelationshipFields(t *testing.T, expected, actual []artifact.Relation require.Equal(t, expected[i].Type, actual[i].Type) } } + +// buildMinimalPE64 assembles the smallest PE64 that debug/pe will parse, with DataDirectory[0] (the +// export directory) set to the given RVA and size. totalSize is how large the resulting file is; the +// point of the fixture is that size can claim far more than that. +// +// newPE uses the directory's VirtualAddress directly as a file offset rather than translating it, so the +// fixture needs no sections and exportRVA doubles as the offset the read starts from. +func buildMinimalPE64(exportRVA, exportSize uint32, totalSize int) []byte { + const peHdrOff = 0x40 + buf := make([]byte, totalSize) + copy(buf, []byte{'M', 'Z'}) + binary.LittleEndian.PutUint32(buf[0x3c:], peHdrOff) + + p := buf[peHdrOff:] + copy(p, []byte{'P', 'E', 0, 0}) + // COFF file header + binary.LittleEndian.PutUint16(p[4:], 0x8664) // Machine = AMD64 + binary.LittleEndian.PutUint16(p[20:], 240) // SizeOfOptionalHeader + binary.LittleEndian.PutUint16(p[22:], 0x22) // Characteristics: executable image + + // optional header (PE32+) + oh := p[24:] + binary.LittleEndian.PutUint16(oh[0:], 0x20b) // Magic = PE32+ + binary.LittleEndian.PutUint32(oh[108:], 16) // NumberOfRvaAndSizes + // DataDirectory[0] = export table + binary.LittleEndian.PutUint32(oh[112:], exportRVA) + binary.LittleEndian.PutUint32(oh[116:], exportSize) + + return buf +} + +func TestNewPE_ExportDirectorySizeIsNotAllocatedUpFront(t *testing.T) { + // exportSymbolsDataDirectory.Size is a user-controlled uint32 from the PE optional header, and it used + // to size a make([]byte, Size) before the read, so an 8KB file claiming 4GB reserved 4GB. + data := buildMinimalPE64(0x1000, 0xFFFFFFFF, 8192) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + _, err := newPE("oversized-export-dir.exe", bytes.NewReader(data)) + runtime.ReadMemStats(&after) + + require.Error(t, err, "an export directory the file cannot satisfy must not read as successful") + require.Contains(t, err.Error(), "truncated") + + allocated := after.TotalAlloc - before.TotalAlloc + t.Logf("allocated %d bytes for a directory declaring %d", allocated, uint64(0xFFFFFFFF)) + require.Less(t, allocated, uint64(8*1024*1024), + "the declared size must not be reserved before the read confirms the bytes exist") +} + +func TestNewPE_ExportDirectoryWithinFileIsRead(t *testing.T) { + // the bound must not reject an export directory that really is present + data := buildMinimalPE64(0x1000, 64, 8192) + + ni, err := newPE("ok.exe", bytes.NewReader(data)) + require.NoError(t, err) + require.NotNil(t, ni) +} + +func TestDecompressSbom_RejectsOutOfRangeOffsets(t *testing.T) { + // every offset and length below is read out of the binary, so each has to be rejected rather than + // wrapped into a value that passes a bound and then panics on the slice that follows + tests := []struct { + name string + bufLen int + sbomStart uint64 + lengthStart uint64 + storedLength uint64 + }{ + { + name: "length offset wraps past the end of the buffer", + bufLen: 64, + lengthStart: ^uint64(0) - 3, // lengthStart+8 wraps to 4, which compares as in range + }, + { + name: "length offset starts beyond the buffer", + bufLen: 64, + lengthStart: 100, + }, + { + name: "stored length wraps past the end of the buffer", + bufLen: 64, + sbomStart: 16, + lengthStart: 0, + storedLength: ^uint64(0) - 3, // sbomStart+storedLength wraps to 12 + }, + { + name: "stored length runs past the end of the buffer", + bufLen: 64, + sbomStart: 16, + lengthStart: 0, + storedLength: 1000, + }, + { + name: "sbom offset starts beyond the buffer", + bufLen: 64, + sbomStart: ^uint64(0) - 3, + lengthStart: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dataBuf := make([]byte, tt.bufLen) + // subtract from the length rather than adding to the offset, for the same reason the code + // under test has to: tt.lengthStart is deliberately large enough to wrap + if tt.lengthStart <= uint64(tt.bufLen) && uint64(tt.bufLen)-tt.lengthStart >= 8 { + binary.LittleEndian.PutUint64(dataBuf[tt.lengthStart:], tt.storedLength) + } + + require.NotPanics(t, func() { + _, _, err := decompressSbom(dataBuf, tt.sbomStart, tt.lengthStart) + require.Error(t, err) + }) + }) + } +} + +func TestDecompressSbom_RejectsStreamExpandingPastTheLimit(t *testing.T) { + // the compressed bytes are bounded by the file, but what they expand to is not, and the decoder + // buffers the whole stream before it can tell whether the payload is even CycloneDX. Zeros stand in + // for any uniform, highly compressible payload: they expand about 1000x, far past what real JSON does. + // the bound is passed in rather than taken from nativeImageMaxDecompressedSbomSize, so that tripping + // it costs a megabyte instead of the several hundred the real limit would demand + const limit = 1024 * 1024 + const decompressedSize = limit + 1 + + var compressed bytes.Buffer + z := gzip.NewWriter(&compressed) + _, err := z.Write(make([]byte, decompressedSize)) + require.NoError(t, err) + require.NoError(t, z.Close()) + + t.Logf("%d compressed bytes expand to %d (%dx)", compressed.Len(), decompressedSize, + decompressedSize/compressed.Len()) + + dataBuf := append(compressed.Bytes(), make([]byte, 8)...) + lengthStart := uint64(compressed.Len()) + binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart) + + _, _, err = decompressSbomWithLimit(dataBuf, 0, lengthStart, limit) + require.Error(t, err) + require.Contains(t, err.Error(), "decompresses past", + "hitting the bound should say so, not report a parse failure") +} + +func TestDecompressSbom_AcceptsLargeSbom(t *testing.T) { + // guards the risk the size limit introduces: a genuinely large SBOM must still be cataloged. + // Components carry distinct names, versions, purls and hashes, so this is shaped like a real + // CycloneDX document rather than a uniform payload. + const components = 20000 + + var sb bytes.Buffer + sb.WriteString(`{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,"components":[`) + for i := 0; i < components; i++ { + if i > 0 { + sb.WriteString(",") + } + // a sha256-shaped value keeps the entropy (and so the compression ratio) realistic + hash := sha256.Sum256([]byte(fmt.Sprintf("component-%d", i))) + fmt.Fprintf(&sb, `{"type":"library","name":"lib-%d","version":"%d.%d.%d",`+ + `"purl":"pkg:maven/com.example.group%d/lib-%d@%d.%d.%d",`+ + `"hashes":[{"alg":"SHA-256","content":"%x"}]}`, + i, i%20, i%7, i%13, i%50, i, i%20, i%7, i%13, hash) + } + sb.WriteString(`]}`) + + raw := sb.Bytes() + var compressed bytes.Buffer + z := gzip.NewWriter(&compressed) + _, err := z.Write(raw) + require.NoError(t, err) + require.NoError(t, z.Close()) + + t.Logf("%d components: %d bytes of JSON, %d compressed (%dx), limit %d", + components, len(raw), compressed.Len(), len(raw)/compressed.Len(), nativeImageMaxDecompressedSbomSize) + require.Less(t, int64(len(raw)), int64(nativeImageMaxDecompressedSbomSize), + "a real SBOM of this size must sit well inside the limit, otherwise the limit is too low") + + dataBuf := append(compressed.Bytes(), make([]byte, 8)...) + lengthStart := uint64(compressed.Len()) + binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart) + + pkgs, _, err := decompressSbom(dataBuf, 0, lengthStart) + require.NoError(t, err, "a large SBOM at a realistic ratio must not be rejected") + require.Len(t, pkgs, components) +}