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