diff --git a/go.mod b/go.mod index 14107e4b7..bd5087147 100644 --- a/go.mod +++ b/go.mod @@ -103,6 +103,7 @@ require ( require ( github.com/pb33f/ordered-map/v2 v2.3.2 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd + golang.org/x/sys v0.49.0 howett.net/plist v1.0.1 ) @@ -332,7 +333,6 @@ require ( golang.org/x/crypto v0.58.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.24.0 // indirect - golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.47.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect diff --git a/syft/pkg/cataloger/ai/cataloger_test.go b/syft/pkg/cataloger/ai/cataloger_test.go index 1ab6b59dd..8a7b3e72c 100644 --- a/syft/pkg/cataloger/ai/cataloger_test.go +++ b/syft/pkg/cataloger/ai/cataloger_test.go @@ -129,6 +129,45 @@ func TestGGUFCataloger(t *testing.T) { }, 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 { diff --git a/syft/pkg/cataloger/ai/parse_gguf.go b/syft/pkg/cataloger/ai/parse_gguf.go index efcfc232d..84ea197ed 100644 --- a/syft/pkg/cataloger/ai/parse_gguf.go +++ b/syft/pkg/cataloger/ai/parse_gguf.go @@ -28,6 +28,8 @@ const ( maxGGUFSplitTensors = 1 << 17 // maxGGUFKeyLength is the key length limit from the GGUF spec. maxGGUFKeyLength = 65535 + // maxGGUFTensorDims is GGML_MAX_DIMS. + maxGGUFTensorDims = 4 // maxGGUFKVCount bounds the metadata KV count. Real files hold hundreds; vocabularies live in arrays. maxGGUFKVCount = 65536 // maxGGUFReadStringBytes bounds, across the header, the key and string bytes the library reads in @@ -78,10 +80,10 @@ func copyHeader(w io.Writer, r io.Reader) error { return nil } -// validateGGUFHeader walks the metadata KV section and rejects values that gguf-parser-go would panic -// on, recurse on without bound, or overallocate for. It reads without storing anything. -// ponytail: tensor infos are not checked here (zero block size types, dimension overflow); those -// panics are recoverable and are left to the per-file panic recovery in the generic cataloger. +// validateGGUFHeader walks the metadata KV and tensor info sections and rejects values that gguf-parser-go +// would panic on, recurse on without bound, or overallocate for. It reads without storing anything. +// ponytail: tensor types and dimensions are not checked here (zero block size types, dimension overflow); +// those panics are recoverable and are left to the per-file panic recovery in the generic cataloger. func validateGGUFHeader(r io.Reader) error { v := &ggufHeaderValidator{ r: bufio.NewReader(r), @@ -96,8 +98,9 @@ func validateGGUFHeader(r io.Reader) error { return fmt.Errorf("reading header prefix: %w", err) } v.v1 = prefix.Version <= 1 - // the tensor count is validated by the library - if _, err := v.readLen(); err != nil { + // tensors are walked below, so a count beyond the header runs into EOF + tensorCount, err := v.readLen() + if err != nil { return fmt.Errorf("reading tensor count: %w", err) } kvCount, err := v.readLen() @@ -121,9 +124,38 @@ func validateGGUFHeader(r io.Reader) error { return fmt.Errorf("metadata key %q: %w", key, err) } } + + for i := uint64(0); i < tensorCount; i++ { + if err := v.skipTensorInfo(); err != nil { + return fmt.Errorf("tensor info %d: %w", i, err) + } + } return nil } +// skipTensorInfo consumes one tensor info: name, dimension count, dimensions, type, data offset. +func (v *ggufHeaderValidator) skipTensorInfo() error { + // the library reads names in full, so they are bounded like keys + if _, err := v.readKey(); err != nil { + return fmt.Errorf("name: %w", err) + } + var nDims uint32 + if err := binary.Read(v.r, binary.LittleEndian, &nDims); err != nil { + return err + } + if nDims > maxGGUFTensorDims { + return fmt.Errorf("dimension count %d exceeds %d", nDims, maxGGUFTensorDims) + } + for range nDims { + if _, err := v.readLen(); err != nil { + return err + } + } + // type (uint32) and data offset (uint64) + _, err := v.r.Discard(4 + 8) + return err +} + type ggufHeaderValidator struct { r *bufio.Reader v1 bool // v1 uses 32-bit lengths diff --git a/syft/pkg/cataloger/ai/parse_gguf_model.go b/syft/pkg/cataloger/ai/parse_gguf_model.go index 6b9a89259..478cca01d 100644 --- a/syft/pkg/cataloger/ai/parse_gguf_model.go +++ b/syft/pkg/cataloger/ai/parse_gguf_model.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "path/filepath" "sort" "strings" @@ -13,6 +14,7 @@ import ( gguf_parser "github.com/gpustack/gguf-parser-go" "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" @@ -22,7 +24,7 @@ import ( // parseGGUFModel parses a GGUF model file and returns the discovered package. // This implementation only reads the header portion of the file, not the entire model. -func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { +func parseGGUFModel(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { defer internal.CloseAndLogError(reader, reader.Path()) td := tmpdir.FromContext(ctx) @@ -34,7 +36,6 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment return nil, nil, fmt.Errorf("failed to create temp file: %w", err) } defer cleanup() - tempPath := tempFile.Name() // copy and validate the GGUF file header (bounded to maxHeaderSize) if err := copyHeader(tempFile, reader); err != nil { @@ -45,14 +46,18 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment tempFile.Close() return nil, nil, fmt.Errorf("failed to rewind GGUF header: %w", err) } - err = validateGGUFHeader(tempFile) + if err := validateGGUFHeader(tempFile); err != nil { + tempFile.Close() + return nil, nil, fmt.Errorf("invalid GGUF header: %w", err) + } + err = extendToModelSize(tempFile, modelSize(resolver, reader.Location)) tempFile.Close() if err != nil { - return nil, nil, fmt.Errorf("invalid GGUF header: %w", err) + return nil, nil, fmt.Errorf("failed to extend GGUF header copy: %w", err) } // Parse using gguf-parser-go with options to skip unnecessary data - ggufFile, err := gguf_parser.ParseGGUFFile(tempPath, + ggufFile, err := gguf_parser.ParseGGUFFile(tempFile.Name(), gguf_parser.SkipLargeMetadata(), ) if err != nil { @@ -99,6 +104,33 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment return []pkg.Package{p}, nil, nil } +// modelSize returns the size of the model file, or 0 when the resolver can't say. +func modelSize(resolver file.Resolver, loc file.Location) int64 { + if resolver == nil { + return 0 + } + m, err := resolver.FileMetadataByLocation(loc) + if err != nil || m.FileInfo == nil { + return 0 + } + return m.Size() +} + +// extendToModelSize grows the header copy to the model's real size, since the parser checks the tensor data +// the header declares against the file size. It must run after validation so the header itself never reads +// from the hole. The file is marked sparse first so the hole takes no disk space; where that isn't possible +// the copy is left as is, and the parser rejects models larger than it. +func extendToModelSize(f *os.File, size int64) error { + if size <= maxHeaderSize { + return nil + } + if err := markSparse(f); err != nil { + log.Debugf("unable to mark GGUF header copy sparse, not extending it: %v", err) + return nil + } + return f.Truncate(size) +} + // computeKVMetadataHash computes a stable hash of the sanitized KV metadata for use as a global identifier func computeKVMetadataHash(metadata gguf_parser.GGUFMetadataKVs) (string, error) { // Sort the KV pairs by key for stable hashing diff --git a/syft/pkg/cataloger/ai/parse_gguf_test.go b/syft/pkg/cataloger/ai/parse_gguf_test.go index c5d8d5253..59874d5e8 100644 --- a/syft/pkg/cataloger/ai/parse_gguf_test.go +++ b/syft/pkg/cataloger/ai/parse_gguf_test.go @@ -57,6 +57,11 @@ func TestParseGGUFModel_malformedHeader(t *testing.T) { // a header that declares more KVs than the cap, with none following tooManyKVs := append(append([]byte("GGUF"), le(uint32(3))...), le([]uint64{0, maxGGUFKVCount + 1})...) + // a tensor whose name declares far more bytes than the file holds + hostileTensorName := newTestGGUFBuilder().withStringKV("general.architecture", "llama") + hostileTensorName.tensorCount = 1 + hugeTensorName := append(hostileTensorName.build(), le(uint64(1<<40))...) + tests := []struct { name string builder *testGGUFBuilder @@ -150,6 +155,11 @@ func TestParseGGUFModel_malformedHeader(t *testing.T) { name: "skipped scalar array", builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.scores", ggufTypeArray, ggufArray(ggufTypeFloat32, 3, make([]byte, 12))), }, + {name: "tensor name too long", data: hugeTensorName, wantErr: true}, + {name: "tensor name empty", builder: newTestGGUFBuilder().withTensor("", ggmlTypeF32, 1), wantErr: true}, + {name: "tensor with too many dimensions", builder: newTestGGUFBuilder().withTensor("t", ggmlTypeF32, 1, 1, 1, 1, 1), wantErr: true}, + {name: "tensor count beyond the header", builder: newTestGGUFBuilder().withTensorCount(1), wantErr: true}, + {name: "tensor info", builder: newTestGGUFBuilder().withTensor("t", ggmlTypeF32, 8)}, // ponytail: zero block size tensor types and dimension overflow panic inside tensor info parsing; // those are left to the generic cataloger's panic handling rather than checked here. } diff --git a/syft/pkg/cataloger/ai/sparse_other.go b/syft/pkg/cataloger/ai/sparse_other.go new file mode 100644 index 000000000..bed7b6c46 --- /dev/null +++ b/syft/pkg/cataloger/ai/sparse_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package ai + +import "os" + +// markSparse is a no-op: the filesystems syft runs on elsewhere leave the range a file is extended by with +// Truncate as a hole. +func markSparse(_ *os.File) error { + return nil +} diff --git a/syft/pkg/cataloger/ai/sparse_windows.go b/syft/pkg/cataloger/ai/sparse_windows.go new file mode 100644 index 000000000..849885584 --- /dev/null +++ b/syft/pkg/cataloger/ai/sparse_windows.go @@ -0,0 +1,14 @@ +package ai + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// markSparse sets the sparse attribute on the file. NTFS reserves disk space for the whole length of a file +// extended with Truncate unless it is sparse. +func markSparse(f *os.File) error { + var n uint32 + return windows.DeviceIoControl(windows.Handle(f.Fd()), windows.FSCTL_SET_SPARSE, nil, 0, nil, 0, &n, nil) +} diff --git a/syft/pkg/cataloger/ai/sparse_windows_test.go b/syft/pkg/cataloger/ai/sparse_windows_test.go new file mode 100644 index 000000000..64d78089e --- /dev/null +++ b/syft/pkg/cataloger/ai/sparse_windows_test.go @@ -0,0 +1,39 @@ +package ai + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// fileStandardInfo mirrors FILE_STANDARD_INFO, which golang.org/x/sys/windows does not define. +type fileStandardInfo struct { + AllocationSize int64 + EndOfFile int64 + NumberOfLinks uint32 + DeletePending bool + Directory bool +} + +func TestExtendToModelSize_doesNotReserveDiskOnWindows(t *testing.T) { + const size = 1 << 30 + f, err := os.Create(filepath.Join(t.TempDir(), "header.gguf")) + require.NoError(t, err) + defer f.Close() + _, err = f.Write(make([]byte, 4096)) + require.NoError(t, err) + + require.NoError(t, extendToModelSize(f, size)) + + var info fileStandardInfo + require.NoError(t, windows.GetFileInformationByHandleEx(windows.Handle(f.Fd()), windows.FileStandardInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))) + assert.Equal(t, int64(size), info.EndOfFile) + // a non-sparse file would have the whole length allocated + assert.Less(t, info.AllocationSize, int64(16<<20)) +} diff --git a/syft/pkg/cataloger/ai/test_helpers_test.go b/syft/pkg/cataloger/ai/test_helpers_test.go index d11ce8bb1..836256f64 100644 --- a/syft/pkg/cataloger/ai/test_helpers_test.go +++ b/syft/pkg/cataloger/ai/test_helpers_test.go @@ -30,6 +30,13 @@ type testGGUFBuilder struct { version uint32 tensorCount uint64 kvPairs []testKVPair + tensors []testTensorInfo +} + +type testTensorInfo struct { + name string + dims []uint64 + ggmlType uint32 } type testKVPair struct { @@ -72,6 +79,13 @@ func (b *testGGUFBuilder) withUint32KV(key string, value uint32) *testGGUFBuilde return b } +// withTensor adds a tensor info and counts it in the header tensor count. +func (b *testGGUFBuilder) withTensor(name string, ggmlType uint32, dims ...uint64) *testGGUFBuilder { + b.tensors = append(b.tensors, testTensorInfo{name: name, dims: dims, ggmlType: ggmlType}) + b.tensorCount++ + return b +} + // withRawKV adds a KV whose value bytes are written verbatim, for arrays and malformed values. func (b *testGGUFBuilder) withRawKV(key string, valueType uint32, raw []byte) *testGGUFBuilder { b.kvPairs = append(b.kvPairs, testKVPair{key: key, valueType: valueType, value: raw}) @@ -145,6 +159,19 @@ func (b *testGGUFBuilder) build() []byte { } } + // Write tensor infos: name, dimension count, dimensions, type, data offset + var offset uint64 + for _, ti := range b.tensors { + b.writeString(ti.name) + binary.Write(b.buf, binary.LittleEndian, uint32(len(ti.dims))) + for _, d := range ti.dims { + b.writeLen(d) + } + binary.Write(b.buf, binary.LittleEndian, ti.ggmlType) + binary.Write(b.buf, binary.LittleEndian, offset) + offset += 32 + } + return b.buf.Bytes() } @@ -154,3 +181,14 @@ func (b *testGGUFBuilder) buildInvalidMagic() []byte { binary.Write(buf, binary.LittleEndian, uint32(0x12345678)) return buf.Bytes() } + +// GGML tensor types used by the test builder +const ( + ggmlTypeF32 = 0 + ggmlTypeF16 = 1 + ggmlTypeQ4_0 = 2 + ggmlTypeQ8_0 = 8 + ggmlTypeQ2_K = 10 + ggmlTypeQ4_K = 12 + ggmlTypeQ6_K = 14 +) diff --git a/test/cli/model_cataloging_test.go b/test/cli/model_cataloging_test.go new file mode 100644 index 000000000..50703b9a2 --- /dev/null +++ b/test/cli/model_cataloging_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// modelSize is larger than the 50 MB syft copies from a model to parse its header, and than the tensor data +// the header fixture declares. +const modelSize = 512 << 20 + +// TestGGUFModelIsCataloged scans a model larger than the header copy syft parses: a real GGUF header followed +// by a sparse range standing in for the tensor data. Models under the copy size are parsed whole, so only a +// model this large catches a parser that checks the declared tensor data against the bytes it was given. +func TestGGUFModelIsCataloged(t *testing.T) { + root := t.TempDir() + header, err := os.ReadFile("testdata/models/gguf-header-only/header.gguf") + require.NoError(t, err) + modelPath := filepath.Join(root, "qwen2.5-0.5b-instruct-q2_k.gguf") + require.NoError(t, os.WriteFile(modelPath, header, 0o600)) + require.NoError(t, os.Truncate(modelPath, modelSize)) + + out := filepath.Join(t.TempDir(), "sbom.json") + cmd, stdout, stderr := runSyft(t, nil, "scan", "dir:"+root, "-o", "syft-json="+out) + defer logOutputOnFailure(t, cmd, stdout, stderr) + assertSuccessfulReturnCode(t, stdout, stderr, cmd.ProcessState.ExitCode()) + + b, err := os.ReadFile(out) + require.NoError(t, err, "no SBOM written") + var doc struct { + Artifacts []struct { + Name string `json:"name"` + Type string `json:"type"` + Licenses []struct { + Value string `json:"value"` + } `json:"licenses"` + Metadata struct { + Architecture string `json:"architecture"` + Quantization string `json:"quantization"` + Parameters uint64 `json:"parameters"` + TensorCount uint64 `json:"tensorCount"` + } `json:"metadata"` + } `json:"artifacts"` + } + require.NoError(t, json.Unmarshal(b, &doc), "SBOM is not valid JSON") + require.Len(t, doc.Artifacts, 1, "expected the model package") + + model := doc.Artifacts[0] + assert.Equal(t, "qwen2.5-0.5b-instruct", model.Name) + assert.Equal(t, "model", model.Type) + require.Len(t, model.Licenses, 1) + assert.Equal(t, "apache-2.0", model.Licenses[0].Value) + assert.Equal(t, "qwen2", model.Metadata.Architecture) + assert.Equal(t, "Q2_K_L", model.Metadata.Quantization) + assert.Equal(t, uint64(290), model.Metadata.TensorCount) + assert.Equal(t, uint64(494032768), model.Metadata.Parameters) +} diff --git a/test/cli/testdata/models/gguf-header-only/generate.go b/test/cli/testdata/models/gguf-header-only/generate.go new file mode 100644 index 000000000..909a88e3b --- /dev/null +++ b/test/cli/testdata/models/gguf-header-only/generate.go @@ -0,0 +1,126 @@ +// generate writes header.gguf: the header of a GGUF model shaped like Qwen2.5-0.5B-Instruct at Q2_K (290 +// tensors declaring about 175 MB of data), padded to the tensor data alignment. Tests extend a copy to a +// model's size as a sparse file, which stands in for the full model without storing its data. +// +// This file lives under testdata/ so the Go build system ignores it. Run it when changing the fixture: +// +// go run ./testdata/models/gguf-header-only/generate.go ./testdata/models/gguf-header-only/header.gguf +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "os" +) + +const ( + typeUint32 = 4 + typeString = 8 + typeArray = 9 + + ggmlF32 = 0 + ggmlQ2K = 10 + ggmlQ3K = 11 + ggmlQ4K = 12 + fileQ2K = 10 +) + +type tensor struct { + name string + typ uint32 + dims []uint64 +} + +type writer struct{ bytes.Buffer } + +func (w *writer) le(v any) { _ = binary.Write(w, binary.LittleEndian, v) } + +func (w *writer) str(s string) { + w.le(uint64(len(s))) + w.WriteString(s) +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: generate ") + os.Exit(1) + } + + const ( + hidden = 896 + kvDim = 128 + ffn = 4864 + vocab = 151936 + layers = 24 + ) + tensors := []tensor{{"token_embd.weight", ggmlQ4K, []uint64{hidden, vocab}}} + for i := range layers { + blk := func(n string) string { return fmt.Sprintf("blk.%d.%s", i, n) } + tensors = append(tensors, + tensor{blk("attn_norm.weight"), ggmlF32, []uint64{hidden}}, + tensor{blk("attn_q.weight"), ggmlQ2K, []uint64{hidden, hidden}}, + tensor{blk("attn_q.bias"), ggmlF32, []uint64{hidden}}, + tensor{blk("attn_k.weight"), ggmlQ2K, []uint64{hidden, kvDim}}, + tensor{blk("attn_k.bias"), ggmlF32, []uint64{kvDim}}, + tensor{blk("attn_v.weight"), ggmlQ3K, []uint64{hidden, kvDim}}, + tensor{blk("attn_v.bias"), ggmlF32, []uint64{kvDim}}, + tensor{blk("attn_output.weight"), ggmlQ3K, []uint64{hidden, hidden}}, + tensor{blk("ffn_norm.weight"), ggmlF32, []uint64{hidden}}, + tensor{blk("ffn_gate.weight"), ggmlQ2K, []uint64{hidden, ffn}}, + tensor{blk("ffn_up.weight"), ggmlQ2K, []uint64{hidden, ffn}}, + tensor{blk("ffn_down.weight"), ggmlQ3K, []uint64{ffn, hidden}}, + ) + } + tensors = append(tensors, tensor{"output_norm.weight", ggmlF32, []uint64{hidden}}) + + var w writer + w.le(uint32(0x46554747)) // "GGUF" + w.le(uint32(3)) + w.le(uint64(len(tensors))) + w.le(uint64(6)) // metadata KV count + + w.str("general.architecture") + w.le(uint32(typeString)) + w.str("qwen2") + w.str("general.name") + w.le(uint32(typeString)) + w.str("qwen2.5-0.5b-instruct") + w.str("general.license") + w.le(uint32(typeString)) + w.str("apache-2.0") + w.str("general.file_type") + w.le(uint32(typeUint32)) + w.le(uint32(fileQ2K)) + w.str("qwen2.block_count") + w.le(uint32(typeUint32)) + w.le(uint32(layers)) + // a vocabulary the parser skips over + w.str("tokenizer.ggml.tokens") + w.le(uint32(typeArray)) + w.le(uint32(typeString)) + w.le(uint64(3)) + for _, tok := range []string{"!", "\"", "#"} { + w.str(tok) + } + + var offset uint64 + for _, t := range tensors { + w.str(t.name) + w.le(uint32(len(t.dims))) + for _, d := range t.dims { + w.le(d) + } + w.le(t.typ) + w.le(offset) + offset += 1 << 20 // not used by syft; real offsets depend on each type's block size + } + + // pad to the default 32-byte alignment, where the tensor data starts + w.Write(make([]byte, (32-w.Len()%32)%32)) + + if err := os.WriteFile(os.Args[1], w.Bytes(), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/test/cli/testdata/models/gguf-header-only/header.gguf b/test/cli/testdata/models/gguf-header-only/header.gguf new file mode 100644 index 000000000..ccf53c968 Binary files /dev/null and b/test/cli/testdata/models/gguf-header-only/header.gguf differ