feat: add deployment foundation and cross-device handoff

This commit is contained in:
2026-09-25 08:49:19 +08:00
parent 8ccb8b7c15
commit e965b0943d
77 changed files with 8018 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Local package verifier
`Verify(directory, expectedDigest)` accepts an absolute staging directory and a
canonical `sha256:` digest of its exact `manifest.json` bytes. It returns the
validated manifest, matching payload bytes keyed by relative path, and manifest
digest. On failure it returns a zero `Verified` and fixed diagnostic text without
embedding file contents, decoder errors, or filesystem paths.
The manifest is limited to 1 MiB, each payload to 4 MiB, and all payloads together
to 16 MiB. Metadata checks precede opening regular files; actual reads also have
a limit plus one overflow-detection byte and must match the observed size.
The manifest digest is checked before shared `wire.Decode` parses it.
Inventory is derived from at most 128 declared files and their parent directories.
Each directory is enumerated one entry at a time; an unexpected entry fails
immediately. Symlinks, special files, empty/unnecessary directories, and unlisted
files are rejected. Lowercase portable paths prevent case collisions. Files and
subdirectories are opened relative to `os.Root`, with identity checks around open.
The caller must select a trusted staging directory with trusted ancestors and
prevent concurrent mutation. These checks do not provide a transactional snapshot
or complete protection against hostile concurrent filesystem changes. Consumers
should use the returned bytes rather than reopen package files.
This authenticates bytes against a caller-provided trust anchor. It does not
authenticate a publisher, validate Compose semantics, or authorize execution.
Signature trust stores and executable policy are outside this package.
Tests use local temporary directories. Symlink cases skip only Windows error 1314
(missing symlink privilege). Linux-specific FIFO cases verify rejection without
opening the FIFO; run tests with a timeout, for example:
```text
go test ./internal/appbundle -count=1 -timeout=30s
go vet ./internal/appbundle
```
+48
View File
@@ -0,0 +1,48 @@
package appbundle
import (
"strings"
"testing"
)
// Check lexical rejection independently of missing-file rejection. Invalid paths
// cannot always be materialized on Windows, so a missing file is not sufficient
// evidence that the manifest validator enforces portable paths.
func TestPortablePathValidation(t *testing.T) {
for _, path := range []string{
"", "../escape", "a/../b", "a/./b", "/root", "c:/file", `a\b`,
"a//b", "a/", ".env", "a/.secret", "a/secret ", "a/secret.",
"con", "prn.txt", "aux.txt", "nul", "a/com1.log", "lpt9",
"UPPER.txt", "a:stream", "café", strings.Repeat("a", 101),
strings.Repeat("a/", 120) + "b",
} {
if validPath(path) {
t.Errorf("accepted invalid path %q", path)
}
}
for _, path := range []string{
"compose.yaml", "dir-a/1_config.json", "com0.txt", "lpt10.txt",
strings.Repeat("a", 100), strings.Repeat("a", 100) + "/" + strings.Repeat("b", 100) + "/" + strings.Repeat("c", 38),
} {
if !validPath(path) {
t.Errorf("rejected valid path %q", path)
}
}
}
func TestImageDigestAndRepositoryValidation(t *testing.T) {
for _, image := range []string{
"api@sha256:" + strings.Repeat("a", 63), "api@sha256:" + strings.Repeat("A", 64),
"api@sha256:" + strings.Repeat("a", 65), "api@sha512:" + strings.Repeat("a", 64),
"api:tag@sha256:" + strings.Repeat("a", 64), "api@sha256:" + strings.Repeat("a", 64) + "@extra",
} {
if validImage(image) {
t.Errorf("accepted invalid image %q", image)
}
}
for _, repo := range []string{"a", "registry.example/team/api", "team_name/app-v2.0"} {
if !validImage(repo + "@sha256:" + strings.Repeat("a", 64)) {
t.Errorf("rejected valid repository %q", repo)
}
}
}
+312
View File
@@ -0,0 +1,312 @@
// Package appbundle authenticates a bounded local bundle as bytes.
package appbundle
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"server-deploy/internal/wire"
)
const (
manifestLimit int64 = 1 << 20
fileLimit int64 = 4 << 20
payloadLimit int64 = 16 << 20
)
var (
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
repositoryPart = regexp.MustCompile(`^[a-z0-9]+(?:[._-]+[a-z0-9]+)*$`)
pathPart = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
)
type Manifest struct {
ProtocolVersion int `json:"protocolVersion"`
AppID string `json:"appId"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
Platforms []string `json:"platforms"`
Components []Component `json:"components"`
Files []File `json:"files"`
}
type Component struct {
Name string `json:"name"`
Image string `json:"image"`
}
type File struct {
Path string `json:"path"`
Digest string `json:"digest"`
}
type Verified struct {
Manifest Manifest
Files map[string][]byte
Digest string
}
// Verify authenticates the exact manifest and listed payload bytes. The expected
// digest is a caller trust anchor, not publisher authentication. Compose syntax
// and execution safety are not evaluated. The caller must use a trusted staging
// directory with trusted ancestors and prevent concurrent mutation; os.Root and
// identity checks do not provide a transaction against hostile concurrent writers.
func Verify(directory, expectedDigest string) (Verified, error) {
if !filepath.IsAbs(directory) {
return Verified{}, errors.New("bundle directory must be absolute")
}
// A trailing separator can make Lstat follow a directory symlink on Unix.
// Normalize it before checking the caller-selected root itself.
directory = filepath.Clean(directory)
if !digestPattern.MatchString(expectedDigest) {
return Verified{}, errors.New("invalid expected manifest digest")
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return Verified{}, errors.New("invalid bundle directory")
}
root, err := os.OpenRoot(directory)
if err != nil {
return Verified{}, errors.New("cannot open bundle directory")
}
defer root.Close()
openedInfo, err := root.Stat(".")
if err != nil || !os.SameFile(info, openedInfo) {
return Verified{}, errors.New("bundle directory changed")
}
raw, err := readRegular(root, "manifest.json", manifestLimit)
if err != nil {
return Verified{}, errors.New("cannot read bounded regular manifest")
}
if hash(raw) != expectedDigest {
return Verified{}, errors.New("manifest digest mismatch")
}
var manifest Manifest
if err := wire.Decode(bytes.NewReader(raw), &manifest, manifestLimit); err != nil {
// Never propagate decoder errors: some include offending input values.
return Verified{}, errors.New("invalid manifest JSON")
}
tree, err := validate(manifest)
if err != nil {
return Verified{}, err
}
files := make(map[string][]byte, len(manifest.Files))
remaining := payloadLimit
if err := readInventory(root, tree, "", files, &remaining); err != nil {
return Verified{}, err
}
return Verified{Manifest: manifest, Files: files, Digest: expectedDigest}, nil
}
func hash(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// readRegular caps both the pre-open size and actual bytes read. The one extra
// byte detects growth or oversize without an unbounded read, even after Stat.
// Names are single validated path segments relative to an already-open Root.
func readRegular(root *os.Root, name string, limit int64) ([]byte, error) {
info, err := root.Lstat(name)
if err != nil || !info.Mode().IsRegular() || info.Size() < 0 || info.Size() > limit {
return nil, errors.New("invalid file type or size")
}
f, err := root.Open(name)
if err != nil {
return nil, errors.New("cannot open file")
}
defer f.Close()
openedInfo, err := f.Stat()
if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) || openedInfo.Size() != info.Size() {
return nil, errors.New("file changed before read")
}
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil || int64(len(data)) > limit || int64(len(data)) != openedInfo.Size() {
return nil, errors.New("file read exceeds bounds or changed")
}
return data, nil
}
type inventory struct {
children map[string]*inventory
digest string
}
func validate(m Manifest) (*inventory, error) {
if m.ProtocolVersion != 1 || m.Runtime != "compose" || !idPattern.MatchString(m.AppID) || !versionPattern.MatchString(m.Version) {
return nil, errors.New("unsupported or invalid manifest identity")
}
if len(m.Platforms) < 1 || len(m.Platforms) > 2 {
return nil, errors.New("invalid platforms")
}
platforms := make(map[string]bool)
for _, platform := range m.Platforms {
if (platform != "linux/amd64" && platform != "linux/arm64") || platforms[platform] {
return nil, errors.New("invalid platforms")
}
platforms[platform] = true
}
if len(m.Components) < 1 || len(m.Components) > 32 {
return nil, errors.New("invalid component count")
}
names := make(map[string]bool)
for _, c := range m.Components {
if !idPattern.MatchString(c.Name) || names[c.Name] || !validImage(c.Image) {
return nil, errors.New("invalid component")
}
names[c.Name] = true
}
if len(m.Files) < 1 || len(m.Files) > 128 {
return nil, errors.New("invalid file count")
}
tree := &inventory{children: map[string]*inventory{"manifest.json": {}}}
paths := make(map[string]bool)
for _, file := range m.Files {
if !validPath(file.Path) || file.Path == "manifest.json" || !digestPattern.MatchString(file.Digest) || paths[file.Path] {
return nil, errors.New("invalid file declaration")
}
paths[file.Path] = true
node := tree
parts := strings.Split(file.Path, "/")
for i, part := range parts {
next, exists := node.children[part]
if i == len(parts)-1 {
if exists {
return nil, errors.New("conflicting file paths")
}
node.children[part] = &inventory{digest: file.Digest}
} else {
if exists && next.children == nil {
return nil, errors.New("conflicting file paths")
}
if !exists {
next = &inventory{children: make(map[string]*inventory)}
node.children[part] = next
}
node = next
}
}
}
if !paths[m.Entrypoint] {
return nil, errors.New("entrypoint must be a listed file")
}
return tree, nil
}
func validImage(image string) bool {
repo, pin, found := strings.Cut(image, "@")
if !found || !digestPattern.MatchString(pin) {
return false
}
for _, part := range strings.Split(repo, "/") {
if !repositoryPart.MatchString(part) {
return false
}
}
return true
}
func validPath(path string) bool {
if len(path) == 0 || len(path) > 240 {
return false
}
for _, part := range strings.Split(path, "/") {
if len(part) > 100 || !pathPart.MatchString(part) || strings.HasSuffix(part, ".") {
return false
}
base, _, _ := strings.Cut(part, ".")
switch base {
case "con", "prn", "aux", "nul", "conin$", "conout$":
return false
}
if len(base) == 4 && (strings.HasPrefix(base, "com") || strings.HasPrefix(base, "lpt")) && base[3] >= '1' && base[3] <= '9' {
return false
}
}
return true
}
// readInventory enumerates only declared directories, one entry at a time. Each
// directory consumes at most its declared child count plus one entry; extras
// fail immediately, with no unbounded ReadDir or recursive filesystem walk.
func readInventory(root *os.Root, node *inventory, prefix string, files map[string][]byte, remaining *int64) error {
dir, err := root.Open(".")
if err != nil {
return errors.New("cannot enumerate bundle")
}
defer dir.Close()
seen := make(map[string]bool, len(node.children))
for {
entries, err := dir.ReadDir(1)
if err != nil && err != io.EOF {
return errors.New("cannot enumerate bundle")
}
if len(entries) == 0 {
if err == io.EOF {
break
}
return errors.New("invalid directory enumeration")
}
entry := entries[0]
name := entry.Name()
child, exists := node.children[name]
if !exists || seen[name] {
return errors.New("unexpected bundle entry")
}
seen[name] = true
info, statErr := root.Lstat(name)
if statErr != nil || info.Mode()&os.ModeSymlink != 0 {
return errors.New("invalid bundle entry")
}
if child.children != nil {
if !info.IsDir() {
return errors.New("expected bundle directory")
}
sub, openErr := root.OpenRoot(name)
if openErr != nil {
return errors.New("cannot open bundle subdirectory")
}
openedInfo, statErr := sub.Stat(".")
if statErr != nil || !os.SameFile(info, openedInfo) {
sub.Close()
return errors.New("bundle subdirectory changed")
}
err := readInventory(sub, child, prefix+name+"/", files, remaining)
sub.Close()
if err != nil {
return err
}
continue
}
if !info.Mode().IsRegular() {
return errors.New("expected regular bundle file")
}
if prefix == "" && name == "manifest.json" {
continue
}
limit := min(fileLimit, *remaining)
data, readErr := readRegular(root, name, limit)
if readErr != nil {
return errors.New("cannot read bounded regular payload")
}
if hash(data) != child.digest {
return errors.New("payload digest mismatch")
}
*remaining -= int64(len(data))
files[prefix+name] = data
}
if len(seen) != len(node.children) {
return errors.New("missing bundle entry")
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package appbundle_test
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestRejectFIFOWithoutOpening(t *testing.T) {
for _, name := range []string{"manifest.json", "config.txt", "extra"} {
t.Run(name, func(t *testing.T) {
files := basicFiles()
dir, pin := packageDir(t, manifest(files), files)
path := filepath.Join(dir, name)
if name != "extra" {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
}
if err := syscall.Mkfifo(path, 0600); err != nil {
t.Fatal(err)
}
// Opening a FIFO for reading would block. Verification must reject its
// metadata before opening it; the parent Linux run uses a test timeout.
rejected(t, dir, pin)
})
}
}
+408
View File
@@ -0,0 +1,408 @@
package appbundle_test
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"server-deploy/internal/appbundle"
)
func digest(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
func manifest(files map[string][]byte) map[string]any {
entries := []any{}
for path, data := range files {
entries = append(entries, map[string]any{"path": path, "digest": digest(data)})
}
return map[string]any{
"protocolVersion": 1, "appId": "demo-app", "version": "1.2.3",
"runtime": "compose", "entrypoint": "compose.yaml",
"platforms": []any{"linux/amd64", "linux/arm64"},
"components": []any{
map[string]any{"name": "api", "image": "registry.example/team/api@sha256:" + strings.Repeat("a", 64)},
map[string]any{"name": "db", "image": "db@sha256:" + strings.Repeat("b", 64)},
},
"files": entries,
}
}
func basicFiles() map[string][]byte {
return map[string][]byte{"compose.yaml": []byte("services: {}\n"), "config.txt": []byte("known fixture\n")}
}
func write(t *testing.T, dir, path string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, data, 0600); err != nil {
t.Fatal(err)
}
}
func packageDir(t *testing.T, m map[string]any, files map[string][]byte) (string, string) {
t.Helper()
dir := t.TempDir()
for path, data := range files {
write(t, dir, path, data)
}
return dir, saveManifest(t, dir, m)
}
func saveManifest(t *testing.T, dir string, m map[string]any) string {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
write(t, dir, "manifest.json", raw)
return digest(raw)
}
func rejected(t *testing.T, dir, pin string) error {
t.Helper()
v, err := appbundle.Verify(dir, pin)
if err == nil {
t.Fatal("accepted invalid package")
}
if v.Digest != "" || v.Files != nil || v.Manifest.AppID != "" {
t.Fatal("returned partial verified data on error")
}
return err
}
func TestVerifyReturnsAuthenticatedBytes(t *testing.T) {
files := basicFiles()
files["nested/config/data.txt"] = []byte("nested bytes")
dir, pin := packageDir(t, manifest(files), files)
v, err := appbundle.Verify(dir, pin)
if err != nil {
t.Fatal(err)
}
if v.Digest != pin || v.Manifest.AppID != "demo-app" || len(v.Manifest.Components) != 2 || len(v.Files) != 3 {
t.Fatalf("incorrect verified result: digest=%q files=%d", v.Digest, len(v.Files))
}
for path, data := range files {
if !bytes.Equal(v.Files[path], data) {
t.Fatalf("incorrect bytes for %s", path)
}
}
write(t, dir, "compose.yaml", []byte("later mutation"))
if !bytes.Equal(v.Files["compose.yaml"], []byte("services: {}\n")) {
t.Fatal("verified bytes changed after disk mutation")
}
}
func TestManifestPinAndParseBoundary(t *testing.T) {
for _, tc := range []struct{ name, raw, pin string }{
{"wrong pin", "not json", "sha256:" + strings.Repeat("0", 64)},
{"empty pin", "{}", ""},
{"bare pin", "{}", strings.Repeat("a", 64)},
{"uppercase pin", "{}", "sha256:" + strings.Repeat("A", 64)},
{"malformed authenticated manifest", "{SECRET_CONTENT", "auto"},
{"oversized manifest", strings.Repeat(" ", (1<<20)+1), "auto"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
write(t, dir, "manifest.json", []byte(tc.raw))
pin := tc.pin
if pin == "auto" {
pin = digest([]byte(tc.raw))
}
err := rejected(t, dir, pin)
if strings.Contains(err.Error(), "SECRET_CONTENT") {
t.Fatal("manifest bytes leaked in error")
}
if tc.name == "wrong pin" && !strings.Contains(strings.ToLower(err.Error()), "digest") {
t.Fatal("digest must be checked before JSON parsing")
}
})
}
}
func TestManifestValidation(t *testing.T) {
cases := []struct {
name string
change func(map[string]any)
}{
{"protocol", func(m map[string]any) { m["protocolVersion"] = 2 }},
{"runtime", func(m map[string]any) { m["runtime"] = "shell" }},
{"app id", func(m map[string]any) { m["appId"] = "Demo" }},
{"long id", func(m map[string]any) { m["appId"] = strings.Repeat("a", 49) }},
{"version", func(m map[string]any) { m["version"] = "1.2.3-beta" }},
{"empty platforms", func(m map[string]any) { m["platforms"] = []any{} }},
{"platform", func(m map[string]any) { m["platforms"] = []any{"windows/amd64"} }},
{"duplicate platform", func(m map[string]any) { m["platforms"] = []any{"linux/amd64", "linux/amd64"} }},
{"null platform", func(m map[string]any) { m["platforms"] = []any{nil} }},
{"empty components", func(m map[string]any) { m["components"] = []any{} }},
{"duplicate component", func(m map[string]any) { c := m["components"].([]any); c[1].(map[string]any)["name"] = "api" }},
{"invalid component name", func(m map[string]any) { m["components"].([]any)[0].(map[string]any)["name"] = "1api" }},
{"missing component field", func(m map[string]any) { delete(m["components"].([]any)[0].(map[string]any), "image") }},
{"unknown component field", func(m map[string]any) { m["components"].([]any)[0].(map[string]any)["secret"] = "SECRET_CONTENT" }},
{"null component field", func(m map[string]any) { m["components"].([]any)[0].(map[string]any)["image"] = nil }},
{"null component", func(m map[string]any) { m["components"] = []any{nil} }},
{"too many components", func(m map[string]any) {
c := []any{}
for i := 0; i < 33; i++ {
c = append(c, map[string]any{"name": "c-" + strings.Repeat("a", i), "image": "api@sha256:" + strings.Repeat("a", 64)})
}
m["components"] = c
}},
{"empty files", func(m map[string]any) { m["files"] = []any{} }},
{"duplicate file", func(m map[string]any) { f := m["files"].([]any); m["files"] = append(f, f[0]) }},
{"null file", func(m map[string]any) { m["files"] = []any{nil} }},
{"bad file digest", func(m map[string]any) {
m["files"].([]any)[0].(map[string]any)["digest"] = "sha256:" + strings.Repeat("A", 64)
}},
{"entrypoint absent", func(m map[string]any) { m["entrypoint"] = "missing.yaml" }},
{"null files", func(m map[string]any) { m["files"] = nil }},
{"missing top field", func(m map[string]any) { delete(m, "appId") }},
{"unknown top field", func(m map[string]any) { m["secret"] = "SECRET_CONTENT" }},
{"case alias", func(m map[string]any) { m["AppId"] = m["appId"]; delete(m, "appId") }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
files := basicFiles()
m := manifest(files)
tc.change(m)
dir, pin := packageDir(t, m, files)
err := rejected(t, dir, pin)
if strings.Contains(err.Error(), "SECRET_CONTENT") {
t.Fatal("manifest content leaked")
}
})
}
}
func TestRejectImageSubsetViolations(t *testing.T) {
for _, repo := range []string{"api:latest", "api", "host:5000/api", "UPPER/api", "a//b", "/api", "api/", "a/$b", "a;b", "a b", ".api", "api."} {
t.Run(repo, func(t *testing.T) {
files := basicFiles()
m := manifest(files)
img := repo + "@sha256:" + strings.Repeat("a", 64)
if repo == "api:latest" || repo == "api" {
img = repo
}
m["components"].([]any)[0].(map[string]any)["image"] = img
dir, pin := packageDir(t, m, files)
rejected(t, dir, pin)
})
}
}
func TestRejectNonportablePaths(t *testing.T) {
for _, path := range []string{"../escape", "a/../b", "a/./b", "/root", "c:/file", `a\b`, "a//b", "a/", ".env", "a/.secret", "a/secret ", "a/secret.", "con", "aux.txt", "a/com1.log", "lpt9", "CLOCK$", "UPPER.txt", "a:stream", "manifest.json", strings.Repeat("a", 101), strings.Repeat("a/", 120) + "b"} {
t.Run(path, func(t *testing.T) {
files := basicFiles()
m := manifest(files)
m["files"] = append(m["files"].([]any), map[string]any{"path": path, "digest": digest(nil)})
dir, pin := packageDir(t, m, files)
rejected(t, dir, pin)
})
}
}
func TestExactInventory(t *testing.T) {
for _, mode := range []string{"changed", "missing", "extra", "secret", "directory", "nested extra", "file directory conflict", "case collision"} {
t.Run(mode, func(t *testing.T) {
files := basicFiles()
files["nested/file.txt"] = []byte("nested")
m := manifest(files)
dir, pin := packageDir(t, m, files)
switch mode {
case "changed":
write(t, dir, "config.txt", []byte("SECRET_CONTENT"))
case "missing":
if err := os.Remove(filepath.Join(dir, "config.txt")); err != nil {
t.Fatal(err)
}
case "extra":
write(t, dir, "extra.txt", []byte("SECRET_CONTENT"))
case "secret":
write(t, dir, ".env", []byte("SECRET_CONTENT"))
case "directory":
if err := os.Mkdir(filepath.Join(dir, "unused"), 0700); err != nil {
t.Fatal(err)
}
case "nested extra":
write(t, dir, "nested/extra.txt", []byte("SECRET_CONTENT"))
case "file directory conflict":
m["files"] = append(m["files"].([]any), map[string]any{"path": "config.txt/child", "digest": digest(nil)})
pin = saveManifest(t, dir, m)
case "case collision":
m["files"] = append(m["files"].([]any), map[string]any{"path": "CONFIG.txt", "digest": digest(nil)})
pin = saveManifest(t, dir, m)
}
err := rejected(t, dir, pin)
if strings.Contains(err.Error(), "SECRET_CONTENT") {
t.Fatal("file contents leaked")
}
})
}
}
func TestPayloadBounds(t *testing.T) {
for _, tc := range []struct {
name string
sizes []int
valid bool
}{
{"file at limit", []int{4 << 20}, true},
{"file over limit", []int{(4 << 20) + 1}, false},
{"total at limit", []int{4 << 20, 4 << 20, 4 << 20, 4 << 20}, true},
{"total over limit", []int{4 << 20, 4 << 20, 4 << 20, 4 << 20, 1}, false},
} {
t.Run(tc.name, func(t *testing.T) {
files := map[string][]byte{}
for i, size := range tc.sizes {
path := string(rune('a'+i)) + ".txt"
if i == 0 {
path = "compose.yaml"
}
files[path] = bytes.Repeat([]byte("x"), size)
}
dir, pin := packageDir(t, manifest(files), files)
if tc.valid {
if _, err := appbundle.Verify(dir, pin); err != nil {
t.Fatal(err)
}
} else {
rejected(t, dir, pin)
}
})
}
}
func symlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1314)) {
t.Skip("Windows symlink privilege unavailable")
}
t.Fatal(err)
}
}
func TestRejectSymlinks(t *testing.T) {
for _, mode := range []string{"file", "manifest", "intermediate", "extra", "root", "root trailing separator"} {
t.Run(mode, func(t *testing.T) {
files := basicFiles()
dir, pin := packageDir(t, manifest(files), files)
switch mode {
case "file", "manifest":
name := "config.txt"
if mode == "manifest" {
name = "manifest.json"
}
path := filepath.Join(dir, name)
target := filepath.Join(t.TempDir(), "target")
if err := os.Rename(path, target); err != nil {
t.Fatal(err)
}
symlink(t, target, path)
case "intermediate":
outside := t.TempDir()
write(t, outside, "data.txt", []byte("outside"))
symlink(t, outside, filepath.Join(dir, "nested"))
m := manifest(files)
m["files"] = append(m["files"].([]any), map[string]any{"path": "nested/data.txt", "digest": digest([]byte("outside"))})
pin = saveManifest(t, dir, m)
case "extra":
symlink(t, t.TempDir(), filepath.Join(dir, "extra"))
case "root", "root trailing separator":
link := filepath.Join(t.TempDir(), "bundle")
symlink(t, dir, link)
dir = link
if mode == "root trailing separator" {
dir += string(os.PathSeparator)
}
}
rejected(t, dir, pin)
})
}
}
func TestAbsoluteDirectoryRequired(t *testing.T) { rejected(t, ".", "sha256:"+strings.Repeat("a", 64)) }
func TestRejectTrailingAndDuplicateJSON(t *testing.T) {
for _, suffix := range []string{" {}", ",\"appId\":\"other\"}"} {
t.Run(suffix, func(t *testing.T) {
files := basicFiles()
dir, _ := packageDir(t, manifest(files), files)
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(suffix, ",") {
raw = raw[:len(raw)-1]
}
raw = append(raw, []byte(suffix)...)
write(t, dir, "manifest.json", raw)
rejected(t, dir, digest(raw))
})
}
}
func TestManifestExactByteLimit(t *testing.T) {
files := basicFiles()
dir, _ := packageDir(t, manifest(files), files)
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
if err != nil {
t.Fatal(err)
}
originalPin := digest(raw)
raw = append(raw, bytes.Repeat([]byte(" "), (1<<20)-len(raw))...)
write(t, dir, "manifest.json", raw)
rejected(t, dir, originalPin) // Whitespace must change the exact-byte pin.
if _, err := appbundle.Verify(dir, digest(raw)); err != nil {
t.Fatal(err)
}
}
func TestInventoryCountBoundaries(t *testing.T) {
for _, n := range []int{128, 129} {
t.Run(fmt.Sprint(n), func(t *testing.T) {
files := map[string][]byte{"compose.yaml": {}}
for i := 1; i < n; i++ {
files[fmt.Sprintf("file-%d.txt", i)] = []byte{}
}
dir, pin := packageDir(t, manifest(files), files)
if n == 128 {
if _, err := appbundle.Verify(dir, pin); err != nil {
t.Fatal(err)
}
} else {
rejected(t, dir, pin)
}
})
}
}
func TestComponentCountBoundary(t *testing.T) {
files := basicFiles()
m := manifest(files)
components := []any{}
for i := 0; i < 32; i++ {
components = append(components, map[string]any{"name": fmt.Sprintf("component-%d", i), "image": "api@sha256:" + strings.Repeat("a", 64)})
}
m["components"] = components
m["appId"] = strings.Repeat("a", 48)
dir, pin := packageDir(t, m, files)
if _, err := appbundle.Verify(dir, pin); err != nil {
t.Fatal(err)
}
}