Files
unkin-agent 60f008debc Add deb (Debian/apt) local and remote repository support (#111)
Brings Debian/apt to artifactapi with feature parity to the existing rpm support (local + remote), so `.deb` packages can be hosted as a flat apt repo and a Debian/Ubuntu mirror can be cached through the proxy.

- Adds `deb` to the package-type enum and registers a new `internal/provider/deb` provider.
- Classifies `.deb` blobs immutable and the apt index surface (`Packages`, `Release`, `InRelease`, `dists/`, by-hash) mutable so the caching engine revalidates it.
- Parses the `.deb` in pure Go (ar archive to `control.tar.{gz,xz,zst}` to `./control`), storing the raw control stanza plus computed size/md5/sha256 as `deb_metadata`.
- Serves a flat apt repo (`deb [trusted=yes] .../ ./`): generates `Packages`, `Packages.gz` and an unsigned `Release` (returns 404 for `InRelease`/`Release.gpg`), mirroring rpm unsigned repodata / gpgcheck=0 trust model.
- Proxies a remote mirror via `UpstreamURL`/`ContentType`/`AuthHeaders` (HTTP Basic).
- Adds the `deb_metadata` table to `migrate()`, DB access methods, a `MinimalDeb` pure-Go fixture, unit tests, and a `dockere2e` `TestLocalDebRepo`.

---------

Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Reviewed-on: #111
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-11 23:21:08 +10:00

409 lines
14 KiB
Go

package deb
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
"github.com/ulikunitz/xz"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
type fakeBlobReader struct{ data []byte }
func (f fakeBlobReader) Download(_ context.Context, _ string) (io.ReadCloser, int64, error) {
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
}
type errBlobReader struct{}
func (errBlobReader) Download(_ context.Context, _ string) (io.ReadCloser, int64, error) {
return nil, 0, io.ErrUnexpectedEOF
}
// fakeDebStore satisfies provider.MetadataStore (both insert methods) and
// records the deb row that AfterUpload writes.
type fakeDebStore struct{ inserted *provider.DebMetadata }
func (f *fakeDebStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
func (f *fakeDebStore) InsertDebMetadata(_ context.Context, m *provider.DebMetadata) error {
f.inserted = m
return nil
}
type fakeDebReader struct{ metas []provider.DebMetadata }
func (f fakeDebReader) ListDebMetadataEntries(context.Context, string) ([]provider.DebMetadata, error) {
return f.metas, nil
}
func (f fakeDebReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (f fakeDebReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
type errDebReader struct{}
func (errDebReader) ListDebMetadataEntries(context.Context, string) ([]provider.DebMetadata, error) {
return nil, io.ErrUnexpectedEOF
}
func (errDebReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (errDebReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func TestDebPureFuncs(t *testing.T) {
p := &Provider{}
if p.Type() != models.PackageDeb {
t.Errorf("type = %q", p.Type())
}
if out, _ := p.RewriteResponse(nil, models.Remote{}, "http://p"); out != nil {
t.Error("deb never rewrites")
}
if got := p.UpstreamURL(models.Remote{BaseURL: "https://mirror/"}, "/dists/bookworm/Release"); got != "https://mirror/dists/bookworm/Release" {
t.Errorf("upstream url %q", got)
}
h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"})
if h.Get("Authorization") == "" {
t.Error("auth header")
}
}
func TestDebClassify(t *testing.T) {
p := &Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"pool/foo_1.0_amd64.deb", provider.Immutable},
{"Packages", provider.Mutable},
{"Packages.gz", provider.Mutable},
{"Release", provider.Mutable},
{"InRelease", provider.Mutable},
{"Release.gpg", provider.Mutable},
{"dists/bookworm/main/binary-amd64/Packages", provider.Mutable},
{"dists/bookworm/Release", provider.Mutable},
{"dists/bookworm/main/by-hash/SHA256/abc", provider.Mutable},
{"dists/bookworm/main/Contents-amd64.gz", provider.Mutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestDebContentType(t *testing.T) {
p := &Provider{}
for path, want := range map[string]string{
"pool/foo_1.0_amd64.deb": "application/vnd.debian.binary-package",
"dists/bookworm/main/bin/Packages.gz": "application/gzip",
"dists/bookworm/main/bin/Packages.xz": "application/x-xz",
"Packages": "text/plain",
"Release": "text/plain",
"InRelease": "text/plain",
"pool/other": "application/octet-stream",
} {
if got := p.ContentType(path); got != want {
t.Errorf("ContentType(%q) = %q, want %q", path, got, want)
}
}
}
func TestDebValidateUpload(t *testing.T) {
p := &Provider{}
sp, ct, err := p.ValidateUpload("dir/foo_1.0_amd64.deb")
if err != nil || sp != "pool/foo_1.0_amd64.deb" || ct != "application/vnd.debian.binary-package" {
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
}
if _, _, err := p.ValidateUpload("foo.rpm"); err == nil {
t.Error("expected error for non-deb")
}
resp := p.UploadResponse("pool/foo_1.0_amd64.deb", "sha256:abc", 42)
if resp["filename"] != "foo_1.0_amd64.deb" || resp["content_hash"] != "sha256:abc" || resp["size_bytes"] != int64(42) {
t.Errorf("upload response %v", resp)
}
}
func TestDebAfterUpload(t *testing.T) {
data := testsupport.MinimalDeb("e2e-testpkg", "1.2.3", "amd64")
store := &fakeDebStore{}
(&Provider{}).AfterUpload(context.Background(), "myrepo", "pool/e2e-testpkg_1.2.3_amd64.deb",
"sha256:deadbeef", fakeBlobReader{data: data}, store)
m := store.inserted
if m == nil {
t.Fatal("no metadata inserted")
}
if m.Name != "e2e-testpkg" || m.Version != "1.2.3" || m.Architecture != "amd64" {
t.Errorf("unexpected metadata: %+v", m)
}
if m.Size != int64(len(data)) {
t.Errorf("Size = %d, want %d", m.Size, len(data))
}
if m.SHA256 != "deadbeef" {
t.Errorf("SHA256 = %q, want deadbeef", m.SHA256)
}
if m.MD5 == "" {
t.Error("MD5 not computed")
}
if !strings.Contains(m.Control, "Package: e2e-testpkg") {
t.Errorf("raw control not stored: %q", m.Control)
}
// The raw stanza is stored verbatim (no trailing newline) so Packages can
// reproduce it faithfully.
if strings.HasSuffix(m.Control, "\n") {
t.Error("control should be trimmed of trailing newline")
}
}
func TestDebAfterUploadErrors(t *testing.T) {
// Download failure: no insert, no panic.
store := &fakeDebStore{}
(&Provider{}).AfterUpload(context.Background(), "r", "p", "sha256:x", errBlobReader{}, store)
if store.inserted != nil {
t.Error("no metadata should be inserted on download error")
}
// Not a .deb (ar) archive.
store2 := &fakeDebStore{}
(&Provider{}).AfterUpload(context.Background(), "r", "p", "sha256:x", fakeBlobReader{data: []byte("not a deb")}, store2)
if store2.inserted != nil {
t.Error("no metadata should be inserted on parse error")
}
}
func TestDebControlDecompression(t *testing.T) {
// The control tarball may be gzip, xz, or zstd (goreleaser/nfpm emit gzip or
// xz); each must round-trip to the same control stanza.
for _, tc := range []struct {
name string
member string
comp func([]byte) []byte
}{
{"gzip", "control.tar.gz", gzipBytes},
{"xz", "control.tar.xz", xzBytes},
{"zstd", "control.tar.zst", zstdBytes},
} {
t.Run(tc.name, func(t *testing.T) {
deb := buildDeb("pkg", "9.9", "arm64", tc.member, tc.comp)
control, err := extractControl(deb)
if err != nil {
t.Fatalf("extractControl: %v", err)
}
fields := parseControlFields(control)
if fields["Package"] != "pkg" || fields["Version"] != "9.9" || fields["Architecture"] != "arm64" {
t.Errorf("fields = %v", fields)
}
})
}
}
func TestDebParseControlContinuationLines(t *testing.T) {
control := "Package: p\nVersion: 1\n" +
"Description: short\n very long\n .\n more\n" +
"Architecture: all\n"
f := parseControlFields(control)
if f["Package"] != "p" || f["Version"] != "1" || f["Architecture"] != "all" {
t.Errorf("continuation lines corrupted parse: %v", f)
}
if f["Description"] != "short" {
t.Errorf("Description folded continuation into value: %q", f["Description"])
}
}
func TestDebServeLocalIndex(t *testing.T) {
p := &Provider{}
reader := fakeDebReader{metas: []provider.DebMetadata{
{Name: "aaa", Version: "1.0", Architecture: "amd64", FilePath: "pool/aaa_1.0_amd64.deb",
Control: "Package: aaa\nVersion: 1.0\nArchitecture: amd64", Size: 100, MD5: "md5aaa", SHA256: "sha256aaa"},
{Name: "bbb", Version: "2.0", Architecture: "arm64", FilePath: "pool/bbb_2.0_arm64.deb",
Control: "Package: bbb\nVersion: 2.0\nArchitecture: arm64", Size: 200, MD5: "md5bbb", SHA256: "sha256bbb"},
}}
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if !p.ServeLocalIndex(w, r, reader, "myrepo", path) {
t.Fatalf("ServeLocalIndex returned false for %q", path)
}
return w
}
// Packages lists both packages with their apt fields.
w := serve("Packages")
body := w.Body.String()
if w.Code != 200 {
t.Fatalf("Packages code %d", w.Code)
}
for _, want := range []string{
"Package: aaa", "Package: bbb",
"Filename: pool/aaa_1.0_amd64.deb", "Size: 100", "MD5sum: md5aaa", "SHA256: sha256aaa",
"Filename: pool/bbb_2.0_arm64.deb", "Size: 200",
} {
if !strings.Contains(body, want) {
t.Errorf("Packages missing %q:\n%s", want, body)
}
}
// Stanzas are blank-line separated.
if !strings.Contains(body, "SHA256: sha256aaa\n\n") {
t.Errorf("stanzas not blank-line separated:\n%s", body)
}
// Packages.gz decompresses to exactly the plain Packages bytes.
w = serve("Packages.gz")
if w.Code != 200 {
t.Fatalf("Packages.gz code %d", w.Code)
}
zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("Packages.gz not gzip: %v", err)
}
plain, _ := io.ReadAll(zr)
if !bytes.Equal(plain, []byte(body)) {
t.Error("Packages.gz does not decompress to Packages")
}
// Release lists arches and both index files under MD5Sum/SHA256.
w = serve("Release")
rel := w.Body.String()
if w.Code != 200 {
t.Fatalf("Release code %d", w.Code)
}
for _, want := range []string{"Date:", "Architectures: amd64 arm64", "Acquire-By-Hash: no", "MD5Sum:", "SHA256:", " Packages\n", " Packages.gz\n"} {
if !strings.Contains(rel, want) {
t.Errorf("Release missing %q:\n%s", want, rel)
}
}
// Unsigned trust model: no InRelease / Release.gpg served here.
for _, path := range []string{"InRelease", "Release.gpg", "pool/aaa_1.0_amd64.deb"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if p.ServeLocalIndex(w, r, reader, "myrepo", path) {
t.Errorf("ServeLocalIndex should return false for %q", path)
}
}
}
// Real apt appends the flat-repo dist "./" verbatim, so it requests "./Packages"
// / "./Release" (curl pre-normalizes /./ which masks this). The handler must
// collapse the dot-segment and return the same bytes as the un-prefixed request.
func TestDebServeLocalIndexAptDotSegment(t *testing.T) {
p := &Provider{}
reader := fakeDebReader{metas: []provider.DebMetadata{
{Name: "aaa", Version: "1.0", Architecture: "amd64", FilePath: "pool/aaa_1.0_amd64.deb",
Control: "Package: aaa\nVersion: 1.0\nArchitecture: amd64", Size: 100, MD5: "md5aaa", SHA256: "sha256aaa"},
}}
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if !p.ServeLocalIndex(w, r, reader, "myrepo", path) {
t.Fatalf("ServeLocalIndex returned false for %q", path)
}
return w
}
// Packages is deterministic: require exact byte identity.
if plain, dotted := serve("Packages"), serve("./Packages"); plain.Code != 200 || dotted.Code != 200 {
t.Fatalf("Packages: plain=%d dotted=%d, want 200/200", plain.Code, dotted.Code)
} else if !bytes.Equal(plain.Body.Bytes(), dotted.Body.Bytes()) {
t.Error("./Packages body differs from Packages body")
}
// Release carries a Date: header stamped from time.Now(); compare the rest.
plain, dotted := serve("Release"), serve("./Release")
if plain.Code != 200 || dotted.Code != 200 {
t.Fatalf("Release: plain=%d dotted=%d, want 200/200", plain.Code, dotted.Code)
}
if stripDate(plain.Body.String()) != stripDate(dotted.Body.String()) {
t.Error("./Release body differs from Release body (ignoring Date)")
}
}
func stripDate(s string) string {
var out []string
for _, line := range strings.Split(s, "\n") {
if strings.HasPrefix(line, "Date:") {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func TestDebServeMetadataError(t *testing.T) {
p := &Provider{}
for _, path := range []string{"Packages", "Packages.gz", "Release"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
p.ServeLocalIndex(w, r, errDebReader{}, "repo", path)
if w.Code != 500 {
t.Errorf("%s with failing reader = %d, want 500", path, w.Code)
}
}
}
func TestDebGenerateLocalIndexUnsupported(t *testing.T) {
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeDebReader{}, "r", "Packages"); err == nil {
t.Error("expected unsupported error")
}
}
// buildDeb assembles an ar .deb whose control member uses the given name and
// compressor, so the decompression branches can be exercised directly.
func buildDeb(name, version, arch, member string, comp func([]byte) []byte) []byte {
control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\n"
controlTar := comp(tarSingle("./control", []byte(control)))
var buf bytes.Buffer
buf.WriteString("!<arch>\n")
arWrite(&buf, "debian-binary", []byte("2.0\n"))
arWrite(&buf, member, controlTar)
arWrite(&buf, "data.tar.gz", gzipBytes(tarSingle("./x", []byte("x"))))
return buf.Bytes()
}
func tarSingle(name string, data []byte) []byte {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg})
tw.Write(data)
tw.Close()
return buf.Bytes()
}
func arWrite(buf *bytes.Buffer, name string, data []byte) {
fmt.Fprintf(buf, "%-16s%-12s%-6s%-6s%-8s%-10d`\n", name, "0", "0", "0", "100644", len(data))
buf.Write(data)
if len(data)%2 == 1 {
buf.WriteByte('\n')
}
}
func xzBytes(data []byte) []byte {
var buf bytes.Buffer
w, _ := xz.NewWriter(&buf)
w.Write(data)
w.Close()
return buf.Bytes()
}
func zstdBytes(data []byte) []byte {
var buf bytes.Buffer
w, _ := zstd.NewWriter(&buf)
w.Write(data)
w.Close()
return buf.Bytes()
}