Pin Gguff lib to prevent dropping packages (#5447)

* 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>
This commit is contained in:
Christopher Angelo Phillips
2026-10-19 14:40:43 -04:00
committed by GitHub
co-authored by Alex Goodman
parent a81193226f
commit f15c3343e2
12 changed files with 415 additions and 12 deletions
+1 -1
View File
@@ -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
+39
View File
@@ -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 {
+38 -6
View File
@@ -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
+37 -5
View File
@@ -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
+10
View File
@@ -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.
}
+11
View File
@@ -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
}
+14
View File
@@ -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)
}
@@ -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))
}
@@ -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
)
+62
View File
@@ -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)
}
+126
View File
@@ -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 <output path>")
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)
}
}
Binary file not shown.