73c0bfc670
ci/woodpecker/tag/docker Pipeline was successful
Part of #117. Local generated repodata must be byte-identical across the two no-affinity replicas and across every regeneration, so apt/apk never hit a checksum mismatch between an index's advertised hash and the bytes actually served. This does the deb+apk half (the rpm half landed in #118). How: - Derive the deb `Release` `Date:` from the newest persisted `created_at` (RFC1123Z, UTC) instead of `time.Now()`; carry `created_at` through the deb metadata SELECT and `DebMetadata`. An empty repo falls back to the Unix epoch. This also stops `Date:` running ahead of wall clock. - Pin the apk `APKINDEX` tar header `ModTime` to the Unix epoch instead of the zero-value `time.Time`, so it is never wall-clock derived. - Give both list queries a genuine total order by adding a `file_path` tiebreak (name/version/arch is not unique). - Add guard tests: deb generators byte-identical across generations; the `Release` checksum/size matches the served `Packages`/`Packages.gz` bytes (the exact apt invariant); `Date:` pinned to `created_at`; apk index byte-identical and tar `ModTime` pinned to epoch. Reviewed-on: #119 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
173 lines
5.1 KiB
Go
173 lines
5.1 KiB
Go
package deb
|
|
|
|
import (
|
|
"bytes"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
)
|
|
|
|
// debFixture returns a fixed set of rows with persisted created_at values, in
|
|
// the total order ListDebMetadataEntries produces (name, version, arch,
|
|
// file_path), so the generators are exercised on a stable input.
|
|
func debFixture() []provider.DebMetadata {
|
|
t1 := time.Date(2026, 3, 1, 8, 30, 0, 0, time.UTC)
|
|
t2 := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC) // newest
|
|
return []provider.DebMetadata{
|
|
{
|
|
RepoName: "r", FilePath: "pool/aaa_1.0_amd64.deb", ContentHash: "sha256:aa",
|
|
Name: "aaa", Version: "1.0", Architecture: "amd64",
|
|
Control: "Package: aaa\nVersion: 1.0\nArchitecture: amd64",
|
|
Size: 100, MD5: "d41d8cd98f00b204e9800998ecf8427e", SHA256: "aa", CreatedAt: t1,
|
|
},
|
|
{
|
|
RepoName: "r", FilePath: "pool/bbb_2.0_arm64.deb", ContentHash: "sha256:bb",
|
|
Name: "bbb", Version: "2.0", Architecture: "arm64",
|
|
Control: "Package: bbb\nVersion: 2.0\nArchitecture: arm64",
|
|
Size: 200, MD5: "0cc175b9c0f1b6a831c399e269772661", SHA256: "bb", CreatedAt: t2,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestDebGeneratorsDeterministic asserts the served bytes are a pure function of
|
|
// DB state: Packages, Packages.gz and Release are byte-identical across two
|
|
// generations separated by wall-clock time. Fails against the old
|
|
// time.Now()-stamped Release Date:.
|
|
func TestDebGeneratorsDeterministic(t *testing.T) {
|
|
metas := debFixture()
|
|
|
|
pkgs1 := generatePackages(metas)
|
|
rel1 := generateRelease(metas)
|
|
gz1 := gzipBytes(pkgs1)
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
pkgs2 := generatePackages(metas)
|
|
rel2 := generateRelease(metas)
|
|
gz2 := gzipBytes(pkgs2)
|
|
|
|
if !bytes.Equal(pkgs1, pkgs2) {
|
|
t.Error("Packages differs across generations")
|
|
}
|
|
if !bytes.Equal(gz1, gz2) {
|
|
t.Error("Packages.gz differs across generations")
|
|
}
|
|
if !bytes.Equal(rel1, rel2) {
|
|
t.Errorf("Release differs across generations:\n--- first ---\n%s\n--- second ---\n%s", rel1, rel2)
|
|
}
|
|
}
|
|
|
|
// TestDebReleaseDateUsesPersistedCreatedAt pins the Release Date: to the newest
|
|
// persisted created_at (RFC1123Z, UTC), not wall clock. Fails against the old
|
|
// time.Now() code.
|
|
func TestDebReleaseDateUsesPersistedCreatedAt(t *testing.T) {
|
|
metas := debFixture()
|
|
want := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC).Format(time.RFC1123Z)
|
|
|
|
rel := string(generateRelease(metas))
|
|
var got string
|
|
for _, line := range strings.Split(rel, "\n") {
|
|
if strings.HasPrefix(line, "Date:") {
|
|
got = strings.TrimSpace(strings.TrimPrefix(line, "Date:"))
|
|
break
|
|
}
|
|
}
|
|
if got != want {
|
|
t.Errorf("Release Date: = %q, want %q (newest created_at)", got, want)
|
|
}
|
|
}
|
|
|
|
// TestDebReleaseDateEmptyRepoIsEpoch guards the fallback: an empty repo yields a
|
|
// deterministic epoch Date: rather than wall clock.
|
|
func TestDebReleaseDateEmptyRepoIsEpoch(t *testing.T) {
|
|
want := time.Unix(0, 0).UTC().Format(time.RFC1123Z)
|
|
rel := string(generateRelease(nil))
|
|
if !strings.Contains(rel, "Date: "+want+"\n") {
|
|
t.Errorf("empty-repo Release missing epoch Date: %q\n%s", want, rel)
|
|
}
|
|
}
|
|
|
|
// TestDebReleaseChecksumsMatchServedBytes is the exact apt invariant: the
|
|
// sha256/size (and md5/size) advertised for Packages and Packages.gz in Release
|
|
// equal the sha256/size of the actual bytes ServeLocalIndex serves. apt rejects
|
|
// any mismatch.
|
|
func TestDebReleaseChecksumsMatchServedBytes(t *testing.T) {
|
|
metas := debFixture()
|
|
|
|
packages := generatePackages(metas)
|
|
packagesGz := gzipBytes(packages)
|
|
rel := string(generateRelease(metas))
|
|
|
|
wantSHA := map[string]struct {
|
|
hash string
|
|
size int
|
|
}{
|
|
"Packages": {sha256Hex(packages), len(packages)},
|
|
"Packages.gz": {sha256Hex(packagesGz), len(packagesGz)},
|
|
}
|
|
wantMD5 := map[string]struct {
|
|
hash string
|
|
size int
|
|
}{
|
|
"Packages": {md5Hex(packages), len(packages)},
|
|
"Packages.gz": {md5Hex(packagesGz), len(packagesGz)},
|
|
}
|
|
|
|
sha := parseReleaseSection(rel, "SHA256:")
|
|
md5s := parseReleaseSection(rel, "MD5Sum:")
|
|
|
|
for name, w := range wantSHA {
|
|
got, ok := sha[name]
|
|
if !ok {
|
|
t.Fatalf("Release SHA256 section missing %q", name)
|
|
}
|
|
if got.hash != w.hash || got.size != w.size {
|
|
t.Errorf("Release SHA256 %s = (%s, %d), served bytes are (%s, %d)", name, got.hash, got.size, w.hash, w.size)
|
|
}
|
|
}
|
|
for name, w := range wantMD5 {
|
|
got, ok := md5s[name]
|
|
if !ok {
|
|
t.Fatalf("Release MD5Sum section missing %q", name)
|
|
}
|
|
if got.hash != w.hash || got.size != w.size {
|
|
t.Errorf("Release MD5Sum %s = (%s, %d), served bytes are (%s, %d)", name, got.hash, got.size, w.hash, w.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
type releaseEntry struct {
|
|
hash string
|
|
size int
|
|
}
|
|
|
|
// parseReleaseSection reads the indented " <hash> <size> <name>" lines that
|
|
// follow a "SHA256:" / "MD5Sum:" header until the next non-indented line.
|
|
func parseReleaseSection(release, header string) map[string]releaseEntry {
|
|
out := map[string]releaseEntry{}
|
|
lines := strings.Split(release, "\n")
|
|
in := false
|
|
for _, line := range lines {
|
|
if line == header {
|
|
in = true
|
|
continue
|
|
}
|
|
if !in {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(line, " ") {
|
|
break
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) != 3 {
|
|
continue
|
|
}
|
|
size, _ := strconv.Atoi(fields[1])
|
|
out[fields[2]] = releaseEntry{hash: fields[0], size: size}
|
|
}
|
|
return out
|
|
}
|