feat: add deployment foundation and cross-device handoff

This commit is contained in:
2026-09-25 08:49:19 +08:00
parent 8ccb8b7c15
commit e965b0943d
77 changed files with 8018 additions and 0 deletions
+90
View File
@@ -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.
+117
View File
@@ -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
}
+44
View File
@@ -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")
}
})
}
}
+99
View File
@@ -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")
}
})
}
}
+232
View File
@@ -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
}
+327
View File
@@ -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)
}
})
}
}
}
+169
View File
@@ -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
}
+105
View File
@@ -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")
}
}
+77
View File
@@ -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")
}
}
+34
View File
@@ -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
}