5a06c16797
## Why
`github_alpine` is the Alpine/apk analog of the existing `github_deb`/`github_rpm` metadata-only remotes. It lets a plain GitHub-releases repo of `.apk` files be consumed as a real apk repository without artifactapi ever precaching the packages: it scans the repo's releases, derives each package's `.PKGINFO` from a ranged prefix fetch, synthesizes a per-arch `APKINDEX.tar.gz` from the cached metadata, and redirects the actual `.apk` downloads to a backend `releases_remote`. It stacks on the apk-local branch, reusing that work's alpine APKINDEX generator, `.apk`/`.PKGINFO` parser, Q1 pull-checksum, and `AlpineMetadata` store.
## How
- **`pkg/models`**: add `PackageGitHubAlpine` to the enum + validators (and test).
- **`internal/provider/alpine/github.go`**: the `github_alpine` provider. `ServeRemote` serves per-arch `<arch>/APKINDEX.tar.gz` (reusing `generateAPKIndex` over arch-filtered `AlpineMetadata` rows, `normalizeIndexPath` for apk's `./` dot-segment), 302-redirects `*.apk` to `{proxyBaseURL}/api/v1/remote/{releases_remote}/{path}`, and cold-starts with a 503 + `Retry-After`. `scanWithState` lists releases with ETag/If-None-Match and incrementally derives/prunes. `deriveAsset` does a **ranged GET of just the front of the `.apk`** — the control gzip stream carrying `.PKGINFO` sits near the front — doubling the range on truncation; it parses `.PKGINFO` and computes the `C:` Q1 checksum (`Q1`+base64(sha1(control stream))). `FilePath` = the github-relative asset path so the redirect resolves.
- **`internal/provider/alpine/syncer.go`**: a parallel background `Syncer` (own worker pool, shared rate limiter, deduped queue, DB lease), separate from the deb/rpm syncers.
- **`internal/database/alpine_github_sync.go`** + `github_alpine_sync_state` table: `ListGitHubAlpineRemotes` + Claim/Release per-remote sync lease, kept separate from the deb/rpm tables.
- **`internal/server/server.go`**: construct + `Run` the alpine syncer alongside deb/rpm and register it in the `PackageType→Primer` map (priming on create then flows through the existing generic `remotes.go` path).
The rpm/deb providers, syncers, and tables are untouched — this adds parallel alpine equivalents and reuses shared helpers already present in the alpine package.
## Tests
Mirror the deb github tests: scan derives `.PKGINFO`/Q1 from a ranged prefix of a `testsupport.MinimalApk` served over an httptest range server (no full download); diff/prune; pattern filter; per-arch `ServeRemote` routing (index served per-arch and grouped, dot-segment collapse, `.apk` → 302, cold-start 503, warm 200, canceled-request-serves-cache); DB lease prevents a second replica; prime bypasses the recency window. `go build`/`go vet`/`go mod tidy` clean, `make test` (-race) green, pre-commit green.
Reviewed-on: #115
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
498 lines
16 KiB
Go
498 lines
16 KiB
Go
package alpine
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
// fakeStore is an in-memory provider.RemoteMetadataStore + AlpineMetadata
|
|
// store/reader/deleter keyed by file_path, mirroring the (repo_name, file_path)
|
|
// uniqueness of the real alpine_metadata table.
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
rows map[string]provider.AlpineMetadata
|
|
}
|
|
|
|
func newFakeStore() *fakeStore { return &fakeStore{rows: map[string]provider.AlpineMetadata{}} }
|
|
|
|
func (f *fakeStore) InsertAlpineMetadata(_ context.Context, m *provider.AlpineMetadata) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.rows[m.FilePath]; ok {
|
|
return nil // ON CONFLICT DO NOTHING
|
|
}
|
|
f.rows[m.FilePath] = *m
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteAlpineMetadata(_ context.Context, _, filePath string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.rows, filePath)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListAlpineMetadataEntries(ctx context.Context, _ string) ([]provider.AlpineMetadata, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]provider.AlpineMetadata, 0, len(f.rows))
|
|
for _, m := range f.rows {
|
|
out = append(out, m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// The generic RemoteMetadataStore surface (rpm/deb) is unused by the alpine
|
|
// github provider but required to satisfy the interface passed to ServeRemote.
|
|
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
|
func (f *fakeStore) DeleteRPMMetadata(context.Context, string, string) error { return nil }
|
|
func (f *fakeStore) ListRPMMetadataEntries(context.Context, string) ([]provider.RPMMetadata, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
|
func (f *fakeStore) DeleteDebMetadata(context.Context, string, string) error { return nil }
|
|
|
|
var _ provider.RemoteMetadataStore = (*fakeStore)(nil)
|
|
|
|
// githubFixture serves the releases API and the .apk asset downloads (with Range
|
|
// support) for a set of packages. digest controls whether the asset carries a
|
|
// sha256 digest (change-detection path) or not.
|
|
type githubFixture struct {
|
|
srv *httptest.Server
|
|
apkBytes map[string][]byte
|
|
rangeHit map[string]int
|
|
fullHit map[string]int
|
|
etag string
|
|
releasesHit int
|
|
notModHit int
|
|
releaseAuth string
|
|
assetAuth string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
|
|
t.Helper()
|
|
f := &githubFixture{
|
|
apkBytes: map[string][]byte{},
|
|
rangeHit: map[string]int{},
|
|
fullHit: map[string]int{},
|
|
}
|
|
f.apkBytes["demo-1.2.3-r0.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "x86_64")
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/repos/acme/tools/releases", func(w http.ResponseWriter, r *http.Request) {
|
|
page := r.URL.Query().Get("page")
|
|
if page != "" && page != "1" {
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
f.releasesHit++
|
|
f.releaseAuth = r.Header.Get("Authorization")
|
|
etag := f.etag
|
|
if etag != "" && r.Header.Get("If-None-Match") == etag {
|
|
f.notModHit++
|
|
f.mu.Unlock()
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
f.mu.Unlock()
|
|
if etag != "" {
|
|
w.Header().Set("ETag", etag)
|
|
}
|
|
var assets []map[string]any
|
|
for name := range f.apkBytes {
|
|
a := map[string]any{
|
|
"name": name,
|
|
"size": len(f.apkBytes[name]),
|
|
"browser_download_url": f.srv.URL + "/acme/tools/releases/download/v1.2.3/" + name,
|
|
}
|
|
if withDigest {
|
|
sum := sha256.Sum256(f.apkBytes[name])
|
|
a["digest"] = "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
assets = append(assets, a)
|
|
}
|
|
rel := []map[string]any{{"tag_name": "v1.2.3", "draft": false, "assets": assets}}
|
|
json.NewEncoder(w).Encode(rel)
|
|
})
|
|
mux.HandleFunc("/acme/tools/releases/download/", func(w http.ResponseWriter, r *http.Request) {
|
|
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
|
body, ok := f.apkBytes[name]
|
|
if !ok {
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
rng := r.Header.Get("Range")
|
|
f.mu.Lock()
|
|
f.assetAuth = r.Header.Get("Authorization")
|
|
if rng != "" {
|
|
f.rangeHit[name]++
|
|
} else {
|
|
f.fullHit[name]++
|
|
}
|
|
f.mu.Unlock()
|
|
|
|
if rng == "" {
|
|
w.WriteHeader(200)
|
|
w.Write(body)
|
|
return
|
|
}
|
|
var end int
|
|
fmt.Sscanf(rng, "bytes=0-%d", &end)
|
|
if end >= len(body)-1 {
|
|
end = len(body) - 1
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", end, len(body)))
|
|
w.Header().Set("Content-Length", strconv.Itoa(end+1))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(body[:end+1])
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *githubFixture) remote() models.Remote {
|
|
return models.Remote{
|
|
Name: "acme-apk",
|
|
PackageType: models.PackageGitHubAlpine,
|
|
BaseURL: f.srv.URL + "/repos/acme/tools",
|
|
ReleasesRemote: "github",
|
|
MutableTTL: 3600,
|
|
}
|
|
}
|
|
|
|
func newTestProvider() *GitHubProvider {
|
|
p := newGitHubProvider()
|
|
p.headerInitial = 32 // force the ranged-fetch retry loop against the tiny fixture
|
|
p.headerMax = 1 << 20
|
|
return p
|
|
}
|
|
|
|
const demoPath = "acme/tools/releases/download/v1.2.3/demo-1.2.3-r0.apk"
|
|
|
|
func TestGitHubScanDerivesPkginfoFromPrefix(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
|
|
if err := p.scan(context.Background(), fx.remote(), store); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
|
|
metas, _ := store.ListAlpineMetadataEntries(context.Background(), "acme-apk")
|
|
if len(metas) != 1 {
|
|
t.Fatalf("want 1 metadata row, got %d", len(metas))
|
|
}
|
|
m := metas[0]
|
|
if m.Name != "demo" || m.Version != "1.2.3-r0" || m.Arch != "x86_64" {
|
|
t.Fatalf("bad .PKGINFO fields: %+v", m)
|
|
}
|
|
if m.FilePath != demoPath {
|
|
t.Fatalf("FilePath = %q, want %q", m.FilePath, demoPath)
|
|
}
|
|
if int(m.DownloadSize) != len(fx.apkBytes["demo-1.2.3-r0.apk"]) {
|
|
t.Fatalf("DownloadSize = %d, want %d", m.DownloadSize, len(fx.apkBytes["demo-1.2.3-r0.apk"]))
|
|
}
|
|
if !strings.HasPrefix(m.Checksum, "Q1") {
|
|
t.Fatalf("Checksum not a Q1 pull checksum: %q", m.Checksum)
|
|
}
|
|
// The C: checksum must equal Q1 over the raw control gzip stream, matching the
|
|
// local-upload parser applied to the same bytes.
|
|
want, err := parseApk(fx.apkBytes["demo-1.2.3-r0.apk"])
|
|
if err != nil {
|
|
t.Fatalf("reference parseApk: %v", err)
|
|
}
|
|
if m.Checksum != want.Checksum {
|
|
t.Fatalf("Checksum = %q, want %q (Q1 of control stream)", m.Checksum, want.Checksum)
|
|
}
|
|
if fx.fullHit["demo-1.2.3-r0.apk"] != 0 {
|
|
t.Fatalf("expected no full download, got %d", fx.fullHit["demo-1.2.3-r0.apk"])
|
|
}
|
|
if fx.rangeHit["demo-1.2.3-r0.apk"] == 0 {
|
|
t.Fatalf("expected ranged .PKGINFO fetch")
|
|
}
|
|
}
|
|
|
|
func TestGitHubServeRemoteIndexAndRedirect(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
const proxyBase = "https://artifactapi.example"
|
|
|
|
// The per-arch index is served and triggers the initial scan.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-apk/x86_64/APKINDEX.tar.gz", nil)
|
|
if !p.ServeRemote(rec, req, remote, "x86_64/APKINDEX.tar.gz", proxyBase, store) {
|
|
t.Fatal("ServeRemote did not handle APKINDEX")
|
|
}
|
|
if rec.Code != 200 {
|
|
t.Fatalf("APKINDEX bad: code=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
idx := readAPKIndex(t, rec.Body.Bytes())
|
|
if !strings.Contains(idx, "P:demo") || !strings.Contains(idx, "A:x86_64") {
|
|
t.Fatalf("APKINDEX missing package record: %s", idx)
|
|
}
|
|
if !strings.Contains(idx, "C:Q1") {
|
|
t.Fatalf("APKINDEX missing pull checksum: %s", idx)
|
|
}
|
|
|
|
// A different arch yields an empty (but valid) index.
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
if !p.ServeRemote(rec, req, remote, "aarch64/APKINDEX.tar.gz", proxyBase, store) {
|
|
t.Fatal("ServeRemote did not handle aarch64 APKINDEX")
|
|
}
|
|
if rec.Code != 200 {
|
|
t.Fatalf("empty-arch index bad: %d", rec.Code)
|
|
}
|
|
if got := readAPKIndex(t, rec.Body.Bytes()); strings.Contains(got, "P:demo") {
|
|
t.Fatalf("aarch64 index should not carry the x86_64 package: %s", got)
|
|
}
|
|
|
|
// An .apk request arrives in apk's reconstructed shape
|
|
// "<arch>/<name>-<version>.apk" (APKINDEX carries no filename), NOT as the
|
|
// github-relative FilePath. ServeRemote must resolve it back to the stored
|
|
// FilePath before redirecting to the backend releases_remote.
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-apk/x86_64/demo-1.2.3-r0.apk", nil)
|
|
if !p.ServeRemote(rec, req, remote, "x86_64/demo-1.2.3-r0.apk", proxyBase, store) {
|
|
t.Fatal("ServeRemote did not handle .apk")
|
|
}
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("want 302, got %d", rec.Code)
|
|
}
|
|
wantLoc := proxyBase + "/api/v1/remote/github/" + demoPath
|
|
if got := rec.Header().Get("Location"); got != wantLoc {
|
|
t.Fatalf("Location = %q, want %q (must be the stored FilePath, not the inbound path)", got, wantLoc)
|
|
}
|
|
}
|
|
|
|
// An apk download whose reconstructed "<arch>/<name>-<version>.apk" matches no
|
|
// cached row must 404, never redirect to a bad path.
|
|
func TestGitHubServeRemoteApkRedirectNotFound(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
|
|
// Warm the cache so the store is populated but lacks the requested package.
|
|
if err := p.scan(context.Background(), remote, store); err != nil {
|
|
t.Fatalf("warm scan: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-apk/x86_64/nope-9.9.9.apk", nil)
|
|
if !p.ServeRemote(rec, req, remote, "x86_64/nope-9.9.9.apk", "https://x", store) {
|
|
t.Fatal("ServeRemote did not handle .apk")
|
|
}
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404 for unknown package, got %d (Location=%q)", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
// apk requests the index at "./<arch>/APKINDEX.tar.gz"; ServeRemote must collapse
|
|
// the dot-segment and synthesize the same index as the un-prefixed request.
|
|
func TestGitHubServeRemoteApkDotSegment(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
const proxyBase = "https://artifactapi.example"
|
|
|
|
serve := func(path string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-apk/"+path, nil)
|
|
if !p.ServeRemote(rec, req, remote, path, proxyBase, store) {
|
|
t.Fatalf("ServeRemote did not handle %q", path)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
plain, dotted := serve("x86_64/APKINDEX.tar.gz"), serve("./x86_64/APKINDEX.tar.gz")
|
|
if plain.Code != 200 || dotted.Code != 200 {
|
|
t.Fatalf("index: plain=%d dotted=%d, want 200/200", plain.Code, dotted.Code)
|
|
}
|
|
if !bytes.Equal(plain.Body.Bytes(), dotted.Body.Bytes()) {
|
|
t.Error("./<arch>/APKINDEX.tar.gz body differs from the un-prefixed body")
|
|
}
|
|
}
|
|
|
|
func TestGitHubServeRemoteRejectsNonPerArchIndex(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
if !p.ServeRemote(rec, req, fx.remote(), "APKINDEX.tar.gz", "https://x", store) {
|
|
t.Fatal("expected handled")
|
|
}
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("bare APKINDEX must 404 (per-arch required), got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// A canceled inbound request must still serve the warm cache (detached context),
|
|
// not turn the metadata read into a 500.
|
|
func TestGitHubServeRemoteCanceledRequestServesCache(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
|
|
if err := p.scan(context.Background(), remote, store); err != nil {
|
|
t.Fatalf("warm scan: %v", err)
|
|
}
|
|
p.mu.Lock()
|
|
p.lastScan[remote.Name] = time.Now()
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/remote/acme-apk/x86_64/APKINDEX.tar.gz", nil).WithContext(ctx)
|
|
|
|
if !p.ServeRemote(rec, req, remote, "x86_64/APKINDEX.tar.gz", "https://x", store) {
|
|
t.Fatal("ServeRemote did not handle APKINDEX")
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("canceled request must serve cache, not error; got code=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := readAPKIndex(t, rec.Body.Bytes()); !strings.Contains(got, "P:demo") {
|
|
t.Fatalf("expected index served from cache, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestGitHubServeRemoteRedirectRequiresReleasesRemote(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
remote.ReleasesRemote = ""
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
if !p.ServeRemote(rec, req, remote, demoPath, "https://x", store) {
|
|
t.Fatal("expected handled")
|
|
}
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("want 500 when releases_remote unset, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGitHubScanPrunesRemovedAssets(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
|
|
if err := p.scan(context.Background(), fx.remote(), store); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if rows, _ := store.ListAlpineMetadataEntries(context.Background(), "acme-apk"); len(rows) != 1 {
|
|
t.Fatalf("want 1 row after first scan, got %d", len(rows))
|
|
}
|
|
|
|
delete(fx.apkBytes, "demo-1.2.3-r0.apk")
|
|
if err := p.scan(context.Background(), fx.remote(), store); err != nil {
|
|
t.Fatalf("rescan: %v", err)
|
|
}
|
|
if rows, _ := store.ListAlpineMetadataEntries(context.Background(), "acme-apk"); len(rows) != 0 {
|
|
t.Fatalf("want 0 rows after prune, got %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestGitHubAssetPatternFilter(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
fx.apkBytes["other-9-r0.apk"] = testsupport.MinimalApk("other", "9-r0", "aarch64")
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
remote.Patterns = []string{`^demo-.*\.apk$`}
|
|
|
|
if err := p.scan(context.Background(), remote, store); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
rows, _ := store.ListAlpineMetadataEntries(context.Background(), "acme-apk")
|
|
if len(rows) != 1 || rows[0].Name != "demo" {
|
|
t.Fatalf("pattern filter failed, rows=%+v", rows)
|
|
}
|
|
}
|
|
|
|
// Multi-arch: each asset's index record lands under its own arch bucket.
|
|
func TestGitHubServeRemotePerArchGrouping(t *testing.T) {
|
|
fx := newGitHubFixture(t, true)
|
|
fx.apkBytes["demo-1.2.3-r0-aarch64.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "aarch64")
|
|
p := newTestProvider()
|
|
store := newFakeStore()
|
|
remote := fx.remote()
|
|
const proxyBase = "https://x"
|
|
|
|
if err := p.scan(context.Background(), remote, store); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
|
|
serve := func(arch string) string {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
if !p.ServeRemote(rec, req, remote, arch+"/APKINDEX.tar.gz", proxyBase, store) {
|
|
t.Fatalf("ServeRemote did not handle %s", arch)
|
|
}
|
|
return readAPKIndex(t, rec.Body.Bytes())
|
|
}
|
|
|
|
x86 := serve("x86_64")
|
|
if !strings.Contains(x86, "A:x86_64") || strings.Contains(x86, "A:aarch64") {
|
|
t.Fatalf("x86_64 index leaked another arch: %s", x86)
|
|
}
|
|
arm := serve("aarch64")
|
|
if !strings.Contains(arm, "A:aarch64") || strings.Contains(arm, "A:x86_64") {
|
|
t.Fatalf("aarch64 index leaked another arch: %s", arm)
|
|
}
|
|
}
|
|
|
|
func readAPKIndex(t *testing.T, gzBytes []byte) string {
|
|
t.Helper()
|
|
gz, err := gzip.NewReader(bytes.NewReader(gzBytes))
|
|
if err != nil {
|
|
t.Fatalf("gzip: %v", err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err != nil {
|
|
t.Fatal("APKINDEX member missing from tar.gz")
|
|
}
|
|
if strings.TrimPrefix(hdr.Name, "./") == "APKINDEX" {
|
|
body, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
t.Fatalf("read APKINDEX: %v", err)
|
|
}
|
|
return string(body)
|
|
}
|
|
}
|
|
}
|