Add public bundle manifest package
This commit is contained in:
118
pkg/bundle/build.go
Normal file
118
pkg/bundle/build.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BuildManifest(opts BuildOptions) (Manifest, error) {
|
||||
if opts.Root == "" {
|
||||
return Manifest{}, fmt.Errorf("root is required")
|
||||
}
|
||||
if opts.ID == "" {
|
||||
return Manifest{}, fmt.Errorf("id is required")
|
||||
}
|
||||
explicit := len(opts.Files) > 0
|
||||
if explicit == opts.Scan {
|
||||
return Manifest{}, fmt.Errorf("select exactly one file mode")
|
||||
}
|
||||
created := opts.Created
|
||||
if created.IsZero() {
|
||||
created = time.Now().UTC()
|
||||
}
|
||||
|
||||
paths := append([]string(nil), opts.Files...)
|
||||
var err error
|
||||
if opts.Scan {
|
||||
paths, err = scanSourcePaths(opts.Root)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
}
|
||||
files := make([]ManifestFile, 0, len(paths))
|
||||
for _, sourcePath := range paths {
|
||||
file, err := buildManifestFile(opts.Root, sourcePath)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: opts.ID,
|
||||
Created: created,
|
||||
Files: files,
|
||||
}
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func scanSourcePaths(root string) ([]string, error) {
|
||||
var paths []string
|
||||
err := filepath.WalkDir(root, func(filePath string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filePath == root {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePath := filepath.ToSlash(relative)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("source file %q must be a regular file", sourcePath)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path.Base(sourcePath) == ManifestName || path.Base(sourcePath) == distributorStateName {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("source file %q must be a regular file", sourcePath)
|
||||
}
|
||||
paths = append(paths, sourcePath)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func buildManifestFile(root, sourcePath string) (ManifestFile, error) {
|
||||
if err := ValidateSourcePath(sourcePath); err != nil {
|
||||
return ManifestFile{}, fmt.Errorf("source file %q: %w", sourcePath, err)
|
||||
}
|
||||
fullPath := filepath.Join(root, filepath.FromSlash(sourcePath))
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err != nil {
|
||||
return ManifestFile{}, fmt.Errorf("source file %q stat: %w", sourcePath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return ManifestFile{}, fmt.Errorf("source file %q must be a regular file", sourcePath)
|
||||
}
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return ManifestFile{}, fmt.Errorf("source file %q read: %w", sourcePath, err)
|
||||
}
|
||||
return ManifestFile{
|
||||
Path: sourcePath,
|
||||
SHA256: FileDigest(data),
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
363
pkg/bundle/bundle_test.go
Normal file
363
pkg/bundle/bundle_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildManifestExplicitFilesPreservesOrder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "b.txt", "bravo")
|
||||
writeFile(t, root, "a.txt", "alpha")
|
||||
before := time.Now().UTC()
|
||||
|
||||
manifest, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.example",
|
||||
Files: []string{"b.txt", "a.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManifest() error = %v", err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
|
||||
paths := manifestPaths(manifest)
|
||||
if want := []string{"b.txt", "a.txt"}; !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
if manifest.SchemaVersion != SchemaVersion {
|
||||
t.Fatalf("schema version = %d, want %d", manifest.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if manifest.ID != "reports.example" {
|
||||
t.Fatalf("id = %q", manifest.ID)
|
||||
}
|
||||
if manifest.Created.Before(before) || manifest.Created.After(after) {
|
||||
t.Fatalf("created = %s, want between %s and %s", manifest.Created, before, after)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
t.Fatalf("ValidateManifest() error = %v", err)
|
||||
}
|
||||
if err := ValidateBundle(root, manifest); err != nil {
|
||||
t.Fatalf("ValidateBundle() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestScanSortsAndFiltersMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "z.txt", "zulu")
|
||||
writeFile(t, root, "nested/.hidden", "hidden")
|
||||
writeFile(t, root, ManifestName, "old manifest")
|
||||
writeFile(t, root, distributorStateName, "state")
|
||||
writeFile(t, root, "nested/manifest.json", "nested manifest")
|
||||
writeFile(t, root, "nested/.distributor.json", "nested state")
|
||||
created := time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC)
|
||||
|
||||
manifest, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.scan",
|
||||
Created: created,
|
||||
Scan: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManifest() error = %v", err)
|
||||
}
|
||||
|
||||
if !manifest.Created.Equal(created) {
|
||||
t.Fatalf("created = %s, want %s", manifest.Created, created)
|
||||
}
|
||||
paths := manifestPaths(manifest)
|
||||
if want := []string{"nested/.hidden", "z.txt"}; !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestRequiresOneFileMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "report.txt", "report")
|
||||
|
||||
tests := []BuildOptions{
|
||||
{Root: root, ID: "reports.none"},
|
||||
{Root: root, ID: "reports.both", Files: []string{"report.txt"}, Scan: true},
|
||||
}
|
||||
for _, opts := range tests {
|
||||
if _, err := BuildManifest(opts); err == nil {
|
||||
t.Fatalf("BuildManifest(%+v) error = nil, want error", opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestRejectsUnsafePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "report.txt", "report")
|
||||
|
||||
_, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.unsafe",
|
||||
Files: []string{"../report.txt"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildManifest() error = nil, want unsafe path error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "target.txt", "target")
|
||||
if err := os.Symlink("target.txt", filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
_, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.symlink",
|
||||
Files: []string{"link.txt"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("BuildManifest() error = %v, want regular file error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "target.txt", "target")
|
||||
if err := os.Symlink("target.txt", filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
_, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.scan.symlink",
|
||||
Scan: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("BuildManifest() error = %v, want regular file error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestFunctionsUseCanonicalFilePayload(t *testing.T) {
|
||||
files := []ManifestFile{
|
||||
{Path: "report.md", SHA256: FileDigest([]byte("report")), Size: 6},
|
||||
{Path: "summary.txt", SHA256: FileDigest([]byte("summary")), Size: 7},
|
||||
}
|
||||
payload := CanonicalFilePayload(files)
|
||||
if want := `[{"path":"report.md","sha256":"sha256:845e91831319e89c4d656bdb80c278ac09a7230d61e5dfd2e1b1fbb436ac8917","size":6},{"path":"summary.txt","sha256":"sha256:761b7ad8ad439b2855fcbb611331c646ef0870b0631247bba3f3025cb6df5a53","size":7}]`; payload != want {
|
||||
t.Fatalf("payload = %q, want %q", payload, want)
|
||||
}
|
||||
if digest := BundleDigest(files); !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 {
|
||||
t.Fatalf("BundleDigest() = %q, want sha256 digest", digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarshalLoadAndWriteManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "report.txt", "report")
|
||||
manifest, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.json",
|
||||
Created: time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC),
|
||||
Files: []string{"report.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManifest() error = %v", err)
|
||||
}
|
||||
data, err := MarshalManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalManifest() error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(string(data), "\n") {
|
||||
t.Fatalf("manifest JSON = %q, want trailing newline", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), `"schema_version": 1`) || !strings.Contains(string(data), `"sha256": "`) {
|
||||
t.Fatalf("manifest JSON = %q, want fixed manifest fields", string(data))
|
||||
}
|
||||
parsed, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parsed, manifest) {
|
||||
t.Fatalf("parsed manifest = %#v, want %#v", parsed, manifest)
|
||||
}
|
||||
if err := WriteManifest(root, manifest, WriteManifestOptions{}); err != nil {
|
||||
t.Fatalf("WriteManifest() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadManifest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded, manifest) {
|
||||
t.Fatalf("loaded manifest = %#v, want %#v", loaded, manifest)
|
||||
}
|
||||
if err := WriteManifest(root, manifest, WriteManifestOptions{}); err == nil {
|
||||
t.Fatal("WriteManifest() error = nil, want exists error")
|
||||
}
|
||||
manifest.ID = "reports.updated"
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
if err := WriteManifest(root, manifest, WriteManifestOptions{Overwrite: true}); err != nil {
|
||||
t.Fatalf("WriteManifest(overwrite) error = %v", err)
|
||||
}
|
||||
loaded, err = LoadManifest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest() after overwrite error = %v", err)
|
||||
}
|
||||
if loaded.ID != "reports.updated" {
|
||||
t.Fatalf("loaded id = %q, want updated", loaded.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsInvalidManifest(t *testing.T) {
|
||||
created := time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC)
|
||||
fileDigest := FileDigest([]byte("report"))
|
||||
valid := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: "reports.valid",
|
||||
Created: created,
|
||||
Files: []ManifestFile{{Path: "report.txt", SHA256: fileDigest, Size: 6}},
|
||||
}
|
||||
valid.Digest = BundleDigest(valid.Files)
|
||||
|
||||
tests := map[string]func(Manifest) Manifest{
|
||||
"schema version": func(manifest Manifest) Manifest {
|
||||
manifest.SchemaVersion = 2
|
||||
return manifest
|
||||
},
|
||||
"id": func(manifest Manifest) Manifest {
|
||||
manifest.ID = ""
|
||||
return manifest
|
||||
},
|
||||
"created": func(manifest Manifest) Manifest {
|
||||
manifest.Created = time.Time{}
|
||||
return manifest
|
||||
},
|
||||
"files": func(manifest Manifest) Manifest {
|
||||
manifest.Files = nil
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"path": func(manifest Manifest) Manifest {
|
||||
manifest.Files[0].Path = "manifest.json"
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"duplicate": func(manifest Manifest) Manifest {
|
||||
manifest.Files = append(manifest.Files, manifest.Files[0])
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"size": func(manifest Manifest) Manifest {
|
||||
manifest.Files[0].Size = -1
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"digest": func(manifest Manifest) Manifest {
|
||||
manifest.Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
return manifest
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := ValidateManifest(mutate(valid)); err == nil {
|
||||
t.Fatal("ValidateManifest() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBundleRejectsLocalFileProblems(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "report.txt", "report")
|
||||
manifest, err := BuildManifest(BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.bundle",
|
||||
Files: []string{"report.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManifest() error = %v", err)
|
||||
}
|
||||
if err := ValidateBundle(root, manifest); err != nil {
|
||||
t.Fatalf("ValidateBundle() error = %v", err)
|
||||
}
|
||||
|
||||
missing := cloneManifest(manifest)
|
||||
missing.Files[0].Path = "missing.txt"
|
||||
missing.Digest = BundleDigest(missing.Files)
|
||||
if err := ValidateBundle(root, missing); err == nil {
|
||||
t.Fatal("ValidateBundle() missing error = nil, want error")
|
||||
}
|
||||
|
||||
sizeMismatch := cloneManifest(manifest)
|
||||
sizeMismatch.Files[0].Size++
|
||||
sizeMismatch.Digest = BundleDigest(sizeMismatch.Files)
|
||||
if err := ValidateBundle(root, sizeMismatch); err == nil || !strings.Contains(err.Error(), "size mismatch") {
|
||||
t.Fatalf("ValidateBundle() size error = %v, want size mismatch", err)
|
||||
}
|
||||
|
||||
digestMismatch := cloneManifest(manifest)
|
||||
writeFile(t, root, "report.txt", "change")
|
||||
if err := ValidateBundle(root, digestMismatch); err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("ValidateBundle() digest error = %v, want digest mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBundleRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "target.txt", "target")
|
||||
if err := os.Symlink("target.txt", filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
file := ManifestFile{Path: "link.txt", SHA256: FileDigest([]byte("target")), Size: 6}
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: "reports.link",
|
||||
Created: time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC),
|
||||
Files: []ManifestFile{file},
|
||||
}
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
|
||||
err := ValidateBundle(root, manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("ValidateBundle() error = %v, want regular file error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSourcePath(t *testing.T) {
|
||||
valid := []string{"report.md", "nested/report.md", ".well-known/report.txt"}
|
||||
for _, path := range valid {
|
||||
if err := ValidateSourcePath(path); err != nil {
|
||||
t.Fatalf("ValidateSourcePath(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "../report.md", "/report.md", "nested/../report.md", `nested\report.md`, ManifestName, distributorStateName}
|
||||
for _, path := range invalid {
|
||||
if err := ValidateSourcePath(path); err == nil {
|
||||
t.Fatalf("ValidateSourcePath(%q) error = nil, want error", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifestPaths(manifest Manifest) []string {
|
||||
paths := make([]string, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
paths = append(paths, file.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func cloneManifest(manifest Manifest) Manifest {
|
||||
manifest.Files = append([]ManifestFile(nil), manifest.Files...)
|
||||
return manifest
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, root, relative, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
49
pkg/bundle/digest.go
Normal file
49
pkg/bundle/digest.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
func validateDigest(value string) error {
|
||||
if !digestPattern.MatchString(value) {
|
||||
return fmt.Errorf("must be lowercase sha256:<64 hex>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FileDigest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func BundleDigest(files []ManifestFile) string {
|
||||
canonical := CanonicalFilePayload(files)
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func CanonicalFilePayload(files []ManifestFile) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteByte('[')
|
||||
for index, file := range files {
|
||||
if index > 0 {
|
||||
builder.WriteByte(',')
|
||||
}
|
||||
builder.WriteString(`{"path":`)
|
||||
builder.WriteString(strconv.Quote(file.Path))
|
||||
builder.WriteString(`,"sha256":`)
|
||||
builder.WriteString(strconv.Quote(file.SHA256))
|
||||
builder.WriteString(`,"size":`)
|
||||
builder.WriteString(strconv.FormatInt(file.Size, 10))
|
||||
builder.WriteByte('}')
|
||||
}
|
||||
builder.WriteByte(']')
|
||||
return builder.String()
|
||||
}
|
||||
8
pkg/bundle/doc.go
Normal file
8
pkg/bundle/doc.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Package bundle provides producer-facing helpers for distributor source
|
||||
// bundle manifests.
|
||||
//
|
||||
// A source bundle is a local directory containing a manifest.json file and the
|
||||
// files listed by that manifest. This package owns the public manifest model,
|
||||
// digest calculation, path validation, manifest parsing, manifest building, and
|
||||
// local bundle validation used by Go producer applications.
|
||||
package bundle
|
||||
40
pkg/bundle/example_test.go
Normal file
40
pkg/bundle/example_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package bundle_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
)
|
||||
|
||||
func ExampleBuildManifest() {
|
||||
root, err := os.MkdirTemp("", "distributor-bundle-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "report.txt"), []byte("report\n"), 0o600); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
|
||||
Root: root,
|
||||
ID: "reports.example",
|
||||
Created: time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC),
|
||||
Files: []string{"report.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(manifest.ID)
|
||||
fmt.Println(manifest.Files[0].Path)
|
||||
fmt.Println(manifest.Files[0].Size)
|
||||
// Output:
|
||||
// reports.example
|
||||
// report.txt
|
||||
// 7
|
||||
}
|
||||
160
pkg/bundle/manifest.go
Normal file
160
pkg/bundle/manifest.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rawManifest struct {
|
||||
SchemaVersion *int `json:"schema_version"`
|
||||
ID *string `json:"id"`
|
||||
Digest *string `json:"digest"`
|
||||
Created *string `json:"created"`
|
||||
Files []rawManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type rawManifestFile struct {
|
||||
Path *string `json:"path"`
|
||||
SHA256 *string `json:"sha256"`
|
||||
Size *int64 `json:"size"`
|
||||
}
|
||||
|
||||
func ParseManifest(data []byte) (Manifest, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
var raw rawManifest
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
return Manifest{}, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return Manifest{}, fmt.Errorf("parse manifest: trailing data")
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if raw.SchemaVersion == nil {
|
||||
return Manifest{}, fmt.Errorf("manifest schema_version is required")
|
||||
}
|
||||
manifest.SchemaVersion = *raw.SchemaVersion
|
||||
if raw.ID == nil || *raw.ID == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest id is required")
|
||||
}
|
||||
manifest.ID = *raw.ID
|
||||
if raw.Digest == nil || *raw.Digest == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest digest is required")
|
||||
}
|
||||
manifest.Digest = *raw.Digest
|
||||
if raw.Created == nil || *raw.Created == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest created is required")
|
||||
}
|
||||
created, err := time.Parse(time.RFC3339, *raw.Created)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("manifest created must be RFC3339: %w", err)
|
||||
}
|
||||
manifest.Created = created
|
||||
if len(raw.Files) == 0 {
|
||||
return Manifest{}, fmt.Errorf("manifest files is required")
|
||||
}
|
||||
|
||||
for index, rawFile := range raw.Files {
|
||||
file, err := parseManifestFile(index, rawFile)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
manifest.Files = append(manifest.Files, file)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("manifest %w", err)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func parseManifestFile(index int, raw rawManifestFile) (ManifestFile, error) {
|
||||
if raw.Path == nil || *raw.Path == "" {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].path is required", index)
|
||||
}
|
||||
if raw.SHA256 == nil || *raw.SHA256 == "" {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].sha256 is required", index)
|
||||
}
|
||||
if raw.Size == nil {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].size is required", index)
|
||||
}
|
||||
return ManifestFile{
|
||||
Path: *raw.Path,
|
||||
SHA256: *raw.SHA256,
|
||||
Size: *raw.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func MarshalManifest(manifest Manifest) ([]byte, error) {
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
func LoadManifest(root string) (Manifest, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, ManifestName))
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
return ParseManifest(data)
|
||||
}
|
||||
|
||||
func WriteManifest(root string, manifest Manifest, opts WriteManifestOptions) error {
|
||||
data, err := MarshalManifest(manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifestPath := filepath.Join(root, ManifestName)
|
||||
if !opts.Overwrite {
|
||||
file, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := root
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write manifest temp: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write manifest temp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write manifest temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, manifestPath); err != nil {
|
||||
return fmt.Errorf("replace manifest: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
31
pkg/bundle/path.go
Normal file
31
pkg/bundle/path.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const distributorStateName = ".distributor.json"
|
||||
|
||||
func ValidateSourcePath(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("source path is required")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.HasPrefix(value, "/") {
|
||||
return fmt.Errorf("source path %q must be a clean relative slash-separated path", value)
|
||||
}
|
||||
if path.Clean(value) != value {
|
||||
return fmt.Errorf("source path %q must be a clean relative slash-separated path", value)
|
||||
}
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return fmt.Errorf("source path %q must be a clean relative slash-separated path", value)
|
||||
}
|
||||
}
|
||||
switch value {
|
||||
case ManifestName, distributorStateName:
|
||||
return fmt.Errorf("%q is reserved", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
33
pkg/bundle/types.go
Normal file
33
pkg/bundle/types.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package bundle
|
||||
|
||||
import "time"
|
||||
|
||||
const ManifestName = "manifest.json"
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Digest string `json:"digest"`
|
||||
Created time.Time `json:"created"`
|
||||
Files []ManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type ManifestFile struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type BuildOptions struct {
|
||||
Root string
|
||||
ID string
|
||||
Created time.Time
|
||||
Files []string
|
||||
Scan bool
|
||||
}
|
||||
|
||||
type WriteManifestOptions struct {
|
||||
Overwrite bool
|
||||
}
|
||||
79
pkg/bundle/validate.go
Normal file
79
pkg/bundle/validate.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func ValidateManifest(manifest Manifest) error {
|
||||
if manifest.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("schema_version must be %d", SchemaVersion)
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
if err := validateDigest(manifest.Digest); err != nil {
|
||||
return fmt.Errorf("digest: %w", err)
|
||||
}
|
||||
if manifest.Created.IsZero() {
|
||||
return fmt.Errorf("created is required")
|
||||
}
|
||||
if len(manifest.Files) == 0 {
|
||||
return fmt.Errorf("files is required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(manifest.Files))
|
||||
for index, file := range manifest.Files {
|
||||
if err := ValidateSourcePath(file.Path); err != nil {
|
||||
return fmt.Errorf("files[%d].path: %w", index, err)
|
||||
}
|
||||
if err := validateDigest(file.SHA256); err != nil {
|
||||
return fmt.Errorf("files[%d].sha256: %w", index, err)
|
||||
}
|
||||
if file.Size < 0 {
|
||||
return fmt.Errorf("files[%d].size must be non-negative", index)
|
||||
}
|
||||
if _, exists := seen[file.Path]; exists {
|
||||
return fmt.Errorf("files[%d].path duplicates %q", index, file.Path)
|
||||
}
|
||||
seen[file.Path] = struct{}{}
|
||||
}
|
||||
if actual := BundleDigest(manifest.Files); actual != manifest.Digest {
|
||||
return fmt.Errorf("digest mismatch: got %s want %s", actual, manifest.Digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateBundle(root string, manifest Manifest) error {
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
files := append([]ManifestFile(nil), manifest.Files...)
|
||||
for index, manifestFile := range files {
|
||||
fullPath := filepath.Join(root, filepath.FromSlash(manifestFile.Path))
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file %q stat: %w", manifestFile.Path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("file %q must be a regular file", manifestFile.Path)
|
||||
}
|
||||
if info.Size() != manifestFile.Size {
|
||||
return fmt.Errorf("file %q size mismatch: got %d want %d", manifestFile.Path, info.Size(), manifestFile.Size)
|
||||
}
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file %q read: %w", manifestFile.Path, err)
|
||||
}
|
||||
actualDigest := FileDigest(data)
|
||||
if actualDigest != manifestFile.SHA256 {
|
||||
return fmt.Errorf("file %q sha256 mismatch: got %s want %s", manifestFile.Path, actualDigest, manifestFile.SHA256)
|
||||
}
|
||||
files[index].SHA256 = actualDigest
|
||||
files[index].Size = int64(len(data))
|
||||
}
|
||||
if actualDigest := BundleDigest(files); actualDigest != manifest.Digest {
|
||||
return fmt.Errorf("digest mismatch: got %s want %s", actualDigest, manifest.Digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user