mirror of
https://github.com/anchore/syft.git
synced 2026-10-11 21:57:22 +02:00
fix(ai): handle malformed GGUF headers without panicking (#5345)
- pre-scan the GGUF metadata KV section and reject values gguf-parser-go would panic on, recurse on without bound, or overallocate for: wrong typed well-known keys, zero alignment, nested or oversized fully decoded arrays, an unreasonable `split.tensors.count`, and empty strings the parser would misread. Keys are matched the way the parser sees them. The KV count, fully read string bytes, and skipped string counts are bounded too. The file becomes an unknown instead of taking down the scan. - `copyHeader` now bounds its own read to the max header size. - non-finite floats in `header` (including inside arrays) become `"NaN"`, `"+Inf"` or `"-Inf"` so JSON encoding can't fail. `header` and `metadataHash` are otherwise unchanged. - a `metadataHash` failure is now an error instead of an empty hash. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user