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