Files

409 lines
13 KiB
Go

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)
}
}