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