mirror of
https://github.com/anchore/syft.git
synced 2026-11-02 15:36:41 +01:00
* test: catalog GGUF models larger than the header gguf-parser-go v0.26.3+ rejects a header whose tensors declare more data than follows it. Syft parses only a 50 MB copy of each model, so any larger model is dropped. Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> * fix: fix parser with rollback and verify with test Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> * fix: bound GGUF tensor info before the parser reads it The header pre-scan stopped after the metadata KVs and left tensor infos to gguf-parser-go. Older releases of the library allocate whatever length a tensor name declares, so a tiny file could force a multi-GB allocation, or a fatal out of memory that panic recovery can't catch. - tensor names are bounded like metadata keys (non-empty, length limit, shared string budget) - dimension count is capped at `GGML_MAX_DIMS` - a tensor count larger than the header now fails at EOF in the pre-scan Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: give the GGUF header copy the model's real size The cataloger copies at most 50 MB of a model and parses that copy. Newer gguf-parser-go releases check the tensor data the header declares against the file size, so the truncated copy of any larger model was rejected. - after the header is validated, the copy is extended to the size the resolver reports for the model, leaving the tail as a hole the parser never reads - sources that report no size (OCI model artifacts today) keep the old behavior Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * chore(deps): restore gguf-parser-go v0.26.4 The header copy now carries the model's real size, so the pin to v0.26.0 and its dependabot ignore are no longer needed. This also brings back upstream's string and array length bounds, and the library's own `IsNumeric`. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: keep the extended GGUF header copy sparse on Windows NTFS reserves disk for the full length of a file extended with truncate unless the file is marked sparse, so cataloging a large model on Windows reserved the model's size in the temp dir until cleanup. - the header copy is marked sparse (`FSCTL_SET_SPARSE`) before it is extended - if the filesystem can't make it sparse (FAT32, exFAT), the copy is not extended, and models larger than it fail to parse as before - `golang.org/x/sys` moves from an indirect to a direct dependency Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- 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>
226 lines
6.9 KiB
Go
226 lines
6.9 KiB
Go
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"
|
|
)
|
|
|
|
func TestGGUFCataloger_Globs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fixture string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "obtain gguf files",
|
|
fixture: "testdata/glob-paths",
|
|
expected: []string{
|
|
"models/model.gguf",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
pkgtest.NewCatalogTester().
|
|
FromDirectory(t, test.fixture).
|
|
ExpectsResolverContentQueries(test.expected).
|
|
TestCataloger(t, NewGGUFCataloger())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGGUFCataloger(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T) string
|
|
expectedPackages []pkg.Package
|
|
expectedRelationships []artifact.Relationship
|
|
}{
|
|
{
|
|
name: "catalog single GGUF file",
|
|
setup: func(t *testing.T) string {
|
|
dir := t.TempDir()
|
|
data := newTestGGUFBuilder().
|
|
withVersion(3).
|
|
withStringKV("general.architecture", "llama").
|
|
withStringKV("general.name", "llama3-8b").
|
|
withStringKV("general.version", "3.0").
|
|
withStringKV("general.license", "Apache-2.0").
|
|
withStringKV("general.quantization", "Q4_K_M").
|
|
withUint64KV("general.parameter_count", 8030000000).
|
|
withStringKV("general.some_random_kv", "foobar").
|
|
build()
|
|
|
|
path := filepath.Join(dir, "llama3-8b.gguf")
|
|
os.WriteFile(path, data, 0644)
|
|
return dir
|
|
},
|
|
expectedPackages: []pkg.Package{
|
|
{
|
|
Name: "llama3-8b",
|
|
Version: "3.0",
|
|
Type: pkg.ModelPkg,
|
|
Licenses: pkg.NewLicenseSet(
|
|
pkg.NewLicenseFromFields("Apache-2.0", "", nil),
|
|
),
|
|
Metadata: pkg.GGUFFileHeader{
|
|
Architecture: "llama",
|
|
Quantization: "Unknown",
|
|
Parameters: 0,
|
|
GGUFVersion: 3,
|
|
TensorCount: 0,
|
|
MetadataKeyValuesHash: "6e3d368066455ce4",
|
|
RemainingKeyValues: map[string]any{
|
|
"general.some_random_kv": "foobar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedRelationships: nil,
|
|
},
|
|
{
|
|
name: "catalog GGUF file with minimal metadata",
|
|
setup: func(t *testing.T) string {
|
|
dir := t.TempDir()
|
|
data := newTestGGUFBuilder().
|
|
withVersion(3).
|
|
withStringKV("general.architecture", "gpt2").
|
|
withStringKV("general.name", "gpt2-small").
|
|
withStringKV("gpt2.context_length", "1024").
|
|
withUint32KV("gpt2.embedding_length", 768).
|
|
build()
|
|
|
|
path := filepath.Join(dir, "gpt2-small.gguf")
|
|
os.WriteFile(path, data, 0644)
|
|
return dir
|
|
},
|
|
expectedPackages: []pkg.Package{
|
|
{
|
|
Name: "gpt2-small",
|
|
Version: "",
|
|
Type: pkg.ModelPkg,
|
|
Licenses: pkg.NewLicenseSet(),
|
|
Metadata: pkg.GGUFFileHeader{
|
|
Architecture: "gpt2",
|
|
Quantization: "Unknown",
|
|
Parameters: 0,
|
|
GGUFVersion: 3,
|
|
TensorCount: 0,
|
|
MetadataKeyValuesHash: "9dc6f23591062a27",
|
|
RemainingKeyValues: map[string]any{
|
|
"gpt2.context_length": "1024",
|
|
"gpt2.embedding_length": uint32(768),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedRelationships: nil,
|
|
},
|
|
{
|
|
// syft copies at most maxHeaderSize bytes of a model to parse, so in any model larger than that the
|
|
// tensors declare far more data than the copy holds
|
|
name: "catalog GGUF file larger than the header copy",
|
|
setup: func(t *testing.T) string {
|
|
dir := t.TempDir()
|
|
data := newTestGGUFBuilder().
|
|
withStringKV("general.architecture", "qwen2").
|
|
withUint32KV("general.file_type", 0). // ALL_F32
|
|
withTensor("token_embd.weight", ggmlTypeF32, 1024, 8192). // 32 MiB
|
|
withTensor("blk.0.ffn_up.weight", ggmlTypeF32, 1024, 8192). // 32 MiB
|
|
build()
|
|
|
|
path := filepath.Join(dir, "qwen2.5-0.5b-instruct-f32.gguf")
|
|
require.NoError(t, os.WriteFile(path, data, 0644))
|
|
// the tensor data is never read, so it is left as a sparse range: alignment padding, then 64 MiB
|
|
require.NoError(t, os.Truncate(path, int64(len(data))+32+64<<20))
|
|
return dir
|
|
},
|
|
expectedPackages: []pkg.Package{
|
|
{
|
|
Name: "qwen2.5-0.5b-instruct-f32",
|
|
Type: pkg.ModelPkg,
|
|
Licenses: pkg.NewLicenseSet(),
|
|
Metadata: pkg.GGUFFileHeader{
|
|
Architecture: "qwen2",
|
|
Quantization: "F32",
|
|
Parameters: 2 * 1024 * 8192,
|
|
GGUFVersion: 3,
|
|
TensorCount: 2,
|
|
MetadataKeyValuesHash: "fddec34fd07b3547",
|
|
RemainingKeyValues: map[string]any{
|
|
"general.file_type": uint32(0),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedRelationships: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fixtureDir := tt.setup(t)
|
|
|
|
// Use pkgtest to catalog and compare
|
|
pkgtest.NewCatalogTester().
|
|
FromDirectory(t, fixtureDir).
|
|
Expects(tt.expectedPackages, tt.expectedRelationships).
|
|
IgnoreLocationLayer().
|
|
IgnorePackageFields("FoundBy", "Locations").
|
|
TestCataloger(t, NewGGUFCataloger())
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|