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