feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user