60f008debc
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>
409 lines
14 KiB
Go
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()
|
|
}
|