Files
server-deploy/internal/aptrepo/metadata_test.go

328 lines
16 KiB
Go

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