7f77666709
## Why The alpine provider only supported remote (proxy) repositories, so there was no way to publish first-party `.apk` packages the way `rpm-local` and `deb-local` already allow. This extends the existing alpine provider into a real apk repository: uploaded `.apk` files are parsed in pure Go and a per-arch `APKINDEX.tar.gz` is generated on demand, at parity with rpm repodata and deb Packages generation. (The metadata-only `github_alpine` type is a separate follow-up and is not part of this PR.) ## How - Implements `LocalUploader` / `LocalIndexer` / `PostUploadHook` / `PostDeleteHook` on the existing `alpine` provider, leaving the remote proxy methods (`UpstreamURL`/`ContentType`/`AuthHeaders`/`RewriteResponse`/`Classify`) intact. - Parses the `.apk` (up to three concatenated, independently gzipped tar streams) in pure Go: locates the control stream by its `.PKGINFO` member, reads the `key = value` fields, and computes the apk pull checksum `C:` = `Q1` + base64(sha1(**control gzip stream bytes**)) — the sha1 of the second gzip member, not of the whole file. - Derives arch from `.PKGINFO` and records download size (`S:` blob size) and installed size (`I:` from `.PKGINFO size`). - Generates an **unsigned** per-arch `APKINDEX.tar.gz` = gzip(tar(`APKINDEX`)) filtered by requested arch (clients use `--allow-untrusted`, matching rpm `gpgcheck=0` / deb `[trusted=yes]`), applying the same dot-segment normalization as deb so `./<arch>/APKINDEX.tar.gz` resolves. Non-index / `.apk` paths return `false` so the generic file streamer serves the stored blob. - Adds `AlpineMetadata` plus **separate** `AlpineMetadataStore` / `AlpineMetadataReader` / `AlpineMetadataDeleter` interfaces (type-asserted from the generic hooks) so the shared rpm/deb metadata interfaces and their test doubles are untouched. - Adds the `alpine_metadata` table (keyed by `repo_name` + `file_path`, per-arch index) and its `Insert`/`Delete`/`List` DB methods. - Adds `testsupport.MinimalApk`, unit tests (`.PKGINFO` parse, Q1 checksum over the control stream, per-arch filtering, empty-field omission, `./` dot-segment handling, ValidateUpload accept/reject), and a `dockere2e` `TestLocalAlpineIndex`. ## Consumption `/etc/apk/repositories` line = `<url>/api/v1/local/<name>` (apk appends `/<arch>/APKINDEX.tar.gz`); `apk update --allow-untrusted && apk add --allow-untrusted <pkg>`. Packages live at `/api/v1/local/<name>/<arch>/<file>.apk`. ## Verification `go build ./...`, `go vet ./...` (incl. `-tags dockere2e`), `go mod tidy` (no change), `make test` (`-race`), and `pre-commit run --all-files` all pass. Reviewed-on: #114 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package alpine
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// fakeAlpineStore satisfies provider.MetadataStore (shared) and
|
|
// provider.AlpineMetadataStore, recording the row AfterUpload writes.
|
|
type fakeAlpineStore struct{ inserted *provider.AlpineMetadata }
|
|
|
|
func (f *fakeAlpineStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
|
func (f *fakeAlpineStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
|
func (f *fakeAlpineStore) InsertAlpineMetadata(_ context.Context, m *provider.AlpineMetadata) error {
|
|
f.inserted = m
|
|
return nil
|
|
}
|
|
|
|
// fakeAlpineDeleter satisfies provider.MetadataDeleter and AlpineMetadataDeleter.
|
|
type fakeAlpineDeleter struct{ deleted bool }
|
|
|
|
func (f *fakeAlpineDeleter) DeleteRPMMetadata(context.Context, string, string) error { return nil }
|
|
func (f *fakeAlpineDeleter) DeleteDebMetadata(context.Context, string, string) error { return nil }
|
|
func (f *fakeAlpineDeleter) DeleteAlpineMetadata(context.Context, string, string) error {
|
|
f.deleted = true
|
|
return nil
|
|
}
|
|
|
|
// fakeAlpineReader is a FileStore that also serves alpine metadata rows.
|
|
type fakeAlpineReader struct{ metas []provider.AlpineMetadata }
|
|
|
|
func (f fakeAlpineReader) ListAlpineMetadataEntries(context.Context, string) ([]provider.AlpineMetadata, error) {
|
|
return f.metas, nil
|
|
}
|
|
func (f fakeAlpineReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
|
return nil, nil
|
|
}
|
|
func (f fakeAlpineReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
|
|
|
type errAlpineReader struct{}
|
|
|
|
func (errAlpineReader) ListAlpineMetadataEntries(context.Context, string) ([]provider.AlpineMetadata, error) {
|
|
return nil, io.ErrUnexpectedEOF
|
|
}
|
|
func (errAlpineReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
|
return nil, nil
|
|
}
|
|
func (errAlpineReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
|
|
|
func TestAlpineValidateUpload(t *testing.T) {
|
|
p := &Provider{}
|
|
sp, ct, err := p.ValidateUpload("x86_64/foo-1.0-r0.apk")
|
|
if err != nil || sp != "x86_64/foo-1.0-r0.apk" || ct != "application/vnd.android.package-archive" {
|
|
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
|
|
}
|
|
// Dot-segment prefix is normalized away.
|
|
if sp, _, err := p.ValidateUpload("./aarch64/bar-2.0-r1.apk"); err != nil || sp != "aarch64/bar-2.0-r1.apk" {
|
|
t.Errorf("dot-seg: sp=%q err=%v", sp, err)
|
|
}
|
|
if _, _, err := p.ValidateUpload("foo.rpm"); err == nil {
|
|
t.Error("expected error for non-apk")
|
|
}
|
|
resp := p.UploadResponse("x86_64/foo-1.0-r0.apk", "sha256:abc", 42)
|
|
if resp["filename"] != "foo-1.0-r0.apk" || resp["content_hash"] != "sha256:abc" || resp["size_bytes"] != int64(42) {
|
|
t.Errorf("upload response %v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAlpineAfterUpload(t *testing.T) {
|
|
data := testsupport.MinimalApk("hello", "1.0-r0", "x86_64")
|
|
store := &fakeAlpineStore{}
|
|
(&Provider{}).AfterUpload(context.Background(), "myrepo", "x86_64/hello-1.0-r0.apk",
|
|
"sha256:deadbeef", fakeBlobReader{data: data}, store)
|
|
|
|
m := store.inserted
|
|
if m == nil {
|
|
t.Fatal("no metadata inserted")
|
|
}
|
|
if m.Name != "hello" || m.Version != "1.0-r0" || m.Arch != "x86_64" {
|
|
t.Errorf("unexpected metadata: %+v", m)
|
|
}
|
|
if m.DownloadSize != int64(len(data)) {
|
|
t.Errorf("DownloadSize = %d, want %d", m.DownloadSize, len(data))
|
|
}
|
|
if m.InstalledSize != 4 {
|
|
t.Errorf("InstalledSize = %d, want 4", m.InstalledSize)
|
|
}
|
|
if m.License != "MIT" || m.Origin != "hello" || !strings.HasPrefix(m.Maintainer, "e2e") {
|
|
t.Errorf("scalar fields not parsed: %+v", m)
|
|
}
|
|
if len(m.Depends) != 1 || m.Depends[0] != "so:libc.musl-x86_64.so.1" {
|
|
t.Errorf("Depends = %v", m.Depends)
|
|
}
|
|
if len(m.Provides) != 1 || m.Provides[0] != "cmd:hello=1.0-r0" {
|
|
t.Errorf("Provides = %v", m.Provides)
|
|
}
|
|
|
|
// The Q1 checksum is the sha1 of the CONTROL gzip stream (the member whose
|
|
// tar carries .PKGINFO), not of the whole file.
|
|
controlRaw := controlStreamBytes(t, data)
|
|
sum := sha1.Sum(controlRaw)
|
|
want := "Q1" + base64.StdEncoding.EncodeToString(sum[:])
|
|
if m.Checksum != want {
|
|
t.Errorf("Checksum = %q, want %q (sha1 of control stream)", m.Checksum, want)
|
|
}
|
|
// And explicitly NOT the sha1 of the whole apk.
|
|
whole := sha1.Sum(data)
|
|
if m.Checksum == "Q1"+base64.StdEncoding.EncodeToString(whole[:]) {
|
|
t.Error("Checksum was computed over the whole file, not the control stream")
|
|
}
|
|
}
|
|
|
|
func TestAlpineAfterUploadErrors(t *testing.T) {
|
|
store := &fakeAlpineStore{}
|
|
(&Provider{}).AfterUpload(context.Background(), "r", "x86_64/p.apk", "sha256:x", errBlobReader{}, store)
|
|
if store.inserted != nil {
|
|
t.Error("no metadata should be inserted on download error")
|
|
}
|
|
store2 := &fakeAlpineStore{}
|
|
(&Provider{}).AfterUpload(context.Background(), "r", "x86_64/p.apk", "sha256:x", fakeBlobReader{data: []byte("not an apk")}, store2)
|
|
if store2.inserted != nil {
|
|
t.Error("no metadata should be inserted on parse error")
|
|
}
|
|
}
|
|
|
|
func TestAlpineAfterDelete(t *testing.T) {
|
|
d := &fakeAlpineDeleter{}
|
|
if err := (&Provider{}).AfterDelete(context.Background(), "r", "x86_64/p.apk", d); err != nil {
|
|
t.Fatalf("AfterDelete: %v", err)
|
|
}
|
|
if !d.deleted {
|
|
t.Error("DeleteAlpineMetadata not called")
|
|
}
|
|
}
|
|
|
|
func TestAlpineServeLocalIndex(t *testing.T) {
|
|
p := &Provider{}
|
|
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
|
|
{Name: "aaa", Version: "1.0-r0", Arch: "x86_64", Checksum: "Q1aaa", DownloadSize: 100, InstalledSize: 10,
|
|
Description: "pkg aaa", URL: "https://a", License: "MIT", Depends: []string{"so:libc"}, Provides: []string{"cmd:aaa"}},
|
|
{Name: "bbb", Version: "2.0-r0", Arch: "aarch64", Checksum: "Q1bbb", DownloadSize: 200, InstalledSize: 20},
|
|
}}
|
|
|
|
// x86_64 index contains only aaa, with its fields, and not bbb.
|
|
w := serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz")
|
|
if w.Code != 200 {
|
|
t.Fatalf("code %d", w.Code)
|
|
}
|
|
idx := untarIndex(t, w.Body.Bytes())
|
|
for _, want := range []string{"C:Q1aaa", "P:aaa", "V:1.0-r0", "A:x86_64", "S:100", "I:10", "T:pkg aaa", "U:https://a", "L:MIT", "D:so:libc", "p:cmd:aaa"} {
|
|
if !strings.Contains(idx, want) {
|
|
t.Errorf("x86_64 APKINDEX missing %q:\n%s", want, idx)
|
|
}
|
|
}
|
|
if strings.Contains(idx, "P:bbb") {
|
|
t.Errorf("x86_64 APKINDEX leaked aarch64 package:\n%s", idx)
|
|
}
|
|
|
|
// aarch64 index contains only bbb.
|
|
w = serveIndex(t, p, reader, "aarch64/APKINDEX.tar.gz")
|
|
idx = untarIndex(t, w.Body.Bytes())
|
|
if !strings.Contains(idx, "P:bbb") || strings.Contains(idx, "P:aaa") {
|
|
t.Errorf("aarch64 filtering wrong:\n%s", idx)
|
|
}
|
|
|
|
// Non-index and .apk paths are not owned by the indexer.
|
|
for _, path := range []string{"x86_64/foo-1.0-r0.apk", "x86_64/", "README"} {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
|
|
if p.ServeLocalIndex(w, r, reader, "repo", path) {
|
|
t.Errorf("ServeLocalIndex should return false for %q", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty fields are omitted from the record (bbb has no description/url).
|
|
func TestAlpineIndexOmitsEmptyFields(t *testing.T) {
|
|
p := &Provider{}
|
|
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
|
|
{Name: "bbb", Version: "2.0-r0", Arch: "x86_64", Checksum: "Q1bbb", DownloadSize: 200, InstalledSize: 20},
|
|
}}
|
|
idx := untarIndex(t, serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz").Body.Bytes())
|
|
for _, absent := range []string{"T:", "U:", "L:", "D:", "p:", "i:", "o:", "m:", "c:", "k:"} {
|
|
if strings.Contains(idx, absent) {
|
|
t.Errorf("empty field %q should be omitted:\n%s", absent, idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// apk requests "./<arch>/APKINDEX.tar.gz" for a bare repo base URL; the
|
|
// dot-segment must be collapsed and yield the same bytes as the plain path.
|
|
func TestAlpineServeLocalIndexDotSegment(t *testing.T) {
|
|
p := &Provider{}
|
|
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
|
|
{Name: "aaa", Version: "1.0-r0", Arch: "x86_64", Checksum: "Q1aaa", DownloadSize: 100, InstalledSize: 10},
|
|
}}
|
|
plain := untarIndex(t, serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz").Body.Bytes())
|
|
dotted := untarIndex(t, serveIndex(t, p, reader, "./x86_64/APKINDEX.tar.gz").Body.Bytes())
|
|
if plain != dotted {
|
|
t.Errorf("dot-segment path differs:\nplain=%q\ndotted=%q", plain, dotted)
|
|
}
|
|
}
|
|
|
|
func TestAlpineServeLocalIndexArchRequired(t *testing.T) {
|
|
p := &Provider{}
|
|
reader := fakeAlpineReader{}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/APKINDEX.tar.gz", nil)
|
|
if !p.ServeLocalIndex(w, r, reader, "repo", "APKINDEX.tar.gz") {
|
|
t.Fatal("bare APKINDEX should be owned (and rejected) by the indexer")
|
|
}
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("bare APKINDEX code = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAlpineServeMetadataError(t *testing.T) {
|
|
p := &Provider{}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/x86_64/APKINDEX.tar.gz", nil)
|
|
p.ServeLocalIndex(w, r, errAlpineReader{}, "repo", "x86_64/APKINDEX.tar.gz")
|
|
if w.Code != 500 {
|
|
t.Errorf("failing reader code = %d, want 500", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAlpineGenerateLocalIndexUnsupported(t *testing.T) {
|
|
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeAlpineReader{}, "r", "x86_64/APKINDEX.tar.gz"); err == nil {
|
|
t.Error("expected unsupported error")
|
|
}
|
|
}
|
|
|
|
func serveIndex(t *testing.T, p *Provider, files provider.FileStore, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
|
|
if !p.ServeLocalIndex(w, r, files, "repo", path) {
|
|
t.Fatalf("ServeLocalIndex returned false for %q", path)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// untarIndex un-gzips and un-tars an APKINDEX.tar.gz and returns the APKINDEX text.
|
|
func untarIndex(t *testing.T, gzTar []byte) string {
|
|
t.Helper()
|
|
zr, err := gzip.NewReader(bytes.NewReader(gzTar))
|
|
if err != nil {
|
|
t.Fatalf("APKINDEX not gzip: %v", err)
|
|
}
|
|
tarBytes, _ := io.ReadAll(zr)
|
|
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("APKINDEX not tar: %v", err)
|
|
}
|
|
if hdr.Name == "APKINDEX" {
|
|
b, _ := io.ReadAll(tr)
|
|
return string(b)
|
|
}
|
|
}
|
|
t.Fatal("no APKINDEX member in tarball")
|
|
return ""
|
|
}
|
|
|
|
// controlStreamBytes returns the raw bytes of the gzip stream whose tar carries
|
|
// .PKGINFO, so the test can independently compute the expected Q1 checksum.
|
|
func controlStreamBytes(t *testing.T, apk []byte) []byte {
|
|
t.Helper()
|
|
br := bytes.NewReader(apk)
|
|
zr, err := gzip.NewReader(br)
|
|
if err != nil {
|
|
t.Fatalf("gzip: %v", err)
|
|
}
|
|
prev := 0
|
|
for {
|
|
zr.Multistream(false)
|
|
out, _ := io.ReadAll(zr)
|
|
end := len(apk) - br.Len()
|
|
tr := tar.NewReader(bytes.NewReader(out))
|
|
for {
|
|
h, err := tr.Next()
|
|
if err != nil {
|
|
break
|
|
}
|
|
if strings.TrimPrefix(h.Name, "./") == ".PKGINFO" {
|
|
return apk[prev:end]
|
|
}
|
|
}
|
|
prev = end
|
|
if err := zr.Reset(br); err != nil {
|
|
break
|
|
}
|
|
}
|
|
t.Fatal("no control stream found")
|
|
return nil
|
|
}
|