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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# Staged Docker repository authentication
|
||||
|
||||
`deployctl verify-repository` accepts strict JSON with `directory` (absolute trusted
|
||||
staging directory), `suite`, `architecture`, and `versions` (exact version strings
|
||||
for docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin and
|
||||
docker-compose-plugin). It does not select a latest version or resolve dependencies.
|
||||
|
||||
The directory must contain `docker.asc`, `Release`, `Release.gpg` and uncompressed
|
||||
`Packages`. The caller obtains these from the Docker Ubuntu repository. No runtime
|
||||
network request is performed by this command. Limits are 1 MiB, 1 MiB, 64 KiB and
|
||||
16 MiB respectively. Files must be nonempty regular files; symlinks are rejected.
|
||||
Trusted ancestors and absence of concurrent writers are required. This is not an
|
||||
atomic filesystem snapshot against hostile writers. Additional staging files are
|
||||
ignored, never executed.
|
||||
|
||||
## Trust and verification
|
||||
|
||||
1. Exact armored key SHA-256 is pinned in source. Initial trust was bootstrapped
|
||||
from `https://download.docker.com/linux/ubuntu/gpg`, not supplied by the request.
|
||||
Primary fingerprint: `9DC858229FC7DD38854AE2D88D81803C0EBFCD88`.
|
||||
Rotation or formatting changes require a reviewed source update; fail closed.
|
||||
2. Linux `/usr/bin/gpg` dearmors into a new private temporary directory; `/usr/bin/gpgv`
|
||||
verifies the detached signature against the copied Release bytes, with that
|
||||
keyring and isolated homedir. No shell, ambient GnuPG config or personal keyring.
|
||||
Each child has a 15-second timeout and bounded output. Errors do not expose raw
|
||||
GnuPG diagnostics or metadata. Non-Linux or missing tools fail closed.
|
||||
3. Successful process exit and a single accepted primary-fingerprint signature
|
||||
are required; weak digests, expired/revoked/bad signatures and unknown status
|
||||
types fail closed. SHA-256/384/512 are accepted.
|
||||
4. Authenticated Release must describe Docker CE and the requested supported Ubuntu
|
||||
suite, architecture and stable component. Date must be within 30 days and no
|
||||
more than 10 minutes in the future; Valid-Until is enforced when present.
|
||||
5. The exact uncompressed stable Packages entry's SHA-256 and byte size must match.
|
||||
Five explicitly requested versions are resolved to authenticated index records;
|
||||
the derived lock is checked by installplan's source/path/version constraints.
|
||||
|
||||
The machine clock, OS and installed GnuPG are trusted. This is not a persistent
|
||||
anti-rollback ledger: an older authentic Release inside the freshness window can
|
||||
pass, and a missing Valid-Until is governed by the local 30-day policy. No online
|
||||
key revocation lookup is performed. Key pin maintenance is an operator responsibility.
|
||||
|
||||
## Result boundaries
|
||||
|
||||
`repositoryAuthenticated: true` attests to these metadata bytes at `verifiedAt`.
|
||||
For `verify-repository`, `packageBytesVerified: false` and `executable: false` remain explicit: no deb bytes,
|
||||
Ubuntu dependency repository, dependency closure, installation scripts, system
|
||||
compatibility or service behavior have been verified. The result is not a signed
|
||||
capability. `plan-environment` still treats a supplied lock as untrusted and does
|
||||
not accept a caller's authentication claim to clear its trust blockers.
|
||||
|
||||
Signature scratch data is removed on normal return; a killed process can leave its own
|
||||
private temporary directory. Deployment writes remain disabled. `writesEnabled`
|
||||
in `version` refers to deployment writes, not verification scratch files.
|
||||
|
||||
`scripts/probe-docker-repository.sh /absolute/path/to/deployctl` is an optional
|
||||
local online integration check. It downloads public metadata over HTTPS into a
|
||||
new temporary directory, tests real authentication and rejects tampering. Versions
|
||||
selected by this test are fixtures, not recommended installation versions. It
|
||||
does not install packages, alter APT, use SSH or touch application data.
|
||||
|
||||
## Verify actual deb bytes
|
||||
|
||||
`verify-artifacts` requires the same request fields as `verify-repository`, plus
|
||||
`artifactDirectory`: an absolute trusted directory containing exactly the five deb
|
||||
files named by the basename of each authenticated `Filename`. No other entries,
|
||||
subdirectories, symlinks or special files are accepted. The two directories must
|
||||
not be concurrently modified. Repository signatures and metadata are verified
|
||||
anew in the same call; the command accepts neither a supplied lock nor trust flags.
|
||||
|
||||
Each file is streamed through SHA-256 with a 64 KiB copy buffer and its signed
|
||||
index size as the read bound (plus one overflow-detection byte). The existing
|
||||
lock policy caps each file at 512 MiB and requires exactly five files. A last-file
|
||||
failure rejects the entire result. No partial successful report is emitted.
|
||||
The verifier never unpacks a deb or executes its contents. It does not change
|
||||
the staged files. Metadata identity checks supplement, not replace, the trusted
|
||||
directory/no concurrent writer requirement.
|
||||
|
||||
Only `verify-artifacts` may set `packageBytesVerified: true`; `executable` stays
|
||||
false. This verifies the selected five Docker package bytes, not the complete
|
||||
Ubuntu dependency closure, package-internal safety or installation compatibility.
|
||||
It is a point-in-time observation: do not reuse this JSON to authorize later
|
||||
execution of paths that may have changed. A future installer must recheck or
|
||||
own immutable verified snapshots under its operation lock.
|
||||
|
||||
Set `DEPLOYCTL_ONLINE_ARTIFACT_PROBE=1` along with
|
||||
`DEPLOYCTL_ONLINE_REPOSITORY_PROBE=1` when running `scripts/verify-linux.sh` to
|
||||
also download the five real public deb fixtures into a new local temporary
|
||||
directory. The optional probe verifies all files, then changes one byte without
|
||||
changing length and asserts rejection. It retains test files for inspection;
|
||||
they are not installed and one is intentionally damaged by the negative test.
|
||||
@@ -0,0 +1,117 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
// VerifyArtifacts authenticates repository metadata anew before checking the
|
||||
// selected five deb files. It never accepts a caller-asserted authenticated lock.
|
||||
// The staging directories and ancestors must be trusted and not concurrently
|
||||
// modified. This is observation evidence, not a capability to later execute paths.
|
||||
func VerifyArtifacts(metadataDirectory, artifactDirectory, suite, arch string, versions map[string]string, now time.Time) (Result, error) {
|
||||
result, err := Verify(metadataDirectory, suite, arch, versions, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := verifyArtifacts(artifactDirectory, result.Lock); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.PackageBytesVerified = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func verifyArtifacts(directory string, lock installplan.Lock) error {
|
||||
reject := errors.New("invalid staged Docker artifacts")
|
||||
if _, err := installplan.Validate(lock, lock.Suite, lock.Architecture); err != nil {
|
||||
return reject
|
||||
}
|
||||
if !filepath.IsAbs(directory) {
|
||||
return reject
|
||||
}
|
||||
directory = filepath.Clean(directory)
|
||||
before, err := os.Lstat(directory)
|
||||
if err != nil || !before.IsDir() || before.Mode()&os.ModeSymlink != 0 {
|
||||
return reject
|
||||
}
|
||||
root, err := os.OpenRoot(directory)
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer root.Close()
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil || !os.SameFile(before, opened) {
|
||||
return reject
|
||||
}
|
||||
expected := make(map[string]installplan.Package, len(lock.Packages))
|
||||
for _, p := range lock.Packages {
|
||||
expected[path.Base(p.Filename)] = p
|
||||
}
|
||||
dir, err := root.Open(".")
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer dir.Close()
|
||||
// Enumerate at most the expected count plus one, never an unbounded directory.
|
||||
seen := make(map[string]bool)
|
||||
for {
|
||||
entries, err := dir.ReadDir(1)
|
||||
if err != nil && err != io.EOF {
|
||||
return reject
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return reject
|
||||
}
|
||||
name := entries[0].Name()
|
||||
p, ok := expected[name]
|
||||
if !ok || seen[name] {
|
||||
return reject
|
||||
}
|
||||
seen[name] = true
|
||||
if err := verifyArtifact(root, name, p); err != nil {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
if len(seen) != len(expected) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyArtifact(root *os.Root, name string, p installplan.Package) error {
|
||||
reject := errors.New("artifact size or digest mismatch")
|
||||
before, err := root.Lstat(name)
|
||||
if err != nil || !before.Mode().IsRegular() || before.Size() != int64(p.Size) {
|
||||
return reject
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer file.Close()
|
||||
opened, err := file.Stat()
|
||||
if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || opened.Size() != before.Size() {
|
||||
return reject
|
||||
}
|
||||
digest := sha256.New()
|
||||
n, err := io.CopyBuffer(digest, io.LimitReader(file, int64(p.Size)+1), make([]byte, 64<<10))
|
||||
if err != nil || n != int64(p.Size) || "sha256:"+hex.EncodeToString(digest.Sum(nil)) != p.Digest {
|
||||
return reject
|
||||
}
|
||||
after, err := file.Stat()
|
||||
if err != nil || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactRejectsSymlinksAndFIFO(t *testing.T) {
|
||||
for _, kind := range []string{"root-link", "file-link", "fifo"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
filename := filepath.Join(dir, path.Base(lock.Packages[0].Filename))
|
||||
switch kind {
|
||||
case "root-link":
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(dir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir = link + "/"
|
||||
case "file-link":
|
||||
target := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.Rename(filename, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "fifo":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filename, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := verifyArtifacts(dir, lock); err == nil {
|
||||
t.Fatal("unsafe file accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
func artifactFixture(t *testing.T) (string, installplan.Lock) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
index := fixtureIndex()
|
||||
lock, err := Resolve(fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range lock.Packages {
|
||||
p := &lock.Packages[i]
|
||||
data := []byte("artifact fixture " + p.Name)
|
||||
p.Size = uint64(len(data))
|
||||
p.Digest = fmt.Sprintf("sha256:%x", sha256.Sum256(data))
|
||||
if err := os.WriteFile(filepath.Join(dir, path.Base(p.Filename)), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir, lock
|
||||
}
|
||||
|
||||
func TestArtifactBytesAndIdentity(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
before := lock
|
||||
before.Packages = append([]installplan.Package(nil), lock.Packages...)
|
||||
if err := verifyArtifacts(dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, lock) {
|
||||
t.Fatal("lock mutated")
|
||||
}
|
||||
for _, p := range lock.Packages {
|
||||
data, err := os.ReadFile(filepath.Join(dir, path.Base(p.Filename)))
|
||||
if err != nil || string(data) != "artifact fixture "+p.Name {
|
||||
t.Fatal("artifact modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactRejectsTamperWithoutPartialSuccess(t *testing.T) {
|
||||
for _, kind := range []string{"same-size", "truncated", "extra-byte", "missing", "directory", "bad-lock", "relative-root", "extra-file"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
p := lock.Packages[4] // The last artifact must fail the whole set.
|
||||
filename := filepath.Join(dir, path.Base(p.Filename))
|
||||
switch kind {
|
||||
case "same-size":
|
||||
data := []byte("artifact fixture " + p.Name)
|
||||
data[0] = 'X'
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "truncated":
|
||||
if err := os.Truncate(filename, int64(p.Size)-1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "extra-byte":
|
||||
if err := os.Truncate(filename, int64(p.Size)+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "missing":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "directory":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filename, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "bad-lock":
|
||||
lock.Packages[0].Filename = "../../secret.deb"
|
||||
case "relative-root":
|
||||
dir = "relative"
|
||||
case "extra-file":
|
||||
if err := os.WriteFile(filepath.Join(dir, "unexpected.deb"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := verifyArtifacts(dir, lock); err == nil {
|
||||
t.Fatal("invalid artifacts accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
var metadataError = errors.New("invalid Docker repository metadata")
|
||||
|
||||
var pinnedPackageNames = [...]string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"}
|
||||
|
||||
// Resolve parses Release bytes already authenticated by the caller and binds
|
||||
// explicitly pinned packages to that Release. It does not verify signatures.
|
||||
func Resolve(release, index []byte, suite, arch string, versions map[string]string, now time.Time) (installplan.Lock, error) {
|
||||
if len(release) > 1<<20 || len(index) > 16<<20 || len(versions) != len(pinnedPackageNames) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
for _, name := range pinnedPackageNames {
|
||||
if versions[name] == "" {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
}
|
||||
|
||||
var fields map[string]string
|
||||
if err := parseControl(release, func(stanza map[string]string) error {
|
||||
if fields != nil {
|
||||
return metadataError
|
||||
}
|
||||
fields = stanza
|
||||
return nil
|
||||
}); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if fields["suite"] != suite || fields["origin"] != "Docker" || fields["label"] != "Docker CE" || !hasWord(fields["architectures"], arch) || !hasWord(fields["components"], "stable") {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
date, err := time.Parse(time.RFC1123Z, fields["date"])
|
||||
if err != nil || date.After(now.Add(10*time.Minute)) || date.Before(now.Add(-30*24*time.Hour)) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if value, ok := fields["valid-until"]; ok {
|
||||
expiry, err := time.Parse(time.RFC1123Z, value)
|
||||
if err != nil || !expiry.After(now) || expiry.Before(date) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
}
|
||||
// Bind the exact uncompressed index bytes before interpreting any records.
|
||||
indexSum := sha256.Sum256(index)
|
||||
expectedPath := "stable/binary-" + arch + "/Packages"
|
||||
seenPaths := make(map[string]bool)
|
||||
matched := false
|
||||
for _, line := range strings.Split(fields["sha256"], "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
entry := strings.Fields(line)
|
||||
if len(entry) != 3 || !validSHA256(entry[0]) || seenPaths[entry[2]] {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
seenPaths[entry[2]] = true
|
||||
size, err := decimalSize(entry[1])
|
||||
if err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if entry[2] == expectedPath {
|
||||
if size != uint64(len(index)) || entry[0] != hex.EncodeToString(indexSum[:]) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
|
||||
selected := make(map[string]installplan.Package)
|
||||
seenRecords := make(map[[3]string]bool)
|
||||
if err := parseControl(index, func(record map[string]string) error {
|
||||
identity := [3]string{record["package"], record["version"], record["architecture"]}
|
||||
if identity[0] != "" && identity[1] != "" && identity[2] != "" {
|
||||
if seenRecords[identity] {
|
||||
return metadataError
|
||||
}
|
||||
seenRecords[identity] = true
|
||||
}
|
||||
version, target := versions[identity[0]]
|
||||
if !target {
|
||||
return nil
|
||||
}
|
||||
for _, field := range []string{"package", "version", "architecture", "filename", "size", "sha256"} {
|
||||
if record[field] == "" || strings.Contains(record[field], "\n") {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
if identity[1] != version || identity[2] != arch {
|
||||
return nil
|
||||
}
|
||||
size, err := decimalSize(record["size"])
|
||||
if err != nil {
|
||||
return metadataError
|
||||
}
|
||||
selected[identity[0]] = installplan.Package{
|
||||
Name: identity[0], Version: version, Filename: record["filename"],
|
||||
Size: size, Digest: "sha256:" + record["sha256"],
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
|
||||
releaseSum := sha256.Sum256(release)
|
||||
lock := installplan.Lock{
|
||||
ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu",
|
||||
Suite: suite, Architecture: arch, ReleaseDigest: "sha256:" + hex.EncodeToString(releaseSum[:]),
|
||||
}
|
||||
for _, name := range pinnedPackageNames {
|
||||
p, ok := selected[name]
|
||||
if !ok {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
lock.Packages = append(lock.Packages, p)
|
||||
}
|
||||
if _, err := installplan.Validate(lock, suite, arch); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
// parseControl preserves continuation boundaries and rejects duplicate fields
|
||||
// before invoking visit. Processing one stanza at a time bounds retained values.
|
||||
func parseControl(raw []byte, visit func(map[string]string) error) error {
|
||||
if !utf8.Valid(raw) {
|
||||
return metadataError
|
||||
}
|
||||
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\t' {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
fields := make(map[string]*strings.Builder)
|
||||
current := ""
|
||||
flush := func() error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
stanza := make(map[string]string, len(fields))
|
||||
for name, value := range fields {
|
||||
stanza[name] = value.String()
|
||||
}
|
||||
if err := visit(stanza); err != nil {
|
||||
return err
|
||||
}
|
||||
fields = make(map[string]*strings.Builder)
|
||||
current = ""
|
||||
return nil
|
||||
}
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
if line == "" {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if current == "" {
|
||||
return metadataError
|
||||
}
|
||||
fields[current].WriteByte('\n')
|
||||
fields[current].WriteString(strings.Trim(line, " \t"))
|
||||
continue
|
||||
}
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok || name == "" {
|
||||
return metadataError
|
||||
}
|
||||
for _, r := range name {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-') {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
current = strings.ToLower(name)
|
||||
if _, exists := fields[current]; exists {
|
||||
return metadataError
|
||||
}
|
||||
fields[current] = &strings.Builder{}
|
||||
fields[current].WriteString(strings.Trim(value, " \t"))
|
||||
}
|
||||
return flush()
|
||||
}
|
||||
|
||||
func hasWord(value, word string) bool {
|
||||
for _, item := range strings.Fields(value) {
|
||||
if item == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decimalSize(value string) (uint64, error) {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, metadataError
|
||||
}
|
||||
}
|
||||
size, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, metadataError
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
var fixtureNow = time.Date(2026, 9, 25, 6, 38, 50, 0, time.UTC)
|
||||
|
||||
func fixturePins() map[string]string {
|
||||
return map[string]string{
|
||||
"docker-ce": "5:29.1.0-1~ubuntu.26.04~resolute",
|
||||
"docker-ce-cli": "5:29.1.0-1~ubuntu.26.04~resolute",
|
||||
"containerd.io": "2.1.5-1~ubuntu.26.04~resolute",
|
||||
"docker-buildx-plugin": "0.30.1-1~ubuntu.26.04~resolute",
|
||||
"docker-compose-plugin": "2.40.3-1~ubuntu.26.04~resolute",
|
||||
}
|
||||
}
|
||||
|
||||
// Inline Debian control fixtures retain Docker's Release field layout (notably
|
||||
// no Codename) and epoch-free pool filenames. Artifact hashes are test data;
|
||||
// authentication of Release is outside Resolve's contract.
|
||||
func fixtureRecord(name, version, arch string) string {
|
||||
fileVersion := version
|
||||
if _, after, ok := strings.Cut(version, ":"); ok {
|
||||
fileVersion = after
|
||||
}
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nMaintainer: Docker <support@docker.com>\nFilename: dists/resolute/pool/stable/%s/%s_%s_%s.deb\nSize: 12345\nSHA256: %s\nDescription: Docker package\n continuation with a colon: allowed\n .\n another paragraph\n\n", name, version, arch, arch, name, fileVersion, arch, strings.Repeat("a", 64))
|
||||
}
|
||||
|
||||
func fixtureIndex() []byte {
|
||||
pins := fixturePins()
|
||||
var index strings.Builder
|
||||
for _, name := range []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"} {
|
||||
index.WriteString(fixtureRecord(name, pins[name], "amd64"))
|
||||
}
|
||||
return []byte(index.String())
|
||||
}
|
||||
|
||||
func fixtureRelease(index []byte) []byte {
|
||||
return []byte(fmt.Sprintf("Architectures: amd64 arm64 armhf s390x ppc64el\nComponents: stable edge test nightly\nDate: Thu, 24 Sep 2026 06:38:50 +0000\nLabel: Docker CE\nOrigin: Docker\nSuite: resolute\nSHA256:\n %x %d stable/binary-amd64/Packages\n", sha256.Sum256(index), len(index)))
|
||||
}
|
||||
|
||||
func replace(raw []byte, old, new string) []byte {
|
||||
return []byte(strings.Replace(string(raw), old, new, 1))
|
||||
}
|
||||
|
||||
func assertRejected(t *testing.T, release, index []byte, suite, arch string, pins map[string]string, now time.Time) {
|
||||
t.Helper()
|
||||
lock, err := Resolve(release, index, suite, arch, pins, now)
|
||||
if err == nil {
|
||||
t.Fatal("invalid metadata accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(lock, installplan.Lock{}) {
|
||||
t.Fatal("failure returned a partial lock")
|
||||
}
|
||||
// Rejection messages must not disclose any untrusted metadata or pins.
|
||||
if strings.Contains(err.Error(), "PRIVATE-MARKER") {
|
||||
t.Fatal("error echoed metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDockerMetadataChain(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
release := fixtureRelease(index)
|
||||
lock, err := Resolve(release, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lock.ProtocolVersion != 1 || lock.Repository != "https://download.docker.com/linux/ubuntu" || lock.Suite != "resolute" || lock.Architecture != "amd64" || lock.ReleaseDigest != fmt.Sprintf("sha256:%x", sha256.Sum256(release)) {
|
||||
t.Fatalf("incorrect release binding: %+v", lock)
|
||||
}
|
||||
if len(lock.Packages) != 5 {
|
||||
t.Fatal("incorrect package count")
|
||||
}
|
||||
want := installplan.Package{Name: "docker-ce", Version: "5:29.1.0-1~ubuntu.26.04~resolute", Filename: "dists/resolute/pool/stable/amd64/docker-ce_29.1.0-1~ubuntu.26.04~resolute_amd64.deb", Digest: "sha256:" + strings.Repeat("a", 64), Size: 12345}
|
||||
if lock.Packages[0] != want {
|
||||
t.Fatalf("wrong selected package: %+v", lock.Packages[0])
|
||||
}
|
||||
for _, p := range lock.Packages {
|
||||
if p.Version != fixturePins()[p.Name] {
|
||||
t.Fatal("pin was not preserved")
|
||||
}
|
||||
}
|
||||
if _, err := installplan.Validate(lock, "resolute", "amd64"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompatibleControlFormatting(t *testing.T) {
|
||||
for _, mode := range []string{"mixed case", "CRLF", "no final newline", "other versions and architectures", "arm64", "valid expiry", "future boundary", "age boundary"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
index, arch, now := fixtureIndex(), "amd64", fixtureNow
|
||||
switch mode {
|
||||
case "mixed case":
|
||||
index = []byte(strings.ReplaceAll(string(index), "Package:", "pAcKaGe:"))
|
||||
case "CRLF":
|
||||
index = []byte(strings.ReplaceAll(string(index), "\n", "\r\n"))
|
||||
case "no final newline":
|
||||
index = []byte(strings.TrimRight(string(index), "\n"))
|
||||
case "other versions and architectures":
|
||||
index = append(index, fixtureRecord("docker-ce", "5:99.0-1", "amd64")...)
|
||||
index = append(index, fixtureRecord("docker-ce", fixturePins()["docker-ce"], "arm64")...)
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
index = []byte(strings.ReplaceAll(string(index), "amd64", arch))
|
||||
case "future boundary":
|
||||
now = fixtureNow.Add(-24*time.Hour - 10*time.Minute)
|
||||
case "age boundary":
|
||||
now = fixtureNow.Add(29 * 24 * time.Hour)
|
||||
}
|
||||
release := fixtureRelease(index)
|
||||
switch mode {
|
||||
case "mixed case":
|
||||
release = replace(release, "SHA256:", "sHa256:")
|
||||
release = replace(release, "Suite:", "sUiTe:")
|
||||
case "CRLF":
|
||||
release = []byte(strings.ReplaceAll(string(release), "\n", "\r\n"))
|
||||
case "no final newline":
|
||||
release = []byte(strings.TrimRight(string(release), "\n"))
|
||||
case "arm64":
|
||||
release = replace(release, "binary-amd64/Packages", "binary-arm64/Packages")
|
||||
case "valid expiry":
|
||||
release = append(release, "Valid-Until: Sat, 26 Sep 2026 06:38:50 +0000\n"...)
|
||||
}
|
||||
lock, err := Resolve(release, index, "resolute", arch, fixturePins(), now)
|
||||
if err != nil || len(lock.Packages) != 5 || lock.Architecture != arch {
|
||||
t.Fatalf("compatible metadata rejected: %v", err)
|
||||
}
|
||||
if lock.ReleaseDigest != fmt.Sprintf("sha256:%x", sha256.Sum256(release)) {
|
||||
t.Fatal("digest did not bind original bytes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsReleaseMetadata(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
for name, mutate := range map[string]func([]byte) []byte{
|
||||
"suite": func(r []byte) []byte { return replace(r, "Suite: resolute", "Suite: noble") },
|
||||
"codename cannot replace suite": func(r []byte) []byte { return replace(r, "Suite:", "Codename:") },
|
||||
"origin": func(r []byte) []byte { return replace(r, "Origin: Docker", "Origin: PRIVATE-MARKER") },
|
||||
"label": func(r []byte) []byte { return replace(r, "Label: Docker CE", "Label: Other") },
|
||||
"arch token": func(r []byte) []byte { return replace(r, "amd64 arm64", "xamd64 arm64") },
|
||||
"component token": func(r []byte) []byte { return replace(r, "stable edge", "unstable edge") },
|
||||
"invalid date": func(r []byte) []byte { return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "PRIVATE-MARKER") },
|
||||
"future date": func(r []byte) []byte {
|
||||
return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "Fri, 25 Sep 2026 06:48:51 +0000")
|
||||
},
|
||||
"stale date": func(r []byte) []byte {
|
||||
return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "Wed, 26 Aug 2026 06:38:49 +0000")
|
||||
},
|
||||
"expired": func(r []byte) []byte { return append(r, "Valid-Until: Fri, 25 Sep 2026 06:38:50 +0000\n"...) },
|
||||
"invalid expiry": func(r []byte) []byte { return append(r, "Valid-Until: PRIVATE-MARKER\n"...) },
|
||||
"duplicate case insensitive field": func(r []byte) []byte { return append(r, "oRiGiN: Docker\n"...) },
|
||||
"duplicate unrelated field": func(r []byte) []byte { return append(r, "X-Info: one\nx-info: two\n"...) },
|
||||
"second stanza": func(r []byte) []byte { return append(r, "\nSuite: resolute\n"...) },
|
||||
"MD5 only": func(r []byte) []byte { return replace(r, "SHA256:", "MD5Sum:") },
|
||||
"SHA1 only": func(r []byte) []byte { return replace(r, "SHA256:", "SHA1:") },
|
||||
"compressed only": func(r []byte) []byte { return replace(r, "/Packages", "/Packages.gz") },
|
||||
"path prefix": func(r []byte) []byte { return replace(r, "stable/binary", "./stable/binary") },
|
||||
"checksum arch": func(r []byte) []byte { return replace(r, "binary-amd64", "binary-arm64") },
|
||||
"checksum size": func(r []byte) []byte {
|
||||
return replace(r, fmt.Sprintf(" %d ", len(index)), fmt.Sprintf(" %d ", len(index)+1))
|
||||
},
|
||||
"checksum negative size": func(r []byte) []byte { return replace(r, fmt.Sprintf(" %d ", len(index)), " -1 ") },
|
||||
"checksum extra column": func(r []byte) []byte { return replace(r, "/Packages\n", "/Packages extra\n") },
|
||||
"checksum invalid digest": func(r []byte) []byte {
|
||||
return replace(r, fmt.Sprintf("%x", sha256.Sum256(index)), strings.Repeat("g", 64))
|
||||
},
|
||||
"duplicate checksum entry": func(r []byte) []byte {
|
||||
return append(r, fmt.Sprintf(" %x %d stable/binary-amd64/Packages\n", sha256.Sum256(index), len(index))...)
|
||||
},
|
||||
"conflicting checksum entry": func(r []byte) []byte {
|
||||
return append(r, fmt.Sprintf(" %s %d stable/binary-amd64/Packages\n", strings.Repeat("b", 64), len(index))...)
|
||||
},
|
||||
"duplicate other checksum entry": func(r []byte) []byte {
|
||||
return append(r, strings.Repeat(" "+strings.Repeat("a", 64)+" 1 other/Packages\n", 2)...)
|
||||
},
|
||||
"orphan continuation": func(r []byte) []byte { return append([]byte(" orphan\n"), r...) },
|
||||
"invalid field name": func(r []byte) []byte { return append(r, "Bad Field: value\n"...) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertRejected(t, mutate(fixtureRelease(index)), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
for _, field := range []string{"Architectures", "Components", "Date", "Label", "Origin", "Suite"} {
|
||||
t.Run("missing "+field, func(t *testing.T) {
|
||||
r := fixtureRelease(index)
|
||||
lines := strings.Split(string(r), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, field+":") {
|
||||
lines = append(lines[:i], lines[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
assertRejected(t, []byte(strings.Join(lines, "\n")), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
// Both times are in the future relative to now, but expiry precedes Date.
|
||||
r := append(fixtureRelease(index), "Valid-Until: Thu, 24 Sep 2026 06:37:50 +0000\n"...)
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow.Add(-24*time.Hour-2*time.Minute))
|
||||
}
|
||||
|
||||
func TestResolveRejectsTamperedIndex(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
r := fixtureRelease(index)
|
||||
index = replace(index, "Size: 12345", "Size: 12346") // Same byte size, different digest.
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
}
|
||||
|
||||
func TestResolveRejectsPackageAmbiguityAndInvalidLock(t *testing.T) {
|
||||
for name, mutate := range map[string]func([]byte) []byte{
|
||||
"duplicate field": func(p []byte) []byte {
|
||||
return replace(p, "Package: docker-ce\n", "Package: docker-ce\npAcKaGe: docker-ce\n")
|
||||
},
|
||||
"duplicate unrelated field": func(p []byte) []byte { return replace(p, "Description:", "X-Info: one\nx-info: two\nDescription:") },
|
||||
"duplicate record": func(p []byte) []byte {
|
||||
return append(p, fixtureRecord("docker-ce", fixturePins()["docker-ce"], "amd64")...)
|
||||
},
|
||||
"conflicting record": func(p []byte) []byte {
|
||||
return append(p, strings.Replace(fixtureRecord("docker-ce", fixturePins()["docker-ce"], "amd64"), "Size: 12345", "Size: 999", 1)...)
|
||||
},
|
||||
"missing record": func(p []byte) []byte { return []byte(strings.SplitN(string(p), "\n\n", 2)[1]) },
|
||||
"wrong architecture": func(p []byte) []byte { return replace(p, "Architecture: amd64", "Architecture: all") },
|
||||
"wrong version": func(p []byte) []byte { return replace(p, "Version: 5:29.1.0", "Version: 5:29.2.0") },
|
||||
"unsafe filename": func(p []byte) []byte { return replace(p, "Filename: dists/resolute", "Filename: ../PRIVATE-MARKER") },
|
||||
"wrong filename version": func(p []byte) []byte { return replace(p, "docker-ce_29.1.0", "docker-ce_29.2.0") },
|
||||
"epoch filename": func(p []byte) []byte { return replace(p, "docker-ce_29.1.0", "docker-ce_5:29.1.0") },
|
||||
"wrong filename suite": func(p []byte) []byte { return replace(p, "Filename: dists/resolute", "Filename: dists/noble") },
|
||||
"zero size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 0") },
|
||||
"negative size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: -1") },
|
||||
"overflow size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 18446744073709551616") },
|
||||
"excess package size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 536870913") },
|
||||
"bad digest": func(p []byte) []byte { return replace(p, "SHA256: "+strings.Repeat("a", 64), "SHA256: PRIVATE-MARKER") },
|
||||
"folded required field": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 12345\n 6") },
|
||||
"invalid syntax": func(p []byte) []byte { return append(p, "PRIVATE-MARKER\n"...) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
index := mutate(fixtureIndex())
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
for _, field := range []string{"Package", "Version", "Architecture", "Filename", "Size", "SHA256"} {
|
||||
t.Run("missing "+field, func(t *testing.T) {
|
||||
index := replace(fixtureIndex(), field+":", "X-Removed:")
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRequiresExactExplicitPins(t *testing.T) {
|
||||
for _, mode := range []string{"nil", "missing", "extra", "empty", "latest", "engine mismatch", "invalid version"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
pins := fixturePins()
|
||||
switch mode {
|
||||
case "nil":
|
||||
pins = nil
|
||||
case "missing":
|
||||
delete(pins, "containerd.io")
|
||||
case "extra":
|
||||
pins["unexpected"] = "1.0"
|
||||
case "empty":
|
||||
pins["containerd.io"] = ""
|
||||
case "latest":
|
||||
pins["containerd.io"] = "latest"
|
||||
case "engine mismatch":
|
||||
pins["docker-ce-cli"] = "5:29.2.0-1~ubuntu.26.04~resolute"
|
||||
case "invalid version":
|
||||
pins["containerd.io"] = "1;PRIVATE-MARKER"
|
||||
}
|
||||
// Make invalid versions available too: Validate, rather than a missing
|
||||
// match, must enforce version syntax and engine/CLI equality.
|
||||
index := fixtureIndex()
|
||||
for _, name := range []string{"containerd.io", "docker-ce-cli"} {
|
||||
if v := pins[name]; v != "" && v != fixturePins()[name] {
|
||||
index = replace(index, fixtureRecord(name, fixturePins()[name], "amd64"), fixtureRecord(name, v, "amd64"))
|
||||
}
|
||||
}
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", pins, fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsInvalidText(t *testing.T) {
|
||||
for _, invalid := range []string{"\x00", "\x01", "\x1b", "\x7f", "\u0085", "\r", "\xff"} {
|
||||
t.Run(fmt.Sprintf("%x", invalid), func(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
r := append(fixtureRelease(index), "X-Info: PRIVATE-MARKER"+invalid+"suffix\n"...)
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
index = append(index, "Package: unrelated\nDescription: PRIVATE-MARKER"+invalid+"suffix\n"...)
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExactByteLimits(t *testing.T) {
|
||||
for _, target := range []string{"release", "index"} {
|
||||
for _, excess := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("%s+%d", target, excess), func(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
if target == "index" {
|
||||
index = append(index, "Package: unrelated\nDescription: "...)
|
||||
index = append(index, strings.Repeat("x", (16<<20)+excess-len(index)-1)...)
|
||||
index = append(index, '\n')
|
||||
}
|
||||
release := fixtureRelease(index)
|
||||
if target == "release" {
|
||||
release = append(release, "X-Padding: "...)
|
||||
release = append(release, strings.Repeat("x", (1<<20)+excess-len(release)-1)...)
|
||||
release = append(release, '\n')
|
||||
}
|
||||
if excess != 0 {
|
||||
assertRejected(t, release, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
} else if _, err := Resolve(release, index, "resolute", "amd64", fixturePins(), fixtureNow); err != nil {
|
||||
t.Fatalf("exact limit rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bootstrapped from Docker's official HTTPS key endpoint. Rotation requires a
|
||||
// reviewed code update, never a caller-supplied key or fingerprint override.
|
||||
const dockerFingerprint = "9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
|
||||
const dockerKeySHA256 = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570"
|
||||
|
||||
func readStaging(directory string) (map[string][]byte, error) {
|
||||
fail := errors.New("invalid repository staging files")
|
||||
if !filepath.IsAbs(directory) {
|
||||
return nil, fail
|
||||
}
|
||||
directory = filepath.Clean(directory)
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fail
|
||||
}
|
||||
root, err := os.OpenRoot(directory)
|
||||
if err != nil {
|
||||
return nil, fail
|
||||
}
|
||||
defer root.Close()
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil || !os.SameFile(info, opened) {
|
||||
return nil, fail
|
||||
}
|
||||
result := make(map[string][]byte)
|
||||
for name, limit := range map[string]int64{"docker.asc": 1 << 20, "Release": 1 << 20, "Release.gpg": 65536, "Packages": 16 << 20} {
|
||||
data, err := readFile(root, name, limit)
|
||||
if err != nil {
|
||||
return nil, fail
|
||||
}
|
||||
result[name] = data
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readFile(root *os.Root, name string, limit int64) ([]byte, error) {
|
||||
fail := errors.New("invalid metadata file")
|
||||
before, err := root.Lstat(name)
|
||||
if err != nil || !before.Mode().IsRegular() || before.Size() <= 0 || before.Size() > limit {
|
||||
return nil, fail
|
||||
}
|
||||
f, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, fail
|
||||
}
|
||||
defer f.Close()
|
||||
opened, err := f.Stat()
|
||||
if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || before.Size() != opened.Size() {
|
||||
return nil, fail
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
if err != nil || int64(len(data)) != opened.Size() || int64(len(data)) > limit {
|
||||
return nil, fail
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func authenticate(files map[string][]byte, now time.Time) error {
|
||||
sum := sha256.Sum256(files["docker.asc"])
|
||||
if hex.EncodeToString(sum[:]) != dockerKeySHA256 {
|
||||
return errors.New("repository trust anchor mismatch")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
return errors.New("repository authentication requires Linux GnuPG")
|
||||
}
|
||||
// All untrusted bytes are copied to a private snapshot. No caller path reaches
|
||||
// a subprocess, and neither system nor personal keyrings are consulted.
|
||||
dir, err := os.MkdirTemp("", "deployctl-signature-")
|
||||
if err != nil {
|
||||
return errors.New("cannot create signature workspace")
|
||||
}
|
||||
defer os.RemoveAll(dir) // Only our own freshly allocated directory.
|
||||
for _, name := range []string{"docker.asc", "Release", "Release.gpg"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), files[name], 0600); err != nil {
|
||||
return errors.New("cannot snapshot repository metadata")
|
||||
}
|
||||
}
|
||||
if _, err := runGPG(dir, "/usr/bin/gpg", "--batch", "--no-options", "--homedir", dir, "--dearmor", "--output", filepath.Join(dir, "docker.gpg"), filepath.Join(dir, "docker.asc")); err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := runGPG(dir, "/usr/bin/gpgv", "--homedir", dir, "--keyring", filepath.Join(dir, "docker.gpg"), "--status-fd", "1", filepath.Join(dir, "Release.gpg"), filepath.Join(dir, "Release"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkStatus(status, now)
|
||||
}
|
||||
|
||||
type cappedOutput struct{ buffer bytes.Buffer }
|
||||
|
||||
func (b *cappedOutput) Len() int { return b.buffer.Len() }
|
||||
func (b *cappedOutput) Bytes() []byte { return b.buffer.Bytes() }
|
||||
|
||||
func (b *cappedOutput) Write(p []byte) (int, error) {
|
||||
if len(p) > 65536-b.Len() {
|
||||
return 0, errors.New("signature output exceeds limit")
|
||||
}
|
||||
return b.buffer.Write(p)
|
||||
}
|
||||
|
||||
func runGPG(dir, program string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, program, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = []string{"LC_ALL=C", "LANG=C", "HOME=" + dir, "GNUPGHOME=" + dir, "PATH=/usr/bin:/bin"}
|
||||
var output, diagnostics cappedOutput
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &diagnostics
|
||||
cmd.WaitDelay = time.Second
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.New("repository signature tool failed")
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func checkStatus(status []byte, now time.Time) error {
|
||||
fail := errors.New("repository signature rejected")
|
||||
valid, good := 0, 0
|
||||
for _, line := range strings.Split(string(status), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || f[0] != "[GNUPG:]" {
|
||||
return fail
|
||||
}
|
||||
switch f[1] {
|
||||
case "NEWSIG", "KEY_CONSIDERED", "SIG_ID":
|
||||
case "GOODSIG":
|
||||
good++
|
||||
case "VALIDSIG":
|
||||
// fingerprint, date, timestamp, expiry, version, reserved, public-key
|
||||
// algorithm, digest algorithm, signature class, primary fingerprint.
|
||||
if len(f) != 12 || f[11] != dockerFingerprint || (f[9] != "8" && f[9] != "9" && f[9] != "10") || f[10] != "00" {
|
||||
return fail
|
||||
}
|
||||
issued, e1 := strconv.ParseInt(f[4], 10, 64)
|
||||
expiry, e2 := strconv.ParseInt(f[5], 10, 64)
|
||||
if e1 != nil || e2 != nil || issued <= 0 || expiry < 0 || issued > now.Add(10*time.Minute).Unix() || (expiry != 0 && expiry <= now.Unix()) {
|
||||
return fail
|
||||
}
|
||||
valid++
|
||||
default:
|
||||
return fail // Includes expired/revoked/bad/unknown signatures.
|
||||
}
|
||||
}
|
||||
if valid != 1 || good != 1 {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"server-deploy/internal/installplan"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStagingRejectsLinksAndFIFO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"docker.asc", "Release", "Release.gpg", "Packages"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("metadata"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(dir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(link + "/"); err == nil {
|
||||
t.Fatal("root symlink accepted")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(dir, "Packages"), filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("file symlink accepted")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(dir, "Release"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("FIFO accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPGFailureRedactsDiagnostics(t *testing.T) {
|
||||
if _, err := runGPG(t.TempDir(), "/nonexistent/signature-tool-secret"); err == nil || err.Error() != "repository signature tool failed" {
|
||||
t.Fatal("unredacted or absent error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputBound(t *testing.T) {
|
||||
var b cappedOutput
|
||||
if _, err := b.Write(make([]byte, 65536)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.Write([]byte{1}); err == nil {
|
||||
t.Fatal("output limit missing")
|
||||
}
|
||||
if b.Len() != 65536 {
|
||||
t.Fatal("buffer grew beyond limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagedSignatureIntegration(t *testing.T) {
|
||||
// Explicit opt-in public fixture, downloaded by the metadata-only probe.
|
||||
dir := os.Getenv("DEPLOYCTL_REPOSITORY_FIXTURE")
|
||||
if dir == "" {
|
||||
t.Skip("public signed fixture not supplied; run repository probe for real signature evidence")
|
||||
}
|
||||
files, err := readStaging(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authenticate(files, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Result comes from the successful real CLI invocation. Recheck the exact
|
||||
// fixture versions before making a semantically valid signature mutation.
|
||||
var response struct {
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "result.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := make(map[string]string)
|
||||
for _, p := range response.Lock.Packages {
|
||||
versions[p.Name] = p.Version
|
||||
}
|
||||
// Unknown Release extension remains valid metadata; only its signature
|
||||
// should reject it. This catches a bypass hidden by syntax failures.
|
||||
files["Release"] = append(bytes.TrimRight(files["Release"], "\n"), []byte("\nX-Verification-Probe: changed\n")...)
|
||||
if _, err := Resolve(files["Release"], files["Packages"], response.Lock.Suite, response.Lock.Architecture, versions, time.Now()); err != nil {
|
||||
t.Fatal("mutation masked by metadata rejection", err)
|
||||
}
|
||||
if err := authenticate(files, time.Now()); err == nil {
|
||||
t.Fatal("tampered signature accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOutputCopyBound(t *testing.T) {
|
||||
var b cappedOutput
|
||||
_, err := io.Copy(&b, io.LimitReader(strings.NewReader(strings.Repeat("x", 65537)), 65537))
|
||||
if err == nil || b.Len() > 65536 {
|
||||
t.Fatal("io.Copy bypassed output limit", b.Len(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellFormedPackageTamperNeedsRebinding(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
release := fixtureRelease(index)
|
||||
changed := []byte(strings.Replace(string(index), "SHA256: a", "SHA256: b", 1))
|
||||
if _, err := Resolve(release, changed, "resolute", "amd64", fixturePins(), fixtureNow); err == nil {
|
||||
t.Fatal("unbound index accepted")
|
||||
}
|
||||
if _, err := Resolve(fixtureRelease(changed), changed, "resolute", "amd64", fixturePins(), fixtureNow); err != nil {
|
||||
t.Fatal("tamper test masked by parser rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
const goodStatus = "[GNUPG:] NEWSIG\n[GNUPG:] GOODSIG 7EA0A9C3F273FCD8 Docker\n[GNUPG:] VALIDSIG D3306A018370199E527AE7997EA0A9C3F273FCD8 2026-09-24 1790231932 0 4 0 1 10 00 9DC858229FC7DD38854AE2D88D81803C0EBFCD88\n"
|
||||
|
||||
func TestSignatureStatus(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
if err := checkStatus([]byte(goodStatus), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"", "[GNUPG:] GOODSIG key name\n", goodStatus + goodStatus,
|
||||
strings.ReplaceAll(goodStatus, dockerFingerprint, strings.Repeat("A", 40)),
|
||||
strings.Replace(goodStatus, " 10 00 ", " 2 00 ", 1),
|
||||
strings.Replace(goodStatus, "1790231932 0", "1990231932 0", 1),
|
||||
strings.Replace(goodStatus, "1790231932 0", "1790231932 1790231933", 1),
|
||||
goodStatus + "[GNUPG:] EXPKEYSIG bad\n", goodStatus + "[GNUPG:] BADSIG bad\n",
|
||||
} {
|
||||
if checkStatus([]byte(s), now) == nil {
|
||||
t.Errorf("accepted invalid signature status: %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStaging(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"docker.asc", "Release", "Release.gpg", "Packages"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("bytes"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := readStaging(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging("relative"); err == nil {
|
||||
t.Fatal("relative directory accepted")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Release.gpg"), make([]byte, 65537), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("oversize signature accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyFailsBeforeExternalProcess(t *testing.T) {
|
||||
if err := authenticate(map[string][]byte{"docker.asc": []byte("untrusted")}, time.Now()); err == nil {
|
||||
t.Fatal("untrusted key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package aptrepo authenticates staged Docker APT metadata, never installs it.
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"server-deploy/internal/installplan"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
RepositoryAuthenticated bool `json:"repositoryAuthenticated"`
|
||||
PackageBytesVerified bool `json:"packageBytesVerified"`
|
||||
Executable bool `json:"executable"`
|
||||
VerifiedAt time.Time `json:"verifiedAt"`
|
||||
PrimaryFingerprint string `json:"primaryFingerprint"`
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}
|
||||
|
||||
// Verify requires a trusted staging directory and trusted ancestors, with no
|
||||
// concurrent writers. Returned JSON is evidence, not an execution capability.
|
||||
func Verify(directory, suite, arch string, versions map[string]string, now time.Time) (Result, error) {
|
||||
files, err := readStaging(directory)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := authenticate(files, now); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
lock, err := Resolve(files["Release"], files["Packages"], suite, arch, versions, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ProtocolVersion: 1, RepositoryAuthenticated: true, VerifiedAt: now.UTC().Truncate(time.Second), PrimaryFingerprint: dockerFingerprint, Lock: lock}, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
"server-deploy/internal/preflight"
|
||||
)
|
||||
|
||||
func TestEnvironmentPlanCLI(t *testing.T) {
|
||||
r := preflight.Collect()
|
||||
suite, arch := "resolute", "amd64"
|
||||
if r.Runtime.OS == "linux" {
|
||||
suite = r.Distribution.Codename
|
||||
arch = r.Runtime.Architecture
|
||||
}
|
||||
if suite != "resolute" && suite != "noble" && suite != "jammy" {
|
||||
t.Skip("local Linux distribution outside initial lock policy")
|
||||
}
|
||||
l := installplan.Lock{ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu", Suite: suite, Architecture: arch, ReleaseDigest: "sha256:" + strings.Repeat("a", 64), Packages: []installplan.Package{}}
|
||||
for _, name := range []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"} {
|
||||
l.Packages = append(l.Packages, installplan.Package{Name: name, Version: "1.2.3-1", Filename: "dists/" + suite + "/pool/stable/" + arch + "/" + name + "_1.2.3-1_" + arch + ".deb", Digest: "sha256:" + strings.Repeat("b", 64), Size: 123})
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}{l})
|
||||
code, out, diagnostics := run([]string{"plan-environment"}, string(raw))
|
||||
var result struct {
|
||||
Mode string `json:"mode"`
|
||||
Draft installplan.Draft `json:"draft"`
|
||||
}
|
||||
if code != 0 || json.Unmarshal([]byte(out), &result) != nil || result.Mode != "local-environment-draft" || result.Draft.Executable || result.Draft.RepositoryAuthenticated || len(result.Draft.RequestedPackages) != 5 || len(result.Draft.Blockers) == 0 {
|
||||
t.Fatalf("bad draft %s %s", out, diagnostics)
|
||||
}
|
||||
for _, bad := range []string{`{}`, strings.Replace(string(raw), `"1.2.3-1"`, `"secret;reboot"`, 1)} {
|
||||
code, out, diagnostics := run([]string{"plan-environment"}, bad)
|
||||
if code == 0 || out != "" || strings.Contains(diagnostics, "secret") {
|
||||
t.Fatal("invalid request accepted or leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"server-deploy/internal/wire"
|
||||
)
|
||||
|
||||
func decodeStrict(in io.Reader, target any) error { return wire.Decode(in, target, 1<<20) }
|
||||
@@ -0,0 +1,117 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func packageFixture(t *testing.T) (string, string) {
|
||||
return packageFixturePayload(t, []byte("services: {}\n"))
|
||||
}
|
||||
|
||||
func packageFixturePayload(t *testing.T, payload []byte) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
digest := func(data []byte) string { sum := sha256.Sum256(data); return "sha256:" + hex.EncodeToString(sum[:]) }
|
||||
manifest := map[string]any{
|
||||
"protocolVersion": 1, "appId": "example", "version": "1.0.0", "runtime": "compose", "entrypoint": "compose.yaml",
|
||||
"platforms": []string{"linux/amd64"},
|
||||
"components": []map[string]string{{"name": "server", "image": "example/server@sha256:" + strings.Repeat("a", 64)}},
|
||||
"files": []map[string]string{{"path": "compose.yaml", "digest": digest(payload)}},
|
||||
}
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), payload, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, digest(raw)
|
||||
}
|
||||
|
||||
func TestCheckPackagePolicyCLI(t *testing.T) {
|
||||
payload := `{"services":{"server":{"image":"example/server@sha256:` + strings.Repeat("a", 64) + `","user":"1000:1000","read_only":true,"cap_drop":["ALL"],"security_opt":["no-new-privileges:true"],"restart":"no","networks":["backend"],"volumes":[]}},"networks":{"backend":{"internal":true}},"volumes":{}}`
|
||||
for _, tc := range []struct {
|
||||
payload string
|
||||
accepted bool
|
||||
}{
|
||||
{payload, true},
|
||||
{strings.Replace(payload, `"read_only":true`, `"read_only":false`, 1), false},
|
||||
{`services: {}`, false},
|
||||
} {
|
||||
dir, digest := packageFixturePayload(t, []byte(tc.payload))
|
||||
input, _ := json.Marshal(map[string]string{"directory": dir, "expectedDigest": digest})
|
||||
code, out, diagnostics := run([]string{"check-package"}, string(input))
|
||||
if tc.accepted {
|
||||
var result struct {
|
||||
PolicyPassed bool `json:"policyPassed"`
|
||||
Executable bool `json:"executable"`
|
||||
Profile string `json:"profile"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
if code != 0 || json.Unmarshal([]byte(out), &result) != nil || !result.PolicyPassed || result.Executable || result.Profile == "" || result.Digest != digest {
|
||||
t.Fatalf("bad check result: %s %s", out, diagnostics)
|
||||
}
|
||||
if strings.Contains(out, "1000:1000") {
|
||||
t.Fatal("payload leaked")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(tc.payload+" "), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, _ := run([]string{"check-package"}, string(input)); code == 0 {
|
||||
t.Fatal("policy bypassed integrity check")
|
||||
}
|
||||
} else if code == 0 || out != "" {
|
||||
t.Fatal("unsafe package accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPackageCLI(t *testing.T) {
|
||||
dir, digest := packageFixture(t)
|
||||
input, _ := json.Marshal(map[string]string{"directory": dir, "expectedDigest": digest})
|
||||
code, out, diagnostics := run([]string{"verify-package"}, string(input))
|
||||
if code != 0 || diagnostics != "" {
|
||||
t.Fatalf("package verification failed: %s", diagnostics)
|
||||
}
|
||||
var response struct {
|
||||
Verified bool `json:"verified"`
|
||||
Executable bool `json:"executable"`
|
||||
PublisherAuthenticated bool `json:"publisherAuthenticated"`
|
||||
FileCount int `json:"fileCount"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.Verified || response.Executable || response.PublisherAuthenticated || response.FileCount != 1 || response.Digest != digest {
|
||||
t.Fatalf("misleading verification: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "services: {}") {
|
||||
t.Fatal("file contents leaked")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("secret tampering"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out, diagnostics = run([]string{"verify-package"}, string(input))
|
||||
if code == 0 || out != "" || strings.Contains(diagnostics, "secret tampering") {
|
||||
t.Fatal("tampering accepted or leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPackageInvalidRequests(t *testing.T) {
|
||||
for _, input := range []string{`{}`, `{"directory":".","expectedDigest":"latest"}`, `{"directory":"secret","expectedDigest":"bad","password":"do-not-echo"}`} {
|
||||
code, out, diagnostics := run([]string{"verify-package"}, input)
|
||||
if code == 0 || out != "" || strings.Contains(diagnostics, "do-not-echo") {
|
||||
t.Fatal("invalid package request accepted or leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreflightDoesNotAuthorizeInstallation(t *testing.T) {
|
||||
code, out, diagnostics := run([]string{"preflight"}, "")
|
||||
var response struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
Proposal struct {
|
||||
Executable bool `json:"executable"`
|
||||
Blockers []string `json:"blockers"`
|
||||
} `json:"proposal"`
|
||||
}
|
||||
if code != 0 || json.Unmarshal([]byte(out), &response) != nil || response.ProtocolVersion != 1 || response.Mode != "local-environment-proposal" || response.Proposal.Executable || len(response.Proposal.Blockers) == 0 {
|
||||
t.Fatalf("unsafe report: %s %s", out, diagnostics)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRepositoryRejectsUntrustedRequests(t *testing.T) {
|
||||
for _, input := range []string{`{}`, `{"directory":"secret-relative","suite":"resolute","architecture":"amd64","versions":{}}`,
|
||||
`{"directory":"secret-relative","suite":"resolute","architecture":"amd64","versions":{},"repositoryAuthenticated":true}`} {
|
||||
var out, diagnostics bytes.Buffer
|
||||
if Run([]string{"verify-repository"}, strings.NewReader(input), &out, &diagnostics, time.Now) == 0 {
|
||||
t.Fatal("accepted invalid request")
|
||||
}
|
||||
if out.Len() != 0 || strings.Contains(diagnostics.String(), "secret-relative") {
|
||||
t.Fatal("invalid response leaked input")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCommandRejectsCallerTrust(t *testing.T) {
|
||||
for _, field := range []string{`"packageBytesVerified":true`, `"repositoryAuthenticated":true`, `"lock":{}`} {
|
||||
var out, diagnostics bytes.Buffer
|
||||
input := `{"directory":"secret-relative","artifactDirectory":"secret-artifacts","suite":"resolute","architecture":"amd64","versions":{},` + field + `}`
|
||||
if Run([]string{"verify-artifacts"}, strings.NewReader(input), &out, &diagnostics, time.Now) == 0 {
|
||||
t.Fatal("caller trust accepted")
|
||||
}
|
||||
if out.Len() != 0 || strings.Contains(diagnostics.String(), "secret") {
|
||||
t.Fatal("input leaked")
|
||||
}
|
||||
if diagnostics.String() != "invalid artifact verification request\n" {
|
||||
t.Fatal("request was not rejected at the protocol boundary", diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package cli exposes read-only protocol endpoints, not a shell wrapper.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/appbundle"
|
||||
"server-deploy/internal/aptrepo"
|
||||
"server-deploy/internal/composepolicy"
|
||||
"server-deploy/internal/inspect"
|
||||
"server-deploy/internal/installplan"
|
||||
"server-deploy/internal/planner"
|
||||
"server-deploy/internal/preflight"
|
||||
)
|
||||
|
||||
func Run(args []string, in io.Reader, out, diagnostics io.Writer, now func() time.Time) int {
|
||||
fail := func(message string) int { fmt.Fprintln(diagnostics, message); return 1 }
|
||||
if len(args) != 1 {
|
||||
return fail("usage: deployctl version | inspect | preflight | plan-environment | verify-repository | verify-artifacts | plan | verify-plan | verify-package | check-package")
|
||||
}
|
||||
var response any
|
||||
switch args[0] {
|
||||
case "verify-artifacts":
|
||||
var request struct {
|
||||
Directory string `json:"directory"`
|
||||
ArtifactDirectory string `json:"artifactDirectory"`
|
||||
Suite string `json:"suite"`
|
||||
Architecture string `json:"architecture"`
|
||||
Versions map[string]string `json:"versions"`
|
||||
}
|
||||
if decodeStrict(in, &request) != nil {
|
||||
return fail("invalid artifact verification request")
|
||||
}
|
||||
verified, err := aptrepo.VerifyArtifacts(request.Directory, request.ArtifactDirectory, request.Suite, request.Architecture, request.Versions, now())
|
||||
if err != nil {
|
||||
return fail("artifact verification failed")
|
||||
}
|
||||
response = verified
|
||||
case "verify-repository":
|
||||
var request struct {
|
||||
Directory string `json:"directory"`
|
||||
Suite string `json:"suite"`
|
||||
Architecture string `json:"architecture"`
|
||||
Versions map[string]string `json:"versions"`
|
||||
}
|
||||
if decodeStrict(in, &request) != nil {
|
||||
return fail("invalid repository verification request")
|
||||
}
|
||||
verified, err := aptrepo.Verify(request.Directory, request.Suite, request.Architecture, request.Versions, now())
|
||||
if err != nil {
|
||||
return fail("repository verification failed")
|
||||
}
|
||||
response = verified
|
||||
case "plan-environment":
|
||||
var request struct {
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}
|
||||
if decodeStrict(in, &request) != nil {
|
||||
return fail("invalid environment lock request")
|
||||
}
|
||||
report := preflight.Collect()
|
||||
draft, err := installplan.Build(report, request.Lock)
|
||||
if err != nil {
|
||||
return fail("environment lock rejected")
|
||||
}
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Report preflight.Report `json:"report"`
|
||||
Draft installplan.Draft `json:"draft"`
|
||||
}{1, "local-environment-draft", now().UTC().Truncate(time.Second), report, draft}
|
||||
case "preflight":
|
||||
report := preflight.Collect()
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Report preflight.Report `json:"report"`
|
||||
Proposal preflight.Proposal `json:"proposal"`
|
||||
}{1, "local-environment-proposal", now().UTC().Truncate(time.Second), report, preflight.Plan(report)}
|
||||
case "verify-package", "check-package":
|
||||
var request struct {
|
||||
Directory string `json:"directory"`
|
||||
ExpectedDigest string `json:"expectedDigest"`
|
||||
}
|
||||
if err := decodeStrict(in, &request); err != nil {
|
||||
return fail("invalid package verification request")
|
||||
}
|
||||
verified, err := appbundle.Verify(request.Directory, request.ExpectedDigest)
|
||||
if err != nil {
|
||||
return fail("package verification failed: invalid manifest, inventory or digest")
|
||||
}
|
||||
if args[0] == "check-package" {
|
||||
if composepolicy.Check(verified.Manifest, verified.Files[verified.Manifest.Entrypoint]) != nil {
|
||||
return fail("package rejected by restricted Compose policy")
|
||||
}
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
PolicyPassed bool `json:"policyPassed"`
|
||||
Profile string `json:"profile"`
|
||||
Digest string `json:"digest"`
|
||||
Executable bool `json:"executable"`
|
||||
PublisherAuthenticated bool `json:"publisherAuthenticated"`
|
||||
}{1, true, composepolicy.Profile, verified.Digest, false, false}
|
||||
break
|
||||
}
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Verified bool `json:"verified"`
|
||||
Executable bool `json:"executable"`
|
||||
PublisherAuthenticated bool `json:"publisherAuthenticated"`
|
||||
Digest string `json:"digest"`
|
||||
FileCount int `json:"fileCount"`
|
||||
Manifest appbundle.Manifest `json:"manifest"`
|
||||
}{1, true, false, false, verified.Digest, len(verified.Files), verified.Manifest}
|
||||
case "inspect":
|
||||
response = inspect.Collect()
|
||||
case "version":
|
||||
response = struct {
|
||||
Version string `json:"version"`
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
WritesEnabled bool `json:"writesEnabled"`
|
||||
}{"0.1.0-dev", planner.ProtocolVersion, false}
|
||||
case "plan":
|
||||
var intent planner.Intent
|
||||
if err := decodeStrict(in, &intent); err != nil {
|
||||
return fail("invalid request: expected strict protocol JSON (maximum 1 MiB)")
|
||||
}
|
||||
plan, err := planner.Build(intent, now())
|
||||
if err != nil {
|
||||
return fail("invalid deployment intent")
|
||||
}
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
Executable bool `json:"executable"`
|
||||
Plan planner.Plan `json:"plan"`
|
||||
}{planner.ProtocolVersion, "offline-preview", false, plan}
|
||||
case "verify-plan":
|
||||
var request struct {
|
||||
Plan planner.Plan `json:"plan"`
|
||||
Current planner.Intent `json:"current"`
|
||||
}
|
||||
if err := decodeStrict(in, &request); err != nil {
|
||||
return fail("invalid verification request")
|
||||
}
|
||||
if err := request.Plan.Verify(request.Current, now()); err != nil {
|
||||
return fail("plan rejected: expired, changed or invalid")
|
||||
}
|
||||
response = struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
Valid bool `json:"valid"`
|
||||
Executable bool `json:"executable"`
|
||||
}{planner.ProtocolVersion, "offline-preview", true, false}
|
||||
default:
|
||||
return fail("unsupported command; deployment writes are not enabled")
|
||||
}
|
||||
if err := json.NewEncoder(out).Encode(response); err != nil {
|
||||
return fail("cannot write response")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/planner"
|
||||
)
|
||||
|
||||
func fixture() string {
|
||||
return `{"protocolVersion":1,"hostId":"host-one","instanceId":"git-one","appId":"gitea","packageDigest":"sha256:` + strings.Repeat("a", 64) + `","imageDigest":"sha256:` + strings.Repeat("b", 64) + `","domain":"git.example.com","observedStateDigest":"sha256:` + strings.Repeat("c", 64) + `"}`
|
||||
}
|
||||
|
||||
func run(args []string, input string) (int, string, string) {
|
||||
var out, diagnostic bytes.Buffer
|
||||
code := Run(args, strings.NewReader(input), &out, &diagnostic, func() time.Time { return time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) })
|
||||
return code, out.String(), diagnostic.String()
|
||||
}
|
||||
|
||||
func TestPlanAndVerify(t *testing.T) {
|
||||
code, output, diagnostic := run([]string{"plan"}, fixture())
|
||||
if code != 0 || diagnostic != "" {
|
||||
t.Fatalf("plan failed: %d %s", code, diagnostic)
|
||||
}
|
||||
var response struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Mode string `json:"mode"`
|
||||
Executable bool `json:"executable"`
|
||||
Plan planner.Plan `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Mode != "offline-preview" || response.Executable || response.ProtocolVersion != 1 || response.Plan.ProjectName != "sd-git-one" {
|
||||
t.Fatalf("misleading plan: %s", output)
|
||||
}
|
||||
request, _ := json.Marshal(struct {
|
||||
Plan planner.Plan `json:"plan"`
|
||||
Current planner.Intent `json:"current"`
|
||||
}{response.Plan, response.Plan.Intent})
|
||||
code, output, diagnostic = run([]string{"verify-plan"}, string(request))
|
||||
if code != 0 || diagnostic != "" || !strings.Contains(output, `"valid":true`) {
|
||||
t.Fatalf("verify failed: %d %s %s", code, output, diagnostic)
|
||||
}
|
||||
response.Plan.Hash = "tampered"
|
||||
request, _ = json.Marshal(struct {
|
||||
Plan planner.Plan `json:"plan"`
|
||||
Current planner.Intent `json:"current"`
|
||||
}{response.Plan, response.Plan.Intent})
|
||||
code, _, _ = run([]string{"verify-plan"}, string(request))
|
||||
if code == 0 {
|
||||
t.Fatal("accepted tampered plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAmbiguousAndOversizedInput(t *testing.T) {
|
||||
cases := []string{
|
||||
``, `{}`, `null`, `[]`,
|
||||
strings.TrimSuffix(fixture(), "}") + `,"password":"do-not-echo"}`,
|
||||
strings.TrimSuffix(fixture(), "}") + `,"hostId":"other"}`,
|
||||
strings.TrimSuffix(fixture(), "}") + `,"HostId":"other"}`,
|
||||
strings.Replace(fixture(), `"hostId"`, `"HostId"`, 1),
|
||||
strings.Replace(fixture(), `"protocolVersion":1`, `"protocolVersion":null`, 1),
|
||||
fixture() + ` {}`, fixture() + ` trailing`,
|
||||
strings.Repeat(" ", 1024*1024) + fixture(),
|
||||
}
|
||||
for _, input := range cases {
|
||||
code, out, diagnostic := run([]string{"plan"}, input)
|
||||
if code == 0 || out != "" || diagnostic == "" {
|
||||
t.Fatalf("invalid input accepted: code=%d", code)
|
||||
}
|
||||
if strings.Contains(diagnostic, "do-not-echo") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsWriteCommandsAndUnexpectedArguments(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"apply"}, {"upgrade"}, {"restore"}, {"plan", "extra"}, {"version", "extra"}} {
|
||||
code, out, _ := run(args, fixture())
|
||||
if code == 0 || out != "" {
|
||||
t.Fatalf("accepted command %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
code, out, diagnostic := run([]string{"version"}, "")
|
||||
if code != 0 || diagnostic != "" || !json.Valid([]byte(out)) {
|
||||
t.Fatal("version failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectIsReadOnlyAndExplicitlyIncomplete(t *testing.T) {
|
||||
code, out, diagnostics := run([]string{"inspect"}, "")
|
||||
if code != 0 || diagnostics != "" {
|
||||
t.Fatalf("inspect failed: %s", diagnostics)
|
||||
}
|
||||
var report struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
OS string `json:"os"`
|
||||
DeploymentReady bool `json:"deploymentReady"`
|
||||
DockerDaemon string `json:"dockerDaemon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.ProtocolVersion != 1 || report.OS == "" || report.DeploymentReady || report.DockerDaemon != "not_checked" {
|
||||
t.Fatalf("misleading report: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedVerificationRejectsDuplicateAndNullFields(t *testing.T) {
|
||||
_, output, _ := run([]string{"plan"}, fixture())
|
||||
var response struct {
|
||||
Plan json.RawMessage `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := `{"plan":` + string(response.Plan) + `,"current":` + fixture() + `}`
|
||||
for _, input := range []string{
|
||||
strings.Replace(valid, `"projectName":"sd-git-one"`, `"projectName":"sd-git-one","projectName":"sd-git-one"`, 1),
|
||||
strings.Replace(valid, `"createdAt":"2026-09-25T12:00:00Z"`, `"createdAt":null`, 1),
|
||||
strings.Replace(valid, `"intent":`, `"Intent":`, 1),
|
||||
} {
|
||||
code, out, _ := run([]string{"verify-plan"}, input)
|
||||
if code == 0 || out != "" {
|
||||
t.Fatal("accepted ambiguous nested request")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRequiresCanonicalUTCTimestamps(t *testing.T) {
|
||||
_, output, _ := run([]string{"plan"}, fixture())
|
||||
var response struct {
|
||||
Plan json.RawMessage `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := `{"plan":` + string(response.Plan) + `,"current":` + fixture() + `}`
|
||||
for _, timestamp := range []string{
|
||||
"2026-09-25T12:00:00,000Z",
|
||||
"2026-09-25T12:00:00.000Z",
|
||||
"2026-09-25T12:00:00+00:00",
|
||||
"2026-09-26T12:00:00+24:00",
|
||||
"2026-09-25T13:00:00+00:60",
|
||||
} {
|
||||
t.Run(timestamp, func(t *testing.T) {
|
||||
input := strings.Replace(valid, "2026-09-25T12:00:00Z", timestamp, 1)
|
||||
code, out, _ := run([]string{"verify-plan"}, input)
|
||||
if code == 0 || out != "" {
|
||||
t.Fatal("accepted noncanonical timestamp")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type brokenReader struct{}
|
||||
|
||||
func (brokenReader) Read([]byte) (int, error) { return 0, errors.New("secret reader error") }
|
||||
|
||||
type brokenWriter struct{}
|
||||
|
||||
func (brokenWriter) Write([]byte) (int, error) { return 0, errors.New("secret writer error") }
|
||||
|
||||
func TestIOErrorsFailWithoutLeakingDetails(t *testing.T) {
|
||||
var out, diagnostics bytes.Buffer
|
||||
if Run([]string{"plan"}, brokenReader{}, &out, &diagnostics, time.Now) == 0 {
|
||||
t.Fatal("ignored read error")
|
||||
}
|
||||
if out.Len() != 0 || strings.Contains(diagnostics.String(), "secret") {
|
||||
t.Fatal("leaked input error")
|
||||
}
|
||||
diagnostics.Reset()
|
||||
if Run([]string{"version"}, strings.NewReader(""), brokenWriter{}, &diagnostics, time.Now) == 0 {
|
||||
t.Fatal("ignored write error")
|
||||
}
|
||||
if strings.Contains(diagnostics.String(), "secret") {
|
||||
t.Fatal("leaked output error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Restricted Compose policy
|
||||
|
||||
`Check(manifest, entrypointBytes)` is a pure, offline policy check. The CLI
|
||||
`check-package` first calls `appbundle.Verify`, then passes the authenticated
|
||||
entrypoint snapshot here. It never reopens the entrypoint, runs Compose, reads
|
||||
`.env`, resolves templates, or contacts a daemon. Direct internal callers must
|
||||
perform the same integrity/trust check first.
|
||||
|
||||
Profile: `isolated-compose-v1`. This is an initial restricted backend profile,
|
||||
not the finished application's Compose contract. It deliberately rejects public
|
||||
routing, secrets/config injection, environment settings, healthchecks, dependency
|
||||
ordering and application-specific privilege exceptions until adapters exist.
|
||||
Existing YAML packages can still pass `verify-package`; that does not mean they
|
||||
pass `check-package`. Only JSON entrypoint content is accepted by this policy.
|
||||
|
||||
## Exact shape
|
||||
|
||||
All fields below are mandatory, all unlisted fields are rejected:
|
||||
|
||||
```json
|
||||
{
|
||||
"services": {
|
||||
"api": {
|
||||
"image": "example/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"user": "1000:1000",
|
||||
"read_only": true,
|
||||
"cap_drop": ["ALL"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"restart": "no",
|
||||
"networks": ["backend"],
|
||||
"volumes": [
|
||||
{"type": "volume", "source": "data", "target": "/data", "read_only": false}
|
||||
]
|
||||
}
|
||||
},
|
||||
"networks": {"backend": {"internal": true}},
|
||||
"volumes": {"data": {}}
|
||||
}
|
||||
```
|
||||
|
||||
The example digest is synthetic, not an installable release.
|
||||
|
||||
- 4 MiB input limit. Shared strict decoder rejects duplicate, case-alias,
|
||||
unknown, missing and null fields, including typed map values.
|
||||
- 1–32 services, exactly matching manifest component names and pinned images.
|
||||
- UID and GID must be canonical positive uint32 decimals, excluding 4294967295.
|
||||
No root, account-name lookup, interpolation or inherited user defaults.
|
||||
- Restart must be `no` or `unless-stopped`; root filesystem must be read-only,
|
||||
all capabilities dropped and privilege escalation disabled.
|
||||
- Exactly one network: `backend`, with `internal: true`. No default network,
|
||||
external network, host networking, published port or arbitrary router label.
|
||||
- At most 128 plain named volumes; every declaration must be mounted exactly
|
||||
once. Empty volume maps/lists are permitted for stateless services. No external
|
||||
names, drivers, driver options, bind mounts or cross-service sharing.
|
||||
- Mount paths must be absolute canonical ASCII paths, at most 240 bytes, with
|
||||
no root, overlapping mount or system-tree mount. Denied trees: /proc, /sys,
|
||||
/dev, /etc, /run, /var/run, /bin, /sbin, /usr, /lib, /lib64 (including ancestors).
|
||||
- No command overrides, hooks, build, include, extends, profile, socket access,
|
||||
devices or arbitrary privilege additions. Unknown future keys also fail closed.
|
||||
|
||||
## What passing does not prove
|
||||
|
||||
This check does not authenticate a publisher, validate image contents or mount
|
||||
destinations inside an image, provision usable volume ownership, reserve resource
|
||||
names, verify engine/Compose compatibility, limit resource consumption, prove
|
||||
application readiness, or provide backup/restore. Image defaults and existing
|
||||
Docker resources must still be validated by future adapters and preflight.
|
||||
|
||||
Future execution must bind an explicit instance project name, verify resource
|
||||
ownership under the host lock, and use the exact checked snapshot without extra
|
||||
Compose files, ambient overrides or subsequent interpolation. An integrity hash
|
||||
and this restricted policy are not authorization to run a deployment.
|
||||
|
||||
References checked during implementation:
|
||||
[Compose services](https://docs.docker.com/reference/compose-file/services/),
|
||||
[Compose networks](https://docs.docker.com/reference/compose-file/networks/),
|
||||
[Compose config](https://docs.docker.com/reference/cli/docker/compose/config/).
|
||||
No real Docker/Compose execution has been validated in this batch.
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package composepolicy validates a deliberately restricted, offline Compose
|
||||
// profile. Passing this policy never authorizes execution or proves image safety.
|
||||
package composepolicy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server-deploy/internal/appbundle"
|
||||
"server-deploy/internal/wire"
|
||||
)
|
||||
|
||||
const Profile = "isolated-compose-v1"
|
||||
|
||||
type document struct {
|
||||
Services map[string]service `json:"services"`
|
||||
Networks map[string]network `json:"networks"`
|
||||
Volumes map[string]struct{} `json:"volumes"`
|
||||
}
|
||||
type network struct {
|
||||
Internal bool `json:"internal"`
|
||||
}
|
||||
type service struct {
|
||||
Image string `json:"image"`
|
||||
User string `json:"user"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
CapDrop []string `json:"cap_drop"`
|
||||
SecurityOpt []string `json:"security_opt"`
|
||||
Restart string `json:"restart"`
|
||||
Networks []string `json:"networks"`
|
||||
Volumes []mount `json:"volumes"`
|
||||
}
|
||||
type mount struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
||||
var pinnedImage = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$`)
|
||||
var targetPath = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
|
||||
|
||||
// Check consumes only the authenticated entrypoint bytes and manifest supplied
|
||||
// by appbundle.Verify. It neither reads files nor renders/interpolates templates.
|
||||
// All fields in the profile are mandatory; unknown Compose features fail closed.
|
||||
func Check(manifest appbundle.Manifest, data []byte) error {
|
||||
reject := errors.New("Compose document rejected by restricted policy")
|
||||
var d document
|
||||
if wire.Decode(bytes.NewReader(data), &d, 4<<20) != nil {
|
||||
return reject
|
||||
}
|
||||
if len(manifest.Components) < 1 || len(manifest.Components) > 32 || len(d.Services) != len(manifest.Components) {
|
||||
return reject
|
||||
}
|
||||
if len(d.Networks) != 1 || !d.Networks["backend"].Internal || len(d.Volumes) > 128 {
|
||||
return reject
|
||||
}
|
||||
images := make(map[string]string, len(manifest.Components))
|
||||
for _, c := range manifest.Components {
|
||||
if !identifier.MatchString(c.Name) || !pinnedImage.MatchString(c.Image) || images[c.Name] != "" {
|
||||
return reject
|
||||
}
|
||||
images[c.Name] = c.Image
|
||||
}
|
||||
for name := range d.Volumes {
|
||||
if !identifier.MatchString(name) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
used := make(map[string]bool)
|
||||
for name, s := range d.Services {
|
||||
if images[name] == "" || s.Image != images[name] || !nonRootUser(s.User) || !s.ReadOnly {
|
||||
return reject
|
||||
}
|
||||
if !only(s.CapDrop, "ALL") || !only(s.SecurityOpt, "no-new-privileges:true") || !only(s.Networks, "backend") {
|
||||
return reject
|
||||
}
|
||||
if s.Restart != "unless-stopped" && s.Restart != "no" {
|
||||
return reject
|
||||
}
|
||||
if len(s.Volumes) > 128 {
|
||||
return reject
|
||||
}
|
||||
targets := make([]string, 0, len(s.Volumes))
|
||||
for _, v := range s.Volumes {
|
||||
if _, exists := d.Volumes[v.Source]; !exists || used[v.Source] || v.Type != "volume" {
|
||||
return reject
|
||||
}
|
||||
if len(v.Target) > 240 || !targetPath.MatchString(v.Target) || path.Clean(v.Target) != v.Target || v.Target == "/" {
|
||||
return reject
|
||||
}
|
||||
// Deny runtime/system trees as well as overlapping mounts. Only
|
||||
// application-data destinations belong in this initial profile.
|
||||
for _, protected := range []string{"/proc", "/sys", "/dev", "/etc", "/run", "/var/run", "/bin", "/sbin", "/usr", "/lib", "/lib64"} {
|
||||
if overlaps(v.Target, protected) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
for _, previous := range targets {
|
||||
if overlaps(v.Target, previous) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
targets = append(targets, v.Target)
|
||||
used[v.Source] = true
|
||||
}
|
||||
}
|
||||
if len(used) != len(d.Volumes) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func only(values []string, expected string) bool { return len(values) == 1 && values[0] == expected }
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
func nonRootUser(value string) bool {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
n, err := strconv.ParseUint(part, 10, 32)
|
||||
if err != nil || n == 0 || n == 4294967295 || strconv.FormatUint(n, 10) != part {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package composepolicy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/appbundle"
|
||||
)
|
||||
|
||||
func fixture() (appbundle.Manifest, map[string]any) {
|
||||
image := "example/api@sha256:" + strings.Repeat("a", 64)
|
||||
m := appbundle.Manifest{Components: []appbundle.Component{{Name: "api", Image: image}}}
|
||||
d := map[string]any{
|
||||
"services": map[string]any{"api": map[string]any{
|
||||
"image": image, "user": "1000:1000", "read_only": true,
|
||||
"cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"},
|
||||
"restart": "unless-stopped", "networks": []string{"backend"},
|
||||
"volumes": []any{map[string]any{"type": "volume", "source": "data", "target": "/data", "read_only": false}},
|
||||
}},
|
||||
"networks": map[string]any{"backend": map[string]any{"internal": true}},
|
||||
"volumes": map[string]any{"data": map[string]any{}},
|
||||
}
|
||||
return m, d
|
||||
}
|
||||
|
||||
func TestAcceptRestrictedService(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
if err := Check(m, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectPrivilegeAndExternalInputs(t *testing.T) {
|
||||
for _, field := range []string{"privileged", "build", "container_name", "network_mode", "pid", "ipc", "userns_mode", "devices", "cap_add", "env_file", "environment", "extends", "ports", "labels", "use_api_socket", "post_start", "pre_stop", "command", "entrypoint", "volumes_from", "develop", "provider"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
m, d := fixture()
|
||||
d["services"].(map[string]any)["api"].(map[string]any)[field] = "secret-sentinel"
|
||||
raw, _ := json.Marshal(d)
|
||||
err := Check(m, raw)
|
||||
if err == nil || strings.Contains(err.Error(), "secret-sentinel") {
|
||||
t.Fatal("forbidden field accepted or echoed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsafeValuesAndReferences(t *testing.T) {
|
||||
mutations := map[string]func(map[string]any, map[string]any){
|
||||
"image drift": func(d, s map[string]any) { s["image"] = "example/api:latest" },
|
||||
"root": func(d, s map[string]any) { s["user"] = "0:0" },
|
||||
"named user": func(d, s map[string]any) { s["user"] = "root" },
|
||||
"writable root": func(d, s map[string]any) { s["read_only"] = false },
|
||||
"caps": func(d, s map[string]any) { s["cap_drop"] = []string{} },
|
||||
"escalation": func(d, s map[string]any) { s["security_opt"] = []string{"seccomp:unconfined"} },
|
||||
"implicit network": func(d, s map[string]any) { s["networks"] = []string{} },
|
||||
"other network": func(d, s map[string]any) { s["networks"] = []string{"default"} },
|
||||
"external network": func(d, s map[string]any) {
|
||||
d["networks"] = map[string]any{"backend": map[string]any{"internal": true, "external": true}}
|
||||
},
|
||||
"outbound network": func(d, s map[string]any) {
|
||||
d["networks"] = map[string]any{"backend": map[string]any{"internal": false}}
|
||||
},
|
||||
"bind": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["type"] = "bind" },
|
||||
"host source": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["source"] = "/var/run/docker.sock" },
|
||||
"missing volume": func(d, s map[string]any) { d["volumes"] = map[string]any{} },
|
||||
"volume driver": func(d, s map[string]any) {
|
||||
d["volumes"] = map[string]any{"data": map[string]any{"driver_opts": map[string]string{"device": "/"}}}
|
||||
},
|
||||
"root mount": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/" },
|
||||
"traversal": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/data/../etc" },
|
||||
"interpolation": func(d, s map[string]any) { s["user"] = "${UID}:1000" },
|
||||
"include": func(d, s map[string]any) { d["include"] = []string{"/secret"} },
|
||||
"null service": func(d, s map[string]any) { d["services"].(map[string]any)["api"] = nil },
|
||||
"missing security": func(d, s map[string]any) { delete(s, "security_opt") },
|
||||
"null volume": func(d, s map[string]any) { d["volumes"] = map[string]any{"data": nil} },
|
||||
"extra service": func(d, s map[string]any) { d["services"].(map[string]any)["rogue"] = s },
|
||||
"no services": func(d, s map[string]any) { d["services"] = map[string]any{} },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, d := fixture()
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
mutate(d, s)
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("unsafe document accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectMalformedDocument(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
for _, input := range []string{"services: {}", string(raw) + "{}", strings.Replace(string(raw), `"read_only":true`, `"read_only":true,"read_only":false`, 1), strings.Repeat(" ", 4<<20) + string(raw)} {
|
||||
if Check(m, []byte(input)) == nil {
|
||||
t.Fatal("malformed input accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountAndIdentityBoundaries(t *testing.T) {
|
||||
for _, target := range []string{"/proc/x", "/var", "/var/run/docker.sock", "/sys", "/dev", "/etc", "/run", "/usr/local", "/lib", "/lib64", "/bin", "/sbin", "/data/", "/data//x", "/data/$HOME", `C:\data`, "/" + strings.Repeat("a", 241)} {
|
||||
m, d := fixture()
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
s["volumes"].([]any)[0].(map[string]any)["target"] = target
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Errorf("accepted protected/noncanonical target %q", target)
|
||||
}
|
||||
}
|
||||
for _, user := range []string{"1000:0", "01:1000", "+1:1000", "-1:1000", "4294967295:1000", "4294967296:1000", "1", "1:2:3"} {
|
||||
m, d := fixture()
|
||||
d["services"].(map[string]any)["api"].(map[string]any)["user"] = user
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Errorf("accepted ambiguous/root identity %q", user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoServicesRequireExactManifestAndSeparateVolumes(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
var copyDoc map[string]any
|
||||
if err := json.Unmarshal(raw, ©Doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := copyDoc["services"].(map[string]any)["api"].(map[string]any)
|
||||
second["volumes"].([]any)[0].(map[string]any)["source"] = "db-data"
|
||||
d["services"].(map[string]any)["db"] = second
|
||||
d["volumes"].(map[string]any)["db-data"] = map[string]any{}
|
||||
m.Components = append(m.Components, appbundle.Component{Name: "db", Image: m.Components[0].Image})
|
||||
raw, _ = json.Marshal(d)
|
||||
if err := Check(m, raw); err != nil {
|
||||
t.Fatal("valid separate-service volumes rejected", err)
|
||||
}
|
||||
second["volumes"].([]any)[0].(map[string]any)["source"] = "data"
|
||||
delete(d["volumes"].(map[string]any), "db-data")
|
||||
raw, _ = json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("shared service data accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlappingAndUnusedVolumesRejected(t *testing.T) {
|
||||
for _, target := range []string{"/data", "/data/nested", "/other"} {
|
||||
m, d := fixture()
|
||||
d["volumes"].(map[string]any)["second"] = map[string]any{}
|
||||
if target != "/other" {
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
s["volumes"] = append(s["volumes"].([]any), map[string]any{"type": "volume", "source": "second", "target": target, "read_only": false})
|
||||
}
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("overlapping or unused volume accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Read-only relevant dpkg inventory
|
||||
|
||||
`Inventory(fs.FS) Snapshot` reads fixed `var/lib/dpkg/status` (regular file,
|
||||
nonempty, at most 16 MiB) and checks the fixed `var/lib/dpkg/updates` directory
|
||||
is empty before and after reading. It invokes no package tools and writes nothing.
|
||||
The filesystem must provide metadata via `fs.StatFS`; unsupported, inaccessible,
|
||||
malformed, oversized, changing or journal-busy input returns `state=unknown`, an
|
||||
empty digest and empty packages array. Missing status is not a clean machine.
|
||||
|
||||
It validates stanza structure/identity/status before filtering. Field names are
|
||||
case-insensitive; duplicate keys, malformed scalar continuations and duplicate
|
||||
package/architecture records are rejected. Descriptions/other values are never
|
||||
returned. Distinct multiarch records are retained; ambiguous all/unspecified
|
||||
architecture duplicates fail closed. Bare not-installed selections are permitted
|
||||
and still returned when relevant, so a caller cannot mistake them for no record.
|
||||
|
||||
An observed snapshot contains the SHA-256 of the complete status file bytes and
|
||||
deterministically ordered name/version/architecture/status records for:
|
||||
docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin,
|
||||
docker-compose-plugin, docker.io, docker-compose, docker-compose-v2, docker-doc,
|
||||
docker-buildx, podman-docker, containerd and runc.
|
||||
|
||||
The `Installed` type means a database record exists, not that it is fully
|
||||
installed. Callers must conservatively review **every** returned record, including
|
||||
hold, partial installation, residual config and not-installed selections.
|
||||
|
||||
OS paths/ancestors and filesystem implementation are trusted. Metadata/journal
|
||||
rechecks detect ordinary changes but do not lock dpkg or produce an atomic
|
||||
transaction against concurrent writes. The digest is not a host identity or
|
||||
approval. This does not scan custom package databases, rootless/manual runtimes,
|
||||
APT sources, package dependencies, or unrelated packages' operational health.
|
||||
|
||||
Format reference: [Debian control files](https://www.debian.org/doc/debian-policy/ch-controlfields.html).
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package debian observes the dpkg database without executing package tools.
|
||||
package debian
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
State string `json:"state"`
|
||||
Digest string `json:"digest"`
|
||||
Packages []Installed `json:"packages"`
|
||||
}
|
||||
|
||||
// Installed is a present database record, including residual or uninstalled selections.
|
||||
type Installed struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const (
|
||||
statusPath = "var/lib/dpkg/status"
|
||||
updatesPath = "var/lib/dpkg/updates"
|
||||
maxStatusBytes = 16 << 20
|
||||
)
|
||||
|
||||
// Inventory reads only the fixed status and updates paths. An observed snapshot
|
||||
// includes every relevant record, regardless of installation state; it is not
|
||||
// an installation permission. Unknown never exposes a partial result or error.
|
||||
// Paths and the FS implementation are trusted. Metadata and journal rechecks
|
||||
// detect ordinary changes, but are not a lock or protection against a hostile
|
||||
// administrator replacing paths between checks.
|
||||
func Inventory(files fs.FS) Snapshot {
|
||||
unknown := Snapshot{State: "unknown", Packages: []Installed{}}
|
||||
// fs.Stat's fallback opens the path. Require a metadata operation so that
|
||||
// checking an already-present FIFO cannot block before we reject its type.
|
||||
metadata, ok := files.(fs.StatFS)
|
||||
if !ok || !emptyJournal(files, metadata) {
|
||||
return unknown
|
||||
}
|
||||
initial, err := metadata.Stat(statusPath)
|
||||
if err != nil || !validStatusFile(initial) {
|
||||
return unknown
|
||||
}
|
||||
f, err := files.Open(statusPath)
|
||||
if err != nil {
|
||||
return unknown
|
||||
}
|
||||
opened, err := f.Stat()
|
||||
if err != nil || !sameMetadata(initial, opened) {
|
||||
f.Close()
|
||||
return unknown
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(f, maxStatusBytes+1))
|
||||
after, statErr := f.Stat()
|
||||
closeErr := f.Close()
|
||||
if readErr != nil || statErr != nil || closeErr != nil || len(raw) == 0 || len(raw) > maxStatusBytes || int64(len(raw)) != initial.Size() || !sameMetadata(initial, after) {
|
||||
return unknown
|
||||
}
|
||||
current, err := metadata.Stat(statusPath)
|
||||
if err != nil || !sameMetadata(initial, current) {
|
||||
return unknown
|
||||
}
|
||||
packages, ok := parseStatus(raw)
|
||||
if !ok || !emptyJournal(files, metadata) {
|
||||
return unknown
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return Snapshot{State: "observed", Digest: "sha256:" + hex.EncodeToString(sum[:]), Packages: packages}
|
||||
}
|
||||
|
||||
func validStatusFile(info fs.FileInfo) bool {
|
||||
return info != nil && info.Mode().IsRegular() && info.Size() > 0 && info.Size() <= maxStatusBytes
|
||||
}
|
||||
|
||||
func sameMetadata(a, b fs.FileInfo) bool {
|
||||
return a != nil && b != nil && a.Mode() == b.Mode() && a.Size() == b.Size() && a.ModTime().Equal(b.ModTime())
|
||||
}
|
||||
|
||||
func emptyJournal(files fs.FS, metadata fs.StatFS) bool {
|
||||
initial, err := metadata.Stat(updatesPath)
|
||||
if err != nil || initial == nil || !initial.IsDir() {
|
||||
return false
|
||||
}
|
||||
f, err := files.Open(updatesPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
opened, statErr := f.Stat()
|
||||
dir, ok := f.(fs.ReadDirFile)
|
||||
if statErr != nil || !sameMetadata(initial, opened) || !ok {
|
||||
f.Close()
|
||||
return false
|
||||
}
|
||||
// Read at most one entry: even a hidden file or a directory is pending work.
|
||||
entries, readErr := dir.ReadDir(1)
|
||||
closeErr := f.Close()
|
||||
return len(entries) == 0 && readErr == io.EOF && closeErr == nil
|
||||
}
|
||||
|
||||
var packageName = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]+$`)
|
||||
var architectureName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
var packageVersion = regexp.MustCompile(`^(?:[0-9]+:)?[0-9][A-Za-z0-9.+:~\-]*$`)
|
||||
|
||||
func relevant(name string) bool {
|
||||
switch name {
|
||||
case "docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin", "docker.io", "docker-compose", "docker-compose-v2", "docker-doc", "docker-buildx", "podman-docker", "containerd", "runc":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scalarField(key string) bool {
|
||||
return key == "package" || key == "status" || key == "architecture" || key == "version"
|
||||
}
|
||||
|
||||
// Validate structure and identifying fields in ALL stanzas, before filtering.
|
||||
// Other field values (including descriptions) are never part of the snapshot.
|
||||
func parseStatus(raw []byte) ([]Installed, bool) {
|
||||
if !utf8.Valid(raw) {
|
||||
return nil, false
|
||||
}
|
||||
packages := []Installed{}
|
||||
seen := make(map[string]map[string]bool)
|
||||
fields := make(map[string]string)
|
||||
last := ""
|
||||
count := 0
|
||||
finish := func() bool {
|
||||
if len(fields) == 0 {
|
||||
return true
|
||||
}
|
||||
p := Installed{Name: fields["package"], Version: fields["version"], Architecture: fields["architecture"]}
|
||||
status, ok := normalizedStatus(fields["status"])
|
||||
if !ok || !packageName.MatchString(p.Name) {
|
||||
return false
|
||||
}
|
||||
p.Status = status
|
||||
// dpkg may retain a bare selection for a never-installed package.
|
||||
notInstalled := strings.HasSuffix(status, " not-installed")
|
||||
if p.Architecture == "" {
|
||||
if !notInstalled || hasField(fields, "architecture") {
|
||||
return false
|
||||
}
|
||||
} else if !architectureName.MatchString(p.Architecture) || p.Architecture == "any" || p.Architecture == "source" || strings.HasPrefix(p.Architecture, "any-") || strings.HasSuffix(p.Architecture, "-any") {
|
||||
return false
|
||||
}
|
||||
if p.Version == "" {
|
||||
if !notInstalled || hasField(fields, "version") {
|
||||
return false
|
||||
}
|
||||
} else if !packageVersion.MatchString(p.Version) || strings.HasSuffix(p.Version, "-") || strings.HasSuffix(p.Version, ":") {
|
||||
return false
|
||||
}
|
||||
arches := seen[p.Name]
|
||||
if len(arches) != 0 && (arches[p.Architecture] || arches[""] || arches["all"] || p.Architecture == "" || p.Architecture == "all") {
|
||||
return false
|
||||
}
|
||||
if arches == nil {
|
||||
arches = make(map[string]bool)
|
||||
seen[p.Name] = arches
|
||||
}
|
||||
arches[p.Architecture] = true
|
||||
if relevant(p.Name) {
|
||||
packages = append(packages, p)
|
||||
}
|
||||
count++
|
||||
fields = make(map[string]string)
|
||||
last = ""
|
||||
return true
|
||||
}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(raw))
|
||||
// The file cap is also the token cap; long legitimate description lines
|
||||
// must not be silently lost to Scanner's default 64 KiB limit.
|
||||
scanner.Buffer(make([]byte, 4096), maxStatusBytes+1)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
for _, c := range line {
|
||||
if (c < 32 && c != '\t') || c == 127 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if strings.Trim(line, " \t") == "" {
|
||||
if !finish() {
|
||||
return nil, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if last == "" || scalarField(last) {
|
||||
return nil, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok || !validFieldName(key) {
|
||||
return nil, false
|
||||
}
|
||||
key = strings.ToLower(key)
|
||||
if hasField(fields, key) {
|
||||
return nil, false
|
||||
}
|
||||
// Retain only the values we project, but track every key for duplicates.
|
||||
fields[key] = ""
|
||||
if scalarField(key) {
|
||||
fields[key] = strings.Trim(value, " \t")
|
||||
}
|
||||
last = key
|
||||
}
|
||||
if scanner.Err() != nil || !finish() || count == 0 {
|
||||
return nil, false
|
||||
}
|
||||
sort.Slice(packages, func(i, j int) bool {
|
||||
if packages[i].Name != packages[j].Name {
|
||||
return packages[i].Name < packages[j].Name
|
||||
}
|
||||
return packages[i].Architecture < packages[j].Architecture
|
||||
})
|
||||
return packages, true
|
||||
}
|
||||
|
||||
func hasField(fields map[string]string, key string) bool {
|
||||
_, ok := fields[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func validFieldName(key string) bool {
|
||||
if key == "" || key[0] == '#' || key[0] == '-' {
|
||||
return false
|
||||
}
|
||||
for i := range len(key) {
|
||||
if key[i] < 33 || key[i] > 126 || key[i] == ':' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizedStatus(value string) (string, bool) {
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == '\t' })
|
||||
if len(parts) != 3 {
|
||||
return "", false
|
||||
}
|
||||
switch parts[0] {
|
||||
case "unknown", "install", "hold", "deinstall", "purge":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if parts[1] != "ok" && parts[1] != "reinstreq" {
|
||||
return "", false
|
||||
}
|
||||
switch parts[2] {
|
||||
case "not-installed", "config-files", "half-installed", "unpacked", "half-configured", "triggers-awaited", "triggers-pending", "installed":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(parts, " "), true
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package debian
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
const fixtureStatus = "var/lib/dpkg/status"
|
||||
const fixtureUpdates = "var/lib/dpkg/updates"
|
||||
|
||||
func stanza(name, arch, status string) string {
|
||||
return "Package: " + name + "\nStatus: " + status + "\nArchitecture: " + arch + "\nVersion: 5:28.0.1-1~ubuntu.24.04~noble\nDescription: container runtime\n continuation with Package: ignored\n .\n\tUTF-8 description: 容器\n"
|
||||
}
|
||||
|
||||
func statusFS(raw string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
fixtureStatus: &fstest.MapFile{Data: []byte(raw), Mode: 0644},
|
||||
fixtureUpdates: &fstest.MapFile{Mode: fs.ModeDir | 0755},
|
||||
}
|
||||
}
|
||||
|
||||
func requireUnknown(t *testing.T, files fs.FS) {
|
||||
t.Helper()
|
||||
got := Inventory(files)
|
||||
if got.State != "unknown" || got.Digest != "" || got.Packages == nil || len(got.Packages) != 0 {
|
||||
t.Fatal("invalid input must produce unknown, empty digest, and non-nil empty packages")
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil || string(raw) != `{"state":"unknown","digest":"","packages":[]}` {
|
||||
t.Fatal("unknown result must expose only the empty public JSON shape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryObservedAndExactDigest(t *testing.T) {
|
||||
base := stanza("base-files", "amd64", "install ok installed")
|
||||
for _, raw := range []string{base, "\n" + base + "\n\n", strings.ReplaceAll(base, "\n", "\r\n"), strings.TrimSuffix(base, "\n")} {
|
||||
got := Inventory(statusFS(raw))
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
if got.State != "observed" || got.Packages == nil || len(got.Packages) != 0 || got.Digest != "sha256:"+hex.EncodeToString(sum[:]) {
|
||||
t.Fatal("valid unrelated package must yield observed empty inventory and exact-byte digest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryAllRelevantNames(t *testing.T) {
|
||||
names := []string{"containerd", "containerd.io", "docker-buildx", "docker-buildx-plugin", "docker-ce", "docker-ce-cli", "docker-compose", "docker-compose-plugin", "docker-compose-v2", "docker-doc", "docker.io", "podman-docker", "runc"}
|
||||
var raw strings.Builder
|
||||
for i := len(names) - 1; i >= 0; i-- {
|
||||
raw.WriteString(stanza(names[i], "amd64", "install ok installed") + "\n")
|
||||
}
|
||||
raw.WriteString(stanza("docker-ce-extra", "amd64", "install ok installed"))
|
||||
got := Inventory(statusFS(raw.String()))
|
||||
if got.State != "observed" || len(got.Packages) != len(names) {
|
||||
t.Fatal("inventory must include exactly the fixed relevant package set")
|
||||
}
|
||||
for i, name := range names {
|
||||
want := Installed{Name: name, Version: "5:28.0.1-1~ubuntu.24.04~noble", Architecture: "amd64", Status: "install ok installed"}
|
||||
if got.Packages[i] != want {
|
||||
t.Fatal("relevant packages must preserve projected fields and sort by name")
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(got.Packages[0])
|
||||
if err != nil || string(encoded) != `{"name":"containerd","version":"5:28.0.1-1~ubuntu.24.04~noble","architecture":"amd64","status":"install ok installed"}` {
|
||||
t.Fatal("installed JSON must contain only the four specified fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryKeepsEveryStatusAndMultiarch(t *testing.T) {
|
||||
statuses := []string{"install ok installed", "hold ok installed", "deinstall ok config-files", "install reinstreq half-installed", "install ok unpacked", "install ok half-configured", "install ok triggers-awaited", "install ok triggers-pending", "purge ok not-installed", "unknown ok not-installed"}
|
||||
for _, status := range statuses {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
raw := stanza("runc", "arm64", status) + "\n" + stanza("runc", "amd64", status)
|
||||
got := Inventory(statusFS(raw))
|
||||
if got.State != "observed" || len(got.Packages) != 2 || got.Packages[0].Architecture != "amd64" || got.Packages[1].Architecture != "arm64" || got.Packages[0].Status != status || got.Packages[1].Status != status {
|
||||
t.Fatal("every present record must survive regardless of installation state; sort multiarch by architecture")
|
||||
}
|
||||
})
|
||||
}
|
||||
got := Inventory(statusFS("Package: docker-ce\nStatus: purge ok not-installed\n"))
|
||||
if got.State != "observed" || !reflect.DeepEqual(got.Packages, []Installed{{Name: "docker-ce", Status: "purge ok not-installed"}}) {
|
||||
t.Fatal("not-installed selection records may lack architecture and version but must still block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryCaseInsensitiveFieldsAndWhitespace(t *testing.T) {
|
||||
raw := "pAcKaGe:\tdocker.io \nSTATUS: hold\t ok installed\narchitecture: all\nversion: 1.2+dfsg-3\nDESCRIPTION: first\n second\n .\n third\n\t \n" + stanza("base-files", "amd64", "install ok installed")
|
||||
got := Inventory(statusFS(raw))
|
||||
if got.State != "observed" || !reflect.DeepEqual(got.Packages, []Installed{{Name: "docker.io", Version: "1.2+dfsg-3", Architecture: "all", Status: "hold ok installed"}}) {
|
||||
t.Fatal("field aliases must normalize safely, including status whitespace and whitespace-only stanza separators")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryRejectsMalformedGlobally(t *testing.T) {
|
||||
valid := stanza("base-files", "amd64", "install ok installed")
|
||||
cases := map[string]string{
|
||||
"empty": "", "blank": "\n \t\n", "orphan continuation": " unexpected\n" + valid,
|
||||
"missing colon": valid + "broken line\n", "empty field name": valid + ": value\n",
|
||||
"field whitespace": valid + "Bad Field: x\n", "field leading hyphen": valid + "-Bad: x\n",
|
||||
"comment": valid + "# comment\n", "duplicate unrelated key": valid + "description: duplicate\n",
|
||||
"duplicate package alias": valid + "PACKAGE: docker-ce\n", "duplicate status alias": valid + "status: purge ok not-installed\n",
|
||||
"duplicate version alias": valid + "VERSION: 1\n", "duplicate architecture alias": valid + "ARCHITECTURE: arm64\n",
|
||||
"duplicate irrelevant stanza": valid + "\n" + valid,
|
||||
"duplicate relevant stanza": stanza("runc", "amd64", "install ok installed") + "\n" + stanza("runc", "amd64", "hold ok installed"),
|
||||
"missing package": "Status: install ok installed\nArchitecture: amd64\nVersion: 1\n",
|
||||
"missing status": "Package: base-files\nArchitecture: amd64\nVersion: 1\n",
|
||||
"missing installed version": "Package: base-files\nStatus: install ok installed\nArchitecture: amd64\n",
|
||||
"missing installed architecture": "Package: base-files\nStatus: install ok installed\nVersion: 1\n",
|
||||
"unknown selection": stanza("base-files", "amd64", "selected ok installed"),
|
||||
"unknown flag": stanza("base-files", "amd64", "install bad installed"),
|
||||
"unknown state": stanza("base-files", "amd64", "install ok ready"),
|
||||
"status suffix": stanza("base-files", "amd64", "install ok installed extra"),
|
||||
"status short": stanza("base-files", "amd64", "ok installed"),
|
||||
"status case": stanza("base-files", "amd64", "Install ok installed"),
|
||||
"unicode status space": stanza("base-files", "amd64", "install\u00a0ok installed"),
|
||||
"architecture list": stanza("base-files", "amd64 arm64", "install ok installed"),
|
||||
"architecture wildcard": stanza("base-files", "any", "install ok installed"),
|
||||
"architecture source": stanza("base-files", "source", "install ok installed"),
|
||||
"architecture punctuation": stanza("base-files", "amd64!", "install ok installed"),
|
||||
"architecture ambiguous": "Package: runc\nStatus: purge ok not-installed\n\n" + stanza("runc", "amd64", "install ok installed"),
|
||||
"architecture all mixed": stanza("runc", "all", "install ok installed") + "\n" + stanza("runc", "amd64", "install ok installed"),
|
||||
"scalar continuation": "Package: base-files\n unexpected\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1\n",
|
||||
"version continuation": "Package: base-files\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1\n 2\n",
|
||||
"nul": valid + "X-Note: hidden\x00value\n", "bare CR": valid + "X-Note: hidden\rvalue\n",
|
||||
"invalid utf8": valid + "X-Note: \xff\n",
|
||||
"unsafe version": strings.Replace(valid, "5:28.0.1-1~ubuntu.24.04~noble", "1;secret", 1),
|
||||
"empty version": strings.Replace(valid, "5:28.0.1-1~ubuntu.24.04~noble", "", 1),
|
||||
}
|
||||
for _, name := range []string{"a", "Docker-ce", "docker_ce", "docker-ce:amd64", "-docker", "docker ce", "dockér"} {
|
||||
cases["invalid package "+name] = stanza(name, "amd64", "install ok installed")
|
||||
}
|
||||
for name, raw := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
requireUnknown(t, statusFS(raw))
|
||||
// A valid relevant record before corrupt data must never leak a partial result.
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
requireUnknown(t, statusFS(stanza("docker-ce", "amd64", "install ok installed")+"\n"+raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryFileAndJournalFailures(t *testing.T) {
|
||||
for _, name := range []string{"missing status", "missing updates", "updates regular", "updates entry", "updates subdir", "updates hidden entry", "status directory", "status fifo", "status device", "status oversized"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
files := statusFS(stanza("base-files", "amd64", "install ok installed"))
|
||||
switch name {
|
||||
case "missing status":
|
||||
delete(files, fixtureStatus)
|
||||
case "missing updates":
|
||||
delete(files, fixtureUpdates)
|
||||
case "updates regular":
|
||||
files[fixtureUpdates].Mode = 0644
|
||||
case "updates entry":
|
||||
files[fixtureUpdates+"/0000"] = &fstest.MapFile{}
|
||||
case "updates subdir":
|
||||
files[fixtureUpdates+"/pending"] = &fstest.MapFile{Mode: fs.ModeDir | 0700}
|
||||
case "updates hidden entry":
|
||||
files[fixtureUpdates+"/.pending"] = &fstest.MapFile{}
|
||||
case "status directory":
|
||||
files[fixtureStatus].Mode = fs.ModeDir | 0755
|
||||
case "status fifo":
|
||||
files[fixtureStatus].Mode = fs.ModeNamedPipe | 0600
|
||||
case "status device":
|
||||
files[fixtureStatus].Mode = fs.ModeDevice | 0600
|
||||
case "status oversized":
|
||||
files[fixtureStatus].Data = []byte(strings.Repeat("x", (16<<20)+1))
|
||||
}
|
||||
requireUnknown(t, files)
|
||||
})
|
||||
}
|
||||
requireUnknown(t, nil)
|
||||
}
|
||||
|
||||
func TestInventoryBoundedLargeDescription(t *testing.T) {
|
||||
// Exceed Scanner's default 64 KiB token size without exceeding the file cap.
|
||||
raw := stanza("docker.io", "amd64", "install ok installed") + " " + strings.Repeat("x", 128<<10) + "\n"
|
||||
if got := Inventory(statusFS(raw)); got.State != "observed" || len(got.Packages) != 1 {
|
||||
t.Fatal("bounded long description continuation must not hide a relevant record")
|
||||
}
|
||||
// A valid file exactly at the cap is accepted; the next byte is rejected.
|
||||
base := stanza("base-files", "amd64", "install ok installed")
|
||||
raw = base + " " + strings.Repeat("x", (16<<20)-len(base)-2) + "\n"
|
||||
if got := Inventory(statusFS(raw)); got.State != "observed" {
|
||||
t.Fatal("exactly 16 MiB must be accepted")
|
||||
}
|
||||
requireUnknown(t, statusFS(raw+"\n"))
|
||||
}
|
||||
|
||||
func TestInventoryRealDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(fixtureUpdates)), 0755); err != nil {
|
||||
t.Fatal("create fixture directory")
|
||||
}
|
||||
raw := stanza("docker-compose-v2", "arm64", "deinstall ok config-files")
|
||||
path := filepath.Join(root, filepath.FromSlash(fixtureStatus))
|
||||
if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
|
||||
t.Fatal("write fixture status")
|
||||
}
|
||||
got := Inventory(os.DirFS(root))
|
||||
if got.State != "observed" || len(got.Packages) != 1 || got.Packages[0].Status != "deinstall ok config-files" {
|
||||
t.Fatal("real filesystem residual record missing")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil || string(after) != raw {
|
||||
t.Fatal("inventory must not modify the database")
|
||||
}
|
||||
}
|
||||
|
||||
// Faults are confined to the FS boundary: permissions and read-time changes
|
||||
// cannot be exercised portably with chmod (notably on Windows or as root).
|
||||
type faultFS struct {
|
||||
fstest.MapFS
|
||||
fault string
|
||||
journalOpens int
|
||||
statusOpens int
|
||||
statusStats int
|
||||
}
|
||||
|
||||
func (f *faultFS) Stat(path string) (fs.FileInfo, error) {
|
||||
if (f.fault == "status stat denied" && path == fixtureStatus) || (f.fault == "journal stat denied" && path == fixtureUpdates) {
|
||||
return nil, errors.New("private stat diagnostic")
|
||||
}
|
||||
info, err := f.MapFS.Stat(path)
|
||||
if path == fixtureStatus {
|
||||
f.statusStats++
|
||||
if f.fault == "path changed" && f.statusStats > 1 && err == nil {
|
||||
return changedInfo{FileInfo: info, change: "time"}, nil
|
||||
}
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
func (f *faultFS) Open(path string) (fs.File, error) {
|
||||
if path != fixtureStatus && path != fixtureUpdates {
|
||||
return nil, errors.New("unexpected path")
|
||||
}
|
||||
if path == fixtureStatus {
|
||||
f.statusOpens++
|
||||
if f.fault == "status open denied" {
|
||||
return nil, errors.New("private open diagnostic")
|
||||
}
|
||||
} else {
|
||||
f.journalOpens++
|
||||
if f.fault == "journal open denied" {
|
||||
return nil, errors.New("private open diagnostic")
|
||||
}
|
||||
if f.fault == "journal becomes pending" && f.journalOpens == 2 {
|
||||
f.MapFS[fixtureUpdates+"/0001"] = &fstest.MapFile{}
|
||||
}
|
||||
}
|
||||
file, err := f.MapFS.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &faultFile{File: file, owner: f, path: path}, nil
|
||||
}
|
||||
|
||||
type faultFile struct {
|
||||
fs.File
|
||||
owner *faultFS
|
||||
path string
|
||||
stats int
|
||||
bytesRead int
|
||||
}
|
||||
|
||||
func (f *faultFile) Stat() (fs.FileInfo, error) {
|
||||
f.stats++
|
||||
info, err := f.File.Stat()
|
||||
if f.path == fixtureStatus && err == nil {
|
||||
switch f.owner.fault {
|
||||
case "opened stat denied":
|
||||
return nil, fs.ErrPermission
|
||||
case "opened fifo":
|
||||
return changedInfo{FileInfo: info, change: "fifo"}, nil
|
||||
case "opened size changed":
|
||||
return changedInfo{FileInfo: info, change: "size"}, nil
|
||||
case "changed while reading":
|
||||
if f.stats > 1 {
|
||||
return changedInfo{FileInfo: info, change: "time"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
func (f *faultFile) Read(p []byte) (int, error) {
|
||||
if f.path == fixtureStatus {
|
||||
switch f.owner.fault {
|
||||
case "read denied":
|
||||
return 0, errors.New("private read diagnostic")
|
||||
case "truncated":
|
||||
return 0, io.EOF
|
||||
case "growing":
|
||||
// Ignore the advertised size, like a file growing after stat.
|
||||
if f.bytesRead+len(p) > (16<<20)+1 {
|
||||
return 0, errors.New("read exceeded bound")
|
||||
}
|
||||
for i := range p {
|
||||
p[i] = 'x'
|
||||
}
|
||||
f.bytesRead += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
||||
return f.File.Read(p)
|
||||
}
|
||||
|
||||
func (f *faultFile) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if f.owner.fault == "journal read denied" {
|
||||
return nil, errors.New("private journal diagnostic")
|
||||
}
|
||||
return f.File.(fs.ReadDirFile).ReadDir(n)
|
||||
}
|
||||
|
||||
func (f *faultFile) Close() error {
|
||||
err := f.File.Close()
|
||||
if f.owner.fault == "close failure" {
|
||||
return errors.New("private close diagnostic")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type changedInfo struct {
|
||||
fs.FileInfo
|
||||
change string
|
||||
}
|
||||
|
||||
func (i changedInfo) Mode() fs.FileMode {
|
||||
if i.change == "fifo" {
|
||||
return fs.ModeNamedPipe | 0600
|
||||
}
|
||||
return i.FileInfo.Mode()
|
||||
}
|
||||
|
||||
func (i changedInfo) Size() int64 {
|
||||
if i.change == "size" {
|
||||
return i.FileInfo.Size() + 1
|
||||
}
|
||||
return i.FileInfo.Size()
|
||||
}
|
||||
|
||||
func (i changedInfo) ModTime() time.Time {
|
||||
if i.change == "time" {
|
||||
return i.FileInfo.ModTime().Add(time.Second)
|
||||
}
|
||||
return i.FileInfo.ModTime()
|
||||
}
|
||||
|
||||
func TestInventoryFailsClosedOnReadFaultsAndChanges(t *testing.T) {
|
||||
for _, fault := range []string{"status stat denied", "journal stat denied", "status open denied", "journal open denied", "opened stat denied", "opened fifo", "opened size changed", "changed while reading", "path changed", "read denied", "truncated", "growing", "journal read denied", "journal becomes pending", "close failure"} {
|
||||
t.Run(fault, func(t *testing.T) {
|
||||
files := &faultFS{MapFS: statusFS(stanza("runc", "amd64", "install ok installed")), fault: fault}
|
||||
requireUnknown(t, files)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryStatsBeforeOpeningNonregularFiles(t *testing.T) {
|
||||
for _, path := range []string{fixtureStatus, fixtureUpdates} {
|
||||
files := &faultFS{MapFS: statusFS(stanza("runc", "amd64", "install ok installed"))}
|
||||
files.MapFS[path].Mode = fs.ModeNamedPipe | 0600
|
||||
requireUnknown(t, files)
|
||||
if files.statusOpens != 0 || (path == fixtureUpdates && files.journalOpens != 0) {
|
||||
t.Fatal("must reject a FIFO using metadata before opening it")
|
||||
}
|
||||
}
|
||||
// fs.Stat would use Open on this implementation, so fail closed instead.
|
||||
requireUnknown(t, openOnlyFS{})
|
||||
}
|
||||
|
||||
type openOnlyFS struct{}
|
||||
|
||||
func (openOnlyFS) Open(string) (fs.File, error) {
|
||||
panic("must not open without safe metadata support")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package inspect reports local prerequisite observations without executing
|
||||
// commands, connecting to Docker or altering configuration.
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
SupportedPlatform bool `json:"supportedPlatform"`
|
||||
SystemdRuntime string `json:"systemdRuntime"`
|
||||
DockerClient string `json:"dockerClient"`
|
||||
DockerDaemon string `json:"dockerDaemon"`
|
||||
Compose string `json:"compose"`
|
||||
Unchecked []string `json:"unchecked"`
|
||||
DeploymentReady bool `json:"deploymentReady"`
|
||||
}
|
||||
|
||||
func Collect() Report { return Probe(runtime.GOOS, runtime.GOARCH, os.DirFS("/")) }
|
||||
|
||||
// Probe observes filesystem metadata only. "present" is not an assertion that
|
||||
// systemd is responsive or the Docker executable is authentic or operational.
|
||||
func Probe(goos, arch string, files fs.FS) Report {
|
||||
r := Report{ProtocolVersion: 1, OS: goos, Architecture: arch, SupportedPlatform: goos == "linux" && (arch == "amd64" || arch == "arm64"), SystemdRuntime: "not_checked", DockerClient: "not_checked", DockerDaemon: "not_checked", Compose: "not_checked", Unchecked: []string{"docker_daemon", "compose_version", "host_identity", "distribution_support", "permissions", "disk_space", "ports", "dns", "firewall", "gateway"}}
|
||||
if goos != "linux" {
|
||||
return r
|
||||
}
|
||||
r.SystemdRuntime = observe(files, "run/systemd/system", true)
|
||||
r.DockerClient = observe(files, "usr/bin/docker", false)
|
||||
if r.DockerClient != "present" {
|
||||
alternate := observe(files, "usr/local/bin/docker", false)
|
||||
// Either known executable establishes presence. Otherwise retain any
|
||||
// uncertainty instead of reporting absence from an incomplete check.
|
||||
switch {
|
||||
case alternate == "present":
|
||||
r.DockerClient = "present"
|
||||
case r.DockerClient == "unknown" || alternate == "unknown":
|
||||
r.DockerClient = "unknown"
|
||||
case r.DockerClient == "invalid" || alternate == "invalid":
|
||||
r.DockerClient = "invalid"
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func observe(files fs.FS, path string, directory bool) string {
|
||||
info, err := fs.Stat(files, path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "missing"
|
||||
}
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if directory {
|
||||
if info.IsDir() {
|
||||
return "present"
|
||||
}
|
||||
return "invalid"
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
|
||||
return "invalid"
|
||||
}
|
||||
return "present"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestReportsFactsWithoutClaimingDeploymentReadiness(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"run/systemd/system": &fstest.MapFile{Mode: os.ModeDir | 0755},
|
||||
"usr/bin/docker": &fstest.MapFile{Mode: 0755, Data: []byte("not executed")},
|
||||
}
|
||||
r := Probe("linux", "amd64", files)
|
||||
if !r.SupportedPlatform || r.SystemdRuntime != "present" || r.DockerClient != "present" || r.DeploymentReady {
|
||||
t.Fatalf("incorrect report: %+v", r)
|
||||
}
|
||||
if r.DockerDaemon != "not_checked" || r.Compose != "not_checked" {
|
||||
t.Fatal("claimed unperformed checks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAndUnsupportedEnvironment(t *testing.T) {
|
||||
r := Probe("linux", "amd64", fstest.MapFS{})
|
||||
if r.DockerClient != "missing" || r.SystemdRuntime != "missing" {
|
||||
t.Fatalf("missing prerequisites masked: %+v", r)
|
||||
}
|
||||
r = Probe("windows", "amd64", fstest.MapFS{})
|
||||
if r.SupportedPlatform || r.DeploymentReady || r.DockerClient != "not_checked" {
|
||||
t.Fatal("Windows accepted as deployment target")
|
||||
}
|
||||
r = Probe("linux", "386", fstest.MapFS{})
|
||||
if r.SupportedPlatform {
|
||||
t.Fatal("unsupported architecture accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongFileKindsAndPermissions(t *testing.T) {
|
||||
r := Probe("linux", "amd64", fstest.MapFS{
|
||||
"run/systemd/system": &fstest.MapFile{Mode: 0644},
|
||||
"usr/bin/docker": &fstest.MapFile{Mode: 0644},
|
||||
})
|
||||
if r.SystemdRuntime != "invalid" || r.DockerClient != "invalid" {
|
||||
t.Fatalf("wrong file kinds accepted: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealDirectoryProbeDoesNotWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "run/systemd/system"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Probe("linux", "arm64", os.DirFS(dir))
|
||||
if r.SystemdRuntime != "present" || r.DockerClient != "missing" {
|
||||
t.Fatalf("bad real probe: %+v", r)
|
||||
}
|
||||
items, err := os.ReadDir(dir)
|
||||
if err != nil || len(items) != 1 || items[0].Name() != "run" {
|
||||
t.Fatal("probe changed filesystem")
|
||||
}
|
||||
}
|
||||
|
||||
type deniedFS struct{}
|
||||
|
||||
func (deniedFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
||||
|
||||
func TestPermissionErrorsAreUnknownNotMissing(t *testing.T) {
|
||||
r := Probe("linux", "amd64", deniedFS{})
|
||||
if r.DockerClient != "unknown" || r.SystemdRuntime != "unknown" {
|
||||
t.Fatalf("hid inspection failure: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerAlternatePath(t *testing.T) {
|
||||
r := Probe("linux", "amd64", fstest.MapFS{"usr/local/bin/docker": &fstest.MapFile{Mode: 0755}})
|
||||
if r.DockerClient != "present" {
|
||||
t.Fatal("alternate installation not found")
|
||||
}
|
||||
}
|
||||
|
||||
type pathDeniedFS struct{ fs.FS }
|
||||
|
||||
func (f pathDeniedFS) Open(name string) (fs.File, error) {
|
||||
if name == "usr/bin/docker" {
|
||||
return nil, fs.ErrPermission
|
||||
}
|
||||
return f.FS.Open(name)
|
||||
}
|
||||
|
||||
func TestAlternateDockerAfterInvalidOrUnknownPrimary(t *testing.T) {
|
||||
for _, mode := range []fs.FileMode{0644, fs.ModeDir | 0755} {
|
||||
files := fstest.MapFS{
|
||||
"usr/bin/docker": &fstest.MapFile{Mode: mode},
|
||||
"usr/local/bin/docker": &fstest.MapFile{Mode: 0755},
|
||||
}
|
||||
if r := Probe("linux", "amd64", files); r.DockerClient != "present" {
|
||||
t.Errorf("valid alternate overlooked after invalid primary: %+v", r)
|
||||
}
|
||||
}
|
||||
files := pathDeniedFS{fstest.MapFS{"usr/local/bin/docker": &fstest.MapFile{Mode: 0755}}}
|
||||
if r := Probe("linux", "amd64", files); r.DockerClient != "present" {
|
||||
t.Errorf("valid alternate overlooked after inaccessible primary: %+v", r)
|
||||
}
|
||||
if r := Probe("linux", "amd64", pathDeniedFS{fstest.MapFS{}}); r.DockerClient != "unknown" {
|
||||
t.Errorf("missing alternate masked inaccessible primary: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Environment lock and draft
|
||||
|
||||
`plan-environment` accepts strict JSON `{ "lock": <Lock> }`, collects local
|
||||
preflight observations itself, and emits a non-executable draft. It does not
|
||||
accept caller-supplied host observations, download anything, run apt, configure
|
||||
sources or start services.
|
||||
|
||||
Lock fields, all required:
|
||||
|
||||
- protocolVersion: 1.
|
||||
- repository: exactly `https://download.docker.com/linux/ubuntu`.
|
||||
- suite: jammy, noble or resolute; architecture: amd64 or arm64.
|
||||
- releaseDigest: `sha256:` plus 64 lowercase hex digits, supplied by the caller.
|
||||
- packages: exactly docker-ce, docker-ce-cli, containerd.io,
|
||||
docker-buildx-plugin and docker-compose-plugin, once each.
|
||||
- Each package has name, version, filename, digest and size. Version is explicit
|
||||
digit-leading Debian-style syntax (optional numeric epoch), at most 128 bytes;
|
||||
digest uses the above SHA-256 format; size is 1 through 512 MiB.
|
||||
- Filename must equal
|
||||
`dists/<suite>/pool/stable/<architecture>/<name>_<version-without-epoch>_<architecture>.deb`.
|
||||
Encoded paths, absolute paths, alternate domains, query strings and traversal
|
||||
are not accepted. docker-ce and docker-ce-cli must use the same version.
|
||||
|
||||
This is a deliberately limited Docker lock format, not a complete Debian version
|
||||
parser. A Linux host's observed distribution/suite and architecture must match;
|
||||
unknown observations remain blockers. On a non-Linux machine a syntactically
|
||||
valid lock can be reviewed, but unsupported-platform blockers remain.
|
||||
|
||||
## Digests and remaining trust boundary
|
||||
|
||||
lockDigest binds Go JSON encoding of the validated Lock in struct/array order.
|
||||
observationDigest binds Go JSON encoding of the collected Report. These are
|
||||
content hashes, not signatures, stable host IDs, freshness tokens or authorization.
|
||||
The local observation is not atomic and changes (including free disk space) can
|
||||
change its hash. No writing consumer may treat it as an approved plan.
|
||||
|
||||
The draft returns requestedPackages, not a complete APT dependency transaction.
|
||||
It never proposes automatic removal or upgrade of existing installations.
|
||||
RepositoryAuthenticated and executable are always false. There is no signature
|
||||
verification, Release-to-Packages-to-deb digest chain verification, metadata
|
||||
freshness policy, artifact download, package dependency resolution, or installation
|
||||
executor yet. Matching a URL allowlist and a caller-provided hash proves none of
|
||||
those. No actual versions are recommended or locked from live metadata in this batch.
|
||||
|
||||
Blockers explicitly retain these gaps along with host/network/runtime checks.
|
||||
Potential APT database changes, dependency changes, service starts and network
|
||||
rule effects are reported. Exit 0 only means a draft was produced.
|
||||
|
||||
Reference for package names and repository layout:
|
||||
[Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/).
|
||||
@@ -0,0 +1,68 @@
|
||||
package installplan
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Lock struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Repository string `json:"repository"`
|
||||
Suite string `json:"suite"`
|
||||
Architecture string `json:"architecture"`
|
||||
ReleaseDigest string `json:"releaseDigest"`
|
||||
Packages []Package `json:"packages"`
|
||||
}
|
||||
type Package struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Filename string `json:"filename"`
|
||||
Digest string `json:"digest"`
|
||||
Size uint64 `json:"size"`
|
||||
}
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^(?:[0-9]+:)?[0-9][0-9A-Za-z.+~-]*$`)
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
var required = []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"}
|
||||
|
||||
// Validate checks a caller-supplied lock against a fixed source policy. This is
|
||||
// NOT repository signature validation or evidence these artifacts exist.
|
||||
func Validate(lock Lock, suite, architecture string) (string, error) {
|
||||
reject := errors.New("invalid Docker package lock")
|
||||
if lock.ProtocolVersion != 1 || lock.Repository != "https://download.docker.com/linux/ubuntu" || lock.Suite != suite || lock.Architecture != architecture {
|
||||
return "", reject
|
||||
}
|
||||
if (suite != "jammy" && suite != "noble" && suite != "resolute") || (architecture != "amd64" && architecture != "arm64") || !digestPattern.MatchString(lock.ReleaseDigest) || len(lock.Packages) != len(required) {
|
||||
return "", reject
|
||||
}
|
||||
versions := map[string]string{}
|
||||
for _, p := range lock.Packages {
|
||||
allowed := false
|
||||
for _, name := range required {
|
||||
if p.Name == name {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed || versions[p.Name] != "" || len(p.Version) > 128 || !versionPattern.MatchString(p.Version) || !digestPattern.MatchString(p.Digest) || p.Size == 0 || p.Size > 512<<20 {
|
||||
return "", reject
|
||||
}
|
||||
fileVersion := p.Version
|
||||
if _, after, ok := strings.Cut(fileVersion, ":"); ok {
|
||||
fileVersion = after
|
||||
}
|
||||
if p.Filename != "dists/"+suite+"/pool/stable/"+architecture+"/"+p.Name+"_"+fileVersion+"_"+architecture+".deb" {
|
||||
return "", reject
|
||||
}
|
||||
versions[p.Name] = p.Version
|
||||
}
|
||||
if versions["docker-ce"] != versions["docker-ce-cli"] {
|
||||
return "", reject
|
||||
}
|
||||
raw, _ := json.Marshal(lock)
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package installplan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func lockFixture() Lock {
|
||||
l := Lock{ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu", Suite: "resolute", Architecture: "amd64", ReleaseDigest: "sha256:" + strings.Repeat("b", 64), Packages: []Package{}}
|
||||
for _, name := range []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"} {
|
||||
l.Packages = append(l.Packages, Package{Name: name, Version: "1.2.3-1", Filename: "dists/resolute/pool/stable/amd64/" + name + "_1.2.3-1_amd64.deb", Digest: "sha256:" + strings.Repeat("a", 64), Size: 123})
|
||||
}
|
||||
return l
|
||||
}
|
||||
func TestValidLockAndStableDigest(t *testing.T) {
|
||||
l := lockFixture()
|
||||
digest, err := Validate(l, "resolute", "amd64")
|
||||
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
||||
t.Fatal("valid lock rejected", err)
|
||||
}
|
||||
l.Packages[0].Size++
|
||||
changed, err := Validate(l, "resolute", "amd64")
|
||||
if err != nil || changed == digest {
|
||||
t.Fatal("lock digest does not bind size")
|
||||
}
|
||||
}
|
||||
func TestRejectUnsafeLock(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*Lock){
|
||||
"protocol": func(l *Lock) { l.ProtocolVersion = 2 },
|
||||
"repository": func(l *Lock) { l.Repository = "https://attacker.example/linux/ubuntu" },
|
||||
"credentials": func(l *Lock) { l.Repository = "https://user:secret@download.docker.com/linux/ubuntu" },
|
||||
"suite": func(l *Lock) { l.Suite = "noble" },
|
||||
"architecture": func(l *Lock) { l.Architecture = "arm64" },
|
||||
"release": func(l *Lock) { l.ReleaseDigest = "" },
|
||||
"missing": func(l *Lock) { l.Packages = l.Packages[:4] },
|
||||
"extra": func(l *Lock) { l.Packages = append(l.Packages, l.Packages[0]) },
|
||||
"duplicate": func(l *Lock) { l.Packages[1] = l.Packages[0] },
|
||||
"latest": func(l *Lock) { l.Packages[0].Version = "latest" },
|
||||
"shell": func(l *Lock) { l.Packages[0].Version = "1;reboot" },
|
||||
"wrong file": func(l *Lock) { l.Packages[0].Filename = "dists/resolute/pool/stable/amd64/other.deb" },
|
||||
"traversal": func(l *Lock) { l.Packages[0].Filename = "../docker.deb" },
|
||||
"encoded path": func(l *Lock) { l.Packages[0].Filename = "dists/resolute/pool/stable/amd64/%2e%2e.deb" },
|
||||
"digest": func(l *Lock) { l.Packages[0].Digest = "bad" },
|
||||
"size": func(l *Lock) { l.Packages[0].Size = 0 },
|
||||
"engine mismatch": func(l *Lock) {
|
||||
l.Packages[1].Version = "2.3.4-1"
|
||||
l.Packages[1].Filename = "dists/resolute/pool/stable/amd64/docker-ce-cli_2.3.4-1_amd64.deb"
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
l := lockFixture()
|
||||
mutate(&l)
|
||||
if _, err := Validate(l, "resolute", "amd64"); err == nil {
|
||||
t.Fatal("unsafe lock accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerEpochAndTargetArchitecture(t *testing.T) {
|
||||
l := lockFixture()
|
||||
for i := range l.Packages {
|
||||
p := &l.Packages[i]
|
||||
if p.Name == "docker-ce" || p.Name == "docker-ce-cli" {
|
||||
p.Version = "5:29.1.0-1~ubuntu.26.04~resolute"
|
||||
p.Filename = "dists/resolute/pool/stable/amd64/" + p.Name + "_29.1.0-1~ubuntu.26.04~resolute_amd64.deb"
|
||||
}
|
||||
}
|
||||
if _, err := Validate(l, "resolute", "amd64"); err != nil {
|
||||
t.Fatal("epoch version rejected", err)
|
||||
}
|
||||
if _, err := Validate(l, "resolute", "arm64"); err == nil {
|
||||
t.Fatal("architecture mismatch accepted")
|
||||
}
|
||||
l.Packages[0].Filename = strings.Replace(l.Packages[0].Filename, "_29.", "_5:29.", 1)
|
||||
if _, err := Validate(l, "resolute", "amd64"); err == nil {
|
||||
t.Fatal("epoch in repository filename accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package installplan
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"server-deploy/internal/preflight"
|
||||
)
|
||||
|
||||
type Draft struct {
|
||||
Executable bool `json:"executable"`
|
||||
RepositoryAuthenticated bool `json:"repositoryAuthenticated"`
|
||||
LockDigest string `json:"lockDigest"`
|
||||
ObservationDigest string `json:"observationDigest"`
|
||||
Blockers []string `json:"blockers"`
|
||||
RequestedPackages []Package `json:"requestedPackages"`
|
||||
Impacts []string `json:"impacts"`
|
||||
}
|
||||
|
||||
// Build binds requested package pins to a local observation for review only.
|
||||
// Neither digest is a signature, host identity, freshness token or approval.
|
||||
func Build(report preflight.Report, lock Lock) (Draft, error) {
|
||||
digest, err := Validate(lock, lock.Suite, lock.Architecture)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
if report.Runtime.OS == "linux" && (report.Runtime.Architecture != lock.Architecture || (report.Distribution.State == "observed" && (report.Distribution.ID != "ubuntu" || report.Distribution.Codename != lock.Suite))) {
|
||||
return Draft{}, errors.New("lock does not match local platform")
|
||||
}
|
||||
proposal := preflight.Plan(report)
|
||||
blockers := []string{}
|
||||
for _, b := range proposal.Blockers {
|
||||
if b != "package_versions_unresolved" {
|
||||
blockers = append(blockers, b)
|
||||
}
|
||||
}
|
||||
blockers = append(blockers, "dependency_transaction_unresolved", "artifact_bytes_unverified", "repository_metadata_freshness_unverified")
|
||||
raw, _ := json.Marshal(report)
|
||||
sum := sha256.Sum256(raw)
|
||||
return Draft{LockDigest: digest, ObservationDigest: "sha256:" + hex.EncodeToString(sum[:]), Blockers: blockers, RequestedPackages: append([]Package{}, lock.Packages...), Impacts: []string{"package_database_and_repository_changes", "services_may_start_during_package_install", "host_network_rules_may_change", "additional_dependencies_not_yet_resolved"}}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package installplan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/inspect"
|
||||
"server-deploy/internal/preflight"
|
||||
)
|
||||
|
||||
func TestDraftPreservesSafetyBlockersAndBindsInputs(t *testing.T) {
|
||||
r := preflight.Report{Runtime: inspect.Report{OS: "linux", Architecture: "amd64"}, Distribution: preflight.Distribution{State: "observed", ID: "ubuntu", Version: "26.04", Codename: "resolute"}}
|
||||
d, err := Build(r, lockFixture())
|
||||
if err != nil || d.Executable || d.RepositoryAuthenticated || len(d.Blockers) == 0 || !strings.HasPrefix(d.LockDigest, "sha256:") || !strings.HasPrefix(d.ObservationDigest, "sha256:") {
|
||||
t.Fatalf("unsafe draft %+v %v", d, err)
|
||||
}
|
||||
for _, want := range []string{"repository_trust_unverified", "dependency_transaction_unresolved", "artifact_bytes_unverified"} {
|
||||
found := false
|
||||
for _, b := range d.Blockers {
|
||||
if b == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing blocker %s", want)
|
||||
}
|
||||
}
|
||||
r.Privilege = "non_root"
|
||||
d2, _ := Build(r, lockFixture())
|
||||
if d2.ObservationDigest == d.ObservationDigest {
|
||||
t.Fatal("report change not bound")
|
||||
}
|
||||
l := lockFixture()
|
||||
r.Distribution.Version = "24.04"
|
||||
r.Distribution.Codename = "noble"
|
||||
if _, err := Build(r, l); err == nil {
|
||||
t.Fatal("host and lock mismatch accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package planner builds offline previews. It does not inspect or change a host.
|
||||
package planner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ProtocolVersion = 1
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
||||
digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
labelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
)
|
||||
|
||||
// Intent contains no secrets. ObservedStateDigest is caller-supplied in offline
|
||||
// mode; a future executor must obtain it independently from the target host.
|
||||
type Intent struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
HostID string `json:"hostId"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
AppID string `json:"appId"`
|
||||
PackageDigest string `json:"packageDigest"`
|
||||
ImageDigest string `json:"imageDigest"`
|
||||
Domain string `json:"domain"`
|
||||
ObservedStateDigest string `json:"observedStateDigest"`
|
||||
}
|
||||
|
||||
func (i Intent) Validate() error {
|
||||
if i.ProtocolVersion != ProtocolVersion {
|
||||
return errors.New("unsupported protocol version")
|
||||
}
|
||||
for _, id := range []string{i.HostID, i.InstanceID, i.AppID} {
|
||||
if !idPattern.MatchString(id) {
|
||||
return errors.New("invalid resource identifier")
|
||||
}
|
||||
}
|
||||
for _, digest := range []string{i.PackageDigest, i.ImageDigest, i.ObservedStateDigest} {
|
||||
if !digestPattern.MatchString(digest) {
|
||||
return errors.New("expected a SHA-256 digest")
|
||||
}
|
||||
}
|
||||
labels := strings.Split(i.Domain, ".")
|
||||
if len(i.Domain) > 253 || len(labels) < 2 || net.ParseIP(i.Domain) != nil {
|
||||
return errors.New("expected an ASCII DNS hostname")
|
||||
}
|
||||
for _, label := range labels {
|
||||
if !labelPattern.MatchString(label) {
|
||||
return errors.New("invalid DNS hostname label")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validIntent() Intent {
|
||||
return Intent{ProtocolVersion: 1, HostID: "host-one", InstanceID: "git-one", AppID: "gitea", PackageDigest: "sha256:" + strings.Repeat("a", 64), ImageDigest: "sha256:" + strings.Repeat("b", 64), Domain: "git.example.com", ObservedStateDigest: "sha256:" + strings.Repeat("c", 64)}
|
||||
}
|
||||
|
||||
func TestIntentValidation(t *testing.T) {
|
||||
if err := validIntent().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*Intent)
|
||||
}{
|
||||
{"protocol", func(i *Intent) { i.ProtocolVersion = 2 }},
|
||||
{"host empty", func(i *Intent) { i.HostID = "" }},
|
||||
{"path escape", func(i *Intent) { i.InstanceID = "../git" }},
|
||||
{"shell", func(i *Intent) { i.AppID = "git;id" }},
|
||||
{"long id", func(i *Intent) { i.InstanceID = strings.Repeat("a", 49) }},
|
||||
{"floating tag", func(i *Intent) { i.ImageDigest = "gitea:latest" }},
|
||||
{"package hash", func(i *Intent) { i.PackageDigest = "sha256:xyz" }},
|
||||
{"state missing", func(i *Intent) { i.ObservedStateDigest = "" }},
|
||||
{"url", func(i *Intent) { i.Domain = "https://git.example.com" }},
|
||||
{"wildcard", func(i *Intent) { i.Domain = "*.example.com" }},
|
||||
{"label", func(i *Intent) { i.Domain = "-git.example.com" }},
|
||||
{"empty label", func(i *Intent) { i.Domain = "git..com" }},
|
||||
{"uppercase", func(i *Intent) { i.Domain = "Git.example.com" }},
|
||||
{"ip", func(i *Intent) { i.Domain = "127.0.0.1" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
i := validIntent()
|
||||
tc.change(&i)
|
||||
if i.Validate() == nil {
|
||||
t.Fatal("accepted invalid intent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const planLifetime = 15 * time.Minute
|
||||
|
||||
// Plan is an offline intent preview, not an executable authorization. Hash binds
|
||||
// its contents but is not a signature. No secret may be added to this structure.
|
||||
type Plan struct {
|
||||
Intent Intent `json:"intent"`
|
||||
ProjectName string `json:"projectName"`
|
||||
DataPath string `json:"dataPath"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func Build(i Intent, now time.Time) (Plan, error) {
|
||||
if err := i.Validate(); err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
now = now.UTC().Truncate(time.Second)
|
||||
p := Plan{Intent: i, ProjectName: "sd-" + i.InstanceID, DataPath: "/var/lib/server-deploy/instances/" + i.InstanceID, CreatedAt: now, ExpiresAt: now.Add(planLifetime)}
|
||||
encoded, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return Plan{}, errors.New("cannot encode plan")
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
p.Hash = "sha256:" + hex.EncodeToString(sum[:])
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Verify checks offline consistency only. Current must be independently inspected
|
||||
// under a host lock before any future write operation uses this comparison.
|
||||
func (p Plan) Verify(current Intent, now time.Time) error {
|
||||
if now.Before(p.CreatedAt) || !now.Before(p.ExpiresAt) {
|
||||
return errors.New("plan is not within its validity window")
|
||||
}
|
||||
if p.Intent != current {
|
||||
return errors.New("plan does not match current intent or observed state")
|
||||
}
|
||||
expected, err := Build(current, p.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.ProjectName != expected.ProjectName || p.DataPath != expected.DataPath || !p.CreatedAt.Equal(expected.CreatedAt) || !p.ExpiresAt.Equal(expected.ExpiresAt) || p.Hash != expected.Hash {
|
||||
return errors.New("plan integrity check failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildPlan(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
i := validIntent()
|
||||
p, err := Build(i, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ProjectName != "sd-git-one" || p.DataPath != "/var/lib/server-deploy/instances/git-one" {
|
||||
t.Fatalf("wrong instance resources: %+v", p)
|
||||
}
|
||||
if p.ExpiresAt.Sub(p.CreatedAt) != 15*time.Minute {
|
||||
t.Fatal("wrong expiration")
|
||||
}
|
||||
if err := p.Verify(i, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := Build(i, now)
|
||||
if again.Hash != p.Hash {
|
||||
t.Fatal("unstable plan hash")
|
||||
}
|
||||
i.InstanceID = "git-two"
|
||||
other, _ := Build(i, now)
|
||||
if other.Hash == p.Hash || other.ProjectName == p.ProjectName || other.DataPath == p.DataPath {
|
||||
t.Fatal("instances share identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsChangedOrExpiredPlan(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
i := validIntent()
|
||||
original, _ := Build(i, now)
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*Plan, *Intent, *time.Time)
|
||||
}{
|
||||
{"expired", func(p *Plan, i *Intent, n *time.Time) { *n = p.ExpiresAt }},
|
||||
{"future", func(p *Plan, i *Intent, n *time.Time) { *n = p.CreatedAt.Add(-time.Second) }},
|
||||
{"tampered hash", func(p *Plan, i *Intent, n *time.Time) { p.Hash = "bad" }},
|
||||
{"tampered path", func(p *Plan, i *Intent, n *time.Time) { p.DataPath = "/" }},
|
||||
{"tampered project", func(p *Plan, i *Intent, n *time.Time) { p.ProjectName = "other" }},
|
||||
{"tampered expiry", func(p *Plan, i *Intent, n *time.Time) { p.ExpiresAt = p.ExpiresAt.Add(time.Hour) }},
|
||||
{"state drift", func(p *Plan, i *Intent, n *time.Time) { i.ObservedStateDigest = i.PackageDigest }},
|
||||
{"host drift", func(p *Plan, i *Intent, n *time.Time) { i.HostID = "another-host" }},
|
||||
{"domain drift", func(p *Plan, i *Intent, n *time.Time) { i.Domain = "other.example.com" }},
|
||||
{"intent tamper", func(p *Plan, i *Intent, n *time.Time) { p.Intent.InstanceID = "other" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, current, at := original, i, now
|
||||
tc.change(&p, ¤t, &at)
|
||||
if p.Verify(current, at) == nil {
|
||||
t.Fatal("accepted invalid plan")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsInvalidIntent(t *testing.T) {
|
||||
i := validIntent()
|
||||
i.InstanceID = "../bad"
|
||||
if _, err := Build(i, time.Now()); err == nil {
|
||||
t.Fatal("built invalid plan")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Read-only local environment proposal
|
||||
|
||||
`deployctl preflight` takes no stdin request. It collects facts from the local
|
||||
machine and emits protocolVersion, mode=`local-environment-proposal`, observedAt,
|
||||
report and proposal. It never connects to SSH/Docker, runs package managers,
|
||||
sources shell files, writes configuration or starts/restarts services.
|
||||
|
||||
## Observations
|
||||
|
||||
- Reuses `inspect` for OS, architecture and systemd/Docker-client file presence.
|
||||
- Reads only ID, VERSION_ID and VERSION_CODENAME from `/etc/os-release`, at most
|
||||
64 KiB. Handles plain and simply quoted values, not a shell grammar. Ambiguous,
|
||||
duplicated, missing or unsupported value syntax fails closed. Other keys are
|
||||
ignored, not evaluated or returned. Trusted host files/ancestors are assumed.
|
||||
- Linux effective UID is classified root/non_root/unknown.
|
||||
- Linux statfs reports available bytes on the filesystem containing `/var/lib`.
|
||||
This does not measure another configured data root, quotas, or inode capacity.
|
||||
Unknown disk observations cannot authorize a fresh-install candidate.
|
||||
- Resource checks inspect directory entries for `/var/lib/docker`,
|
||||
`/var/lib/containerd`, `/etc/docker`, `/var/lib/server-deploy`, and the Docker
|
||||
`.sources`/`.list` paths under `/etc/apt/sources.list.d`. Contents are not read.
|
||||
Any link or non-directory intermediate component is treated as existing;
|
||||
access failures become unknown, not absent. Checks are conservative hints,
|
||||
not a complete scan of runtime installations or APT sources.
|
||||
- Non-Linux hosts do not read Linux paths or report Linux disk availability.
|
||||
- Reads a bounded local dpkg status snapshot and requires an empty update journal.
|
||||
Reports only the Docker/runtime-related package records and the complete status
|
||||
file digest. Missing/malformed/journal-busy input is unknown, not an empty host.
|
||||
Installed, held, partial and residual relevant records all require manual review.
|
||||
It does not audit unrelated dependency health or non-dpkg installations.
|
||||
|
||||
## Candidate policy
|
||||
|
||||
Linux amd64/arm64; Ubuntu version/codename pairs 22.04/jammy, 24.04/noble,
|
||||
26.04/resolute; root; systemd path present; at least 5 GiB available on /var/lib;
|
||||
Docker client absent; all listed resource paths observed absent. The 5 GiB floor
|
||||
is only a bootstrap screening threshold, not a calculated application/image/backup
|
||||
capacity requirement. Docker official Ubuntu support was checked at implementation:
|
||||
[Docker installation requirements](https://docs.docker.com/engine/install/ubuntu/).
|
||||
This project has not certified these distributions with actual installation tests.
|
||||
|
||||
If all these observations pass, proposal lists candidate source configuration,
|
||||
version-locked package installation and engine/Compose verification steps, plus
|
||||
APT/disk/service-start/firewall impacts. It does not produce shell commands or
|
||||
claim those steps can yet execute. Existing resources cause manual-review blockers;
|
||||
there is no automatic removal, adoption, migration or package conflict cleanup.
|
||||
|
||||
Every proposal remains `executable=false`, with blockers for unverified host
|
||||
identity, unknown package inventory, unresolved versions, repository trust, network/firewall
|
||||
and the unimplemented installer. Empty candidate steps mean preliminary host
|
||||
observations also failed. Nonempty steps are NOT an approved install transaction.
|
||||
|
||||
Reports describe this process's environment (possibly a container/WSL instance),
|
||||
not necessarily the intended cloud server. No snapshot hash, SSH identity binding,
|
||||
freshness token or lock-based revalidation exists yet. These must be implemented
|
||||
before any future write path consumes observations. Exit 0 means a report was
|
||||
produced; inspect `proposal.blockers`, never exit status alone, for readiness.
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"server-deploy/internal/inspect"
|
||||
)
|
||||
|
||||
func Collect() Report {
|
||||
return Probe(inspect.Collect(), os.DirFS("/"), os.Geteuid(), diskAvailable("/var/lib"))
|
||||
}
|
||||
|
||||
func diskAvailable(path string) Disk {
|
||||
var stat syscall.Statfs_t
|
||||
if syscall.Statfs(path, &stat) != nil || stat.Bsize <= 0 || stat.Bavail > ^uint64(0)/uint64(stat.Bsize) {
|
||||
return Disk{State: "unknown"}
|
||||
}
|
||||
return Disk{State: "observed", AvailableBytes: stat.Bavail * uint64(stat.Bsize)}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build linux
|
||||
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealDiskObservation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if diskAvailable(dir).State != "observed" {
|
||||
t.Fatal("existing filesystem not observed")
|
||||
}
|
||||
if diskAvailable(filepath.Join(dir, "missing")).State != "unknown" {
|
||||
t.Fatal("missing target reported capacity")
|
||||
}
|
||||
}
|
||||
func TestDanglingResourceLinkRequiresReview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "var/lib"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("missing", filepath.Join(dir, "var/lib/docker")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resourceState(os.DirFS(dir), "var/lib/docker") != "present" {
|
||||
t.Fatal("dangling link treated as absent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package preflight
|
||||
|
||||
import "server-deploy/internal/inspect"
|
||||
|
||||
func Collect() Report { return Probe(inspect.Collect(), nil, -1, Disk{State: "not_checked"}) }
|
||||
@@ -0,0 +1,208 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"server-deploy/internal/debian"
|
||||
"server-deploy/internal/inspect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Disk struct {
|
||||
State string `json:"state"`
|
||||
AvailableBytes uint64 `json:"availableBytes"`
|
||||
}
|
||||
type Distribution struct {
|
||||
State string `json:"state"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Codename string `json:"codename"`
|
||||
}
|
||||
type Resource struct {
|
||||
Path string `json:"path"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
type Report struct {
|
||||
Runtime inspect.Report `json:"runtime"`
|
||||
Distribution Distribution `json:"distribution"`
|
||||
Privilege string `json:"privilege"`
|
||||
Disk Disk `json:"disk"`
|
||||
Resources []Resource `json:"resources"`
|
||||
Inventory debian.Snapshot `json:"inventory"`
|
||||
}
|
||||
type Proposal struct {
|
||||
Executable bool `json:"executable"`
|
||||
Blockers []string `json:"blockers"`
|
||||
ProposedChanges []string `json:"proposedChanges"`
|
||||
Impacts []string `json:"impacts"`
|
||||
}
|
||||
|
||||
var resourcePaths = []string{"var/lib/docker", "var/lib/containerd", "etc/docker", "var/lib/server-deploy", "etc/apt/sources.list.d/docker.sources", "etc/apt/sources.list.d/docker.list"}
|
||||
|
||||
// Probe reads metadata and a bounded os-release file. It never sources shell
|
||||
// files or invokes executables. Disk describes /var/lib, not an arbitrary target.
|
||||
func Probe(runtime inspect.Report, files fs.FS, uid int, disk Disk) Report {
|
||||
r := Report{Runtime: runtime, Distribution: Distribution{State: "not_checked"}, Privilege: "not_checked", Disk: Disk{State: "not_checked"}, Resources: []Resource{}, Inventory: debian.Snapshot{State: "not_checked", Packages: []debian.Installed{}}}
|
||||
if runtime.OS != "linux" {
|
||||
return r
|
||||
}
|
||||
r.Disk = disk
|
||||
r.Privilege = "non_root"
|
||||
if uid == 0 {
|
||||
r.Privilege = "root"
|
||||
} else if uid < 0 {
|
||||
r.Privilege = "unknown"
|
||||
}
|
||||
r.Distribution = readDistribution(files)
|
||||
r.Inventory = debian.Inventory(files)
|
||||
for _, p := range resourcePaths {
|
||||
r.Resources = append(r.Resources, Resource{Path: "/" + p, State: resourceState(files, p)})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Plan is a proposal, not an executable or approved installation plan. Missing
|
||||
// package inventory/version locks and host identity always block execution.
|
||||
func Plan(r Report) Proposal {
|
||||
p := Proposal{Blockers: []string{}, ProposedChanges: []string{}, Impacts: []string{}}
|
||||
if r.Runtime.OS != "linux" || (r.Runtime.Architecture != "amd64" && r.Runtime.Architecture != "arm64") {
|
||||
p.Blockers = append(p.Blockers, "unsupported_platform")
|
||||
}
|
||||
if r.Distribution.State != "observed" {
|
||||
p.Blockers = append(p.Blockers, "distribution_unverified")
|
||||
} else if r.Distribution.ID != "ubuntu" || !supportedSuite(r.Distribution) {
|
||||
p.Blockers = append(p.Blockers, "unsupported_distribution")
|
||||
}
|
||||
if r.Privilege != "root" {
|
||||
p.Blockers = append(p.Blockers, "root_required")
|
||||
}
|
||||
if r.Runtime.SystemdRuntime != "present" {
|
||||
p.Blockers = append(p.Blockers, "systemd_unverified")
|
||||
}
|
||||
if r.Disk.State != "observed" {
|
||||
p.Blockers = append(p.Blockers, "disk_unverified")
|
||||
} else if r.Disk.AvailableBytes < 5<<30 {
|
||||
p.Blockers = append(p.Blockers, "disk_below_bootstrap_floor")
|
||||
}
|
||||
if r.Runtime.DockerClient != "missing" {
|
||||
p.Blockers = append(p.Blockers, "existing_or_unknown_runtime_requires_review")
|
||||
}
|
||||
// Validate the complete path set as well: an incomplete report is not clean.
|
||||
seen := make(map[string]bool)
|
||||
resourcesClean := len(r.Resources) == len(resourcePaths)
|
||||
for _, v := range r.Resources {
|
||||
if v.State != "missing" || seen[v.Path] {
|
||||
resourcesClean = false
|
||||
}
|
||||
seen[v.Path] = true
|
||||
}
|
||||
for _, path := range resourcePaths {
|
||||
if !seen["/"+path] {
|
||||
resourcesClean = false
|
||||
}
|
||||
}
|
||||
if !resourcesClean {
|
||||
p.Blockers = append(p.Blockers, "existing_resources_require_review")
|
||||
}
|
||||
if r.Inventory.State != "observed" {
|
||||
p.Blockers = append(p.Blockers, "package_inventory_unverified")
|
||||
} else if len(r.Inventory.Packages) > 0 {
|
||||
p.Blockers = append(p.Blockers, "existing_packages_require_review")
|
||||
}
|
||||
if len(p.Blockers) == 0 {
|
||||
p.ProposedChanges = []string{"configure_verified_docker_apt_source", "install_version_locked_docker_packages", "verify_local_engine_and_compose"}
|
||||
p.Impacts = []string{"apt_configuration_and_package_database_changes", "docker_service_may_start_during_install", "docker_may_change_host_network_firewall_rules", "system_disk_usage_increases"}
|
||||
}
|
||||
p.Blockers = append(p.Blockers, "host_identity_unverified", "package_versions_unresolved", "repository_trust_unverified", "network_and_firewall_unverified", "installation_executor_unimplemented")
|
||||
return p
|
||||
}
|
||||
|
||||
func supportedSuite(d Distribution) bool {
|
||||
return map[string]string{"22.04": "jammy", "24.04": "noble", "26.04": "resolute"}[d.Version] == d.Codename && d.Codename != ""
|
||||
}
|
||||
|
||||
var releaseValue = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||
|
||||
func readDistribution(files fs.FS) Distribution {
|
||||
initial, err := fs.Stat(files, "etc/os-release")
|
||||
if err != nil {
|
||||
return Distribution{State: "unknown"}
|
||||
}
|
||||
if !initial.Mode().IsRegular() || initial.Size() > 65536 {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
f, err := files.Open("etc/os-release")
|
||||
if err != nil {
|
||||
return Distribution{State: "unknown"}
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return Distribution{State: "unknown"}
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > 65536 {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
||||
if err != nil {
|
||||
return Distribution{State: "unknown"}
|
||||
}
|
||||
if len(raw) > 65536 {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if key != "ID" && key != "VERSION_ID" && key != "VERSION_CODENAME" {
|
||||
continue
|
||||
}
|
||||
if !ok || values[key] != "" {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
if !releaseValue.MatchString(value) {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
if len(values) != 3 {
|
||||
return Distribution{State: "invalid"}
|
||||
}
|
||||
return Distribution{State: "observed", ID: values["ID"], Version: values["VERSION_ID"], Codename: values["VERSION_CODENAME"]}
|
||||
}
|
||||
|
||||
// Walk directory entries to observe dangling/intermediate links as existing
|
||||
// resources instead of following them and misreporting a clean install target.
|
||||
func resourceState(files fs.FS, path string) string {
|
||||
parent := "."
|
||||
parts := strings.Split(path, "/")
|
||||
for i, part := range parts {
|
||||
entries, err := fs.ReadDir(files, parent)
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != part {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if i == len(parts)-1 || entry.Type()&fs.ModeSymlink != 0 || !entry.IsDir() {
|
||||
return "present"
|
||||
}
|
||||
if parent == "." {
|
||||
parent = part
|
||||
} else {
|
||||
parent += "/" + part
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"server-deploy/internal/inspect"
|
||||
)
|
||||
|
||||
func hostFiles() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"etc/os-release": {Data: []byte("ID=ubuntu\nVERSION_ID=\"26.04\"\nVERSION_CODENAME=resolute\n")},
|
||||
"var/lib/dpkg/status": {Data: []byte("Package: base-files\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1.0\n")},
|
||||
"var/lib/dpkg/updates": {Mode: fs.ModeDir | 0700},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageStateBlocksFreshInstallCandidates(t *testing.T) {
|
||||
for _, state := range []string{"install ok installed", "deinstall ok config-files", "install reinstreq half-installed"} {
|
||||
files := hostFiles()
|
||||
files["var/lib/dpkg/status"].Data = []byte("Package: containerd\nStatus: " + state + "\nArchitecture: amd64\nVersion: 1.2.3\n")
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "existing_packages_require_review") {
|
||||
t.Errorf("existing package state %s overlooked", state)
|
||||
}
|
||||
}
|
||||
files := hostFiles()
|
||||
delete(files, "var/lib/dpkg/status")
|
||||
if p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30})); len(p.ProposedChanges) != 0 {
|
||||
t.Fatal("unknown inventory treated as empty")
|
||||
}
|
||||
}
|
||||
func baseline() inspect.Report {
|
||||
return inspect.Report{ProtocolVersion: 1, OS: "linux", Architecture: "amd64", SystemdRuntime: "present", DockerClient: "missing"}
|
||||
}
|
||||
func TestHostFactsAndNonExecutableProposal(t *testing.T) {
|
||||
r := Probe(baseline(), hostFiles(), 0, Disk{State: "observed", AvailableBytes: 20 << 30})
|
||||
if r.Distribution.ID != "ubuntu" || r.Distribution.Version != "26.04" || r.Distribution.State != "observed" || r.Privilege != "root" {
|
||||
t.Fatalf("incorrect host facts: %+v", r)
|
||||
}
|
||||
p := Plan(r)
|
||||
if p.Executable || len(p.ProposedChanges) == 0 || !contains(p.Blockers, "package_versions_unresolved") || !contains(p.Blockers, "host_identity_unverified") {
|
||||
t.Fatalf("unsafe proposal: %+v", p)
|
||||
}
|
||||
}
|
||||
func TestExistingResourcesNeverProposeFreshInstall(t *testing.T) {
|
||||
for _, path := range []string{"var/lib/docker", "var/lib/containerd", "etc/docker", "var/lib/server-deploy", "etc/apt/sources.list.d/docker.sources", "etc/apt/sources.list.d/docker.list"} {
|
||||
files := hostFiles()
|
||||
files[path] = &fstest.MapFile{Mode: fs.ModeDir | 0700}
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "existing_resources_require_review") {
|
||||
t.Errorf("fresh install proposed over %s", path)
|
||||
}
|
||||
}
|
||||
runtime := baseline()
|
||||
runtime.DockerClient = "present"
|
||||
if p := Plan(Probe(runtime, hostFiles(), 0, Disk{State: "observed", AvailableBytes: 20 << 30})); len(p.ProposedChanges) != 0 {
|
||||
t.Fatal("existing Docker overlooked")
|
||||
}
|
||||
}
|
||||
func TestUnknownAndInsufficientHostFailsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
runtime inspect.Report
|
||||
uid int
|
||||
disk Disk
|
||||
blocker string
|
||||
}{
|
||||
{"nonroot", baseline(), 1000, Disk{State: "observed", AvailableBytes: 20 << 30}, "root_required"},
|
||||
{"disk unknown", baseline(), 0, Disk{State: "unknown"}, "disk_unverified"},
|
||||
{"disk low", baseline(), 0, Disk{State: "observed", AvailableBytes: 1}, "disk_below_bootstrap_floor"},
|
||||
{"unsupported", inspect.Report{OS: "windows", Architecture: "amd64"}, 0, Disk{}, "unsupported_platform"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := Plan(Probe(tc.runtime, hostFiles(), tc.uid, tc.disk))
|
||||
if !contains(p.Blockers, tc.blocker) || p.Executable || len(p.ProposedChanges) != 0 {
|
||||
t.Fatalf("unsafe proposal: %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestOSReleaseRejectsAmbiguityAndNeverEvaluatesShell(t *testing.T) {
|
||||
for _, content := range []string{"ID=ubuntu\nID=debian\nVERSION_ID=26.04\nVERSION_CODENAME=resolute", "ID=$(touch secret)\nVERSION_ID=26.04\nVERSION_CODENAME=resolute", "ID=ubuntu\nVERSION_ID=\"26.04\nVERSION_CODENAME=resolute", strings.Repeat("#", 65537)} {
|
||||
files := hostFiles()
|
||||
files["etc/os-release"].Data = []byte(content)
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "distribution_unverified") {
|
||||
t.Fatal("ambiguous distribution accepted")
|
||||
}
|
||||
}
|
||||
files := hostFiles()
|
||||
files["etc/os-release"].Data = []byte("ID=ubuntu\nVERSION_ID=26.04\nVERSION_CODENAME=noble\n")
|
||||
if p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30})); !contains(p.Blockers, "unsupported_distribution") {
|
||||
t.Fatal("mismatched suite accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type deniedFS struct{}
|
||||
|
||||
func (deniedFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
||||
func TestAccessFailuresAreNotAbsence(t *testing.T) {
|
||||
r := Probe(baseline(), deniedFS{}, 0, Disk{State: "observed", AvailableBytes: 20 << 30})
|
||||
if r.Distribution.State != "unknown" || r.Resources[0].State != "unknown" || len(Plan(r).ProposedChanges) != 0 {
|
||||
t.Fatal("permission failure treated as clean host")
|
||||
}
|
||||
}
|
||||
func contains(values []string, want string) bool {
|
||||
for _, v := range values {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Operation metadata store
|
||||
|
||||
Internal execution foundation; not a CLI write endpoint, application executor,
|
||||
backup engine or systemd supervisor. The existing CLI remains read-only.
|
||||
|
||||
## Contract
|
||||
|
||||
Bootstrap must supply one fixed, existing, administrator-owned local directory
|
||||
per host. Use the same canonical directory for every runner on that host.
|
||||
Untrusted users/applications must not be able to replace the directory, its
|
||||
ancestors or files. Linux deployment permissions should be 0700 for the directory
|
||||
and 0600 for metadata. Windows ACL configuration belongs to the installer.
|
||||
NFS/SMB/distributed locking is not supported.
|
||||
|
||||
`Acquire(directory, hostID)` opens an os.Root and takes a nonblocking exclusive
|
||||
OS lock on `host.lock`. Different paths/roots are not a distributed host registry.
|
||||
Hold the returned Session throughout any future application write operation.
|
||||
Never remove/replace `host.lock`, including during cleanup: its inode is the lock
|
||||
identity. Close releases it; abrupt process termination releases it at OS level.
|
||||
|
||||
- `Begin(id, planHash)` registers queued work and returns `(operation, created)`.
|
||||
Reusing ID/hash returns the original record with created=false. Another hash
|
||||
conflicts. Different IDs are blocked while an unresolved record exists.
|
||||
- `Advance(id, revision, next)` compares revision and validates the transition.
|
||||
A queued-to-running transition is the claim; a second claim fails.
|
||||
- `Get(id)` returns a value copy. Closing or poisoning a session forbids its use.
|
||||
|
||||
Allowed transitions:
|
||||
|
||||
```text
|
||||
queued → running | cancelled
|
||||
running → succeeded | failed_recovered | needs_attention | unknown
|
||||
needs_attention / unknown → succeeded | failed_recovered
|
||||
terminal states → no transitions
|
||||
```
|
||||
|
||||
Success/recovery labels are assertions by the caller, not proof. The future
|
||||
executor must verify actual effects before recording them. Unknown/running work
|
||||
survives restart without replay. Reconciliation requires inspecting actual state;
|
||||
no automatic retry, forced reset, lease expiry or stale-lock deletion is provided.
|
||||
|
||||
## Persistence
|
||||
|
||||
state.json is a versioned, canonical JSON snapshot, maximum 16 MiB, with host
|
||||
identity and SHA-256 integrity checksum. Unknown fields, duplicates, truncation,
|
||||
changed data and host mismatch are rejected. The checksum is not authentication.
|
||||
It is not an append-only audit log; event logging is a separate future component.
|
||||
|
||||
Mutation writes a unique private temporary file, syncs it, closes it, renames over
|
||||
the snapshot and (Linux) syncs the containing directory. A save error poisons the
|
||||
session because the rename may already have happened; close, reacquire and query
|
||||
before deciding anything. Temporary remnants from a killed process are ignored,
|
||||
never interpreted as successful work; automatic cleanup is not implemented.
|
||||
|
||||
Current snapshots retain all operation IDs. No pruning is provided, because
|
||||
forgetting completed IDs can re-enable an old request. At the size limit, writes
|
||||
fail closed. Admission reserves 64 bytes for the active record's later status
|
||||
and revision growth; capacity rejection does not poison read access. A retention/tombstone design is required before bounded production
|
||||
history cleanup is introduced.
|
||||
|
||||
host.lock also contains a synced initialization marker. Once work is persisted,
|
||||
a missing snapshot is rejected rather than treated as a fresh store. A crash
|
||||
between the first snapshot and marker can be repaired only from a valid snapshot.
|
||||
Protect both files. Restoring an older valid snapshot still rolls back idempotency
|
||||
history; do not restart execution without independent reconciliation. This package
|
||||
cannot detect malicious administrator edits or rollback/deletion of the entire
|
||||
state directory.
|
||||
|
||||
## Platform boundary
|
||||
|
||||
Linux uses flock and file/directory fsync. Windows uses LockFileEx for development
|
||||
tests, file sync and rename; Windows power-loss durability is not promised.
|
||||
Other OSes refuse acquisition. The local macOS panel will communicate with the
|
||||
Linux runner, not use this package as its local SQLite replacement.
|
||||
|
||||
Kernel locks are advisory on Linux; all participating writers must obey them.
|
||||
External Docker/Portainer operations are outside this lock and require drift
|
||||
checks. Multi-process tests prove process-crash behavior, not sudden power failure
|
||||
or storage-hardware reliability.
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build linux
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockExclusive(f *os.File) error {
|
||||
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return ErrBusy
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func syncDirectory(r *os.Root) error {
|
||||
f, err := r.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Sync()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func lockExclusive(*os.File) error {
|
||||
return errors.New("operation store supports Linux and Windows only")
|
||||
}
|
||||
func syncDirectory(*os.Root) error { return errors.New("unsupported state durability platform") }
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var lockFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("LockFileEx")
|
||||
|
||||
func lockExclusive(f *os.File) error {
|
||||
var overlapped syscall.Overlapped
|
||||
// LOCKFILE_FAIL_IMMEDIATELY | LOCKFILE_EXCLUSIVE_LOCK, first byte only.
|
||||
ok, _, err := lockFileEx.Call(f.Fd(), 3, 0, 1, 0, uintptr(unsafe.Pointer(&overlapped)))
|
||||
runtime.KeepAlive(f)
|
||||
if ok != 0 {
|
||||
return nil
|
||||
}
|
||||
if err == syscall.Errno(33) {
|
||||
return ErrBusy
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Windows is a development platform. File.Sync is used, but Go does not provide
|
||||
// a portable directory fsync here. Do not claim Windows power-loss durability.
|
||||
func syncDirectory(*os.Root) error { return nil }
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package state persists task metadata. It never executes application actions.
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBusy = errors.New("host state is locked")
|
||||
ErrClosed = errors.New("session is closed")
|
||||
ErrPoisoned = errors.New("state write outcome uncertain; reopen and reconcile")
|
||||
ErrConflict = errors.New("operation identity or revision conflict")
|
||||
ErrUnresolved = errors.New("another operation requires completion or reconciliation")
|
||||
ErrTransition = errors.New("invalid operation transition")
|
||||
ErrNotFound = errors.New("operation not found")
|
||||
ErrCapacity = errors.New("operation history capacity exhausted")
|
||||
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
||||
hashPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
Queued Status = "queued"
|
||||
Running Status = "running"
|
||||
Succeeded Status = "succeeded"
|
||||
FailedRecovered Status = "failed_recovered"
|
||||
NeedsAttention Status = "needs_attention"
|
||||
Unknown Status = "unknown"
|
||||
Cancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
ID string `json:"id"`
|
||||
PlanHash string `json:"planHash"`
|
||||
Status Status `json:"status"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
func (s Status) terminal() bool { return s == Succeeded || s == FailedRecovered || s == Cancelled }
|
||||
func (s Status) valid() bool {
|
||||
return s.terminal() || s == Queued || s == Running || s == NeedsAttention || s == Unknown
|
||||
}
|
||||
func allowed(from, to Status) bool {
|
||||
switch from {
|
||||
case Queued:
|
||||
return to == Running || to == Cancelled
|
||||
case Running:
|
||||
return to == Succeeded || to == FailedRecovered || to == NeedsAttention || to == Unknown
|
||||
case NeedsAttention, Unknown:
|
||||
return to == Succeeded || to == FailedRecovered
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIdempotencyAndTransitions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
op, created, err := s.Begin("op-one", testHash)
|
||||
if err != nil || !created || op.Status != Queued || op.Revision != 1 {
|
||||
t.Fatalf("begin: %+v %v", op, err)
|
||||
}
|
||||
duplicate, created, err := s.Begin("op-one", testHash)
|
||||
if err != nil || created || duplicate != op {
|
||||
t.Fatal("duplicate mutated operation")
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", "sha256:"+strings.Repeat("b", 64)); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("id rebound to another plan")
|
||||
}
|
||||
if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrUnresolved) {
|
||||
t.Fatal("unresolved operation bypass")
|
||||
}
|
||||
if _, err := s.Advance(op.ID, op.Revision, Succeeded); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal("queued operation succeeded without running")
|
||||
}
|
||||
op, err = s.Advance(op.ID, op.Revision, Running)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Advance(op.ID, 1, Succeeded); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("stale revision accepted")
|
||||
}
|
||||
if _, err := s.Advance(op.ID, op.Revision, Running); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal("operation claimed twice")
|
||||
}
|
||||
op, err = s.Advance(op.ID, op.Revision, NeedsAttention)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrUnresolved) {
|
||||
t.Fatal("ignored manual recovery")
|
||||
}
|
||||
op, err = s.Advance(op.ID, op.Revision, FailedRecovered)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Advance(op.ID, op.Revision, Running); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal("terminal operation restarted")
|
||||
}
|
||||
s.Close()
|
||||
s, err = Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
got, err := s.Get("op-one")
|
||||
if err != nil || got != op {
|
||||
t.Fatalf("persistence mismatch: %+v %v", got, err)
|
||||
}
|
||||
if _, created, err := s.Begin("op-two", testHash); err != nil || !created {
|
||||
t.Fatal("completed operation blocks new work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptionAndHostMismatchFailClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", testHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
if other, err := Acquire(dir, "host-two"); err == nil {
|
||||
other.Close()
|
||||
t.Fatal("wrong host accepted")
|
||||
}
|
||||
for _, data := range []string{`{`, `{}`, `null`, strings.Repeat("x", maxSnapshotBytes+1)} {
|
||||
if err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(data), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other, err := Acquire(dir, "host-one"); err == nil {
|
||||
other.Close()
|
||||
t.Fatal("corrupt state silently reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidOperationsDoNotCreateSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
for _, id := range []string{"", "../outside", "bad/id"} {
|
||||
if _, _, err := s.Begin(id, testHash); err == nil {
|
||||
t.Fatal("invalid operation id accepted")
|
||||
}
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", "latest"); err == nil {
|
||||
t.Fatal("mutable plan binding accepted")
|
||||
}
|
||||
if _, err := s.Get("missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal("missing operation not reported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("invalid request persisted state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceFailurePoisonsSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
// A directory at the destination makes atomic replacement fail on both OSes.
|
||||
if err := os.Mkdir(filepath.Join(dir, "state.json"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", testHash); err == nil {
|
||||
t.Fatal("reported failed persistence as success")
|
||||
}
|
||||
if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrPoisoned) {
|
||||
t.Fatalf("continued after ambiguous write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentClaimsHaveOneWinner(t *testing.T) {
|
||||
s, err := Acquire(t.TempDir(), "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
var createdCount, claimedCount atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 16; n++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, created, err := s.Begin("op-one", testHash)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if created {
|
||||
createdCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for n := 0; n < 16; n++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := s.Advance("op-one", 1, Running)
|
||||
if err == nil {
|
||||
claimedCount.Add(1)
|
||||
} else if !errors.Is(err, ErrConflict) {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if createdCount.Load() != 1 || claimedCount.Load() != 1 {
|
||||
t.Fatalf("duplicate winners: %d %d", createdCount.Load(), claimedCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedSnapshotRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", testHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, changed := range []string{
|
||||
strings.Replace(string(raw), `"queued"`, `"succeeded"`, 1),
|
||||
strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1),
|
||||
string(raw) + ` {}`,
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(changed), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := Acquire(dir, "host-one"); err == nil {
|
||||
s.Close()
|
||||
t.Fatal("tampered snapshot accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTerminalAndReconciliationPaths(t *testing.T) {
|
||||
for _, path := range [][]Status{{Cancelled}, {Running, Succeeded}, {Running, FailedRecovered}, {Running, Unknown, FailedRecovered}, {Running, NeedsAttention, Succeeded}} {
|
||||
s, err := Acquire(t.TempDir(), "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, _, err := s.Begin("op-one", testHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, next := range path {
|
||||
op, err = s.Advance(op.ID, op.Revision, next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, err := s.Begin("op-two", testHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSnapshotDoesNotResetInitializedStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", testHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
if err := os.Remove(filepath.Join(dir, "state.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := Acquire(dir, "host-one"); err == nil {
|
||||
s.Close()
|
||||
t.Fatal("silently reset initialized store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionReservesSpaceForCompletion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := snapshot{Version: 1, HostID: "host-one", Operations: make(map[string]Operation)}
|
||||
for n := 0; n < 113357; n++ {
|
||||
id := fmt.Sprintf("op%06d", n)
|
||||
if n < 2 {
|
||||
id += strings.Repeat("a", 26)
|
||||
}
|
||||
data.Operations[id] = Operation{ID: id, PlanHash: testHash, Status: Cancelled, Revision: 2}
|
||||
}
|
||||
raw, err := encodeSnapshot(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "state.json"), raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Confirm this fixture reaches the actual bug boundary, not an arbitrary limit.
|
||||
data.Operations["op-new"] = Operation{ID: "op-new", PlanHash: testHash, Status: Queued, Revision: 1}
|
||||
queued, err := encodeSnapshot(data)
|
||||
if err != nil || len(queued) != maxSnapshotBytes-2 {
|
||||
t.Fatalf("fixture outside boundary: %d %v", len(queued), err)
|
||||
}
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if _, _, err := s.Begin("op-new", testHash); err == nil {
|
||||
t.Fatal("admitted task without room for terminal state")
|
||||
}
|
||||
if _, err := s.Get("op000002"); err != nil {
|
||||
t.Fatalf("capacity rejection poisoned read access: %v", err)
|
||||
}
|
||||
if _, err := s.Get("op-new"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal("rejected admission persisted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxSnapshotBytes = 16 << 20
|
||||
|
||||
type snapshot struct {
|
||||
Version int `json:"version"`
|
||||
HostID string `json:"hostId"`
|
||||
Operations map[string]Operation `json:"operations"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
func encodeSnapshot(s snapshot) ([]byte, error) {
|
||||
s.Checksum = ""
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
s.Checksum = "sha256:" + hex.EncodeToString(sum[:])
|
||||
raw, err = json.Marshal(s)
|
||||
if len(raw) > maxSnapshotBytes {
|
||||
return nil, errors.New("state capacity exceeded")
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
|
||||
func readSnapshot(r *os.Root, hostID string, initialized bool) (snapshot, error) {
|
||||
empty := snapshot{Version: 1, HostID: hostID, Operations: make(map[string]Operation)}
|
||||
if err := regularOrMissing(r, "state.json"); err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
f, err := r.Open("state.json")
|
||||
if os.IsNotExist(err) {
|
||||
if initialized {
|
||||
return snapshot{}, errors.New("initialized store has lost its snapshot")
|
||||
}
|
||||
return empty, nil
|
||||
}
|
||||
if err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxSnapshotBytes+1))
|
||||
if err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
if len(raw) > maxSnapshotBytes {
|
||||
return snapshot{}, errors.New("state exceeds size limit")
|
||||
}
|
||||
var s snapshot
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return snapshot{}, errors.New("corrupt state")
|
||||
}
|
||||
if s.Version != 1 || s.HostID != hostID || s.Operations == nil {
|
||||
return snapshot{}, errors.New("incompatible state or host mismatch")
|
||||
}
|
||||
canonical, err := encodeSnapshot(s)
|
||||
if err != nil || !bytes.Equal(raw, canonical) {
|
||||
return snapshot{}, errors.New("state integrity check failed")
|
||||
}
|
||||
unresolved := 0
|
||||
for id, op := range s.Operations {
|
||||
if id != op.ID || !idPattern.MatchString(id) || !hashPattern.MatchString(op.PlanHash) || !op.Status.valid() || op.Revision == 0 {
|
||||
return snapshot{}, errors.New("invalid operation record")
|
||||
}
|
||||
if !op.Status.terminal() {
|
||||
unresolved++
|
||||
}
|
||||
}
|
||||
if unresolved > 1 {
|
||||
return snapshot{}, errors.New("multiple unresolved operations")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func writeSnapshot(r *os.Root, s snapshot) error {
|
||||
raw, err := encodeSnapshot(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := regularOrMissing(r, "state.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
var entropy [16]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
name := ".state-" + hex.EncodeToString(entropy[:]) + ".tmp"
|
||||
f, err := r.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Remove(name) // Only the unique file just created; never state.json or host.lock.
|
||||
if _, err := f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.Rename(name, "state.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDirectory(r)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Session holds an exclusive host lock for its entire lifetime. The caller must
|
||||
// retain it throughout any future write operation, not only metadata updates.
|
||||
// Never unlink host.lock: replacing its inode defeats OS locking.
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
root *os.Root
|
||||
lock *os.File
|
||||
data snapshot
|
||||
closed bool
|
||||
poisoned bool
|
||||
initialized bool
|
||||
}
|
||||
|
||||
const initializedMarker = "server-deploy-state-v1\n"
|
||||
|
||||
// Acquire requires an existing administrator-owned LOCAL directory. Bootstrap
|
||||
// owns directory creation/permissions; this function never creates a new root.
|
||||
func Acquire(directory, hostID string) (*Session, error) {
|
||||
if !idPattern.MatchString(hostID) || !filepath.IsAbs(directory) {
|
||||
return nil, errors.New("invalid host or state directory")
|
||||
}
|
||||
r, err := os.OpenRoot(directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fail := func(err error) (*Session, error) { r.Close(); return nil, err }
|
||||
if err := regularOrMissing(r, "host.lock"); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
lock, err := r.OpenFile("host.lock", os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := lockExclusive(lock); err != nil {
|
||||
lock.Close()
|
||||
return fail(err)
|
||||
}
|
||||
s := &Session{root: r, lock: lock}
|
||||
info, err := lock.Stat()
|
||||
if err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
if info.Size() != int64(len(initializedMarker)) {
|
||||
s.Close()
|
||||
return nil, errors.New("invalid state initialization marker")
|
||||
}
|
||||
marker := make([]byte, len(initializedMarker))
|
||||
if _, err := lock.ReadAt(marker, 0); err != nil || string(marker) != initializedMarker {
|
||||
s.Close()
|
||||
return nil, errors.New("corrupt state initialization marker")
|
||||
}
|
||||
s.initialized = true
|
||||
}
|
||||
s.data, err = readSnapshot(r, hostID, s.initialized)
|
||||
if err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
// A crash may happen after the first snapshot rename but before its marker.
|
||||
// Repair only from a fully validated existing snapshot, never from absence.
|
||||
if len(s.data.Operations) > 0 {
|
||||
if err := s.markInitialized(); err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) markInitialized() error {
|
||||
if s.initialized {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.lock.WriteAt([]byte(initializedMarker), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.lock.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func regularOrMissing(r *os.Root, name string) error {
|
||||
info, err := r.Lstat(name)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("state path must be a regular file, not a link")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) ready() error {
|
||||
if s.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
if s.poisoned {
|
||||
return ErrPoisoned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return errors.Join(s.lock.Close(), s.root.Close())
|
||||
}
|
||||
|
||||
func (s *Session) Get(id string) (Operation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ready(); err != nil {
|
||||
return Operation{}, err
|
||||
}
|
||||
op, ok := s.data.Operations[id]
|
||||
if !ok {
|
||||
return Operation{}, ErrNotFound
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
|
||||
// Begin is idempotent, not an execution claim. A repeated ID cannot be rebound
|
||||
// to a different plan, and any unresolved operation blocks unrelated work.
|
||||
func (s *Session) Begin(id, planHash string) (Operation, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ready(); err != nil {
|
||||
return Operation{}, false, err
|
||||
}
|
||||
if !idPattern.MatchString(id) || !hashPattern.MatchString(planHash) {
|
||||
return Operation{}, false, errors.New("invalid operation identity")
|
||||
}
|
||||
if op, ok := s.data.Operations[id]; ok {
|
||||
if op.PlanHash != planHash {
|
||||
return Operation{}, false, ErrConflict
|
||||
}
|
||||
return op, false, nil
|
||||
}
|
||||
for _, op := range s.data.Operations {
|
||||
if !op.Status.terminal() {
|
||||
return Operation{}, false, ErrUnresolved
|
||||
}
|
||||
}
|
||||
op := Operation{ID: id, PlanHash: planHash, Status: Queued, Revision: 1}
|
||||
if err := s.save(op); err != nil {
|
||||
return Operation{}, false, err
|
||||
}
|
||||
return op, true, nil
|
||||
}
|
||||
|
||||
// Advance uses a revision check so only one caller can claim queued work.
|
||||
// Reconciliation to a terminal status requires external evidence; this metadata
|
||||
// layer cannot prove that a deployment or restore actually succeeded.
|
||||
func (s *Session) Advance(id string, revision uint64, next Status) (Operation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ready(); err != nil {
|
||||
return Operation{}, err
|
||||
}
|
||||
op, ok := s.data.Operations[id]
|
||||
if !ok {
|
||||
return Operation{}, ErrNotFound
|
||||
}
|
||||
if op.Revision != revision {
|
||||
return Operation{}, ErrConflict
|
||||
}
|
||||
if !allowed(op.Status, next) {
|
||||
return Operation{}, ErrTransition
|
||||
}
|
||||
if op.Revision == ^uint64(0) {
|
||||
return Operation{}, ErrConflict
|
||||
}
|
||||
op.Status = next
|
||||
op.Revision++
|
||||
if err := s.save(op); err != nil {
|
||||
return Operation{}, err
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func (s *Session) save(op Operation) error {
|
||||
updated := snapshot{Version: 1, HostID: s.data.HostID, Operations: make(map[string]Operation, len(s.data.Operations)+1)}
|
||||
for id, old := range s.data.Operations {
|
||||
updated.Operations[id] = old
|
||||
}
|
||||
updated.Operations[op.ID] = op
|
||||
if op.Revision == 1 {
|
||||
// Only one unresolved operation is admitted. Reserve more than the
|
||||
// longest status growth plus uint64 revision growth (at most 29 bytes).
|
||||
raw, err := encodeSnapshot(updated)
|
||||
if err != nil || len(raw) > maxSnapshotBytes-64 {
|
||||
return ErrCapacity
|
||||
}
|
||||
}
|
||||
if err := writeSnapshot(s.root, updated); err != nil {
|
||||
s.poisoned = true
|
||||
return errors.Join(ErrPoisoned, err)
|
||||
}
|
||||
if err := s.markInitialized(); err != nil {
|
||||
s.poisoned = true
|
||||
return errors.Join(ErrPoisoned, err)
|
||||
}
|
||||
s.data = updated
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func TestExclusiveSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
if second, err := Acquire(dir, "host-one"); second != nil || !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("lock bypass: %v", err)
|
||||
}
|
||||
other, err := Acquire(t.TempDir(), "host-two")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other.Close()
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-one", testHash); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal("used closed session")
|
||||
}
|
||||
next, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next.Close()
|
||||
}
|
||||
|
||||
func TestInvalidHost(t *testing.T) {
|
||||
for _, host := range []string{"", "../host", "Host", strings.Repeat("a", 49)} {
|
||||
if s, err := Acquire(t.TempDir(), host); err == nil {
|
||||
s.Close()
|
||||
t.Fatal("accepted invalid host")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatePathsRejectSymlinks(t *testing.T) {
|
||||
for _, name := range []string{"host.lock", "state.json"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir, outside := t.TempDir(), filepath.Join(t.TempDir(), "outside")
|
||||
if err := os.WriteFile(outside, []byte("protected"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(dir, name)); err != nil {
|
||||
t.Skipf("symlink privilege unavailable: %v", err)
|
||||
}
|
||||
if s, err := Acquire(dir, "host-one"); err == nil {
|
||||
s.Close()
|
||||
t.Fatal("accepted symlink state")
|
||||
}
|
||||
content, err := os.ReadFile(outside)
|
||||
if err != nil || string(content) != "protected" {
|
||||
t.Fatal("modified outside file")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The subprocess exits without Close: the OS must release its lock, but its
|
||||
// running operation must remain recorded. No mock can test this boundary.
|
||||
func TestProcessDeathPreservesRunningOperation(t *testing.T) {
|
||||
if os.Getenv("DEPLOYCTL_STATE_CHILD") == "1" {
|
||||
s, err := Acquire(os.Getenv("DEPLOYCTL_STATE_DIR"), "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, _, err := s.Begin("op-one", testHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Advance(op.ID, op.Revision, Running); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("READY")
|
||||
bufio.NewReader(os.Stdin).ReadByte()
|
||||
os.Exit(3)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestProcessDeathPreservesRunningOperation$")
|
||||
cmd.Env = append(os.Environ(), "DEPLOYCTL_STATE_CHILD=1", "DEPLOYCTL_STATE_DIR="+dir)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stdin.Close()
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(line) != "READY" {
|
||||
t.Fatalf("child failed: %q %v", line, err)
|
||||
}
|
||||
if _, err := Acquire(dir, "host-one"); !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("cross-process lock bypass: %v", err)
|
||||
}
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Wait()
|
||||
s, err := Acquire(dir, "host-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
op, created, err := s.Begin("op-one", testHash)
|
||||
if err != nil || created || op.Status != Running || op.Revision != 2 {
|
||||
t.Fatalf("lost interrupted task: %+v %v", op, err)
|
||||
}
|
||||
if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrUnresolved) {
|
||||
t.Fatal("allowed writes before reconciliation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package wire enforces the bounded, exact JSON protocol shared by CLI and packages.
|
||||
package wire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Decode refuses duplicate keys, case aliases, unknown/missing fields,
|
||||
// nulls and trailing values. encoding/json alone accepts several of these.
|
||||
func Decode(in io.Reader, target any, limit int64) error {
|
||||
if limit <= 0 || limit > 16<<20 {
|
||||
return errors.New("invalid input limit")
|
||||
}
|
||||
typ := reflect.TypeOf(target)
|
||||
if typ == nil || typ.Kind() != reflect.Pointer || reflect.ValueOf(target).IsNil() {
|
||||
return errors.New("expected nonnil decode target")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(in, limit+1))
|
||||
if err != nil || int64(len(data)) > limit || !utf8.Valid(data) {
|
||||
return errors.New("invalid input")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
if err := uniqueValue(decoder, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
return errors.New("trailing input")
|
||||
}
|
||||
if err := exactFields(data, typ.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
func uniqueValue(d *json.Decoder, depth int) error {
|
||||
if depth > 32 {
|
||||
return errors.New("input nesting exceeds limit")
|
||||
}
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delim, composite := token.(json.Delim)
|
||||
if !composite {
|
||||
return nil
|
||||
}
|
||||
switch delim {
|
||||
case '{':
|
||||
seen := make(map[string]bool)
|
||||
for d.More() {
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := token.(string)
|
||||
if !ok || seen[key] {
|
||||
return errors.New("duplicate or invalid field")
|
||||
}
|
||||
seen[key] = true
|
||||
if err := uniqueValue(d, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for d.More() {
|
||||
if err := uniqueValue(d, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("unexpected delimiter")
|
||||
}
|
||||
_, err = d.Token()
|
||||
return err
|
||||
}
|
||||
|
||||
func exactFields(data []byte, typ reflect.Type) error {
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
return errors.New("null is not permitted")
|
||||
}
|
||||
if typ == reflect.TypeOf(time.Time{}) {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, text)
|
||||
if err != nil || text != parsed.UTC().Format("2006-01-02T15:04:05Z") {
|
||||
return errors.New("expected canonical UTC timestamp")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() == reflect.Slice {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := exactFields(item, typ.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() == reflect.Map {
|
||||
var entries map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range entries {
|
||||
if err := exactFields(value, typ.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) != typ.NumField() {
|
||||
return errors.New("unexpected field set")
|
||||
}
|
||||
for n := 0; n < typ.NumField(); n++ {
|
||||
field := typ.Field(n)
|
||||
value, exists := fields[field.Tag.Get("json")]
|
||||
if !exists {
|
||||
return errors.New("missing field")
|
||||
}
|
||||
if err := exactFields(value, field.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type component struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type document struct {
|
||||
Components []component `json:"components"`
|
||||
}
|
||||
|
||||
func TestStrictNestedArrays(t *testing.T) {
|
||||
var doc document
|
||||
if err := Decode(strings.NewReader(`{"components":[{"name":"server"}]}`), &doc, 1024); err != nil || doc.Components[0].Name != "server" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, input := range []string{
|
||||
`{"components":[{"name":"server","command":"secret"}]}`,
|
||||
`{"components":[{}]}`,
|
||||
`{"components":[{"Name":"server"}]}`,
|
||||
`{"components":[null]}`,
|
||||
`{"components":null}`,
|
||||
`{"components":[{"name":null}]}`,
|
||||
`{"components":[{"name":"one","name":"two"}]}`,
|
||||
"{\"components\":[{\"name\":\"\xff\"}]}",
|
||||
} {
|
||||
if Decode(strings.NewReader(input), &doc, 1024) == nil {
|
||||
t.Errorf("accepted malformed nested JSON: %q", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBounds(t *testing.T) {
|
||||
var doc document
|
||||
for _, input := range []string{`{"components":[]} {}`, strings.Repeat(" ", 1025) + `{"components":[]}`} {
|
||||
if Decode(strings.NewReader(input), &doc, 1024) == nil {
|
||||
t.Fatal("accepted trailing or oversized input")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidDecoderArguments(t *testing.T) {
|
||||
var doc document
|
||||
for _, limit := range []int64{0, -1, 1<<63 - 1} {
|
||||
if Decode(strings.NewReader(`{}`), &doc, limit) == nil {
|
||||
t.Fatal("accepted invalid limit")
|
||||
}
|
||||
}
|
||||
var nilDoc *document
|
||||
for _, target := range []any{nil, doc, nilDoc} {
|
||||
if Decode(strings.NewReader(`{}`), target, 1024) == nil {
|
||||
t.Fatal("accepted invalid target")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictTypedMaps(t *testing.T) {
|
||||
var target map[string]component
|
||||
for _, input := range []string{`{"api":{"name":"ok","extra":true}}`, `{"api":{"Name":"ok"}}`, `{"api":{}}`, `{"api":null}`} {
|
||||
if Decode(strings.NewReader(input), &target, 1024) == nil {
|
||||
t.Errorf("accepted malformed map entry: %s", input)
|
||||
}
|
||||
}
|
||||
if err := Decode(strings.NewReader(`{"api":{"name":"ok"}}`), &target, 1024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user