diff --git a/syft/pkg/cataloger/ai/cataloger.go b/syft/pkg/cataloger/ai/cataloger.go index 5b7a75882..9aa7ef4b3 100644 --- a/syft/pkg/cataloger/ai/cataloger.go +++ b/syft/pkg/cataloger/ai/cataloger.go @@ -11,8 +11,12 @@ import ( const ( catalogerName = "gguf-cataloger" - ggufLayerMediaType = "application/vnd.docker.ai*" safeTensorsCatalogerName = "safetensors-cataloger" + + // dockerAIGGUFMediaType mirrors ggufLayerMediaType in + // syft/source/ocimodelsource/registry_client.go. It is matched exactly: the + // other application/vnd.docker.ai.* layers are not GGUF. + dockerAIGGUFMediaType = "application/vnd.docker.ai.gguf.v3" ) // NewGGUFCataloger returns a new cataloger instance for GGUF model files. @@ -21,7 +25,7 @@ const ( func NewGGUFCataloger() pkg.Cataloger { return generic.NewCataloger(catalogerName). WithParserByGlobs(parseGGUFModel, "**/*.gguf"). - WithParserByMediaType(parseGGUFModel, ggufLayerMediaType). + WithParserByMediaType(parseGGUFModel, dockerAIGGUFMediaType). WithProcessors(ggufMergeProcessor) } diff --git a/syft/pkg/cataloger/ai/cataloger_test.go b/syft/pkg/cataloger/ai/cataloger_test.go index d8c28d6d0..1ab6b59dd 100644 --- a/syft/pkg/cataloger/ai/cataloger_test.go +++ b/syft/pkg/cataloger/ai/cataloger_test.go @@ -1,10 +1,17 @@ package ai import ( + "context" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/tmpdir" + "github.com/anchore/syft/syft/internal/fileresolver" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" @@ -138,3 +145,42 @@ func TestGGUFCataloger(t *testing.T) { }) } } + +func TestGGUFCataloger_ociLayers(t *testing.T) { + // a GGUF OCI artifact is merged into one named package, while the other + // application/vnd.docker.ai.* layers (config, model.file, license and + // safetensors weights) never reach the GGUF parser. + named := newTestGGUFBuilder(). + withStringKV("general.architecture", "llama"). + withStringKV("general.name", "llama3-8b"). + build() + part := newTestGGUFBuilder().withStringKV("general.architecture", "llama").build() + + dir := t.TempDir() + layers := map[string]fileresolver.LayerInfo{} + for digest, l := range map[string]struct { + mediaType string + content []byte + }{ + "sha256:named": {dockerAIGGUFMediaType, named}, + "sha256:part": {dockerAIGGUFMediaType, part}, + "sha256:config": {dockerAIModelConfigMediaTypes[0], []byte("not a gguf file")}, + "sha256:file": {dockerAIModelFileMediaType, []byte("not a gguf file")}, + "sha256:license": {dockerAILicenseMediaType, []byte("not a gguf file")}, + "sha256:weights": {dockerAISafeTensorsMediaType, []byte("not a gguf file")}, + } { + p := filepath.Join(dir, digest[len("sha256:"):]) + require.NoError(t, os.WriteFile(p, l.content, 0o600)) + layers[digest] = fileresolver.LayerInfo{TempPath: p, MediaType: l.mediaType} + } + resolver := fileresolver.NewContainerImageModel(dir, layers, "example/model:latest") + ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir())) + + pkgs, _, err := NewGGUFCataloger().Catalog(ctx, resolver) + require.NoError(t, err) + require.Len(t, pkgs, 1) + assert.Equal(t, "llama3-8b", pkgs[0].Name) + header, ok := pkgs[0].Metadata.(pkg.GGUFFileHeader) + require.True(t, ok) + assert.Len(t, header.Parts, 1) +} diff --git a/syft/pkg/cataloger/ai/gguf_processor.go b/syft/pkg/cataloger/ai/gguf_processor.go index fe31e3f8b..34b93f006 100644 --- a/syft/pkg/cataloger/ai/gguf_processor.go +++ b/syft/pkg/cataloger/ai/gguf_processor.go @@ -8,17 +8,17 @@ import ( // ggufMergeProcessor consolidates multiple GGUF packages into a single package // representing the AI model. When scanning OCI images with multiple layers, // each layer may produce a separate package. This processor finds the package -// with a name and merges metadata from nameless packages into its GGUFFileParts field. +// with a name and merges metadata from nameless packages into its Parts field. // Only packages with a non-empty name are returned in the final result. func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) { - if err != nil { - return pkgs, rels, err - } + // don't early-return on err: it aggregates per-file failures, and skipping the + // merge would drop every nameless layer and the named package's Parts along + // with it (see safeTensorsMergeProcessor). if len(pkgs) == 0 { return pkgs, rels, err } - // Separate packages with names from those without + // separate packages with names from those without var namedPkgs []pkg.Package var namelessHeaders []pkg.GGUFFileHeader @@ -27,14 +27,14 @@ func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err er namedPkgs = append(namedPkgs, p) } else { if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok { - // We do not want a kv hash for nameless headers + // we do not want a kv hash for nameless headers header.MetadataKeyValuesHash = "" namelessHeaders = append(namelessHeaders, header) } } } - // If there are no named packages, return nothing + // if there are no named packages, return nothing if len(namedPkgs) == 0 { return nil, rels, err } diff --git a/syft/pkg/cataloger/ai/merge.go b/syft/pkg/cataloger/ai/merge.go index a8d2b4e74..24e37e685 100644 --- a/syft/pkg/cataloger/ai/merge.go +++ b/syft/pkg/cataloger/ai/merge.go @@ -75,7 +75,7 @@ func mergeShardsInto(merged *pkg.SafeTensorsModelInfo, shards []pkg.SafeTensorsM seenKV := map[string]bool{} for _, s := range shards { shardTensorTotal += s.TensorCount - merged.Parameters += s.Parameters + merged.Parameters = saturatingAdd(merged.Parameters, s.Parameters) firstNonEmpty(&merged.Quantization, s.Quantization) for _, kv := range s.UserMetadata { if seenKV[kv.Key] { diff --git a/syft/pkg/cataloger/ai/merge_test.go b/syft/pkg/cataloger/ai/merge_test.go index 7276d8896..08f22939f 100644 --- a/syft/pkg/cataloger/ai/merge_test.go +++ b/syft/pkg/cataloger/ai/merge_test.go @@ -1,6 +1,7 @@ package ai import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -164,3 +165,10 @@ func TestRollupHash(t *testing.T) { assert.NotEqual(t, "a", ab) assert.NotEqual(t, "b", ab) } + +func TestMergeShardsInto_parametersSaturate(t *testing.T) { + var merged pkg.SafeTensorsModelInfo + shards := []pkg.SafeTensorsModelInfo{{Parameters: math.MaxUint64 - 1}, {Parameters: 2}} + mergeShardsInto(&merged, shards) + assert.Equal(t, uint64(math.MaxUint64), merged.Parameters) +} diff --git a/syft/pkg/cataloger/ai/package.go b/syft/pkg/cataloger/ai/package.go index 3abb42343..978ad2a95 100644 --- a/syft/pkg/cataloger/ai/package.go +++ b/syft/pkg/cataloger/ai/package.go @@ -1,17 +1,19 @@ package ai import ( + "context" + "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" ) -func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license string, locations ...file.Location) pkg.Package { +func newGGUFPackage(ctx context.Context, metadata *pkg.GGUFFileHeader, modelName, version, license string, locations ...file.Location) pkg.Package { p := pkg.Package{ Name: modelName, Version: version, Locations: file.NewLocationSet(locations...), Type: pkg.ModelPkg, - Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValues(license)...), + Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...), Metadata: *metadata, // NOTE: PURL is intentionally not set as the package-url spec // has not yet finalized support for ML model packages diff --git a/syft/pkg/cataloger/ai/package_test.go b/syft/pkg/cataloger/ai/package_test.go index ea58304d6..3c8efbb7f 100644 --- a/syft/pkg/cataloger/ai/package_test.go +++ b/syft/pkg/cataloger/ai/package_test.go @@ -1,6 +1,7 @@ package ai import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -103,6 +104,7 @@ func TestNewGGUFPackage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := newGGUFPackage( + context.Background(), tt.metadata, tt.input.modelName, tt.input.version, diff --git a/syft/pkg/cataloger/ai/parse_gguf.go b/syft/pkg/cataloger/ai/parse_gguf.go index 3150110a3..9437cafe2 100644 --- a/syft/pkg/cataloger/ai/parse_gguf.go +++ b/syft/pkg/cataloger/ai/parse_gguf.go @@ -45,7 +45,7 @@ func copyHeader(w io.Writer, r io.Reader) error { return nil } -// Helper to convert gguf_parser metadata to simpler types +// helper to convert gguf_parser metadata to simpler types func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any { result := make(map[string]any) diff --git a/syft/pkg/cataloger/ai/parse_gguf_model.go b/syft/pkg/cataloger/ai/parse_gguf_model.go index f59fddfd5..d879a1165 100644 --- a/syft/pkg/cataloger/ai/parse_gguf_model.go +++ b/syft/pkg/cataloger/ai/parse_gguf_model.go @@ -15,7 +15,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/tmpdir" - "github.com/anchore/syft/internal/unknown" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -79,6 +78,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment // Create package from metadata p := newGGUFPackage( + ctx, syftMetadata, metadata.Name, modelVersion, @@ -86,7 +86,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation), ) - return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse GGUF file") + return []pkg.Package{p}, nil, nil } // computeKVMetadataHash computes a stable hash of the KV metadata for use as a global identifier diff --git a/syft/pkg/cataloger/ai/parse_safetensors.go b/syft/pkg/cataloger/ai/parse_safetensors.go index 119a182ff..2ee3f206b 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors.go +++ b/syft/pkg/cataloger/ai/parse_safetensors.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "io" + "math" + "math/bits" "sort" "strings" @@ -82,47 +84,67 @@ func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, error) { return h, nil } -// parameterCount sums the element counts across all tensors in the header. -func (h *safeTensorsHeader) parameterCount() uint64 { - var total uint64 - for _, t := range h.tensors { - count := uint64(1) - for _, dim := range t.Shape { - if dim <= 0 { - count = 0 - break - } - count *= uint64(dim) - } - total += count +// modelInfo returns the metadata derivable from the header bytes alone. Naming, +// licenses and ShardCount are left to the merge processor. +func (h *safeTensorsHeader) modelInfo() pkg.SafeTensorsModelInfo { + params, dtype := h.parameterStats() + return pkg.SafeTensorsModelInfo{ + Format: "safetensors", + TensorCount: uint64(len(h.tensors)), + Parameters: params, + Quantization: normalizeDType(dtype), + UserMetadata: userMetadataKeyValues(h.metadata), + MetadataHash: h.metadataHash(), } - return total } -// dominantDType returns the dtype that accounts for the largest fraction of parameters. -// For mixed-precision models the "dominant" dtype is still a useful summary. -func (h *safeTensorsHeader) dominantDType() string { +// parameterStats sums the element counts across all tensors and returns the +// dtype that accounts for the largest share of them. For mixed-precision models +// the "dominant" dtype is still a useful summary. Totals saturate rather than wrap. +func (h *safeTensorsHeader) parameterStats() (total uint64, dominantDType string) { sizeByDType := make(map[string]uint64) for _, t := range h.tensors { - count := uint64(1) - for _, dim := range t.Shape { - if dim <= 0 { - count = 0 - break - } - count *= uint64(dim) + count, ok := tensorElements(t.Shape) + if !ok { + continue } - sizeByDType[t.DType] += count + total = saturatingAdd(total, count) + sizeByDType[t.DType] = saturatingAdd(sizeByDType[t.DType], count) } - var best string var bestSize uint64 for dtype, size := range sizeByDType { - if size > bestSize || (size == bestSize && dtype < best) { - best = dtype + if size > bestSize || (size == bestSize && dtype < dominantDType) { + dominantDType = dtype bestSize = size } } - return best + return total, dominantDType +} + +// tensorElements returns the element count for a shape, or ok=false on a +// non-positive dim or when the product overflows uint64. +func tensorElements(shape []int64) (uint64, bool) { + n := uint64(1) + for _, d := range shape { + if d <= 0 { + return 0, false + } + hi, lo := bits.Mul64(n, uint64(d)) + if hi != 0 { + return 0, false + } + n = lo + } + return n, true +} + +// saturatingAdd returns a+b, clamped to math.MaxUint64 instead of wrapping. +func saturatingAdd(a, b uint64) uint64 { + sum, carry := bits.Add64(a, b, 0) + if carry != 0 { + return math.MaxUint64 + } + return sum } // metadataHash returns a stable xxhash64 over the logical tensor content diff --git a/syft/pkg/cataloger/ai/parse_safetensors_model.go b/syft/pkg/cataloger/ai/parse_safetensors_model.go index a9a8f3c66..da3234240 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors_model.go +++ b/syft/pkg/cataloger/ai/parse_safetensors_model.go @@ -6,7 +6,6 @@ import ( "io" "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/unknown" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -26,22 +25,13 @@ func parseSafeTensorsFile(_ context.Context, _ file.Resolver, _ *generic.Environ return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err) } - // ShardCount is intentionally not set here: the merge processor is the single - // owner of ShardCount and derives it from the number of shards in the group. - md := pkg.SafeTensorsModelInfo{ - Format: "safetensors", - TensorCount: uint64(len(header.tensors)), - Parameters: header.parameterCount(), - Quantization: normalizeDType(header.dominantDType()), - UserMetadata: userMetadataKeyValues(header.metadata), - MetadataHash: header.metadataHash(), - } + md := header.modelInfo() p := newSafeTensorsPackage( &md, reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation), ) - return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file") + return []pkg.Package{p}, nil, nil } // integrity check diff --git a/syft/pkg/cataloger/ai/parse_safetensors_oci.go b/syft/pkg/cataloger/ai/parse_safetensors_oci.go index 5b517b846..8aa04e5b0 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors_oci.go +++ b/syft/pkg/cataloger/ai/parse_safetensors_oci.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/unknown" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -81,7 +80,7 @@ func parseSafeTensorsOCIConfig(_ context.Context, _ file.Resolver, _ *generic.En &md, reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation), ) - return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config") + return []pkg.Package{p}, nil, nil } // parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight @@ -94,14 +93,7 @@ func parseSafeTensorsOCILayer(_ context.Context, _ file.Resolver, _ *generic.Env return nil, nil, fmt.Errorf("failed to read safetensors layer header: %w", err) } - md := pkg.SafeTensorsModelInfo{ - Format: "safetensors", - TensorCount: uint64(len(header.tensors)), - Parameters: header.parameterCount(), - Quantization: normalizeDType(header.dominantDType()), - UserMetadata: userMetadataKeyValues(header.metadata), - MetadataHash: header.metadataHash(), - } + md := header.modelInfo() p := newSafeTensorsPackage( &md, diff --git a/syft/pkg/cataloger/ai/parse_safetensors_test.go b/syft/pkg/cataloger/ai/parse_safetensors_test.go index 7b98cb915..caf7d21d9 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors_test.go +++ b/syft/pkg/cataloger/ai/parse_safetensors_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/json" "io" + "math" "os" "path/filepath" "slices" @@ -1146,8 +1147,9 @@ func TestSafeTensorsHeader_parameterCountAndDType(t *testing.T) { "small": {DType: "F32", Shape: []int64{16, 16}}, "scalar": {DType: "F32", Shape: []int64{}}, // empty shape contributes 1 }} - assert.Equal(t, uint64(1000*16+16*16+1), h.parameterCount()) - assert.Equal(t, "BF16", h.dominantDType()) + params, dtype := h.parameterStats() + assert.Equal(t, uint64(1000*16+16*16+1), params) + assert.Equal(t, "BF16", dtype) } func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) { @@ -1159,7 +1161,29 @@ func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) { "zero": {DType: "F32", Shape: []int64{4, 0}}, "negative": {DType: "F32", Shape: []int64{-1, 8}}, }} - assert.Equal(t, uint64(16), h.parameterCount()) + params, _ := h.parameterStats() + assert.Equal(t, uint64(16), params) +} + +func TestSafeTensorsHeader_parameterStats_overflow(t *testing.T) { + // a shape whose product overflows uint64 is skipped instead of wrapping (the + // first tensor would wrap to 0) and can't flip the dominant dtype; the total + // saturates instead of wrapping. + h := &safeTensorsHeader{tensors: map[string]safeTensorsEntry{ + "good": {DType: "BF16", Shape: []int64{4, 4}}, + "wraps": {DType: "F32", Shape: []int64{4294967296, 4294967296}}, + "overflow": {DType: "F32", Shape: []int64{math.MaxInt64, 3}}, + }} + params, dtype := h.parameterStats() + assert.Equal(t, uint64(16), params) + assert.Equal(t, "BF16", dtype) + + h = &safeTensorsHeader{tensors: map[string]safeTensorsEntry{ + "a": {DType: "F32", Shape: []int64{math.MaxInt64, 2}}, + "b": {DType: "F32", Shape: []int64{math.MaxInt64, 2}}, + }} + params, _ = h.parameterStats() + assert.Equal(t, uint64(math.MaxUint64), params) } func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) { @@ -1169,7 +1193,8 @@ func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) { "a": {DType: "F32", Shape: []int64{4, 4}}, "b": {DType: "BF16", Shape: []int64{4, 4}}, }} - assert.Equal(t, "BF16", h.dominantDType()) + _, dtype := h.parameterStats() + assert.Equal(t, "BF16", dtype) } func TestNormalizeDType(t *testing.T) { diff --git a/syft/pkg/cataloger/ai/processor_test.go b/syft/pkg/cataloger/ai/processor_test.go index 6d216ae95..e99f9a9ce 100644 --- a/syft/pkg/cataloger/ai/processor_test.go +++ b/syft/pkg/cataloger/ai/processor_test.go @@ -1,6 +1,7 @@ package ai import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -61,3 +62,20 @@ func Test_ggufMergeProcessor(t *testing.T) { }) } } + +func Test_ggufMergeProcessor_mergesDespiteError(t *testing.T) { + // one layer failing to parse must not drop the rest of the model + parseErr := errors.New("one layer failed to parse") + pkgs := []pkg.Package{ + {Name: "model", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "abc"}}, + {Name: "", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "part1"}}, + } + + got, _, err := ggufMergeProcessor(pkgs, nil, parseErr) + require.ErrorIs(t, err, parseErr) + require.Len(t, got, 1) + assert.Equal(t, "model", got[0].Name) + header, ok := got[0].Metadata.(pkg.GGUFFileHeader) + require.True(t, ok) + assert.Len(t, header.Parts, 1) +}