328 lines
16 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|