mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
feat: add safe tensor model type to SBOM output (#4844)
--------- Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
co-authored by
Alex Goodman
parent
ed499fd2d0
commit
aff2c247db
@@ -44,3 +44,46 @@ catalogers:
|
||||
default: true
|
||||
evidence:
|
||||
- GGUFFileHeader.MetadataKeyValuesHash
|
||||
- ecosystem: ai # MANUAL
|
||||
name: safetensors-cataloger # AUTO-GENERATED
|
||||
type: generic # AUTO-GENERATED
|
||||
source: # AUTO-GENERATED
|
||||
file: syft/pkg/cataloger/ai/cataloger.go
|
||||
function: NewSafeTensorsCataloger
|
||||
selectors: # AUTO-GENERATED
|
||||
- ai
|
||||
- directory
|
||||
- image
|
||||
- ml
|
||||
- model
|
||||
- package
|
||||
- safetensors
|
||||
parsers: # AUTO-GENERATED structure
|
||||
- function: parseSafeTensorsFile
|
||||
detector: # AUTO-GENERATED
|
||||
method: glob # AUTO-GENERATED
|
||||
criteria: # AUTO-GENERATED
|
||||
- '**/*.safetensors'
|
||||
metadata_types: # AUTO-GENERATED
|
||||
- pkg.SafeTensorsModelInfo
|
||||
package_types: # AUTO-GENERATED
|
||||
- model
|
||||
json_schema_types: # AUTO-GENERATED
|
||||
- SafetensorsModelInfo
|
||||
capabilities: # MANUAL - preserved across regeneration
|
||||
- name: license
|
||||
default: true
|
||||
- name: dependency.depth
|
||||
default: []
|
||||
- name: dependency.edges
|
||||
default: ""
|
||||
- name: dependency.kinds
|
||||
default: []
|
||||
- name: package_manager.files.listing
|
||||
default: false
|
||||
- name: package_manager.files.digests
|
||||
default: false
|
||||
- name: package_manager.package_integrity_hash
|
||||
default: true
|
||||
evidence:
|
||||
- SafeTensorsModelInfo.MetadataHash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
Package ai provides concrete Cataloger implementations for AI artifacts and machine learning models,
|
||||
including support for GGUF (GPT-Generated Unified Format) model files.
|
||||
including support for GGUF (GPT-Generated Unified Format) and SafeTensors model files.
|
||||
*/
|
||||
package ai
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
catalogerName = "gguf-cataloger"
|
||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
||||
catalogerName = "gguf-cataloger"
|
||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
||||
safeTensorsCatalogerName = "safetensors-cataloger"
|
||||
)
|
||||
|
||||
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
|
||||
@@ -23,3 +24,26 @@ func NewGGUFCataloger() pkg.Cataloger {
|
||||
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
|
||||
WithProcessors(ggufMergeProcessor)
|
||||
}
|
||||
|
||||
// NewSafeTensorsCataloger returns a cataloger for SafeTensors model files,
|
||||
// covering three discovery paths:
|
||||
// - **/*.safetensors files (single-file models and individual shards;
|
||||
// header-only parse)
|
||||
// - application/vnd.docker.ai.model.config.v0.1+json / v0.2+json OCI layers
|
||||
// (Docker Model Runner artifacts whose config advertises format=="safetensors")
|
||||
// - application/vnd.docker.ai.safetensors OCI layers (per-shard JSON headers,
|
||||
// fetched as a prefix by the OCI model source; emitted as nameless
|
||||
// packages and merged into the config-derived package as Parts)
|
||||
//
|
||||
// model.safetensors.index.json files are intentionally not parsed today: the
|
||||
// index describes how tensors map to shards but contributes no metadata the
|
||||
// cataloger can't derive from the shard headers themselves. If a model is
|
||||
// distributed as just an index.json with no accompanying shard files, the
|
||||
// cataloger emits nothing for that directory.
|
||||
func NewSafeTensorsCataloger() pkg.Cataloger {
|
||||
return generic.NewCataloger(safeTensorsCatalogerName).
|
||||
WithParserByGlobs(parseSafeTensorsFile, "**/*.safetensors").
|
||||
WithParserByMediaType(parseSafeTensorsOCIConfig, dockerAIModelConfigMediaTypes...).
|
||||
WithParserByMediaType(parseSafeTensorsOCILayer, dockerAISafeTensorsMediaType).
|
||||
WithResolvingProcessors(safeTensorsMergeProcessor)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// Separate packages with names from those without
|
||||
var namedPkgs []pkg.Package
|
||||
var namelessHeaders []pkg.GGUFFileHeader
|
||||
|
||||
for _, p := range pkgs {
|
||||
if p.Name != "" {
|
||||
namedPkgs = append(namedPkgs, p)
|
||||
} else {
|
||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
// 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 len(namedPkgs) == 0 {
|
||||
return nil, rels, err
|
||||
}
|
||||
|
||||
// merge nameless headers into a single named package;
|
||||
// if there are multiple named packages, return them without trying to merge headers.
|
||||
// we cannot determine which nameless headers belong to which package
|
||||
// this is because the order we receive the gguf headers in is not guaranteed
|
||||
// to match the layer order in the original oci image
|
||||
if len(namedPkgs) == 1 && len(namelessHeaders) > 0 {
|
||||
winner := &namedPkgs[0]
|
||||
if header, ok := winner.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
header.Parts = namelessHeaders
|
||||
winner.Metadata = header
|
||||
}
|
||||
}
|
||||
|
||||
return namedPkgs, rels, err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// hfConfig is a minimal projection of Hugging Face config.json fields.
|
||||
type hfConfig struct {
|
||||
Architectures []string `json:"architectures"`
|
||||
NameOrPath string `json:"_name_or_path"`
|
||||
}
|
||||
|
||||
func applyHFConfig(md *pkg.SafeTensorsModelInfo, cfg *hfConfig) {
|
||||
if md.Architecture == "" && len(cfg.Architectures) > 0 {
|
||||
md.Architecture = cfg.Architectures[0]
|
||||
}
|
||||
}
|
||||
|
||||
// readmeFrontmatter holds the subset of YAML frontmatter fields we extract.
|
||||
type readmeFrontmatter struct {
|
||||
License string `yaml:"license"`
|
||||
BaseModel []string `yaml:"base_model"`
|
||||
}
|
||||
|
||||
type licenseFrontmatter struct {
|
||||
SPDXID string `yaml:"spdx-id"`
|
||||
}
|
||||
|
||||
// extractFrontmatterBlock returns the YAML bytes between the first and second
|
||||
// "---" delimiters of a file
|
||||
func extractFrontmatterBlock(buf []byte) []byte {
|
||||
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
|
||||
if !bytes.HasPrefix(trimmed, []byte("---")) {
|
||||
return nil
|
||||
}
|
||||
rest := trimmed[3:]
|
||||
if i := bytes.IndexByte(rest, '\n'); i >= 0 {
|
||||
rest = rest[i+1:]
|
||||
}
|
||||
block, _, found := bytes.Cut(rest, []byte("\n---"))
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
// parseFrontmatter decodes a Hugging Face model card YAML frontmatter block
|
||||
// and returns the license and base_model fields.
|
||||
func parseFrontmatter(buf []byte) *readmeFrontmatter {
|
||||
block := extractFrontmatterBlock(buf)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
License string `yaml:"license"`
|
||||
BaseModel yaml.Node `yaml:"base_model"`
|
||||
}
|
||||
if err := yaml.Unmarshal(block, &raw); err != nil {
|
||||
log.Debugf("failed to parse README frontmatter: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
fm := readmeFrontmatter{License: raw.License}
|
||||
switch raw.BaseModel.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if raw.BaseModel.Value != "" {
|
||||
fm.BaseModel = []string{raw.BaseModel.Value}
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
_ = raw.BaseModel.Decode(&fm.BaseModel)
|
||||
}
|
||||
return &fm
|
||||
}
|
||||
|
||||
// parseLicenseFrontmatter returns the producer-declared SPDX identifier
|
||||
func parseLicenseFrontmatter(buf []byte) string {
|
||||
block := extractFrontmatterBlock(buf)
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
var fm licenseFrontmatter
|
||||
if err := yaml.Unmarshal(block, &fm); err != nil {
|
||||
log.Debugf("failed to parse license frontmatter: %v", err)
|
||||
return ""
|
||||
}
|
||||
return fm.SPDXID
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// resolveSafeTensorsDirIdentity handles the directory-scan case for safe tensors
|
||||
// find config.json beside the model files (walking up parent directories to the
|
||||
// scanned source root if no sibling exists) and a sibling README.md. It returns
|
||||
// the group's name candidates, resolved licenses, and supporting evidence.
|
||||
func resolveSafeTensorsDirIdentity(ctx context.Context, resolver file.Resolver, dir string, md *pkg.SafeTensorsModelInfo) safeTensorsIdentity {
|
||||
id := safeTensorsIdentity{fallbackName: safeTensorsDirName(dir)}
|
||||
|
||||
if loc, cfg := findDirHFConfig(resolver, dir); cfg != nil {
|
||||
applyHFConfig(md, cfg)
|
||||
id.nameOrPath = cfg.NameOrPath
|
||||
id.supporting = append(id.supporting, *loc)
|
||||
}
|
||||
|
||||
if loc, fm := readDirReadmeFrontmatter(resolver, path.Join(dir, "README.md")); fm != nil {
|
||||
if fm.License != "" {
|
||||
id.licenses = pkg.NewLicensesFromValuesWithContext(ctx, fm.License)
|
||||
}
|
||||
if id.nameOrPath == "" && len(fm.BaseModel) > 0 {
|
||||
id.nameOrPath = fm.BaseModel[0]
|
||||
}
|
||||
id.supporting = append(id.supporting, *loc)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// findDirHFConfig looks for a config.json beside the model files
|
||||
func findDirHFConfig(resolver file.Resolver, dir string) (*file.Location, *hfConfig) {
|
||||
for {
|
||||
if loc, cfg := readDirHFConfig(resolver, path.Join(dir, "config.json")); cfg != nil {
|
||||
return loc, cfg
|
||||
}
|
||||
parent := path.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, nil // reached the source root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func readDirHFConfig(resolver file.Resolver, p string) (*file.Location, *hfConfig) {
|
||||
locations, err := resolver.FilesByPath(p)
|
||||
if err != nil || len(locations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rc, err := resolver.FileContentsByLocation(locations[0])
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, p)
|
||||
|
||||
var cfg hfConfig
|
||||
if err := json.NewDecoder(rc).Decode(&cfg); err != nil {
|
||||
log.Debugf("failed to decode %s: %v", p, err)
|
||||
return nil, nil
|
||||
}
|
||||
return &locations[0], &cfg
|
||||
}
|
||||
|
||||
func readDirReadmeFrontmatter(resolver file.Resolver, p string) (*file.Location, *readmeFrontmatter) {
|
||||
locations, err := resolver.FilesByPath(p)
|
||||
if err != nil || len(locations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rc, err := resolver.FileContentsByLocation(locations[0])
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, p)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
fm := parseFrontmatter(buf)
|
||||
if fm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &locations[0], fm
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path"
|
||||
|
||||
gcrname "github.com/google/go-containerregistry/pkg/name"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/licenses"
|
||||
)
|
||||
|
||||
// resolveSafeTensorsOCIIdentity handles the OCI-artifact case
|
||||
func resolveSafeTensorsOCIIdentity(ctx context.Context, resolver file.Resolver, md *pkg.SafeTensorsModelInfo) safeTensorsIdentity {
|
||||
ociResolver, ok := resolver.(file.OCIMediaTypeResolver)
|
||||
if !ok {
|
||||
return safeTensorsIdentity{}
|
||||
}
|
||||
|
||||
modelFileLocs, err := ociResolver.FilesByMediaType(dockerAIModelFileMediaType)
|
||||
if err != nil {
|
||||
log.Debugf("failed to list docker AI model-file layers: %v", err)
|
||||
}
|
||||
|
||||
// Collect config / readme candidates separately so the layer-iteration order
|
||||
// returned by the resolver doesn't decide the precedence.
|
||||
var configName, readmeName, readmeLicense string
|
||||
var supporting []file.Location
|
||||
for _, loc := range modelFileLocs {
|
||||
cfg, fm := classifyOCIModelFileLayer(resolver, loc)
|
||||
switch {
|
||||
case cfg != nil:
|
||||
applyHFConfig(md, cfg)
|
||||
if configName == "" {
|
||||
configName = cfg.NameOrPath
|
||||
}
|
||||
supporting = append(supporting, loc)
|
||||
case fm != nil:
|
||||
if readmeLicense == "" {
|
||||
readmeLicense = fm.License
|
||||
}
|
||||
if readmeName == "" && len(fm.BaseModel) > 0 {
|
||||
readmeName = fm.BaseModel[0]
|
||||
}
|
||||
supporting = append(supporting, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: config.json _name_or_path > README base_model.
|
||||
nameOrPath := configName
|
||||
if nameOrPath == "" {
|
||||
nameOrPath = readmeName
|
||||
}
|
||||
|
||||
id := safeTensorsIdentity{
|
||||
nameOrPath: nameOrPath,
|
||||
fallbackName: ociImageRefBasename(resolver),
|
||||
supporting: supporting,
|
||||
}
|
||||
|
||||
// License precedence: a dedicated vnd.docker.ai.license layer is a
|
||||
// outranks the free-text license field in a model card's README frontmatter.
|
||||
licLocs, err := ociResolver.FilesByMediaType(dockerAILicenseMediaType)
|
||||
if err != nil {
|
||||
log.Debugf("failed to list docker AI license layers: %v", err)
|
||||
}
|
||||
switch {
|
||||
case len(licLocs) > 0:
|
||||
id.licenses = identifyLicenseLayers(ctx, resolver, licLocs)
|
||||
id.supporting = append(id.supporting, licLocs...)
|
||||
case readmeLicense != "":
|
||||
id.licenses = pkg.NewLicensesFromValuesWithContext(ctx, readmeLicense)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// ociImageReferencer is the minimal capability ociImageRefBasename needs: a
|
||||
// resolver that can surface the OCI image reference it was built from. It is
|
||||
// kept local to this package (rather than exported from the file package) so the
|
||||
// assertion stays with its only consumer.
|
||||
type ociImageReferencer interface {
|
||||
ImageReference() string
|
||||
}
|
||||
|
||||
func ociImageRefBasename(resolver file.Resolver) string {
|
||||
// TODO: we don't think this approach is generalizable quite yet, but we really do need this information.
|
||||
// (Ideally we should be NOT be type asserting on the file resolver directly).
|
||||
info, ok := resolver.(ociImageReferencer)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ref := info.ImageReference()
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := gcrname.ParseReference(ref)
|
||||
if err != nil {
|
||||
log.Debugf("failed to parse OCI ref %q: %v", ref, err)
|
||||
return ""
|
||||
}
|
||||
return path.Base(parsed.Context().RepositoryStr())
|
||||
}
|
||||
|
||||
// classifyOCIModelFileLayer reads up to 4 MiB of a model.file layer and decodes
|
||||
// it as either an HF config.json or a README model card's YAML frontmatter,
|
||||
// based on its leading bytes. It returns whichever it recognized; both are nil
|
||||
// when the layer is neither (or fails to decode). The caller owns precedence and
|
||||
// metadata enrichment.
|
||||
func classifyOCIModelFileLayer(resolver file.Resolver, loc file.Location) (*hfConfig, *readmeFrontmatter) {
|
||||
rc, err := resolver.FileContentsByLocation(loc)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, loc.RealPath)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 4*1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
|
||||
switch {
|
||||
case bytes.HasPrefix(trimmed, []byte("---")):
|
||||
return nil, parseFrontmatter(buf)
|
||||
case bytes.HasPrefix(trimmed, []byte("{")):
|
||||
var cfg hfConfig
|
||||
if err := json.Unmarshal(buf, &cfg); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// identifyLicenseLayers turns Docker AI license-layer locations into pkg.License values.
|
||||
func identifyLicenseLayers(ctx context.Context, resolver file.Resolver, locs []file.Location) []pkg.License {
|
||||
var out []pkg.License
|
||||
var scanFallback []file.Location
|
||||
for i := range locs {
|
||||
loc := locs[i]
|
||||
if spdx := readLicenseSPDXIDFromFrontmatter(resolver, loc); spdx != "" {
|
||||
out = append(out, pkg.NewLicenseFromFieldsWithContext(ctx, spdx, "", &loc))
|
||||
continue
|
||||
}
|
||||
scanFallback = append(scanFallback, loc)
|
||||
}
|
||||
if len(scanFallback) > 0 {
|
||||
out = append(out, licenses.FindAtLocations(ctx, resolver, scanFallback...)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLicenseSPDXIDFromFrontmatter reads a bounded prefix of a license-layer
|
||||
// blob and returns the spdx-id declared in its YAML frontmatter
|
||||
func readLicenseSPDXIDFromFrontmatter(resolver file.Resolver, loc file.Location) string {
|
||||
rc, err := resolver.FileContentsByLocation(loc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, loc.RealPath)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 64*1024))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parseLicenseFrontmatter(buf)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// mergeSafeTensorsGroup folds a group's per-member metadata into a single package.
|
||||
func mergeSafeTensorsGroup(members []pkg.Package) pkg.Package {
|
||||
locSet := unionLocations(members)
|
||||
aggregates, shards := bucketSafeTensorsMembers(members)
|
||||
|
||||
merged := pkg.SafeTensorsModelInfo{Format: "safetensors"}
|
||||
mergeAggregatesInto(&merged, aggregates)
|
||||
shardTensorTotal, hashes := mergeShardsInto(&merged, shards)
|
||||
|
||||
// Keep merged UserMetadata globally key-sorted so the SBOM is stable
|
||||
sort.Slice(merged.UserMetadata, func(i, j int) bool {
|
||||
return merged.UserMetadata[i].Key < merged.UserMetadata[j].Key
|
||||
})
|
||||
|
||||
if merged.TensorCount == 0 {
|
||||
merged.TensorCount = shardTensorTotal
|
||||
}
|
||||
if merged.ShardCount == 0 {
|
||||
if len(shards) > 0 {
|
||||
merged.ShardCount = len(shards)
|
||||
} else {
|
||||
merged.ShardCount = 1
|
||||
}
|
||||
}
|
||||
merged.MetadataHash = rollupHash(hashes)
|
||||
|
||||
// Parts only carry value for multi-shard models; for a single shard the
|
||||
// outer view already exposes every per-shard field.
|
||||
if len(shards) > 1 {
|
||||
parts := append([]pkg.SafeTensorsModelInfo(nil), shards...)
|
||||
sort.Slice(parts, func(i, j int) bool {
|
||||
return parts[i].MetadataHash < parts[j].MetadataHash
|
||||
})
|
||||
merged.Parts = parts
|
||||
}
|
||||
|
||||
return pkg.Package{
|
||||
Locations: locSet,
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: merged,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAggregatesInto(merged *pkg.SafeTensorsModelInfo, aggregates []pkg.SafeTensorsModelInfo) {
|
||||
for _, a := range aggregates {
|
||||
if merged.TensorCount == 0 {
|
||||
merged.TensorCount = a.TensorCount
|
||||
}
|
||||
firstNonEmpty(&merged.TotalSize, a.TotalSize)
|
||||
firstNonEmpty(&merged.Quantization, a.Quantization)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeShardsInto folds the per-shard header metadata into merged, returning
|
||||
// the summed shard TensorCount and the list of non-empty per-shard hashes for
|
||||
// the rollup. Shards carry only the content-derived fields (Quantization,
|
||||
// Parameters, UserMetadata), so those are the only fields folded in here.
|
||||
// TensorCount and Parameters are summed because each shard holds a distinct
|
||||
// slice of the model; Quantization takes the first value since all shards share
|
||||
// one precision.
|
||||
func mergeShardsInto(merged *pkg.SafeTensorsModelInfo, shards []pkg.SafeTensorsModelInfo) (shardTensorTotal uint64, hashes []string) {
|
||||
seenKV := map[string]bool{}
|
||||
for _, s := range shards {
|
||||
shardTensorTotal += s.TensorCount
|
||||
merged.Parameters += s.Parameters
|
||||
firstNonEmpty(&merged.Quantization, s.Quantization)
|
||||
for _, kv := range s.UserMetadata {
|
||||
if seenKV[kv.Key] {
|
||||
continue
|
||||
}
|
||||
seenKV[kv.Key] = true
|
||||
merged.UserMetadata = append(merged.UserMetadata, kv)
|
||||
}
|
||||
if s.MetadataHash != "" {
|
||||
hashes = append(hashes, s.MetadataHash)
|
||||
}
|
||||
}
|
||||
return shardTensorTotal, hashes
|
||||
}
|
||||
|
||||
func firstNonEmpty(dst *string, v string) {
|
||||
if *dst == "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
// unionLocations gathers every location from every member into a single set.
|
||||
func unionLocations(members []pkg.Package) file.LocationSet {
|
||||
out := file.NewLocationSet()
|
||||
for _, m := range members {
|
||||
for _, l := range m.Locations.ToSlice() {
|
||||
out.Add(l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bucketSafeTensorsMembers splits group members into aggregate-flavored entries
|
||||
// (no MetadataHash — Docker AI config blob or sharded index) and shard-flavored
|
||||
// entries (carry a content-derived MetadataHash from a header parser).
|
||||
func bucketSafeTensorsMembers(members []pkg.Package) (aggregates, shards []pkg.SafeTensorsModelInfo) {
|
||||
for _, m := range members {
|
||||
md, ok := m.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if md.MetadataHash != "" {
|
||||
shards = append(shards, md)
|
||||
continue
|
||||
}
|
||||
aggregates = append(aggregates, md)
|
||||
}
|
||||
return aggregates, shards
|
||||
}
|
||||
|
||||
func rollupHash(hashes []string) string {
|
||||
if len(hashes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(hashes) == 1 {
|
||||
return hashes[0]
|
||||
}
|
||||
sorted := append([]string(nil), hashes...)
|
||||
sort.Strings(sorted)
|
||||
return fmt.Sprintf("%016x", xxhash.Sum64String(strings.Join(sorted, "|")))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// stPkg builds a model package carrying the given metadata, with each path
|
||||
// recorded as a primary-evidence location.
|
||||
func stPkg(md pkg.SafeTensorsModelInfo, paths ...string) pkg.Package {
|
||||
locs := make([]file.Location, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
locs = append(locs, file.NewLocation(p).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
|
||||
}
|
||||
return pkg.Package{Type: pkg.ModelPkg, Metadata: md, Locations: file.NewLocationSet(locs...)}
|
||||
}
|
||||
|
||||
// shardMeta is a content-derived shard entry: it carries a MetadataHash, which is
|
||||
// what marks a group member as a shard (vs. a hash-less aggregate config blob).
|
||||
func shardMeta(hash string, tensorCount uint64) pkg.SafeTensorsModelInfo {
|
||||
return pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: tensorCount,
|
||||
Quantization: "BF16",
|
||||
Parameters: 1000,
|
||||
MetadataHash: hash,
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeSafeTensorsGroup exercises the rollup contract directly (the cataloger
|
||||
// tests cover it only as a side effect of the merge processor). It locks how a
|
||||
// group's per-member metadata folds into one package: tensor-count summing,
|
||||
// aggregate-over-shard field precedence, UserMetadata dedup + sorting, Parts
|
||||
// rollup, ShardCount derivation, and the content MetadataHash rollup.
|
||||
func TestMergeSafeTensorsGroup(t *testing.T) {
|
||||
t.Run("single shard: hash passes through, ShardCount 1, no Parts", func(t *testing.T) {
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(shardMeta("aaaa", 5), "/m/a.safetensors")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, pkg.ModelPkg, out.Type)
|
||||
assert.Equal(t, 1, md.ShardCount)
|
||||
assert.Equal(t, uint64(5), md.TensorCount)
|
||||
assert.Equal(t, "aaaa", md.MetadataHash, "a single shard's hash passes through unchanged")
|
||||
assert.Nil(t, md.Parts, "single-shard models do not populate Parts")
|
||||
})
|
||||
|
||||
t.Run("multi-shard: tensors summed, Parts sorted by hash, rollup is order-independent", func(t *testing.T) {
|
||||
in := []pkg.Package{
|
||||
stPkg(shardMeta("cccc", 3), "/m/c.safetensors"),
|
||||
stPkg(shardMeta("aaaa", 3), "/m/a.safetensors"),
|
||||
stPkg(shardMeta("bbbb", 3), "/m/b.safetensors"),
|
||||
}
|
||||
out := mergeSafeTensorsGroup(in)
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, 3, md.ShardCount)
|
||||
assert.Equal(t, uint64(9), md.TensorCount, "tensor counts are summed across shards")
|
||||
assert.Equal(t, uint64(3000), md.Parameters, "parameter counts are summed across shards")
|
||||
require.Len(t, md.Parts, 3)
|
||||
assert.Equal(t,
|
||||
[]string{"aaaa", "bbbb", "cccc"},
|
||||
[]string{md.Parts[0].MetadataHash, md.Parts[1].MetadataHash, md.Parts[2].MetadataHash},
|
||||
"Parts are sorted by metadata hash",
|
||||
)
|
||||
assert.Equal(t, rollupHash([]string{"aaaa", "bbbb", "cccc"}), md.MetadataHash)
|
||||
|
||||
// the rollup hash must not depend on the order members arrive in
|
||||
shuffled := []pkg.Package{
|
||||
stPkg(shardMeta("bbbb", 3), "/m/b.safetensors"),
|
||||
stPkg(shardMeta("cccc", 3), "/m/c.safetensors"),
|
||||
stPkg(shardMeta("aaaa", 3), "/m/a.safetensors"),
|
||||
}
|
||||
out2 := mergeSafeTensorsGroup(shuffled)
|
||||
assert.Equal(t, md.MetadataHash, out2.Metadata.(pkg.SafeTensorsModelInfo).MetadataHash)
|
||||
})
|
||||
|
||||
t.Run("aggregate fields win over shard-derived fields", func(t *testing.T) {
|
||||
// an aggregate (OCI config blob) carries no MetadataHash but declares the
|
||||
// authoritative totals.
|
||||
aggregate := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: 999,
|
||||
TotalSize: "5.00GB",
|
||||
Quantization: "Q4_K_M",
|
||||
}
|
||||
in := []pkg.Package{
|
||||
stPkg(aggregate, "/"),
|
||||
stPkg(shardMeta("aaaa", 3), "/"),
|
||||
stPkg(shardMeta("bbbb", 3), "/"),
|
||||
}
|
||||
out := mergeSafeTensorsGroup(in)
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(999), md.TensorCount, "aggregate TensorCount is authoritative; shard counts are not summed in")
|
||||
assert.Equal(t, "5.00GB", md.TotalSize)
|
||||
assert.Equal(t, uint64(2000), md.Parameters, "parameters are always measured from the shards (summed), not taken from the aggregate")
|
||||
assert.Equal(t, "Q4_K_M", md.Quantization, "aggregate quantization wins over the shard dtype")
|
||||
assert.Equal(t, 2, md.ShardCount, "ShardCount comes from the number of shards, not the aggregate")
|
||||
assert.Equal(t, rollupHash([]string{"aaaa", "bbbb"}), md.MetadataHash, "the content hash still rolls up the shard hashes")
|
||||
})
|
||||
|
||||
t.Run("aggregate-only group: ShardCount 1, empty hash, no Parts", func(t *testing.T) {
|
||||
aggregate := pkg.SafeTensorsModelInfo{Format: "safetensors", TensorCount: 42, TotalSize: "1.00GB"}
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(aggregate, "/")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(42), md.TensorCount)
|
||||
assert.Equal(t, 1, md.ShardCount, "a group with no shards still reports a single shard")
|
||||
assert.Equal(t, "", md.MetadataHash, "there are no shard hashes to roll up")
|
||||
assert.Nil(t, md.Parts)
|
||||
})
|
||||
|
||||
t.Run("UserMetadata: keys merged and sorted, first value wins on conflict", func(t *testing.T) {
|
||||
// keys are intentionally unsorted within each shard so the assertion proves
|
||||
// the merge re-sorts globally; "format" appears in both shards so dedup
|
||||
// precedence (first wins) is exercised too.
|
||||
s1 := shardMeta("aaaa", 1)
|
||||
s1.UserMetadata = pkg.KeyValues{{Key: "format", Value: "pt"}, {Key: "author", Value: "alice"}}
|
||||
s2 := shardMeta("bbbb", 1)
|
||||
s2.UserMetadata = pkg.KeyValues{{Key: "format", Value: "gguf"}, {Key: "license", Value: "mit"}}
|
||||
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(s1, "/m/a.safetensors"), stPkg(s2, "/m/b.safetensors")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, pkg.KeyValues{
|
||||
{Key: "author", Value: "alice"},
|
||||
{Key: "format", Value: "pt"}, // first shard's value wins over s2's "gguf"
|
||||
{Key: "license", Value: "mit"},
|
||||
}, md.UserMetadata)
|
||||
})
|
||||
|
||||
t.Run("members without safetensors metadata are ignored in the rollup", func(t *testing.T) {
|
||||
notST := pkg.Package{
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: pkg.GGUFFileHeader{},
|
||||
Locations: file.NewLocationSet(file.NewLocation("/m/x.gguf")),
|
||||
}
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(shardMeta("aaaa", 2), "/m/a.safetensors"), notST})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(2), md.TensorCount, "only the safetensors shard contributes")
|
||||
assert.Equal(t, 1, md.ShardCount)
|
||||
assert.Equal(t, "aaaa", md.MetadataHash)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRollupHash locks the cross-source content-fingerprint rollup: empty input
|
||||
// yields no hash, a lone shard's hash passes through unchanged (so a single-shard
|
||||
// model fingerprints identically across directory and OCI sources), and multiple
|
||||
// shards fold into one order-independent digest.
|
||||
func TestRollupHash(t *testing.T) {
|
||||
assert.Equal(t, "", rollupHash(nil), "no hashes → empty")
|
||||
assert.Equal(t, "solo", rollupHash([]string{"solo"}), "a single hash passes through unchanged")
|
||||
|
||||
ab := rollupHash([]string{"a", "b"})
|
||||
ba := rollupHash([]string{"b", "a"})
|
||||
assert.Equal(t, ab, ba, "the rollup is independent of input order")
|
||||
assert.Len(t, ab, 16, "a multi-hash rollup is a 16-char xxhash")
|
||||
assert.NotEqual(t, "a", ab)
|
||||
assert.NotEqual(t, "b", ab)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ai
|
||||
|
||||
import "path"
|
||||
|
||||
// pickSafeTensorsName implements the documented naming precedence chain:
|
||||
func pickSafeTensorsName(nameOrPath, fallbackName string) string {
|
||||
if nameOrPath != "" {
|
||||
return path.Base(nameOrPath)
|
||||
}
|
||||
return fallbackName
|
||||
}
|
||||
|
||||
// safeTensorsDirName returns the directory-scan naming fallback: the base name
|
||||
// of the group's parent directory (the group key is already that directory).
|
||||
func safeTensorsDirName(directory string) string {
|
||||
base := path.Base(directory)
|
||||
switch base {
|
||||
case "/", ".", "":
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -20,3 +20,17 @@ func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license st
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// newSafeTensorsPackage creates a SafeTensors package with the given metadata
|
||||
// and locations. Name and Licenses are intentionally not set here and done at the processor level
|
||||
func newSafeTensorsPackage(metadata *pkg.SafeTensorsModelInfo, locations ...file.Location) pkg.Package {
|
||||
p := pkg.Package{
|
||||
Locations: file.NewLocationSet(locations...),
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: *metadata,
|
||||
// PURL is intentionally not set: package-url has not yet finalized ML model support.
|
||||
}
|
||||
p.SetID()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// SafeTensors file format: [8 bytes u64 LE header size] [N bytes JSON header] [tensor data].
|
||||
// Reference: https://github.com/huggingface/safetensors#format
|
||||
const (
|
||||
maxSafeTensorsHeaderSize = 100 * 1024 * 1024 // 100MB ceiling on header JSON to prevent OOM
|
||||
)
|
||||
|
||||
// safeTensorsHeader is the decoded JSON header. Tensor entries live alongside a
|
||||
// reserved "__metadata__" key holding a string-to-string producer map. We decode
|
||||
// tensor entries into a generic map so we can iterate and count without a fixed
|
||||
// schema for every field.
|
||||
type safeTensorsHeader struct {
|
||||
metadata map[string]string
|
||||
tensors map[string]safeTensorsEntry
|
||||
}
|
||||
|
||||
// safeTensorsEntry describes a single tensor within the header JSON.
|
||||
type safeTensorsEntry struct {
|
||||
DType string `json:"dtype"`
|
||||
Shape []int64 `json:"shape"`
|
||||
DataOffsets []int64 `json:"data_offsets"`
|
||||
}
|
||||
|
||||
// readSafeTensorsHeader reads and parses the JSON header from a .safetensors
|
||||
// file (the leading `[8-byte LE length] [length bytes of JSON]` block) and
|
||||
// returns the decoded header.
|
||||
func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, error) {
|
||||
var lenBuf [8]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, fmt.Errorf("failed to read header length: %w", err)
|
||||
}
|
||||
headerLen := binary.LittleEndian.Uint64(lenBuf[:])
|
||||
if headerLen == 0 {
|
||||
return nil, fmt.Errorf("safetensors header length is zero")
|
||||
}
|
||||
if headerLen > maxSafeTensorsHeaderSize {
|
||||
return nil, fmt.Errorf("safetensors header size %d exceeds maximum %d", headerLen, maxSafeTensorsHeaderSize)
|
||||
}
|
||||
|
||||
// Read incrementally rather than pre-allocating headerLen up front
|
||||
body, err := io.ReadAll(io.LimitReader(r, int64(headerLen)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header body: %w", err)
|
||||
}
|
||||
if uint64(len(body)) != headerLen {
|
||||
return nil, fmt.Errorf("safetensors header truncated: read %d of %d bytes", len(body), headerLen)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode safetensors header JSON: %w", err)
|
||||
}
|
||||
|
||||
h := &safeTensorsHeader{tensors: make(map[string]safeTensorsEntry, len(raw))}
|
||||
for key, val := range raw {
|
||||
if key == "__metadata__" {
|
||||
if err := json.Unmarshal(val, &h.metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode __metadata__: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var entry safeTensorsEntry
|
||||
if err := json.Unmarshal(val, &entry); err != nil {
|
||||
// Not all entries must conform; skip anything we cannot decode rather than fail.
|
||||
continue
|
||||
}
|
||||
h.tensors[key] = entry
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
sizeByDType[t.DType] += count
|
||||
}
|
||||
var best string
|
||||
var bestSize uint64
|
||||
for dtype, size := range sizeByDType {
|
||||
if size > bestSize || (size == bestSize && dtype < best) {
|
||||
best = dtype
|
||||
bestSize = size
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// metadataHash returns a stable xxhash64 over the logical tensor content
|
||||
// (name + dtype + shape) plus the __metadata__ map. Tensor keys are sorted to
|
||||
// keep the hash deterministic across producers.
|
||||
func (h *safeTensorsHeader) metadataHash() string {
|
||||
type logicalEntry struct {
|
||||
Name string `json:"name"`
|
||||
DType string `json:"dtype"`
|
||||
Shape []int64 `json:"shape"`
|
||||
}
|
||||
entries := make([]logicalEntry, 0, len(h.tensors))
|
||||
for name, t := range h.tensors {
|
||||
entries = append(entries, logicalEntry{Name: name, DType: t.DType, Shape: t.Shape})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
type hashInput struct {
|
||||
Tensors []logicalEntry `json:"tensors"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
b, err := json.Marshal(hashInput{Tensors: entries, Metadata: h.metadata})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%016x", xxhash.Sum64(b))
|
||||
}
|
||||
|
||||
// userMetadataKeyValues converts the safetensors __metadata__ map into a
|
||||
// KeyValues slice sorted by key, so SBOM output is stable across runs. Returns
|
||||
// nil for empty input (omitempty then drops the field).
|
||||
func userMetadataKeyValues(m map[string]string) pkg.KeyValues {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make(pkg.KeyValues, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, pkg.KeyValue{Key: k, Value: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeDType maps a safetensors/torch dtype label to an uppercase quantization
|
||||
// shorthand matching conventions used elsewhere in syft (e.g., BF16, F16, I8).
|
||||
func normalizeDType(dtype string) string {
|
||||
switch strings.ToUpper(dtype) {
|
||||
case "BF16":
|
||||
return "BF16"
|
||||
case "F16", "FP16", "FLOAT16", "HALF":
|
||||
return "F16"
|
||||
case "F32", "FP32", "FLOAT32", "FLOAT":
|
||||
return "F32"
|
||||
case "F64", "FP64", "FLOAT64", "DOUBLE":
|
||||
return "F64"
|
||||
case "I8", "INT8":
|
||||
return "I8"
|
||||
case "U8", "UINT8":
|
||||
return "U8"
|
||||
case "I16", "INT16":
|
||||
return "I16"
|
||||
case "I32", "INT32":
|
||||
return "I32"
|
||||
case "I64", "INT64":
|
||||
return "I64"
|
||||
case "BOOL":
|
||||
return "BOOL"
|
||||
default:
|
||||
return strings.ToUpper(dtype)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
)
|
||||
|
||||
// parseSafeTensorsFile decodes the JSON header of a single .safetensors file
|
||||
// (also called once per shard for sharded models) and emits a nameless package
|
||||
// whose metadata is derived purely from the header bytes. Naming, license
|
||||
// resolution, sibling enrichment, and cross-shard rollup are all handled by
|
||||
// safeTensorsMergeProcessor.
|
||||
func parseSafeTensorsFile(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
header, err := readSafeTensorsHeader(&io.LimitedReader{R: reader, N: maxSafeTensorsHeaderSize + 8})
|
||||
if err != nil {
|
||||
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(),
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file")
|
||||
}
|
||||
|
||||
// integrity check
|
||||
var _ generic.Parser = parseSafeTensorsFile
|
||||
@@ -0,0 +1,117 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
)
|
||||
|
||||
// Docker AI OCI media types used by Docker Model Runner artifacts.
|
||||
const (
|
||||
dockerAIModelFileMediaType = "application/vnd.docker.ai.model.file"
|
||||
dockerAILicenseMediaType = "application/vnd.docker.ai.license"
|
||||
dockerAISafeTensorsMediaType = "application/vnd.docker.ai.safetensors"
|
||||
)
|
||||
|
||||
// dockerAIModelConfigMediaTypes are the model-config schema versions this
|
||||
// cataloger understands. Versions are enumerated explicitly rather than matched
|
||||
// with a wildcard so that a future, potentially breaking, config schema is not
|
||||
// silently consumed; add a new version here only after confirming the fields we
|
||||
// parse still apply.
|
||||
var dockerAIModelConfigMediaTypes = []string{
|
||||
"application/vnd.docker.ai.model.config.v0.1+json",
|
||||
"application/vnd.docker.ai.model.config.v0.2+json",
|
||||
}
|
||||
|
||||
// dockerAIModelConfig mirrors the JSON shape of the vnd.docker.ai.model.config
|
||||
// blob written by Docker Model Runner for AI artifacts. Only fields we use are
|
||||
// declared; unknown fields are ignored.
|
||||
type dockerAIModelConfig struct {
|
||||
Config struct {
|
||||
Format string `json:"format"`
|
||||
Quantization string `json:"quantization"`
|
||||
Size string `json:"size"`
|
||||
SafeTensors struct {
|
||||
TensorCount json.Number `json:"tensor_count"`
|
||||
} `json:"safetensors"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// parseSafeTensorsOCIConfig decodes the Docker AI model-config blob
|
||||
func parseSafeTensorsOCIConfig(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(reader, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read docker AI model config: %w", err)
|
||||
}
|
||||
|
||||
var cfg dockerAIModelConfig
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to decode docker AI model config: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(cfg.Config.Format, "safetensors") {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Parameters is intentionally not read from the config blob: we measure the
|
||||
// true parameter count from the SafeTensors layer headers (parseSafeTensorsOCILayer)
|
||||
// so OCI and directory scans of the same model agree, rather than trusting the
|
||||
// producer-supplied label here.
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
Quantization: cfg.Config.Quantization,
|
||||
TotalSize: cfg.Config.Size,
|
||||
}
|
||||
if n, err := cfg.Config.SafeTensors.TensorCount.Int64(); err == nil && n > 0 {
|
||||
md.TensorCount = uint64(n)
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config")
|
||||
}
|
||||
|
||||
// parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight
|
||||
// layer fetched from an OCI model artifact
|
||||
func parseSafeTensorsOCILayer(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
header, err := readSafeTensorsHeader(&io.LimitedReader{R: reader, N: maxSafeTensorsHeaderSize + 8})
|
||||
if err != nil {
|
||||
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(),
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// integrity checks
|
||||
var (
|
||||
_ generic.Parser = parseSafeTensorsOCIConfig
|
||||
_ generic.Parser = parseSafeTensorsOCILayer
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,172 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
// safeTensorsMergeProcessor owns naming, license resolution, and final package
|
||||
// assembly. SafeTensors packages reach it nameless from the parsers; it groups
|
||||
// them per model, merges the per-shard metadata, resolves a name + licenses, and
|
||||
// drops any model it cannot name.
|
||||
func safeTensorsMergeProcessor(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
if len(pkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// Separate packages with names from those without
|
||||
var namedPkgs []pkg.Package
|
||||
var namelessHeaders []pkg.GGUFFileHeader
|
||||
|
||||
for _, p := range pkgs {
|
||||
if p.Name != "" {
|
||||
namedPkgs = append(namedPkgs, p)
|
||||
} else {
|
||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
// We do not want a kv hash for nameless headers
|
||||
header.MetadataKeyValuesHash = ""
|
||||
namelessHeaders = append(namelessHeaders, header)
|
||||
}
|
||||
}
|
||||
// Note: we do NOT early-return when err != nil. A non-nil err here means some
|
||||
// file in the run failed to parse, but the successfully-parsed packages are
|
||||
// still nameless until this processor names or drops them. Skipping that work
|
||||
// would let those nameless packages flow downstream, where they are silently
|
||||
// dropped by missing-name compliance — turning one bad file into the loss of
|
||||
// every otherwise-valid model. So we always name/drop and propagate err.
|
||||
stPkgs, other := partitionSafeTensorsPackages(pkgs)
|
||||
if len(stPkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// If there are no named packages, return nothing
|
||||
if len(namedPkgs) == 0 {
|
||||
return nil, rels, err
|
||||
if fromOCIArtifact(stPkgs) {
|
||||
return append(other, mergeOCIModel(ctx, resolver, stPkgs)...), rels, err
|
||||
}
|
||||
return append(other, mergeDirModels(ctx, resolver, stPkgs)...), rels, err
|
||||
}
|
||||
|
||||
// merge nameless headers into a single named package;
|
||||
// if there are multiple named packages, return them without trying to merge headers.
|
||||
// we cannot determine which nameless headers belong to which package
|
||||
// this is because the order we receive the gguf headers in is not guaranteed
|
||||
// to match the layer order in the original oci image
|
||||
if len(namedPkgs) == 1 && len(namelessHeaders) > 0 {
|
||||
winner := &namedPkgs[0]
|
||||
if header, ok := winner.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
header.Parts = namelessHeaders
|
||||
winner.Metadata = header
|
||||
// partitionSafeTensorsPackages separates safetensors packages from anything else
|
||||
// flowing through the processor.
|
||||
func partitionSafeTensorsPackages(pkgs []pkg.Package) (safeTensors, other []pkg.Package) {
|
||||
for _, p := range pkgs {
|
||||
if _, ok := p.Metadata.(pkg.SafeTensorsModelInfo); ok {
|
||||
safeTensors = append(safeTensors, p)
|
||||
continue
|
||||
}
|
||||
other = append(other, p)
|
||||
}
|
||||
return safeTensors, other
|
||||
}
|
||||
|
||||
// Largest number of key value
|
||||
// fromOCIArtifact reports whether the packages came from an OCI model artifact.
|
||||
// That source (the ContainerImageModel resolver) presents every layer at the
|
||||
// virtual path "/", whereas a filesystem scan always carries a real file path. A
|
||||
// single scan is one source, so the first package is representative of the rest.
|
||||
func fromOCIArtifact(pkgs []pkg.Package) bool {
|
||||
loc := primaryEvidenceLocation(pkgs[0])
|
||||
return loc != nil && loc.RealPath == "/"
|
||||
}
|
||||
|
||||
return namedPkgs, rels, err
|
||||
// mergeOCIModel treats the whole OCI artifact as a single model: every layer
|
||||
// merges into one package, named from the artifact's config.json/README or its
|
||||
// image reference.
|
||||
func mergeOCIModel(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package) []pkg.Package {
|
||||
merged := mergeSafeTensorsGroup(pkgs)
|
||||
|
||||
md := merged.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
id := resolveSafeTensorsOCIIdentity(ctx, resolver, &md)
|
||||
merged.Metadata = md // write architecture enrichment back before assembly
|
||||
|
||||
if p, ok := assembleSafeTensorsPackage(merged, id); ok {
|
||||
return []pkg.Package{p}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeDirModels groups filesystem-scanned files by their parent directory and
|
||||
// emits one model per directory
|
||||
func mergeDirModels(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package) []pkg.Package {
|
||||
groups := groupByParentDir(pkgs)
|
||||
|
||||
// deterministic iteration order so the SBOM doesn't depend on map order
|
||||
dirs := make([]string, 0, len(groups))
|
||||
for dir := range groups {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
|
||||
var out []pkg.Package
|
||||
for _, dir := range dirs {
|
||||
merged := mergeSafeTensorsGroup(groups[dir])
|
||||
|
||||
md := merged.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
id := resolveSafeTensorsDirIdentity(ctx, resolver, dir, &md)
|
||||
merged.Metadata = md // write architecture enrichment back before assembly
|
||||
|
||||
if p, ok := assembleSafeTensorsPackage(merged, id); ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupByParentDir buckets filesystem-scanned models by the directory their
|
||||
// primary-evidence file lives in.
|
||||
|
||||
// This encodes a deliberate assumption: a directory holds one logical
|
||||
// model, so every .safetensors file in a directory is treated as a shard of the
|
||||
// same modeland merged into one package.
|
||||
// The trade-off is that if a directory happens to contain several unrelated models,
|
||||
// they are merged into one package rather than reported separately.
|
||||
// We accept that because the conventional on-disk layout gives each model
|
||||
// (with all of its shards) its own directory.
|
||||
// We have no reliable per-file signal to tell co-located-but-independent models apart.
|
||||
func groupByParentDir(pkgs []pkg.Package) map[string][]pkg.Package {
|
||||
out := make(map[string][]pkg.Package)
|
||||
for _, p := range pkgs {
|
||||
loc := primaryEvidenceLocation(p)
|
||||
if loc == nil {
|
||||
continue
|
||||
}
|
||||
dir := path.Dir(loc.RealPath)
|
||||
out[dir] = append(out[dir], p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func primaryEvidenceLocation(p pkg.Package) *file.Location {
|
||||
locs := p.Locations.ToSlice()
|
||||
for i, l := range locs {
|
||||
if l.Annotations != nil && l.Annotations[pkg.EvidenceAnnotationKey] == pkg.PrimaryEvidenceAnnotation {
|
||||
return &locs[i]
|
||||
}
|
||||
}
|
||||
if len(locs) > 0 {
|
||||
return &locs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeTensorsIdentity is the fully-resolved naming/license result for a model.
|
||||
// Each source resolver (dir or OCI) populates it so assembly stays source-agnostic.
|
||||
type safeTensorsIdentity struct {
|
||||
nameOrPath string
|
||||
fallbackName string
|
||||
licenses []pkg.License
|
||||
supporting []file.Location
|
||||
}
|
||||
|
||||
// assembleSafeTensorsPackage finalizes a merged model from its resolved identity:
|
||||
// it picks the name, attaches licenses and supporting evidence, and sets the ID.
|
||||
// A model with no name source is dropped (ok=false).
|
||||
func assembleSafeTensorsPackage(merged pkg.Package, id safeTensorsIdentity) (pkg.Package, bool) {
|
||||
name := pickSafeTensorsName(id.nameOrPath, id.fallbackName)
|
||||
if name == "" {
|
||||
log.Debugf("dropped safetensors model package (metadata hash %q): no name source",
|
||||
merged.Metadata.(pkg.SafeTensorsModelInfo).MetadataHash)
|
||||
return pkg.Package{}, false
|
||||
}
|
||||
|
||||
if len(id.licenses) > 0 {
|
||||
merged.Licenses = pkg.NewLicenseSet(id.licenses...)
|
||||
}
|
||||
for _, loc := range id.supporting {
|
||||
merged.Locations.Add(loc.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation))
|
||||
}
|
||||
|
||||
merged.Name = name
|
||||
merged.SetID()
|
||||
return merged, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# SafeTensors header fixtures
|
||||
|
||||
These fixtures are `[8-byte length prefix + JSON header]` captures from
|
||||
public Docker AI model artifacts on the registry.
|
||||
|
||||
`extract_header.go` does a range-GET of the first several MB of the layer,
|
||||
slices off just `[prefix + JSON header]`, and writes
|
||||
that to disk.
|
||||
|
||||
## Refreshing a fixture
|
||||
|
||||
```sh
|
||||
# from the package root
|
||||
go run ./testdata/safetensors/extract_header.go \
|
||||
docker.io/ai/nomic-embed-text-v2-moe-safetensors:475M \
|
||||
./testdata/safetensors/nomic-embed-475M.header.safetensors
|
||||
```
|
||||
|
||||
The tool prints the layer digest it selected and the number of top-level keys
|
||||
in the captured header. If you see `header length N does not fit in M fetched
|
||||
bytes`, raise `fetchBytes` in `extract_header.go` and rerun.
|
||||
|
||||
## Notes
|
||||
|
||||
- Pick one shard, not the full sharded set. The fixture is meant to exercise
|
||||
the per-shard parser; merging across shards has its own tests.
|
||||
- Don't commit anything larger than ~1 MB. If a model has an unusually large
|
||||
header, capture a smaller model instead.
|
||||
@@ -0,0 +1,149 @@
|
||||
// extract_header is a manual fixture tool that captures the real on-disk
|
||||
// safetensors header from a Docker AI OCI model artifact (a vnd.docker.ai.safetensors
|
||||
// layer) and writes just [8-byte length prefix + JSON header] to a destination
|
||||
// file. Tensor data following the header is never downloaded, so the resulting
|
||||
// fixture is a few KB to a few MB even for multi-GB models.
|
||||
//
|
||||
// This file lives under testdata/ so the Go build system ignores it. Run it
|
||||
// manually when refreshing fixtures:
|
||||
//
|
||||
// go run ./testdata/safetensors/extract_header.go \
|
||||
// docker.io/ai/nomic-embed-text-v2-moe-safetensors:475M \
|
||||
// ./testdata/safetensors/nomic-embed-475M.header.safetensors
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
const (
|
||||
safetensorsLayerMediaType = "application/vnd.docker.ai.safetensors"
|
||||
// 8 MB matches maxHeaderBytes in the OCI model source. Real model headers
|
||||
// are well under 1 MB; the extra slack covers outliers.
|
||||
fetchBytes = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Fprintf(os.Stderr, "usage: %s <registry-ref> <output-path>\n", os.Args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Args[1], os.Args[2]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(refStr, outPath string) error {
|
||||
ctx := context.Background()
|
||||
ref, err := name.ParseReference(refStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse reference: %w", err)
|
||||
}
|
||||
|
||||
opts := []remote.Option{
|
||||
remote.WithAuthFromKeychain(authn.DefaultKeychain),
|
||||
remote.WithContext(ctx),
|
||||
}
|
||||
|
||||
desc, err := remote.Get(ref, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch descriptor: %w", err)
|
||||
}
|
||||
|
||||
manifest := &v1.Manifest{}
|
||||
if err := json.Unmarshal(desc.Manifest, manifest); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
|
||||
weightLayer := pickWeightLayer(manifest)
|
||||
if weightLayer == nil {
|
||||
return fmt.Errorf("no %q layer found in %s", safetensorsLayerMediaType, ref)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "selected layer %s (%d bytes on-disk)\n", weightLayer.Digest, weightLayer.Size)
|
||||
|
||||
prefix, err := fetchPrefix(ctx, ref, weightLayer.Digest, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch layer prefix: %w", err)
|
||||
}
|
||||
|
||||
header, err := sliceHeader(prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract header: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outPath, header, 0o644); err != nil {
|
||||
return fmt.Errorf("write fixture: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "wrote %d bytes to %s\n", len(header), outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickWeightLayer returns the first vnd.docker.ai.safetensors layer in the
|
||||
// manifest, or nil if none exists. For sharded models we deliberately only
|
||||
// capture one shard: the fixture is meant to exercise the parser, not the
|
||||
// merge step.
|
||||
func pickWeightLayer(manifest *v1.Manifest) *v1.Descriptor {
|
||||
for i := range manifest.Layers {
|
||||
if string(manifest.Layers[i].MediaType) == safetensorsLayerMediaType {
|
||||
return &manifest.Layers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchPrefix range-reads the first fetchBytes of a layer. Closing the reader
|
||||
// terminates the underlying HTTP body, so we never download the tensor data
|
||||
// that follows the header.
|
||||
func fetchPrefix(_ context.Context, ref name.Reference, digest v1.Hash, opts []remote.Option) ([]byte, error) {
|
||||
layer, err := remote.Layer(ref.Context().Digest(digest.String()), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := layer.Compressed()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
buf := make([]byte, fetchBytes)
|
||||
n, err := io.ReadFull(reader, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
// sliceHeader reads the 8-byte little-endian length prefix and returns just
|
||||
// [prefix + JSON header]. It also probes the JSON to make sure the captured
|
||||
// fixture is well-formed, so we never commit a half-truncated header.
|
||||
func sliceHeader(buf []byte) ([]byte, error) {
|
||||
if len(buf) < 8 {
|
||||
return nil, fmt.Errorf("short read: only %d bytes", len(buf))
|
||||
}
|
||||
headerLen := binary.LittleEndian.Uint64(buf[:8])
|
||||
if headerLen == 0 {
|
||||
return nil, fmt.Errorf("header length is zero")
|
||||
}
|
||||
if headerLen > uint64(len(buf)-8) {
|
||||
return nil, fmt.Errorf("header length %d does not fit in %d fetched bytes; increase fetchBytes", headerLen, len(buf))
|
||||
}
|
||||
|
||||
out := buf[:8+int(headerLen)]
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out[8:], &probe); err != nil {
|
||||
return nil, fmt.Errorf("captured JSON does not parse: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "header parses cleanly: %d top-level keys\n", len(probe))
|
||||
return out, nil
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -88,7 +88,7 @@ func Test_stdlibPackageAndRelationships(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
|
||||
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, tt.pkgs)
|
||||
assert.Len(t, gotPkgs, tt.wantPkgs)
|
||||
assert.Len(t, gotRels, tt.wantRels)
|
||||
@@ -138,7 +138,7 @@ func Test_stdlibPackageAndRelationships_values(t *testing.T) {
|
||||
Type: artifact.DependencyOfRelationship,
|
||||
}
|
||||
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
|
||||
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, []pkg.Package{p})
|
||||
require.Len(t, gotPkgs, 1)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package pkg
|
||||
|
||||
// SafeTensorsModelInfo holds the model details extracted from SafeTensors content.
|
||||
// SafeTensors is a simple, safe serialization format for storing tensors, used
|
||||
// as the default weight format for Hugging Face transformer models.
|
||||
// Model name, license, and version live on the syft Package
|
||||
type SafeTensorsModelInfo struct {
|
||||
// Format is the source format label (always "safetensors" for this metadata type).
|
||||
// Present because the Docker AI model config blob carries an explicit format field
|
||||
Format string `json:"format,omitempty" cyclonedx:"format"`
|
||||
|
||||
// Architecture is the model architecture (e.g., "LlamaForCausalLM",
|
||||
// "Qwen3MoeForConditionalGeneration"). It is not present in the SafeTensors
|
||||
// header itself; it is enriched from the companion config.json
|
||||
// "architectures" array when one is found alongside the model.
|
||||
Architecture string `json:"architecture,omitempty" cyclonedx:"architecture"`
|
||||
|
||||
// Quantization describes tensor precision (e.g., "BF16", "F16", "F32", "INT8").
|
||||
Quantization string `json:"quantization,omitempty" cyclonedx:"quantization"`
|
||||
|
||||
// Parameters is the total number of model parameters, computed from the tensor
|
||||
// shapes in the SafeTensors header(s). For a sharded model it is the sum across
|
||||
// every shard.
|
||||
Parameters uint64 `json:"parameters,omitempty" cyclonedx:"parameters"`
|
||||
|
||||
// TensorCount is the number of tensor entries in the file header.
|
||||
TensorCount uint64 `json:"tensorCount,omitempty" cyclonedx:"tensorCount"`
|
||||
|
||||
// TotalSize is the total byte size of tensor data across all shards when known
|
||||
// (from the Docker AI model config "size" field).
|
||||
TotalSize string `json:"totalSize,omitempty" cyclonedx:"totalSize"`
|
||||
|
||||
// ShardCount is the number of .safetensors shards for a sharded model (1 for a
|
||||
// single-file model).
|
||||
ShardCount int `json:"shardCount,omitempty" cyclonedx:"shardCount"`
|
||||
|
||||
// UserMetadata is the optional "__metadata__" map from a .safetensors file header
|
||||
// (string-to-string key/values set by the producer).
|
||||
UserMetadata KeyValues `json:"userMetadata,omitempty" cyclonedx:"userMetadata"`
|
||||
|
||||
// MetadataHash is an xxhash over the on-disk SafeTensors header (sorted tensor
|
||||
// entries + __metadata__). It is derived ONLY from the safetensors file bytes.
|
||||
MetadataHash string `json:"metadataHash,omitempty" cyclonedx:"metadataHash"`
|
||||
|
||||
// Parts contains metadata from additional SafeTensors shards or OCI layers that
|
||||
// were merged into this package during post-processing.
|
||||
Parts []SafeTensorsModelInfo `json:"parts,omitempty" cyclonedx:"parts"`
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package pkg
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/packageurl-go"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/packageurl-go"
|
||||
)
|
||||
|
||||
func TestTypeFromPURL(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user