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 <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2026-08-13 15:07:05 -04:00
parent 2293641e3b
commit def9bc2e16
No known key found for this signature in database
2 changed files with 301 additions and 61 deletions

View File

@ -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 {

View File

@ -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)
}