409 lines
13 KiB
Go
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)
|
|
}
|
|
}
|