diff --git a/syft/pkg/cataloger/ai/parse_gguf.go b/syft/pkg/cataloger/ai/parse_gguf.go index 9437cafe2..efcfc232d 100644 --- a/syft/pkg/cataloger/ai/parse_gguf.go +++ b/syft/pkg/cataloger/ai/parse_gguf.go @@ -1,9 +1,14 @@ package ai import ( + "bufio" "encoding/binary" + "errors" "fmt" "io" + "math" + "slices" + "strings" gguf_parser "github.com/gpustack/gguf-parser-go" ) @@ -12,12 +17,41 @@ import ( const ( ggufMagicNumber = 0x46554747 // "GGUF" in little-endian maxHeaderSize = 50 * 1024 * 1024 // 50MB for large tokenizer vocabularies + + // maxGGUFArrayDepth allows arrays of arrays but nothing deeper. + maxGGUFArrayDepth = 2 + // maxGGUFDecodedItems bounds the items, across the whole header, of arrays that gguf-parser-go + // always decodes in full. Real files hold a few per-layer arrays. + maxGGUFDecodedItems = 65536 + // maxGGUFSplitTensors bounds split.tensors.count, which the library preallocates tensor infos for. + // Shards declare the total across all splits, so this can't be the header tensor count. + maxGGUFSplitTensors = 1 << 17 + // maxGGUFKeyLength is the key length limit from the GGUF spec. + maxGGUFKeyLength = 65535 + // maxGGUFKVCount bounds the metadata KV count. Real files hold hundreds; vocabularies live in arrays. + maxGGUFKVCount = 65536 + // maxGGUFReadStringBytes bounds, across the header, the key and string bytes the library reads in + // full and syft then hashes and encodes (chat templates are the largest, at tens of KB). + maxGGUFReadStringBytes = 8 << 20 + // maxGGUFSkippedStrings bounds, across the header, the strings the library skips one at a time + // inside arrays (vocabularies run to a few hundred thousand tokens and merges). + maxGGUFSkippedStrings = 1 << 22 ) -// copyHeader copies the GGUF header from the reader to the writer. +// ggufDecodedArraySuffixes are the keys gguf-parser-go decodes in full even with SkipLargeMetadata +// (hardcoded in its ReadArray as of v0.26.3; re-check on upgrade). +var ggufDecodedArraySuffixes = []string{ + ".feed_forward_length", ".attention.head_count", ".attention.head_count_kv", ".attention.sliding_window_pattern", +} + +// ggufScalarSizes is the encoded size of each value type, indexed by type (0 for strings and arrays). +var ggufScalarSizes = [...]int{1, 1, 2, 2, 4, 4, 4, 1, 0, 0, 8, 8, 8} + +// copyHeader copies at most maxHeaderSize bytes of the GGUF header from the reader to the writer. // It validates the magic number first, then copies the rest of the data. -// The reader should be wrapped with io.LimitedReader to prevent OOM issues. func copyHeader(w io.Writer, r io.Reader) error { + r = io.LimitReader(r, maxHeaderSize) + // Read initial chunk to validate magic number // GGUF format: magic(4) + version(4) + tensor_count(8) + metadata_kv_count(8) + metadata_kvs + tensors_info initialBuf := make([]byte, 24) // Enough for magic, version, tensor count, and kv count @@ -37,7 +71,6 @@ func copyHeader(w io.Writer, r io.Reader) error { } // Copy the rest of the header from reader to writer - // The LimitedReader will return EOF once maxHeaderSize is reached if _, err := io.Copy(w, r); err != nil { return fmt.Errorf("failed to copy GGUF header: %w", err) } @@ -45,6 +78,268 @@ func copyHeader(w io.Writer, r io.Reader) error { return nil } +// validateGGUFHeader walks the metadata KV section and rejects values that gguf-parser-go would panic +// on, recurse on without bound, or overallocate for. It reads without storing anything. +// ponytail: tensor infos are not checked here (zero block size types, dimension overflow); those +// panics are recoverable and are left to the per-file panic recovery in the generic cataloger. +func validateGGUFHeader(r io.Reader) error { + v := &ggufHeaderValidator{ + r: bufio.NewReader(r), + decodedBudget: maxGGUFDecodedItems, + readStringBudget: maxGGUFReadStringBytes, + skippedStringBudget: maxGGUFSkippedStrings, + } + var prefix struct { + Magic, Version uint32 + } + if err := binary.Read(v.r, binary.LittleEndian, &prefix); err != nil { + return fmt.Errorf("reading header prefix: %w", err) + } + v.v1 = prefix.Version <= 1 + // the tensor count is validated by the library + if _, err := v.readLen(); err != nil { + return fmt.Errorf("reading tensor count: %w", err) + } + kvCount, err := v.readLen() + if err != nil { + return fmt.Errorf("reading metadata count: %w", err) + } + if kvCount > maxGGUFKVCount { + return fmt.Errorf("metadata count %d exceeds %d", kvCount, maxGGUFKVCount) + } + + for i := uint64(0); i < kvCount; i++ { + key, err := v.readKey() + if err != nil { + return fmt.Errorf("metadata key %d: %w", i, err) + } + var vt uint32 + if err := binary.Read(v.r, binary.LittleEndian, &vt); err != nil { + return fmt.Errorf("metadata key %q: %w", key, err) + } + if err := v.checkKey(key, gguf_parser.GGUFMetadataValueType(vt)); err != nil { + return fmt.Errorf("metadata key %q: %w", key, err) + } + } + return nil +} + +type ggufHeaderValidator struct { + r *bufio.Reader + v1 bool // v1 uses 32-bit lengths + decodedBudget uint64 + readStringBudget uint64 + skippedStringBudget uint64 +} + +func (v *ggufHeaderValidator) checkKey(key string, vt gguf_parser.GGUFMetadataValueType) error { + decoded := slices.ContainsFunc(ggufDecodedArraySuffixes, func(s string) bool { return strings.HasSuffix(key, s) }) + n, err := v.readValue(vt, decoded, 1) + if err != nil { + return err + } + + // these are read with typed accessors that panic on a type mismatch + switch key { + case "general.architecture", "general.name", "general.license", "general.type", "general.author", + "general.url", "general.description", "controlvector.model_hint": + if vt != gguf_parser.GGUFMetadataValueTypeString { + return fmt.Errorf("expected string, got %v", vt) + } + case "general.quantization_version", "general.file_type": + if !vt.IsNumeric() { + return fmt.Errorf("expected number, got %v", vt) + } + case "general.alignment": + if vt != gguf_parser.GGUFMetadataValueTypeUint32 || n == 0 { + return fmt.Errorf("expected non-zero uint32, got %v %v", vt, n) + } + case "split.tensors.count": + if !vt.IsNumeric() { + return fmt.Errorf("expected number, got %v", vt) + } + if math.IsNaN(n) || n < 0 || n > maxGGUFSplitTensors { + return fmt.Errorf("unreasonable split tensor count %.0f", n) + } + } + return nil +} + +// readValue consumes one value, returning scalar numbers as float64 for the key checks. +func (v *ggufHeaderValidator) readValue(vt gguf_parser.GGUFMetadataValueType, decoded bool, depth int) (float64, error) { + if uint32(vt) >= uint32(len(ggufScalarSizes)) { + return 0, fmt.Errorf("invalid value type %d", vt) + } + switch vt { + case gguf_parser.GGUFMetadataValueTypeString: + // only strings inside skipped arrays are skipped by the library, the rest are read in full + return 0, v.skipString(depth > 1 && !decoded) + case gguf_parser.GGUFMetadataValueTypeArray: + return 0, v.readArray(decoded, depth) + } + size := ggufScalarSizes[vt] + b, err := v.r.Peek(size) + if err != nil { + return 0, err + } + var buf [8]byte + copy(buf[:], b) + if _, err := v.r.Discard(size); err != nil { + return 0, err + } + u := binary.LittleEndian.Uint64(buf[:]) + switch vt { + case gguf_parser.GGUFMetadataValueTypeInt8: + return float64(int8(u)), nil + case gguf_parser.GGUFMetadataValueTypeInt16: + return float64(int16(u)), nil + case gguf_parser.GGUFMetadataValueTypeInt32: + return float64(int32(u)), nil + case gguf_parser.GGUFMetadataValueTypeInt64: + return float64(int64(u)), nil + case gguf_parser.GGUFMetadataValueTypeFloat32: + return float64(math.Float32frombits(uint32(u))), nil + case gguf_parser.GGUFMetadataValueTypeFloat64: + return math.Float64frombits(u), nil + } + return float64(u), nil +} + +func (v *ggufHeaderValidator) readArray(decoded bool, depth int) error { + var itemType uint32 + if err := binary.Read(v.r, binary.LittleEndian, &itemType); err != nil { + return err + } + n, err := v.readLen() + if err != nil { + return err + } + it := gguf_parser.GGUFMetadataValueType(itemType) + switch { + case itemType >= uint32(len(ggufScalarSizes)): + return fmt.Errorf("invalid array item type %d", itemType) + case decoded && n > v.decodedBudget: + return fmt.Errorf("decoded arrays exceed %d items", maxGGUFDecodedItems) + case it == gguf_parser.GGUFMetadataValueTypeArray && (!decoded || depth >= maxGGUFArrayDepth): + // the library can only skip over arrays of scalars + return errors.New("unsupported nested array") + case n > maxHeaderSize: + return fmt.Errorf("array length %d exceeds header size", n) + case !decoded && ggufScalarSizes[itemType] > 0: + // skipped arrays of fixed size items can be passed over in one go + _, err := v.r.Discard(int(n) * ggufScalarSizes[itemType]) + return err + } + if decoded { + v.decodedBudget -= n + } + for i := uint64(0); i < n; i++ { + if _, err := v.readValue(it, decoded, depth+1); err != nil { + return err + } + } + return nil +} + +func (v *ggufHeaderValidator) readLen() (uint64, error) { + if v.v1 { + var n uint32 + err := binary.Read(v.r, binary.LittleEndian, &n) + return uint64(n), err + } + var n uint64 + err := binary.Read(v.r, binary.LittleEndian, &n) + return n, err +} + +// readKey reads a key the way gguf-parser-go will see it, whitespace trimmed. +func (v *ggufHeaderValidator) readKey() (string, error) { + n, err := v.readLen() + if err != nil { + return "", err + } + if n == 0 || n > maxGGUFKeyLength { + return "", fmt.Errorf("invalid key length %d", n) + } + if err := v.spendReadString(n); err != nil { + return "", err + } + buf := make([]byte, n) + if _, err := io.ReadFull(v.r, buf); err != nil { + return "", err + } + return strings.TrimSpace(string(buf)), nil +} + +func (v *ggufHeaderValidator) skipString(allowEmpty bool) error { + n, err := v.readLen() + if err != nil { + return err + } + // gguf-parser-go reads an empty string as a full pool buffer, so everything after it would be + // parsed out of step with this walk + if n == 0 && !allowEmpty { + return errors.New("empty string") + } + if n > maxHeaderSize { + return fmt.Errorf("string length %d exceeds header size", n) + } + if allowEmpty { + if v.skippedStringBudget == 0 { + return fmt.Errorf("skipped strings exceed %d", maxGGUFSkippedStrings) + } + v.skippedStringBudget-- + } else if err := v.spendReadString(n); err != nil { + return err + } + _, err = v.r.Discard(int(n)) + return err +} + +func (v *ggufHeaderValidator) spendReadString(n uint64) error { + if n > v.readStringBudget { + return fmt.Errorf("strings exceed %d bytes", maxGGUFReadStringBytes) + } + v.readStringBudget -= n + return nil +} + +// sanitizeGGUFValue replaces non-finite floats, which JSON can not encode, leaving everything else as is. +func sanitizeGGUFValue(v any) any { + switch t := v.(type) { + case float32: + if f := float64(t); math.IsNaN(f) || math.IsInf(f, 0) { + return nonFiniteString(f) + } + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return nonFiniteString(t) + } + case gguf_parser.GGUFMetadataKVArrayValue: + // keep the library shape as is; items are only present for the few keys the library + // decodes in full, which the pre-scan keeps small and shallow + if len(t.Array) > 0 { + items := make([]any, len(t.Array)) + for i, item := range t.Array { + items[i] = sanitizeGGUFValue(item) + } + t.Array = items + } + return t + } + return v +} + +func nonFiniteString(f float64) string { + switch { + case math.IsInf(f, 1): + return "+Inf" + case math.IsInf(f, -1): + return "-Inf" + } + return "NaN" +} + // helper to convert gguf_parser metadata to simpler types func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any { result := make(map[string]any) @@ -56,7 +351,7 @@ func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any { "general.version", "general.parameter_count", "general.quantization": continue } - result[kv.Key] = kv.Value + result[kv.Key] = sanitizeGGUFValue(kv.Value) } return result diff --git a/syft/pkg/cataloger/ai/parse_gguf_model.go b/syft/pkg/cataloger/ai/parse_gguf_model.go index d879a1165..6b9a89259 100644 --- a/syft/pkg/cataloger/ai/parse_gguf_model.go +++ b/syft/pkg/cataloger/ai/parse_gguf_model.go @@ -13,7 +13,6 @@ import ( gguf_parser "github.com/gpustack/gguf-parser-go" "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" @@ -37,14 +36,20 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment defer cleanup() tempPath := tempFile.Name() - // Copy and validate the GGUF file header using LimitedReader to prevent OOM - // We use LimitedReader to cap reads at maxHeaderSize (50MB) - limitedReader := &io.LimitedReader{R: reader, N: maxHeaderSize} - if err := copyHeader(tempFile, limitedReader); err != nil { + // copy and validate the GGUF file header (bounded to maxHeaderSize) + if err := copyHeader(tempFile, reader); err != nil { tempFile.Close() return nil, nil, fmt.Errorf("failed to copy GGUF header: %w", err) } + if _, err := tempFile.Seek(0, io.SeekStart); err != nil { + tempFile.Close() + return nil, nil, fmt.Errorf("failed to rewind GGUF header: %w", err) + } + err = validateGGUFHeader(tempFile) tempFile.Close() + if err != nil { + return nil, nil, fmt.Errorf("invalid GGUF header: %w", err) + } // Parse using gguf-parser-go with options to skip unnecessary data ggufFile, err := gguf_parser.ParseGGUFFile(tempPath, @@ -60,6 +65,11 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment // Extract version separately (will be set on Package.Version) modelVersion := extractVersion(ggufFile.Header.MetadataKV) + metadataHash, err := computeKVMetadataHash(ggufFile.Header.MetadataKV) + if err != nil { + return nil, nil, err + } + // Convert to syft metadata structure syftMetadata := &pkg.GGUFFileHeader{ Architecture: metadata.Architecture, @@ -68,7 +78,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment GGUFVersion: uint32(ggufFile.Header.Version), TensorCount: ggufFile.Header.TensorCount, RemainingKeyValues: convertGGUFMetadataKVs(ggufFile.Header.MetadataKV), - MetadataKeyValuesHash: computeKVMetadataHash(ggufFile.Header.MetadataKV), + MetadataKeyValuesHash: metadataHash, } // If model name is not in metadata, use filename @@ -89,11 +99,14 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment return []pkg.Package{p}, nil, nil } -// computeKVMetadataHash computes a stable hash of the KV metadata for use as a global identifier -func computeKVMetadataHash(metadata gguf_parser.GGUFMetadataKVs) string { +// computeKVMetadataHash computes a stable hash of the sanitized KV metadata for use as a global identifier +func computeKVMetadataHash(metadata gguf_parser.GGUFMetadataKVs) (string, error) { // Sort the KV pairs by key for stable hashing sortedKVs := make([]gguf_parser.GGUFMetadataKV, len(metadata)) - copy(sortedKVs, metadata) + for i, kv := range metadata { + kv.Value = sanitizeGGUFValue(kv.Value) + sortedKVs[i] = kv + } sort.Slice(sortedKVs, func(i, j int) bool { return sortedKVs[i].Key < sortedKVs[j].Key }) @@ -101,13 +114,12 @@ func computeKVMetadataHash(metadata gguf_parser.GGUFMetadataKVs) string { // Marshal sorted KVs to JSON for stable hashing jsonBytes, err := json.Marshal(sortedKVs) if err != nil { - log.Debugf("failed to marshal metadata for hashing: %v", err) - return "" + return "", fmt.Errorf("failed to marshal GGUF metadata for hashing: %w", err) } // Compute xxhash hash := xxhash.Sum64(jsonBytes) - return fmt.Sprintf("%016x", hash) // 16 hex chars (64 bits) + return fmt.Sprintf("%016x", hash), nil // 16 hex chars (64 bits) } // extractVersion attempts to extract version from metadata KV pairs diff --git a/syft/pkg/cataloger/ai/parse_gguf_test.go b/syft/pkg/cataloger/ai/parse_gguf_test.go new file mode 100644 index 000000000..c5d8d5253 --- /dev/null +++ b/syft/pkg/cataloger/ai/parse_gguf_test.go @@ -0,0 +1,251 @@ +package ai + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "math" + "strings" + "testing" + + gguf_parser "github.com/gpustack/gguf-parser-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/tmpdir" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/format/syftjson" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/sbom" +) + +func le(v any) []byte { + buf := new(bytes.Buffer) + binary.Write(buf, binary.LittleEndian, v) + return buf.Bytes() +} + +func ggufString(s string) []byte { + return append(le(uint64(len(s))), s...) +} + +func parseGGUFBytes(t *testing.T, data []byte) ([]pkg.Package, error) { + t.Helper() + ctx, td := tmpdir.Root(context.Background(), "gguf-test") + t.Cleanup(func() { _ = td.Cleanup() }) + reader := file.NewLocationReadCloser(file.NewLocation("/model.gguf"), io.NopCloser(bytes.NewReader(data))) + var pkgs []pkg.Package + var err error + require.NotPanics(t, func() { + pkgs, _, err = parseGGUFModel(ctx, nil, nil, reader) + }) + return pkgs, err +} + +func TestParseGGUFModel_malformedHeader(t *testing.T) { + // nested arrays of arrays, each one level deeper, ending in an empty uint8 array + nested := func(depth int) []byte { + v := ggufArray(ggufTypeUint8, 0, nil) + for i := 1; i < depth; i++ { + v = ggufArray(ggufTypeArray, 1, v) + } + return v + } + + // a header that declares more KVs than the cap, with none following + tooManyKVs := append(append([]byte("GGUF"), le(uint32(3))...), le([]uint64{0, maxGGUFKVCount + 1})...) + + tests := []struct { + name string + builder *testGGUFBuilder + data []byte // used instead of builder when set + wantErr bool + }{ + {name: "too many KVs", data: tooManyKVs, wantErr: true}, + {name: "split tensor count is a string", builder: newTestGGUFBuilder().withStringKV("split.tensors.count", "1"), wantErr: true}, + {name: "split tensor count over the cap", builder: newTestGGUFBuilder().withUint32KV("split.tensors.count", maxGGUFSplitTensors+1), wantErr: true}, + {name: "read strings too long in total", builder: newTestGGUFBuilder().withStringKV("x", strings.Repeat("a", maxGGUFReadStringBytes)), wantErr: true}, + { + name: "skipped strings too many in total", + builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.tokens", ggufTypeArray, ggufArray(ggufTypeString, maxGGUFSkippedStrings+1, make([]byte, 8*(maxGGUFSkippedStrings+1)))), + wantErr: true, + }, + {name: "v1 header", builder: newTestGGUFBuilder().withVersion(1).withStringKV("general.name", "m").withUint32KV("llama.context_length", 8)}, + {name: "architecture not a string", builder: newTestGGUFBuilder().withUint32KV("general.architecture", 1), wantErr: true}, + {name: "name not a string", builder: newTestGGUFBuilder().withUint32KV("general.name", 1), wantErr: true}, + {name: "license not a string", builder: newTestGGUFBuilder().withUint32KV("general.license", 1), wantErr: true}, + {name: "type not a string", builder: newTestGGUFBuilder().withUint32KV("general.type", 1), wantErr: true}, + {name: "author not a string", builder: newTestGGUFBuilder().withUint32KV("general.author", 1), wantErr: true}, + {name: "url not a string", builder: newTestGGUFBuilder().withUint32KV("general.url", 1), wantErr: true}, + {name: "description not a string", builder: newTestGGUFBuilder().withUint32KV("general.description", 1), wantErr: true}, + {name: "model hint not a string", builder: newTestGGUFBuilder().withUint32KV("controlvector.model_hint", 1), wantErr: true}, + {name: "quantization version is a string", builder: newTestGGUFBuilder().withStringKV("general.quantization_version", "2"), wantErr: true}, + {name: "alignment is a string", builder: newTestGGUFBuilder().withStringKV("general.alignment", "32"), wantErr: true}, + {name: "alignment is uint64", builder: newTestGGUFBuilder().withUint64KV("general.alignment", 32), wantErr: true}, + {name: "alignment is zero", builder: newTestGGUFBuilder().withUint32KV("general.alignment", 0), wantErr: true}, + { + name: "array of arrays on a skipped key", + builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.merges", ggufTypeArray, ggufArray(ggufTypeArray, 1, ggufArray(ggufTypeUint8, 0, nil))), + wantErr: true, + }, + { + name: "invalid array item type", + builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.scores", ggufTypeArray, ggufArray(99, 0, nil)), + wantErr: true, + }, + {name: "split tensor count out of range", builder: newTestGGUFBuilder().withUint64KV("split.tensors.count", 1<<62), wantErr: true}, + {name: "split tensor count huge", builder: newTestGGUFBuilder().withUint64KV("split.tensors.count", 1<<40), wantErr: true}, + {name: "split tensor count negative", builder: newTestGGUFBuilder().withRawKV("split.tensors.count", ggufTypeInt64, le(int64(-1))), wantErr: true}, + { + name: "nesting too deep on a decoded key", + builder: newTestGGUFBuilder().withRawKV("llama.attention.head_count", ggufTypeArray, nested(3)), + wantErr: true, + }, + { + name: "decoded array too long", + builder: newTestGGUFBuilder().withRawKV("llama.feed_forward_length", ggufTypeArray, ggufArray(ggufTypeUint8, maxGGUFDecodedItems+1, make([]byte, maxGGUFDecodedItems+1))), + wantErr: true, + }, + {name: "empty string value", builder: newTestGGUFBuilder().withStringKV("general.author", ""), wantErr: true}, + {name: "padded key", builder: newTestGGUFBuilder().withUint32KV(" general.alignment\t", 0), wantErr: true}, + {name: "long padded key", builder: newTestGGUFBuilder().withUint32KV(strings.Repeat(" ", 300)+"general.alignment", 0), wantErr: true}, + {name: "file type is a string", builder: newTestGGUFBuilder().withUint32KV("general.file_type", 1).withStringKV("general.file_type", "x"), wantErr: true}, + { + name: "decoded arrays too long in total", + builder: newTestGGUFBuilder(). + withRawKV("a.feed_forward_length", ggufTypeArray, ggufArray(ggufTypeUint8, maxGGUFDecodedItems, make([]byte, maxGGUFDecodedItems))). + withRawKV("b.feed_forward_length", ggufTypeArray, ggufArray(ggufTypeUint8, 1, make([]byte, 1))), + wantErr: true, + }, + {name: "invalid value type", builder: newTestGGUFBuilder().withRawKV("x", 0xFFFFFFFF, nil), wantErr: true}, + {name: "empty key", builder: newTestGGUFBuilder().withUint32KV("", 1), wantErr: true}, + { + name: "empty string in a decoded array", + builder: newTestGGUFBuilder().withRawKV("llama.attention.head_count", ggufTypeArray, ggufArray(ggufTypeString, 1, le(uint64(0)))), + wantErr: true, + }, + { + name: "empty string in a skipped array", + builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.tokens", ggufTypeArray, ggufArray(ggufTypeString, 1, le(uint64(0)))), + }, + { + name: "array of arrays on a decoded key", + builder: newTestGGUFBuilder().withRawKV("llama.attention.head_count", ggufTypeArray, nested(2)), + }, + {name: "alignment is a uint32", builder: newTestGGUFBuilder().withUint32KV("general.alignment", 32)}, + { + name: "split keys as written by llama.cpp", + builder: newTestGGUFBuilder(). + withRawKV("split.no", ggufTypeUint16, le(uint16(0))). + withRawKV("split.count", ggufTypeUint16, le(uint16(3))). + withRawKV("split.tensors.count", ggufTypeInt32, le(int32(3000))), + }, + { + name: "split shard declares the total tensor count", + builder: newTestGGUFBuilder().withRawKV("split.tensors.count", ggufTypeInt32, le(int32(3000))), + }, + { + name: "skipped scalar array", + builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.scores", ggufTypeArray, ggufArray(ggufTypeFloat32, 3, make([]byte, 12))), + }, + // ponytail: zero block size tensor types and dimension overflow panic inside tensor info parsing; + // those are left to the generic cataloger's panic handling rather than checked here. + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := tt.data + if data == nil { + data = tt.builder.withStringKV("general.architecture", "llama").build() + } + pkgs, err := parseGGUFBytes(t, data) + if tt.wantErr { + // the pre-scan has to be what rejects these, before the library sees them + require.ErrorContains(t, err, "invalid GGUF header") + assert.Empty(t, pkgs) + return + } + require.NoError(t, err) + require.Len(t, pkgs, 1) + }) + } +} + +func TestParseGGUFModel_nonFiniteFloat(t *testing.T) { + data := newTestGGUFBuilder(). + withStringKV("general.architecture", "llama"). + withRawKV("llama.rope.freq_base", ggufTypeFloat32, le(float32(math.Inf(1)))). + withRawKV("llama.rope.scale", ggufTypeFloat64, le(math.Inf(-1))). + withRawKV("llama.attention.head_count", ggufTypeArray, ggufArray(ggufTypeFloat32, 2, le([]float32{8, float32(math.NaN())}))). + withRawKV("tokenizer.ggml.scores", ggufTypeArray, ggufArray(ggufTypeFloat32, 1, le(float32(0)))). + build() + + pkgs, err := parseGGUFBytes(t, data) + require.NoError(t, err) + require.Len(t, pkgs, 1) + + meta := pkgs[0].Metadata.(pkg.GGUFFileHeader) + assert.NotEmpty(t, meta.MetadataKeyValuesHash) + assert.Equal(t, "+Inf", meta.RemainingKeyValues["llama.rope.freq_base"]) + assert.Equal(t, "-Inf", meta.RemainingKeyValues["llama.rope.scale"]) + headCount := meta.RemainingKeyValues["llama.attention.head_count"].(gguf_parser.GGUFMetadataKVArrayValue) + assert.Equal(t, []any{float32(8), "NaN"}, headCount.Array) + + var buf bytes.Buffer + s := sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection(pkgs...)}} + require.NoError(t, syftjson.NewFormatEncoder().Encode(&buf, s)) + assert.NotEmpty(t, buf.Bytes()) +} + +// TestParseGGUFModel_outputUnchanged pins the header output and hash for ordinary values to what syft +// produced as of f7f3c6efc, before malformed header handling. The expected values were captured by +// running this same fixture on that commit. float32 0.1 catches any widening to float64. +func TestParseGGUFModel_outputUnchanged(t *testing.T) { + data := newTestGGUFBuilder(). + withStringKV("general.architecture", "llama"). + withRawKV("tokenizer.ggml.tokens", ggufTypeArray, ggufArray(ggufTypeString, 2, append(ggufString("a"), ggufString("b")...))). + withRawKV("llama.attention.head_count", ggufTypeArray, ggufArray(ggufTypeFloat32, 2, le([]float32{0.1, 0.2}))). + withRawKV("llama.feed_forward_length", ggufTypeArray, ggufArray(ggufTypeArray, 2, append( + ggufArray(ggufTypeUint32, 2, le([]uint32{1, 2})), + ggufArray(ggufTypeUint32, 1, le([]uint32{3}))...))). + withRawKV("llama.rope.freq_base", ggufTypeFloat32, le(float32(0.1))). + build() + + pkgs, err := parseGGUFBytes(t, data) + require.NoError(t, err) + require.Len(t, pkgs, 1) + + meta := pkgs[0].Metadata.(pkg.GGUFFileHeader) + assert.Equal(t, "4f28d55f0985a985", meta.MetadataKeyValuesHash) + header, err := json.Marshal(meta.RemainingKeyValues) + require.NoError(t, err) + assert.JSONEq(t, `{ + "llama.attention.head_count": {"type": 6, "len": 2, "array": [0.1, 0.2], "startOffset": 170, "size": 8}, + "llama.feed_forward_length": {"type": 9, "len": 2, "array": [ + {"type": 4, "len": 2, "array": [1, 2], "startOffset": 239, "size": 8}, + {"type": 4, "len": 1, "array": [3], "startOffset": 259, "size": 4} + ], "startOffset": 227, "size": 36}, + "llama.rope.freq_base": 0.1, + "tokenizer.ggml.tokens": {"type": 8, "len": 2, "startOffset": 102, "size": 18} + }`, string(header)) +} + +// TestCopyHeader_appliesItsOwnBound pins that copyHeader caps the copy at maxHeaderSize itself, +// whatever reader it is handed. +// +// Bytes retained is asserted directly rather than through testutils.MeasureAlloc: the destination is a +// bytes.Buffer, whose doubling growth costs a multiple of what it holds and lands on a different power +// of two depending on the platform. buf.Len() is the same property without that noise. +func TestCopyHeader_appliesItsOwnBound(t *testing.T) { + const payload = 2*maxHeaderSize + 1 + + file := make([]byte, 24, 24+payload) + binary.LittleEndian.PutUint32(file[0:4], ggufMagicNumber) + file = append(file, bytes.Repeat([]byte{'x'}, payload)...) + + var buf bytes.Buffer + require.NoError(t, copyHeader(&buf, bytes.NewReader(file))) + assert.Equal(t, maxHeaderSize, buf.Len(), "copyHeader must cap the copy at maxHeaderSize") +} diff --git a/syft/pkg/cataloger/ai/parse_safetensors_test.go b/syft/pkg/cataloger/ai/parse_safetensors_test.go index caf7d21d9..c5d4457cb 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors_test.go +++ b/syft/pkg/cataloger/ai/parse_safetensors_test.go @@ -1485,28 +1485,3 @@ func TestReadSafeTensorsHeader_capBoundsHeap(t *testing.T) { assert.Less(t, bounded, uint64(8*1024*1024), "the declared-size check has to reject before the body is read, not after") } - -// TestCopyHeader_boundIsTheCallersLimitedReader pins where the GGUF bound actually lives. copyHeader -// io.Copy's whatever it is handed, so maxHeaderSize only holds because every caller wraps the reader -// first. A caller that stops doing that reads the whole file, and nothing in copyHeader would object. -// -// Bytes retained is asserted directly rather than through testutils.MeasureAlloc: the destination is a -// bytes.Buffer, whose doubling growth costs a multiple of what it holds and lands on a different power -// of two depending on the platform. buf.Len() is the same property without that noise. -func TestCopyHeader_boundIsTheCallersLimitedReader(t *testing.T) { - const payload = 2*maxHeaderSize + 1 - - file := make([]byte, 24, 24+payload) - binary.LittleEndian.PutUint32(file[0:4], ggufMagicNumber) - file = append(file, bytes.Repeat([]byte{'x'}, payload)...) - - var unbounded bytes.Buffer - require.NoError(t, copyHeader(&unbounded, bytes.NewReader(file))) - require.Equal(t, len(file), unbounded.Len(), - "an unwrapped reader is copied whole; copyHeader applies no limit of its own") - - var bounded bytes.Buffer - require.NoError(t, copyHeader(&bounded, &io.LimitedReader{R: bytes.NewReader(file), N: maxHeaderSize})) - assert.Equal(t, maxHeaderSize, bounded.Len(), - "a wrapped reader must cap the copy at maxHeaderSize") -} diff --git a/syft/pkg/cataloger/ai/test_helpers_test.go b/syft/pkg/cataloger/ai/test_helpers_test.go index 195e90d92..d11ce8bb1 100644 --- a/syft/pkg/cataloger/ai/test_helpers_test.go +++ b/syft/pkg/cataloger/ai/test_helpers_test.go @@ -72,8 +72,32 @@ func (b *testGGUFBuilder) withUint32KV(key string, value uint32) *testGGUFBuilde return b } +// withRawKV adds a KV whose value bytes are written verbatim, for arrays and malformed values. +func (b *testGGUFBuilder) withRawKV(key string, valueType uint32, raw []byte) *testGGUFBuilder { + b.kvPairs = append(b.kvPairs, testKVPair{key: key, valueType: valueType, value: raw}) + return b +} + +// ggufArray encodes an array value: item type, length, then the already encoded items. +func ggufArray(itemType uint32, n uint64, items []byte) []byte { + buf := new(bytes.Buffer) + binary.Write(buf, binary.LittleEndian, itemType) + binary.Write(buf, binary.LittleEndian, n) + buf.Write(items) + return buf.Bytes() +} + +// writeLen writes a count or length, which is 32-bit in GGUF v1 and 64-bit after. +func (b *testGGUFBuilder) writeLen(n uint64) { + if b.version <= 1 { + binary.Write(b.buf, binary.LittleEndian, uint32(n)) + return + } + binary.Write(b.buf, binary.LittleEndian, n) +} + func (b *testGGUFBuilder) writeString(s string) { - binary.Write(b.buf, binary.LittleEndian, uint64(len(s))) + b.writeLen(uint64(len(s))) b.buf.WriteString(s) } @@ -85,10 +109,10 @@ func (b *testGGUFBuilder) build() []byte { binary.Write(b.buf, binary.LittleEndian, b.version) // Write tensor count - binary.Write(b.buf, binary.LittleEndian, b.tensorCount) + b.writeLen(b.tensorCount) // Write KV count - binary.Write(b.buf, binary.LittleEndian, uint64(len(b.kvPairs))) + b.writeLen(uint64(len(b.kvPairs))) // Write KV pairs for _, kv := range b.kvPairs { @@ -97,6 +121,10 @@ func (b *testGGUFBuilder) build() []byte { // Write value type binary.Write(b.buf, binary.LittleEndian, kv.valueType) // Write value based on type + if raw, ok := kv.value.([]byte); ok { + b.buf.Write(raw) + continue + } switch kv.valueType { case ggufTypeString: b.writeString(kv.value.(string))