\n")
- sb.WriteString(`{"meta":{"api-version":"1.1"},"name":"big","files":[`)
- jb.WriteString(`{"info":{"name":"big","version":"1.0.0"},"releases":{`)
- n := 0
- for v := range versions {
- version := fmt.Sprintf("1.%d.0", v)
- if v > 0 {
- jb.WriteByte(',')
- }
- fmt.Fprintf(&jb, "%q:[", version)
- for f := range filesPerVersion {
- filename := fmt.Sprintf("big-%s-py3-none-any%d.whl", version, f)
- if f == 1 {
- filename = fmt.Sprintf("big-%s.tar.gz", version)
- }
- url := fmt.Sprintf("https://files.pythonhosted.org/packages/%02x/%02x/%060x/%s", v%256, f, v*filesPerVersion+f, filename)
- fmt.Fprintf(&hb, " %s \n", url, n, n, n, filename)
- if n > 0 {
- sb.WriteByte(',')
- }
- fmt.Fprintf(&sb, `{"filename":%q,"url":%q,"hashes":{"sha256":"%064x"},"requires-python":">=3.8","core-metadata":{"sha256":"%064x"},"size":12345,"upload-time":"2021-01-02T03:04:05.123456Z","yanked":false}`, filename, url, n, n)
- if f > 0 {
- jb.WriteByte(',')
- }
- fmt.Fprintf(&jb, `{"filename":%q,"url":%q,"digests":{"sha256":"%064x","md5":"%032x"},"packagetype":"bdist_wheel","python_version":"py3","size":12345,"upload_time_iso_8601":"2021-01-02T03:04:05.123456Z","yanked":false}`, filename, url, n, n)
- n++
- }
- jb.WriteByte(']')
- }
- hb.WriteString(" \n\n\n")
- sb.WriteString("]}")
- jb.WriteString(`},"urls":[]}`)
- return hb.String(), sb.String(), jb.String()
-}
-
-func benchPyPIHandler(b *testing.B, deny ...string) http.Handler {
- b.Helper()
- simpleHTML, simpleJSON, jsonAPI := benchPyPIProject(2000, 2)
- h, proxy := setupPyPIHandler(b, func(r *http.Request) (*http.Response, error) {
- switch {
- case r.URL.Path == "/pypi/big/json":
- return pypiHTTPResponse(r, "application/json", jsonAPI), nil
- case r.Header.Get("Accept") == pypiSimpleJSON:
- return pypiHTTPResponse(r, pypiSimpleJSON, simpleJSON), nil
- default:
- return pypiHTTPResponse(r, "text/html", simpleHTML), nil
- }
- })
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(256 << 20)
- if len(deny) > 0 {
- setTestDenylist(b, proxy, deny...)
- }
- return h.Routes()
-}
-
-func BenchmarkPyPISimpleHTMLRequest(b *testing.B) {
- benchServe(b, benchPyPIHandler(b), "/simple/big/", "")
-}
-
-// BenchmarkPyPISimpleHTMLFilteredRequest has a version denied, so the page
-// is filtered as well as rewritten.
-func BenchmarkPyPISimpleHTMLFilteredRequest(b *testing.B) {
- benchServe(b, benchPyPIHandler(b, "pkg:pypi/big@1.5.0"), "/simple/big/", "")
-}
-
-func BenchmarkPyPISimpleJSONRequest(b *testing.B) {
- benchServe(b, benchPyPIHandler(b), "/simple/big/", pypiSimpleJSON)
-}
-
-func BenchmarkPyPIJSONAPIRequest(b *testing.B) {
- benchServe(b, benchPyPIHandler(b), "/pypi/big/json", "")
-}
-
-// BenchmarkPyPIRewriteSimpleHTML is the rewrite a cache miss pays.
-func BenchmarkPyPIRewriteSimpleHTML(b *testing.B) {
- simpleHTML, _, _ := benchPyPIProject(2000, 2)
- body := []byte(simpleHTML)
- h := NewPyPIHandlerWithUpstreams(testProxy(), "http://proxy.example", "", "")
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- _ = h.rewriteSimpleHTML(body, nil)
- }
-}
-
-// BenchmarkPyPIRewriteSimpleHTMLFiltered is the same with a version filtered
-// out.
-func BenchmarkPyPIRewriteSimpleHTMLFiltered(b *testing.B) {
- simpleHTML, _, _ := benchPyPIProject(2000, 2)
- body := []byte(simpleHTML)
- h := NewPyPIHandlerWithUpstreams(testProxy(), "http://proxy.example", "", "")
- filtered := map[string]bool{"1.5.0": true}
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- _ = h.rewriteSimpleHTML(h.filterSimpleHTMLLinks(body, filtered), filtered)
- }
-}
diff --git a/internal/handler/helm_pypi_rewrite_test.go b/internal/handler/helm_pypi_rewrite_test.go
deleted file mode 100644
index 3500f8e..0000000
--- a/internal/handler/helm_pypi_rewrite_test.go
+++ /dev/null
@@ -1,308 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "net/http/httptest"
- "regexp"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// getRewritten requests target and checks the response status.
-func getRewritten(t *testing.T, h http.Handler, target string, header http.Header, want int) *httptest.ResponseRecorder {
- t.Helper()
- req := httptest.NewRequest(http.MethodGet, target, nil)
- for k, v := range header {
- req.Header[k] = v
- }
- rec := httptest.NewRecorder()
- h.ServeHTTP(rec, req)
- if rec.Code != want {
- t.Fatalf("GET %s: status %d, want %d: %s", target, rec.Code, want, rec.Body.String())
- }
- return rec
-}
-
-// checkServedFromRewriteCache requests target twice more after a first
-// request, with storage failing, and checks the repeat is the cached rewrite:
-// same body and ETag, 304 for the ETag, gzip for a client accepting it.
-func checkServedFromRewriteCache(t *testing.T, h http.Handler, store *mockStorage, target string, header http.Header) string {
- t.Helper()
- first := getRewritten(t, h, target, header, http.StatusOK)
- etag := first.Header().Get(headerETag)
- if etag == "" {
- t.Fatalf("no ETag on rewritten metadata; headers %v", first.Header())
- }
-
- // The repeat must not read the stored document back.
- store.mu.Lock()
- store.openErr = errors.New("storage must not be read")
- store.mu.Unlock()
- t.Cleanup(func() {
- store.mu.Lock()
- store.openErr = nil
- store.mu.Unlock()
- })
-
- second := getRewritten(t, h, target, header, http.StatusOK)
- if second.Body.String() != first.Body.String() || second.Header().Get(headerETag) != etag {
- t.Errorf("repeat differs: ETag %q vs %q", second.Header().Get(headerETag), etag)
- }
- if got, want := second.Header().Get(headerContentType), first.Header().Get(headerContentType); got != want {
- t.Errorf("repeat Content-Type = %q, want %q", got, want)
- }
-
- conditional := header.Clone()
- if conditional == nil {
- conditional = http.Header{}
- }
- conditional.Set("If-None-Match", etag)
- getRewritten(t, h, target, conditional, http.StatusNotModified)
-
- gzipped := header.Clone()
- if gzipped == nil {
- gzipped = http.Header{}
- }
- gzipped.Set(headerAcceptEncoding, "gzip")
- compressed := getRewritten(t, h, target, gzipped, http.StatusOK)
- if got := compressed.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("Content-Encoding = %q, want gzip", got)
- }
- return first.Body.String()
-}
-
-func TestHelmIndexServedFromRewriteCache(t *testing.T) {
- digest := strings.Repeat("a", 64)
- var indexRequests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- indexRequests.Add(1)
- w.Header().Set("Content-Type", "application/x-yaml")
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n%s", strings.Repeat(fmt.Sprintf(
- " - digest: %s\n name: demo\n urls: [demo-1.0.0.tgz]\n version: 1.0.0\n", digest), 20))
- }))
- defer upstream.Close()
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL}).Routes()
-
- body := checkServedFromRewriteCache(t, h, store, "/stable/index.yaml", nil)
- if !strings.Contains(body, "http://proxy.example/helm/stable/charts/"+digest+"/demo-1.0.0.tgz") {
- t.Errorf("index not rewritten: %s", body)
- }
- if got := indexRequests.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
- if got := proxy.rewrites.len(); got != 1 {
- t.Errorf("cached rewrites = %d, want 1", got)
- }
-}
-
-// TestHelmChartLookupFollowsIndex checks that chart downloads are looked up
-// in charts kept from the last parse of the same index, and that a changed
-// index is parsed again.
-func TestHelmChartLookupFollowsIndex(t *testing.T) {
- chartA, chartB := []byte("chart a"), []byte("chart b")
- digestA, digestB := helmSHA256Hex(chartA), helmSHA256Hex(chartB)
- var withB atomic.Bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/index.yaml":
- w.Header().Set("Content-Type", "application/x-yaml")
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n a:\n - digest: %s\n urls: [a-1.0.0.tgz]\n", digestA)
- if withB.Load() {
- _, _ = fmt.Fprintf(w, " b:\n - digest: %s\n urls: [b-1.0.0.tgz]\n", digestB)
- }
- case "/a-1.0.0.tgz":
- _, _ = w.Write(chartA)
- case "/b-1.0.0.tgz":
- _, _ = w.Write(chartB)
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL})
- index := func() metadataResult {
- return h.fetchIndex(context.Background(), "stable", upstream.URL)
- }
-
- first, err := h.findChartDownload("stable", upstream.URL, index(), digestA, "a-1.0.0.tgz")
- if err != nil || first != upstream.URL+"/a-1.0.0.tgz" {
- t.Fatalf("lookup = %q, %v", first, err)
- }
- lookup := h.lookups["stable\x00"+upstream.URL]
- if lookup == nil {
- t.Fatal("chart lookup was not kept")
- }
- if _, err := h.findChartDownload("stable", upstream.URL, index(), digestB, "b-1.0.0.tgz"); !errors.Is(err, errHelmChartNotFound) {
- t.Fatalf("lookup of unlisted chart: %v", err)
- }
- if h.lookups["stable\x00"+upstream.URL] != lookup {
- t.Error("same index was parsed again")
- }
-
- // A new index from upstream lists a new chart.
- withB.Store(true)
- proxy.MetadataTTL = 0
- got, err := h.findChartDownload("stable", upstream.URL, index(), digestB, "b-1.0.0.tgz")
- if err != nil || got != upstream.URL+"/b-1.0.0.tgz" {
- t.Fatalf("lookup in new index = %q, %v", got, err)
- }
-}
-
-func TestPyPIMetadataServedFromRewriteCache(t *testing.T) {
- const files = "https://files.pythonhosted.org/packages/ab/cd/"
- simpleHTML := `demo-1.0.0.tar.gz ` +
- `demo-2.0.0.tar.gz ` +
- strings.Repeat("\n", 20)
- simpleJSON := `{"meta":{"api-version":"1.1"},"name":"demo","files":[` +
- `{"filename":"demo-1.0.0.tar.gz","url":"` + files + `demo-1.0.0.tar.gz"},` +
- `{"filename":"demo-2.0.0.tar.gz","url":"` + files + `demo-2.0.0.tar.gz"}],` +
- `"padding":"` + strings.Repeat("x", 1000) + `"}`
- jsonAPI := `{"info":{"name":"demo","version":"1.0.0","description":"` + strings.Repeat("x", 1000) + `"},` +
- `"releases":{"1.0.0":[{"url":"` + files + `demo-1.0.0.tar.gz"}],"2.0.0":[{"url":"` + files + `demo-2.0.0.tar.gz"}]},` +
- `"urls":[{"url":"` + files + `demo-1.0.0.tar.gz"}]}`
-
- cases := []struct {
- name, target, accept string
- }{
- {"simple html", "/simple/demo/", ""},
- {"simple json", "/simple/demo/", pypiSimpleJSON},
- {"json api", "/pypi/demo/json", ""},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- var upstreamCalls atomic.Int32
- proxy, _, store, _ := setupTestProxy(t)
- proxy.HTTPClient = &http.Client{Transport: pypiRoundTripFunc(func(r *http.Request) (*http.Response, error) {
- upstreamCalls.Add(1)
- switch {
- case r.URL.Path == "/pypi/demo/json":
- return pypiHTTPResponse(r, "application/json", jsonAPI), nil
- case r.Header.Get("Accept") == pypiSimpleJSON:
- return pypiHTTPResponse(r, pypiSimpleJSON, simpleJSON), nil
- default:
- return pypiHTTPResponse(r, "text/html", simpleHTML), nil
- }
- })}
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- setTestDenylist(t, proxy, "pkg:pypi/demo@2.0.0")
- h := NewPyPIHandlerWithUpstreams(proxy, "http://proxy.test", "https://pypi.test", "")
-
- var header http.Header
- if c.accept != "" {
- header = http.Header{"Accept": {c.accept}}
- }
- body := checkServedFromRewriteCache(t, h.Routes(), store, c.target, header)
- if !strings.Contains(body, "http://proxy.test/pypi/packages/packages/ab/cd/demo-1.0.0.tar.gz") {
- t.Errorf("download URL not rewritten: %s", body)
- }
- if strings.Contains(body, "demo-2.0.0") {
- t.Errorf("denied version served: %s", body)
- }
- if got := upstreamCalls.Load(); got != 1 {
- t.Errorf("upstream calls = %d, want 1", got)
- }
-
- if c.target == "/simple/demo/" {
- rec := getRewritten(t, h.Routes(), c.target, header, http.StatusOK)
- vary := strings.Join(rec.Header().Values("Vary"), ", ")
- if !strings.Contains(vary, "Accept-Encoding") || !strings.Contains(strings.ReplaceAll(vary, "Accept-Encoding", ""), "Accept") {
- t.Errorf("Vary = %q, want Accept and Accept-Encoding", vary)
- }
- }
- })
- }
-}
-
-// TestPyPISimpleRewriteFollowsCooldown checks that a cached rewrite of a
-// simple page is not served once the versions cooldown filters out change,
-// though the page itself is the same.
-func TestPyPISimpleRewriteFollowsCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)
- var released atomic.Value
- released.Store(now.Add(-time.Hour).Format(time.RFC3339))
-
- h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- switch r.URL.Path {
- case "/simple/ruff/":
- return pypiHTTPResponse(r, "text/html",
- `ruff-1.0.0.tar.gz`+
- `ruff-2.0.0.tar.gz`), nil
- case "/pypi/ruff/json":
- return pypiHTTPResponse(r, "application/json", `{"releases":{`+
- `"1.0.0":[{"upload_time_iso_8601":"`+old+`"}],`+
- `"2.0.0":[{"upload_time_iso_8601":"`+released.Load().(string)+`"}]}}`), nil
- default:
- t.Fatalf("unexpected upstream path: %s", r.URL.Path)
- return nil, nil
- }
- })
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- first := getRewritten(t, h.Routes(), "/simple/ruff/", nil, http.StatusOK).Body.String()
- if strings.Contains(first, "ruff-2.0.0") || !strings.Contains(first, "ruff-1.0.0") {
- t.Fatalf("first page = %s, want only ruff-1.0.0", first)
- }
-
- released.Store(old)
- second := getRewritten(t, h.Routes(), "/simple/ruff/", nil, http.StatusOK).Body.String()
- if !strings.Contains(second, "ruff-2.0.0") {
- t.Errorf("version out of cooldown still filtered: %s", second)
- }
-}
-
-// rewriteDownloadHrefsRegexp is the regular expression rewrite that
-// rewriteDownloadHrefs replaces.
-func rewriteDownloadHrefsRegexp(h *PyPIHandler, body []byte) []byte {
- re := regexp.MustCompile(`href="(` + regexp.QuoteMeta(h.downloadURL) + `/packages/[^"]+)"`)
- return re.ReplaceAllFunc(body, func(match []byte) []byte {
- submatch := re.FindSubmatch(match)
- return []byte(`href="` + h.proxyURL + "/pypi/packages" + strings.TrimPrefix(string(submatch[1]), h.downloadURL) + `"`)
- })
-}
-
-func FuzzPyPIRewriteDownloadHrefs(f *testing.F) {
- const dl = "https://files.pythonhosted.org"
- for _, seed := range []string{
- `x`,
- `empty`,
- ``,
- ``,
- `x`,
- `x`,
- `x`,
- `href="` + dl + `/packages/` + "\n" + `multi line"`,
- `href="href="` + dl + `/packages/x"`,
- } {
- f.Add(seed)
- }
- h := NewPyPIHandlerWithUpstreams(testProxy(), "http://proxy.test", "", dl)
- f.Fuzz(func(t *testing.T, body string) {
- want := rewriteDownloadHrefsRegexp(h, []byte(body))
- if got := h.rewriteDownloadHrefs([]byte(body)); string(got) != string(want) {
- t.Errorf("rewriteDownloadHrefs(%q)\n = %q\nwant %q", body, got, want)
- }
- })
-}
diff --git a/internal/handler/helm_test.go b/internal/handler/helm_test.go
deleted file mode 100644
index 837934a..0000000
--- a/internal/handler/helm_test.go
+++ /dev/null
@@ -1,349 +0,0 @@
-package handler
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "net/http"
- "net/http/httptest"
- "slices"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
- "github.com/git-pkgs/registries/fetch"
- "gopkg.in/yaml.v3"
-)
-
-func TestHelmHandler_RewritesIndexAndCachesChart(t *testing.T) {
- chart := []byte("a Helm chart")
- digest := helmSHA256Hex(chart)
- var available atomic.Bool
- available.Store(true)
- var indexRequests atomic.Int32
- var chartRequests atomic.Int32
-
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !available.Load() {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- switch r.URL.Path {
- case "/charts/index.yaml":
- indexRequests.Add(1)
- w.Header().Set("Content-Type", "application/x-yaml")
- _, _ = fmt.Fprintf(w, `apiVersion: v1
-entries:
- demo:
- - annotations:
- example.com/retained: "true"
- created: 2020-01-02T03:04:05Z
- digest: %s
- name: demo
- urls:
- - demo-1.0.0.tgz
- - %s/charts/mirror/demo-1.0.0.tgz
- version: 1.0.0
-generated: 2020-01-02T03:04:05Z
-`, digest, upstream.URL)
- case "/charts/demo-1.0.0.tgz", "/charts/mirror/demo-1.0.0.tgz":
- chartRequests.Add(1)
- w.Header().Set("Content-Type", "application/gzip")
- _, _ = w.Write(chart)
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
-
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL + "/charts"})
-
- indexResponse := serveHelmRequest(h, "/stable/index.yaml")
- if indexResponse.Code != http.StatusOK {
- t.Fatalf("index status = %d, want 200: %s", indexResponse.Code, indexResponse.Body.String())
- }
- if got := indexResponse.Header().Get("Content-Type"); got != "application/x-yaml" {
- t.Errorf("index Content-Type = %q, want application/x-yaml", got)
- }
- if strings.Contains(indexResponse.Body.String(), upstream.URL) {
- t.Errorf("rewritten index contains upstream URL: %s", indexResponse.Body.String())
- }
- if !strings.Contains(indexResponse.Body.String(), "example.com/retained") {
- t.Errorf("rewritten index lost an unrelated field: %s", indexResponse.Body.String())
- }
-
- var index map[string]any
- if err := yaml.Unmarshal(indexResponse.Body.Bytes(), &index); err != nil {
- t.Fatalf("parse rewritten index: %v", err)
- }
- entries := index["entries"].(map[string]any)
- release := entries["demo"].([]any)[0].(map[string]any)
- urls := release["urls"].([]any)
- wantURL := "http://proxy.example/helm/stable/charts/" + digest + "/demo-1.0.0.tgz"
- for _, rawURL := range urls {
- if rawURL != wantURL {
- t.Errorf("rewritten URL = %q, want %q", rawURL, wantURL)
- }
- }
-
- firstChart := serveHelmRequest(h, "/stable/charts/"+digest+"/demo-1.0.0.tgz")
- if firstChart.Code != http.StatusOK {
- t.Fatalf("chart status = %d, want 200: %s", firstChart.Code, firstChart.Body.String())
- }
- if got := firstChart.Body.String(); got != string(chart) {
- t.Errorf("chart body = %q, want %q", got, chart)
- }
- if got := firstChart.Header().Get("Content-Type"); got != "application/gzip" {
- t.Errorf("chart Content-Type = %q, want application/gzip", got)
- }
-
- // Artifact cache availability must not depend on metadata caching or a
- // reachable index upstream.
- proxy.CacheMetadata = false
- available.Store(false)
- cachedChart := serveHelmRequest(h, "/stable/charts/"+digest+"/demo-1.0.0.tgz")
- if cachedChart.Code != http.StatusOK {
- t.Fatalf("cached chart status = %d, want 200: %s", cachedChart.Code, cachedChart.Body.String())
- }
- if got := cachedChart.Body.String(); got != string(chart) {
- t.Errorf("cached chart body = %q, want %q", got, chart)
- }
- if got := indexRequests.Load(); got != 1 {
- t.Errorf("index requests = %d, want 1", got)
- }
- if got := chartRequests.Load(); got != 1 {
- t.Errorf("chart requests = %d, want 1", got)
- }
-}
-
-func TestHelmHandler_RejectsChartDigestMismatch(t *testing.T) {
- chart := []byte("tampered chart")
- digest := helmSHA256Hex([]byte("expected chart"))
- requests := 0
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/index.yaml":
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", digest)
- case "/demo.tgz":
- requests++
- _, _ = w.Write(chart)
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"test": upstream.URL})
-
- for range 2 {
- response := serveHelmRequest(h, "/test/charts/"+digest+"/demo.tgz")
- if response.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want 502: %s", response.Code, response.Body.String())
- }
- }
- if requests != 2 {
- t.Errorf("chart requests = %d, want 2 after invalid cache entry is cleared", requests)
- }
- queued, err := proxy.DB.GetDuePendingDeletes(time.Now().Add(time.Hour), 10)
- if err != nil {
- t.Fatalf("listing queued deletes: %v", err)
- }
- chartDir := helmMetadataEcosystem + "/test/" + digest + "/"
- stored := 0
- for path := range store.files {
- if !strings.HasPrefix(path, chartDir) {
- continue
- }
- stored++
- if !slices.Contains(queued, path) {
- t.Errorf("rejected chart at %q is not queued for deletion", path)
- }
- }
- if stored != 0 {
- t.Errorf("found %d stored charts, want none: a mismatch is discarded before it is committed", stored)
- }
-}
-
-func TestHelmHandler_IndexCacheChangesWithUpstreamURL(t *testing.T) {
- firstDigest := strings.Repeat("a", sha256HexLength)
- secondDigest := strings.Repeat("b", sha256HexLength)
- firstRequests := 0
- secondRequests := 0
- first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- firstRequests++
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", firstDigest)
- }))
- defer first.Close()
- second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- secondRequests++
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", secondDigest)
- }))
- defer second.Close()
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = first.Client()
- firstHandler := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": first.URL})
- if response := serveHelmRequest(firstHandler, "/stable/index.yaml"); response.Code != http.StatusOK {
- t.Fatalf("first index status = %d, want 200: %s", response.Code, response.Body.String())
- }
-
- // Model a restarted server with the same database and storage but a changed
- // repository URL. Its cache key must not reuse the previous index or ETag.
- restartedProxy := NewProxy(db, store, &mockFetcher{}, fetch.NewResolver(), nil)
- restartedProxy.CacheMetadata = true
- restartedProxy.MetadataTTL = time.Hour
- restartedProxy.HTTPClient = second.Client()
- secondHandler := NewHelmHandler(restartedProxy, "http://proxy.example", map[string]string{"stable": second.URL})
- response := serveHelmRequest(secondHandler, "/stable/index.yaml")
- if response.Code != http.StatusOK {
- t.Fatalf("second index status = %d, want 200: %s", response.Code, response.Body.String())
- }
- if !strings.Contains(response.Body.String(), secondDigest) {
- t.Errorf("second index did not use the new upstream: %s", response.Body.String())
- }
- if firstRequests != 1 {
- t.Errorf("first upstream requests = %d, want 1", firstRequests)
- }
- if secondRequests != 1 {
- t.Errorf("second upstream requests = %d, want 1", secondRequests)
- }
-}
-
-func TestHelmHandler_UsesConfiguredUpstreamAuthentication(t *testing.T) {
- chart := []byte("private Helm chart")
- digest := helmSHA256Hex(chart)
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("Authorization") != "Bearer private-token" {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- switch r.URL.Path {
- case "/index.yaml":
- _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n - digest: %s\n urls: [demo.tgz]\n", digest)
- case "/demo.tgz":
- _, _ = w.Write(chart)
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- authClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport,
- upstreamhttp.AuthFunc(func(string) (string, string) {
- return "Authorization", "Bearer private-token"
- }))}
- proxy.HTTPClient = authClient
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(authClient), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"private": upstream.URL})
-
- response := serveHelmRequest(h, "/private/charts/"+digest+"/demo.tgz")
- if response.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", response.Code, response.Body.String())
- }
- if got := response.Body.String(); got != string(chart) {
- t.Errorf("body = %q, want %q", got, chart)
- }
-}
-
-func TestHelmHandler_FiltersNewChartsFromIndex(t *testing.T) {
- oldDigest := strings.Repeat("a", 64)
- newDigest := strings.Repeat("b", 64)
- proxy := &Proxy{Cooldown: &cooldown.Config{Default: "3d"}}
- h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"test": "https://charts.example"})
-
- body := fmt.Sprintf(`apiVersion: v1
-entries:
- demo:
- - created: %s
- digest: %s
- urls: [demo-old.tgz]
- - created: %s
- digest: %s
- urls: [demo-new.tgz]
-`, time.Now().Add(-10*24*time.Hour).Format(time.RFC3339), oldDigest,
- time.Now().Add(-time.Hour).Format(time.RFC3339), newDigest)
-
- rewritten, _, err := h.rewriteIndex("test", "https://charts.example", []byte(body))
- if err != nil {
- t.Fatalf("rewriteIndex() error = %v", err)
- }
- if strings.Contains(string(rewritten), newDigest) {
- t.Errorf("rewritten index includes a chart still in cooldown: %s", rewritten)
- }
- if !strings.Contains(string(rewritten), oldDigest) {
- t.Errorf("rewritten index omitted an old chart: %s", rewritten)
- }
-}
-
-func TestNormalizeHelmDigest(t *testing.T) {
- digest := strings.Repeat("a", 64)
- for _, input := range []string{digest, "sha256:" + digest, "SHA256:" + strings.ToUpper(digest)} {
- if got, ok := normalizeHelmDigest(input); !ok || got != digest {
- t.Errorf("normalizeHelmDigest(%q) = %q, %t; want %q, true", input, got, ok, digest)
- }
- }
- if _, ok := normalizeHelmDigest("bad"); ok {
- t.Error("normalizeHelmDigest accepted an invalid digest")
- }
-}
-
-func TestHelmIndexEntriesRejectsIncompleteMapping(t *testing.T) {
- entries := &yaml.Node{
- Kind: yaml.MappingNode,
- Content: []*yaml.Node{
- {Kind: yaml.ScalarNode, Value: "demo"},
- },
- }
- document := &yaml.Node{
- Kind: yaml.DocumentNode,
- Content: []*yaml.Node{{
- Kind: yaml.MappingNode,
- Content: []*yaml.Node{
- {Kind: yaml.ScalarNode, Value: "entries"},
- entries,
- },
- }},
- }
-
- if _, err := helmIndexEntries(document); err == nil {
- t.Fatal("helmIndexEntries() error = nil, want incomplete mapping error")
- }
-}
-
-func serveHelmRequest(h *HelmHandler, target string) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
- return w
-}
-
-func helmSHA256Hex(data []byte) string {
- digest := sha256.Sum256(data)
- return hex.EncodeToString(digest[:])
-}
diff --git a/internal/handler/hex.go b/internal/handler/hex.go
index d394c4d..7a34795 100644
--- a/internal/handler/hex.go
+++ b/internal/handler/hex.go
@@ -1,34 +1,20 @@
package handler
import (
- "bytes"
- "compress/gzip"
- "encoding/json"
"fmt"
+ "io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/retention"
- "google.golang.org/protobuf/encoding/protowire"
)
const (
hexUpstream = "https://repo.hex.pm"
)
-// Tarballs are stored under pkg:hex/{name} with the name from the tarball
-// filename, and purl keeps hex names as they are, so package rules use the
-// default key mapping.
-func init() {
- retention.Default.Register(retention.Spec{Key: "hex", CanonicalPackage: retention.DefaultCanonical("hex")})
-}
-
// HexHandler handles Hex.pm registry protocol requests.
type HexHandler struct {
proxy *Proxy
upstreamURL string
- apiURL string
proxyURL string
}
@@ -37,20 +23,10 @@ func NewHexHandler(proxy *Proxy, proxyURL string) *HexHandler {
return &HexHandler{
proxy: proxy,
upstreamURL: hexUpstream,
- apiURL: hexAPIURL,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
-// NewHexHandlerWithUpstreams creates a Hex handler with custom repository and
-// API upstreams.
-func NewHexHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, apiURL string) *HexHandler {
- h := NewHexHandler(proxy, proxyURL)
- h.upstreamURL = configuredUpstreamURL(upstreamURL, hexUpstream)
- h.apiURL = configuredUpstreamURL(apiURL, hexAPIURL)
- return h
-}
-
// Routes returns the HTTP handler for Hex requests.
func (h *HexHandler) Routes() http.Handler {
mux := http.NewServeMux()
@@ -58,10 +34,10 @@ func (h *HexHandler) Routes() http.Handler {
// Package tarballs (cache these)
mux.HandleFunc("GET /tarballs/{filename}", h.handleDownload)
- // Registry resources (cached for offline)
- mux.HandleFunc("GET /names", h.proxyCached)
- mux.HandleFunc("GET /versions", h.proxyCached)
- mux.HandleFunc("GET /packages/{name}", h.handlePackages)
+ // Registry resources (proxy without caching)
+ mux.HandleFunc("GET /names", h.proxyUpstream)
+ mux.HandleFunc("GET /versions", h.proxyUpstream)
+ mux.HandleFunc("GET /packages/{name}", h.proxyUpstream)
// Public keys
mux.HandleFunc("GET /public_key", h.proxyUpstream)
@@ -71,371 +47,82 @@ func (h *HexHandler) Routes() http.Handler {
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
- h.proxy.handleFilenameDownload(w, r, filenameDownload{
- ecosystem: "hex",
- upstreamURL: h.upstreamURL,
- suffix: ".tar",
- parseErr: "could not parse tarball filename",
- fetchErr: "failed to fetch package",
- parse: h.parseTarballFilename,
- publishTime: h.publishTime,
- })
+ filename := r.PathValue("filename")
+ if filename == "" || !strings.HasSuffix(filename, ".tar") {
+ http.Error(w, "invalid filename", http.StatusBadRequest)
+ return
+ }
+
+ // Extract name and version from filename (e.g., "phoenix-1.7.10.tar")
+ name, version := h.parseTarballFilename(filename)
+ if name == "" || version == "" {
+ http.Error(w, "could not parse tarball filename", http.StatusBadRequest)
+ return
+ }
+
+ h.proxy.Logger.Info("hex download request",
+ "name", name, "version", version, "filename", filename)
+
+ result, err := h.proxy.GetOrFetchArtifact(r.Context(), "hex", name, version, filename)
+ if err != nil {
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ return
+ }
+
+ ServeArtifact(w, result)
}
// parseTarballFilename extracts name and version from a hex tarball filename.
// e.g., "phoenix-1.7.10.tar" -> ("phoenix", "1.7.10")
-// e.g., "amqp_client-4.1.5-1.tar" -> ("amqp_client", "4.1.5-1")
-//
-// Hex package names can't contain a hyphen, so the first one separates the
-// name from a version that may carry hyphens of its own.
func (h *HexHandler) parseTarballFilename(filename string) (name, version string) {
- name, version, ok := strings.Cut(strings.TrimSuffix(filename, ".tar"), "-")
- if !ok || name == "" || version == "" {
- return "", ""
- }
- return name, version
-}
+ base := strings.TrimSuffix(filename, ".tar")
-// hexAPIURL is the Hex HTTP API base URL for fetching package metadata with timestamps.
-const hexAPIURL = "https://hex.pm"
-
-// handlePackages proxies the /packages/{name} endpoint, applying cooldown filtering
-// when enabled. Since the protobuf format has no timestamps, we fetch them from the
-// Hex HTTP API concurrently.
-//
-// Both are read through the metadata cache, so within metadata_ttl a package
-// costs no request to repo.hex.pm or to the rate limited Hex API, and after
-// it both are revalidated with their ETags. The filtered package is kept in
-// the rewrite cache and served with an ETag of its own.
-func (h *HexHandler) handlePackages(w http.ResponseWriter, r *http.Request) {
- name := r.PathValue("name")
- if name == "" || !h.proxy.cooldownApplies("hex", name) {
- h.proxyCached(w, r)
- return
- }
-
- h.proxy.Logger.Info("hex package request with cooldown", "name", name)
-
- docs, err := h.proxy.fetchTimedMetadata(r.Context(), h.timedPackage(name), h.packageVersions)
- switch {
- case docs.doc.err != nil:
- h.proxy.writeMetadataFetchError(w, docs.doc.err)
- return
- case docs.times.err != nil:
- h.proxy.writeCooldownLookupError(w, "hex", name, docs.times.err)
- return
- case err != nil:
- h.proxy.Logger.Error("failed to read hex package", "name", name, "error", err)
- http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
- return
- }
-
- packagePURL := canonicalPackagePURL("hex", name)
- m, err := h.proxy.cachedRewrite(r.Context(), "hex", h.proxyURL, "packages/"+name, docs.doc.body, docs.rewriteDigest(),
- func([]byte) ([]byte, []string, error) {
- body, err := h.proxy.decodedMetadataBody(docs.doc)
- if err != nil {
- return nil, nil, err
- }
- versions, err := h.packageVersions(docs.doc)
- if err != nil {
- return nil, nil, err
- }
- filtered := make(map[string]bool)
- var kept []string
- for _, version := range versions {
- if docs.withheld(h.proxy, "hex", packagePURL, version) {
- filtered[version] = true
- h.proxy.Logger.Info("cooldown: filtering hex version", "package", name, "version", version)
- continue
- }
- kept = append(kept, version)
- }
- if len(filtered) == 0 {
- // Nothing in cooldown: upstream's bytes, signature included.
- return body, kept, nil
- }
- out, err := h.filterSignedPackage(body, filtered)
- return out, kept, err
- })
- if err != nil {
- h.proxy.Logger.Error("failed to filter hex package", "name", name, "error", err)
- http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
- return
- }
-
- markStale(w, docs.doc.stale || docs.times.stale)
- serveHexPackage(w, r, m)
-}
-
-// serveHexPackage writes a package with its validators, or 304 when they
-// show the client's copy is current. The registry file is gzip itself, and
-// Hex clients gunzip it however it is labelled, so it is never compressed
-// again.
-func serveHexPackage(w http.ResponseWriter, r *http.Request, m rewrittenMetadata) {
- w.Header().Set(headerETag, m.etag)
- if !m.lastModified.IsZero() {
- w.Header().Set(headerLastModified, m.lastModified.UTC().Format(http.TimeFormat))
- }
- if notModified(r, m.etag, m.lastModified) {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentType, "application/octet-stream")
- w.Header().Set(headerContentEncoding, "gzip")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(m.content())
-}
-
-// timedPackage describes a package's registry file and the Hex API entry
-// it takes publish times from.
-func (h *HexHandler) timedPackage(name string) timedMetadata {
- return timedMetadata{
- ecosystem: "hex",
- docKey: "packages/" + name,
- docURL: h.upstreamURL + "/packages/" + name,
- timesKey: hashedCacheKey(h.apiURL, "api/packages/"+name),
- timesURL: fmt.Sprintf("%s/api/packages/%s", h.apiURL, name),
- timesAccept: contentTypeJSON,
- parseTimes: parseHexReleases,
- }
-}
-
-// packageVersions lists the releases in a gzipped Signed package.
-func (h *HexHandler) packageVersions(doc metadataResult) ([]string, error) {
- body, err := h.proxy.decodedMetadataBody(doc)
- if err != nil {
- return nil, err
- }
- payload, err := h.signedPayload(body)
- if err != nil {
- return nil, err
- }
- return packageReleaseVersions(payload)
-}
-
-// hexRelease represents a version entry from the Hex API.
-type hexRelease struct {
- Version string `json:"version"`
- InsertedAt string `json:"inserted_at"`
-}
-
-// hexPackageAPI represents the Hex API response for a package.
-type hexPackageAPI struct {
- Releases []hexRelease `json:"releases"`
-}
-
-// parseHexReleases reads the publish times from a Hex API package response.
-func parseHexReleases(body []byte) (publishTimes, error) {
- var pkg hexPackageAPI
- if err := json.Unmarshal(body, &pkg); err != nil {
- return nil, err
- }
- times := make(publishTimes, len(pkg.Releases))
- for _, release := range pkg.Releases {
- insertedAt, err := time.Parse(time.RFC3339Nano, release.InsertedAt)
- if err != nil {
- insertedAt = time.Time{}
- }
- times[release.Version] = insertedAt
- }
- return times, nil
-}
-
-func (h *HexHandler) publishTime(r *http.Request, name, version string) (time.Time, bool, bool, error) {
- return h.proxy.lookupPublishTime(r.Context(), h.timedPackage(name), version, h.packageVersions)
-}
-
-// signedPayload decompresses a gzipped Signed message, with the metadata
-// limit applied to the decompressed size, and returns its payload.
-func (h *HexHandler) signedPayload(gzippedData []byte) ([]byte, error) {
- gr, err := gzip.NewReader(bytes.NewReader(gzippedData))
- if err != nil {
- return nil, err
- }
- signed, err := h.proxy.ReadMetadata(gr)
- if err != nil {
- return nil, err
- }
- _ = gr.Close()
-
- // Parse Signed message: field 1 = payload (bytes), field 2 = signature (bytes)
- payload, err := extractProtobufBytes(signed, 1)
- if err != nil {
- return nil, fmt.Errorf("extracting payload: %w", err)
- }
- return payload, nil
-}
-
-// packageReleaseVersions lists the versions of the releases in a Package
-// message.
-func packageReleaseVersions(payload []byte) ([]string, error) {
- var versions []string
- _, err := filterPackageReleases(payload, nil, func(version string) {
- versions = append(versions, version)
- })
- return versions, err
-}
-
-// filterSignedPackage decompresses gzipped data, decodes the Signed protobuf wrapper,
-// filters releases from the Package payload, and re-encodes as gzipped protobuf
-// (without the original signature since the payload has changed).
-func (h *HexHandler) filterSignedPackage(gzippedData []byte, filteredVersions map[string]bool) ([]byte, error) {
- payload, err := h.signedPayload(gzippedData)
- if err != nil {
- return nil, err
- }
-
- // Filter releases from the Package message
- filteredPayload, err := filterPackageReleases(payload, filteredVersions, nil)
- if err != nil {
- return nil, fmt.Errorf("filtering releases: %w", err)
- }
-
- // Re-encode Signed message with modified payload and no signature
- var newSigned []byte
- newSigned = protowire.AppendTag(newSigned, 1, protowire.BytesType)
- newSigned = protowire.AppendBytes(newSigned, filteredPayload)
-
- // Gzip compress
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- if _, err := gw.Write(newSigned); err != nil {
- return nil, err
- }
- if err := gw.Close(); err != nil {
- return nil, err
- }
-
- return buf.Bytes(), nil
-}
-
-// filterPackageReleases filters releases from a Package protobuf message,
-// calling seen, when not nil, with the version of each release.
-// Package: field 1 = releases (repeated), field 2 = name, field 3 = repository
-func filterPackageReleases(payload []byte, filteredVersions map[string]bool, seen func(string)) ([]byte, error) {
- var result []byte
- data := payload
-
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return nil, fmt.Errorf("invalid protobuf tag")
- }
-
- tagBytes := data[:n]
- data = data[n:]
-
- var fieldBytes []byte
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf bytes field")
- }
- fieldBytes = data[:vn]
- data = data[vn:]
-
- if num == 1 { // releases field
- version := extractReleaseVersion(v)
- if seen != nil {
- seen(version)
- }
- if filteredVersions[version] {
- continue // skip this release
- }
- }
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf varint")
- }
- fieldBytes = data[:vn]
- data = data[vn:]
- default:
- return nil, fmt.Errorf("unexpected wire type %d", wtype)
- }
-
- result = append(result, tagBytes...)
- result = append(result, fieldBytes...)
- }
-
- return result, nil
-}
-
-// extractReleaseVersion extracts the version string from a Release protobuf message.
-// Release: field 1 = version (string)
-func extractReleaseVersion(release []byte) string {
- data := release
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return ""
- }
- data = data[n:]
-
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return ""
- }
- if num == 1 {
- return string(v)
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return ""
- }
- data = data[vn:]
- default:
- return ""
+ // Find the last hyphen followed by a version number
+ for i := len(base) - 1; i >= 0; i-- {
+ if base[i] == '-' && i+1 < len(base) && base[i+1] >= '0' && base[i+1] <= '9' {
+ return base[:i], base[i+1:]
}
}
- return ""
-}
-
-// extractProtobufBytes extracts a bytes field from a protobuf message by field number.
-func extractProtobufBytes(data []byte, fieldNum protowire.Number) ([]byte, error) {
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return nil, fmt.Errorf("invalid protobuf tag")
- }
- data = data[n:]
-
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf bytes")
- }
- if num == fieldNum {
- return v, nil
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf varint")
- }
- data = data[vn:]
- default:
- return nil, fmt.Errorf("unexpected wire type %d", wtype)
- }
- }
- return nil, fmt.Errorf("field %d not found", fieldNum)
-}
-
-// proxyCached forwards a request with metadata caching.
-func (h *HexHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "hex", cacheKey, "*/*")
+ return "", ""
}
// proxyUpstream forwards a request to hex.pm without caching.
func (h *HexHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{"Accept"})
+ upstreamURL := h.upstreamURL + r.URL.Path
+
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ // Copy accept header for content negotiation
+ if accept := r.Header.Get("Accept"); accept != "" {
+ req.Header.Set("Accept", accept)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("upstream request failed", "error", err)
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Copy response headers
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+}
+
+func init() {
+ _ = fmt.Sprintf // silence import if unused
}
diff --git a/internal/handler/hex_test.go b/internal/handler/hex_test.go
index a0b0448..f8516bd 100644
--- a/internal/handler/hex_test.go
+++ b/internal/handler/hex_test.go
@@ -1,19 +1,8 @@
package handler
import (
- "bytes"
- "compress/gzip"
- cryptorand "crypto/rand"
- "encoding/json"
- "io"
"log/slog"
- "net/http"
- "net/http/httptest"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "google.golang.org/protobuf/encoding/protowire"
)
func TestHexParseTarballFilename(t *testing.T) {
@@ -27,11 +16,6 @@ func TestHexParseTarballFilename(t *testing.T) {
{"phoenix-1.7.10.tar", "phoenix", "1.7.10"},
{"ecto-3.11.0.tar", "ecto", "3.11.0"},
{"phoenix_live_view-0.20.1.tar", "phoenix_live_view", "0.20.1"},
- {"amqp_client-4.1.5-1.tar", "amqp_client", "4.1.5-1"},
- {"bandit-0.1.0-dev-10.tar", "bandit", "0.1.0-dev-10"},
- {"ex_doc-0.34.0-rc.1.tar", "ex_doc", "0.34.0-rc.1"},
- {"-1.0.0.tar", "", ""},
- {"phoenix-.tar", "", ""},
{"invalid", "", ""},
}
@@ -43,354 +27,3 @@ func TestHexParseTarballFilename(t *testing.T) {
}
}
}
-
-// buildHexRelease encodes a Release protobuf message.
-func buildHexRelease(version string) []byte {
- var release []byte
- // field 1 = version (string)
- release = protowire.AppendTag(release, 1, protowire.BytesType)
- release = protowire.AppendString(release, version)
- // field 2 = inner_checksum (bytes) - required
- release = protowire.AppendTag(release, 2, protowire.BytesType)
- release = protowire.AppendBytes(release, []byte("fakechecksum1234567890123456789012"))
- // field 5 = outer_checksum (bytes)
- release = protowire.AppendTag(release, 5, protowire.BytesType)
- release = protowire.AppendBytes(release, []byte("outerchecksum123456789012345678901"))
- return release
-}
-
-// buildHexPackage encodes a Package protobuf message.
-func buildHexPackage(name string, versions []string) []byte {
- var pkg []byte
- for _, v := range versions {
- release := buildHexRelease(v)
- pkg = protowire.AppendTag(pkg, 1, protowire.BytesType)
- pkg = protowire.AppendBytes(pkg, release)
- }
- // field 2 = name
- pkg = protowire.AppendTag(pkg, 2, protowire.BytesType)
- pkg = protowire.AppendString(pkg, name)
- // field 3 = repository
- pkg = protowire.AppendTag(pkg, 3, protowire.BytesType)
- pkg = protowire.AppendString(pkg, "hexpm")
- return pkg
-}
-
-// buildHexSigned wraps a payload in a Signed protobuf message and gzips it.
-func buildHexSigned(payload []byte) []byte {
- var signed []byte
- signed = protowire.AppendTag(signed, 1, protowire.BytesType)
- signed = protowire.AppendBytes(signed, payload)
- // field 2 = signature (optional, add a fake one)
- signed = protowire.AppendTag(signed, 2, protowire.BytesType)
- signed = protowire.AppendBytes(signed, []byte("fakesignature"))
-
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- _, _ = gw.Write(signed)
- _ = gw.Close()
- return buf.Bytes()
-}
-
-func TestHexFilterPackageReleases(t *testing.T) {
- pkg := buildHexPackage("phoenix", []string{testVersion100, "2.0.0", "3.0.0"})
-
- filtered, err := filterPackageReleases(pkg, map[string]bool{"2.0.0": true}, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- // Extract remaining versions
- var versions []string
- data := filtered
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- break
- }
- data = data[n:]
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- break
- }
- if num == 1 { // release field
- version := extractReleaseVersion(v)
- if version != "" {
- versions = append(versions, version)
- }
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- break
- }
- data = data[vn:]
- }
- }
-
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d: %v", len(versions), versions)
- }
- if versions[0] != testVersion100 || versions[1] != "3.0.0" {
- t.Errorf("expected [1.0.0, 3.0.0], got %v", versions)
- }
-}
-
-func TestHexFilterSignedPackage(t *testing.T) {
- pkg := buildHexPackage("phoenix", []string{testVersion100, "2.0.0"})
- gzipped := buildHexSigned(pkg)
-
- h := &HexHandler{
- proxy: testProxy(),
- proxyURL: "http://proxy.local",
- }
-
- filtered, err := h.filterSignedPackage(gzipped, map[string]bool{"2.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
-
- // Decompress and check
- gr, err := gzip.NewReader(bytes.NewReader(filtered))
- if err != nil {
- t.Fatal(err)
- }
- signed, err := io.ReadAll(gr)
- if err != nil {
- t.Fatal(err)
- }
-
- payload, err := extractProtobufBytes(signed, 1)
- if err != nil {
- t.Fatal(err)
- }
-
- // Check that only version 1.0.0 remains
- version := extractReleaseVersion(mustExtractFirstRelease(t, payload))
- if version != testVersion100 {
- t.Errorf("expected version 1.0.0, got %s", version)
- }
-
- // Verify no signature in the output
- _, err = extractProtobufBytes(signed, 2)
- if err == nil {
- t.Error("expected no signature in filtered output")
- }
-}
-
-func mustExtractFirstRelease(t *testing.T, payload []byte) []byte {
- t.Helper()
- data := payload
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- t.Fatal("invalid protobuf")
- }
- data = data[n:]
- if wtype == protowire.BytesType {
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- t.Fatal("invalid bytes")
- }
- if num == 1 {
- return v
- }
- data = data[vn:]
- }
- }
- t.Fatal("no release found")
- return nil
-}
-
-func TestHexExtractReleaseVersion(t *testing.T) {
- release := buildHexRelease("1.2.3")
- version := extractReleaseVersion(release)
- if version != "1.2.3" {
- t.Errorf("expected 1.2.3, got %s", version)
- }
-}
-
-func TestHexHandlePackagesWithCooldown(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339Nano)
-
- pkg := buildHexPackage("testpkg", []string{testVersion100, "2.0.0"})
- gzippedProto := buildHexSigned(pkg)
-
- apiJSON, _ := json.Marshal(hexPackageAPI{
- Releases: []hexRelease{
- {Version: testVersion100, InsertedAt: oldTime},
- {Version: "2.0.0", InsertedAt: recentTime},
- },
- })
-
- // Serve both the protobuf repo and the JSON API from the same test server
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/packages/testpkg":
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(gzippedProto)
- case "/api/packages/testpkg":
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(apiJSON)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- // Override hexAPIURL for testing by using the upstream URL
- h := &HexHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- // We need to override the API URL - but it's a const. Let's test via the lower-level methods instead.
- // Test fetchFilteredVersions by making a request to the API endpoint
- // Actually, let me test the full flow through handlePackages
-
- req := httptest.NewRequest(http.MethodGet, "/packages/testpkg", nil)
- req.SetPathValue("name", "testpkg")
- w := httptest.NewRecorder()
-
- // Since hexAPIURL is a const pointing to hex.pm, we can't easily override it in tests.
- // Instead test the protobuf filtering directly which is the core logic.
- filtered, err := h.filterSignedPackage(gzippedProto, map[string]bool{"2.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
-
- // Verify only version 1.0.0 survives
- gr, _ := gzip.NewReader(bytes.NewReader(filtered))
- signed, _ := io.ReadAll(gr)
- payload, _ := extractProtobufBytes(signed, 1)
-
- var versions []string
- data := payload
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- break
- }
- data = data[n:]
- if wtype == protowire.BytesType {
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- break
- }
- if num == 1 {
- if ver := extractReleaseVersion(v); ver != "" {
- versions = append(versions, ver)
- }
- }
- data = data[vn:]
- }
- }
-
- if len(versions) != 1 || versions[0] != testVersion100 {
- t.Errorf("expected [1.0.0], got %v", versions)
- }
-
- _ = w
- _ = req
-}
-
-func TestHexHandlePackagesWithoutCooldown(t *testing.T) {
- pkg := buildHexPackage("testpkg", []string{testVersion100})
- gzipped := buildHexSigned(pkg)
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(gzipped)
- }))
- defer upstream.Close()
-
- h := &HexHandler{
- proxy: testProxy(), // no cooldown
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/packages/testpkg", nil)
- req.SetPathValue("name", "testpkg")
- w := httptest.NewRecorder()
- h.handlePackages(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestHexCooldownMetadataSizeLimit(t *testing.T) {
- const limit = 1024
- now := time.Now()
- releases := []hexRelease{
- {Version: testVersion100, InsertedAt: now.Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)},
- {Version: "2.0.0", InsertedAt: now.Add(-time.Hour).Format(time.RFC3339Nano)},
- }
- apiJSON, _ := json.Marshal(hexPackageAPI{Releases: releases})
- pkg := buildHexPackage("testpkg", []string{testVersion100, "2.0.0"})
- proto := buildHexSigned(pkg)
-
- // An unknown bytes field pads the payload: it compresses to a few bytes
- // but decompresses past the limit.
- padded := protowire.AppendTag(append([]byte(nil), pkg...), 4, protowire.BytesType)
- padded = protowire.AppendBytes(padded, make([]byte, 64*limit))
- bomb := buildHexSigned(padded)
- if len(bomb) >= limit {
- t.Fatalf("compressed package is %d bytes, want under %d", len(bomb), limit)
- }
- // Padding that doesn't compress makes the response itself too large.
- noise := make([]byte, 2*limit)
- _, _ = cryptorand.Read(noise)
- large := protowire.AppendTag(append([]byte(nil), pkg...), 4, protowire.BytesType)
- large = buildHexSigned(protowire.AppendBytes(large, noise))
- if len(large) <= limit {
- t.Fatalf("compressed package is %d bytes, want over %d", len(large), limit)
- }
-
- tests := []struct {
- name string
- pkg, api []byte
- }{
- {"package over limit", large, apiJSON},
- {"package decompresses past limit", bomb, apiJSON},
- {"API response over limit", proto, append(append([]byte(nil), apiJSON...), bytes.Repeat([]byte(" "), 2*limit)...)},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/packages/testpkg":
- _, _ = w.Write(tt.pkg)
- case "/api/packages/testpkg":
- _, _ = w.Write(tt.api)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- proxy.MetadataMaxSize = limit
- h := NewHexHandlerWithUpstreams(proxy, "http://proxy.test", upstream.URL, upstream.URL)
-
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/packages/testpkg", nil))
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
- })
- }
-}
diff --git a/internal/handler/homebrew.go b/internal/handler/homebrew.go
deleted file mode 100644
index 4e40d02..0000000
--- a/internal/handler/homebrew.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package handler
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "net/http"
- "strings"
-)
-
-const (
- homebrewArtifactNamespace = "homebrew"
- homebrewArtifactRepository = "homebrew/core"
- homebrewMetadataEcosystem = "homebrew"
-)
-
-// HomebrewHandler proxies Homebrew's JSON API without modifying signed files.
-type HomebrewHandler struct {
- proxy *Proxy
- apiUpstream string
-}
-
-// NewHomebrewHandler creates a Homebrew JSON API handler.
-func NewHomebrewHandler(proxy *Proxy, apiUpstream string) *HomebrewHandler {
- return &HomebrewHandler{
- proxy: proxy,
- apiUpstream: strings.TrimSuffix(apiUpstream, "/"),
- }
-}
-
-// RegisterHomebrewArtifacts routes homebrew/core OCI requests to its configured
-// registry and blocks other homebrew repositories from reaching the default
-// OCI registry.
-func RegisterHomebrewArtifacts(container *ContainerHandler, artifactUpstream string) {
- container.BlockRegistry(homebrewArtifactNamespace)
- container.RegisterRegistry(homebrewArtifactRepository, artifactUpstream)
-}
-
-// Routes returns the Homebrew JSON API handler. Mount this at /homebrew.
-func (h *HomebrewHandler) Routes() http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet && r.Method != http.MethodHead {
- w.Header().Set("Allow", "GET, HEAD")
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- requestPath := strings.TrimPrefix(r.URL.EscapedPath(), "/")
- if requestPath == "" || containsPathTraversal(requestPath) {
- http.NotFound(w, r)
- return
- }
-
- upstreamURL := h.apiUpstream + "/" + requestPath
- if r.URL.RawQuery != "" {
- upstreamURL += "?" + r.URL.RawQuery
- }
-
- // brew fetches every JSON API download with `curl --compressed` and
- // decodes Content-Encoding itself, and formula.jws.json is ~33 MB plain
- // versus ~5 MB gzip, so keep both hops compressed. The analytics
- // endpoints are the one consumer brew fetches without --compressed;
- // they stay identity.
- acceptEncoding := "gzip"
- if strings.HasPrefix(requestPath, "analytics/") {
- acceptEncoding = "identity"
- }
- h.proxy.proxyCachedWithEncoding(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), acceptEncoding, nil, "*/*")
- })
-}
-
-func homebrewMetadataCacheKey(requestPath, rawQuery string) string {
- sum := sha256.Sum256([]byte(requestPath + "\x00" + rawQuery))
- return hex.EncodeToString(sum[:])
-}
diff --git a/internal/handler/homebrew_bottles.go b/internal/handler/homebrew_bottles.go
deleted file mode 100644
index 169ed61..0000000
--- a/internal/handler/homebrew_bottles.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package handler
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strconv"
- "strings"
-)
-
-var (
- homebrewFormulaPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9@+._-]*$`)
- homebrewVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._+-]*$`)
-)
-
-// homebrewMaxFormulaJSON bounds one formula's JSON API document, a few KB.
-const homebrewMaxFormulaJSON = 1 << 20
-
-// homebrewFormula is the part of a formula's JSON API document that names
-// its bottles' image tag.
-type homebrewFormula struct {
- Versions struct {
- Stable string `json:"stable"`
- } `json:"versions"`
- Revision int `json:"revision"`
- Bottle struct {
- Stable struct {
- Rebuild int `json:"rebuild"`
- Files map[string]any `json:"files"`
- } `json:"stable"`
- } `json:"bottle"`
-}
-
-// BottleImage returns the homebrew/core image and tag for a formula version.
-// Only the current version gets its revision and rebuild from the JSON API;
-// any other version is used as the tag as given.
-func (h *HomebrewHandler) BottleImage(ctx context.Context, formula, version string) (ImageRef, error) {
- if !homebrewFormulaPattern.MatchString(formula) {
- return ImageRef{}, fmt.Errorf("invalid Homebrew formula name %q", formula)
- }
- if !homebrewVersionPattern.MatchString(version) {
- return ImageRef{}, fmt.Errorf("invalid Homebrew version %q", version)
- }
- f, err := h.fetchFormula(ctx, formula)
- if err != nil {
- return ImageRef{}, err
- }
- tag := version
- if version == f.Versions.Stable {
- if len(f.Bottle.Stable.Files) == 0 {
- return ImageRef{}, fmt.Errorf("homebrew formula %s %s has no bottles", formula, version)
- }
- if f.Revision > 0 {
- tag += "_" + strconv.Itoa(f.Revision)
- }
- if f.Bottle.Stable.Rebuild > 0 {
- tag += "-" + strconv.Itoa(f.Bottle.Stable.Rebuild)
- }
- }
- name := strings.NewReplacer("@", "/", "+", "x").Replace(formula)
- return ImageRef{Name: homebrewArtifactRepository + "/" + name, References: []string{tag}}, nil
-}
-
-func (h *HomebrewHandler) fetchFormula(ctx context.Context, formula string) (*homebrewFormula, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.apiUpstream+"/formula/"+formula+".json", nil)
- if err != nil {
- return nil, err
- }
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("reading the Homebrew formula: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- switch {
- case resp.StatusCode == http.StatusNotFound:
- return nil, fmt.Errorf("homebrew/core has no formula %q", formula)
- case resp.StatusCode != http.StatusOK:
- return nil, fmt.Errorf("reading the Homebrew formula: upstream answered %d", resp.StatusCode)
- }
- var f homebrewFormula
- if err := json.NewDecoder(io.LimitReader(resp.Body, homebrewMaxFormulaJSON)).Decode(&f); err != nil {
- return nil, fmt.Errorf("reading the Homebrew formula: %w", err)
- }
- if f.Versions.Stable == "" {
- return nil, errors.New("reading the Homebrew formula: it has no stable version")
- }
- return &f, nil
-}
diff --git a/internal/handler/homebrew_bottles_test.go b/internal/handler/homebrew_bottles_test.go
deleted file mode 100644
index a2ddb64..0000000
--- a/internal/handler/homebrew_bottles_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package handler
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-)
-
-func TestHomebrewBottleImage(t *testing.T) {
- formulas := map[string]string{
- "/formula/hello.json": `{"versions":{"stable":"2.12.3"},"revision":0,
- "bottle":{"stable":{"rebuild":1,"files":{"arm64_linux":{}}}}}`,
- "/formula/openssl@3.json": `{"versions":{"stable":"3.5.0"},"revision":2,
- "bottle":{"stable":{"rebuild":0,"files":{"arm64_sequoia":{}}}}}`,
- "/formula/libsigc++.json": `{"versions":{"stable":"3.6.0"},"revision":0,
- "bottle":{"stable":{"rebuild":0,"files":{"x86_64_linux":{}}}}}`,
- "/formula/nobottle.json": `{"versions":{"stable":"1.0"},"bottle":{}}`,
- }
- var requests atomic.Int32
- api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- body, ok := formulas[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write([]byte(body))
- }))
- defer api.Close()
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = api.Client()
- h := NewHomebrewHandler(proxy, api.URL)
-
- tests := []struct {
- formula, version string
- image, tag, err string
- }{
- {formula: "hello", version: "2.12.3", image: "homebrew/core/hello", tag: "2.12.3-1"},
- {formula: "openssl@3", version: "3.5.0", image: "homebrew/core/openssl/3", tag: "3.5.0_2"},
- {formula: "libsigc++", version: "3.6.0", image: "homebrew/core/libsigcxx", tag: "3.6.0"},
- // Only the current version is described; another is the tag as given.
- {formula: "hello", version: "2.12.2", image: "homebrew/core/hello", tag: "2.12.2"},
- {formula: "hello", version: "2.12.3-1", image: "homebrew/core/hello", tag: "2.12.3-1"},
- {formula: "nobottle", version: "1.0", err: "has no bottles"},
- {formula: "missing", version: "1.0", err: `no formula "missing"`},
- }
- for _, tt := range tests {
- ref, err := h.BottleImage(context.Background(), tt.formula, tt.version)
- if tt.err != "" {
- if err == nil || !strings.Contains(err.Error(), tt.err) {
- t.Errorf("%s@%s: err = %v, want %q", tt.formula, tt.version, err, tt.err)
- }
- continue
- }
- if err != nil || ref.Name != tt.image || len(ref.References) != 1 || ref.References[0] != tt.tag {
- t.Errorf("%s@%s = %+v, %v; want %s:%s", tt.formula, tt.version, ref, err, tt.image, tt.tag)
- }
- }
-
- before := requests.Load()
- for _, bad := range [][2]string{{"../hello", "1.0"}, {"Hello", "1.0"}, {"hello", "1.0/../x"}, {"hello", ""}} {
- if _, err := h.BottleImage(context.Background(), bad[0], bad[1]); err == nil {
- t.Errorf("%q@%q accepted", bad[0], bad[1])
- }
- }
- if requests.Load() != before {
- t.Error("an invalid name or version reached the API")
- }
-}
diff --git a/internal/handler/homebrew_test.go b/internal/handler/homebrew_test.go
deleted file mode 100644
index e5d7e5c..0000000
--- a/internal/handler/homebrew_test.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package handler
-
-import (
- "bytes"
- "io"
- "net/http"
- "net/http/httptest"
- "strconv"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestHomebrewHandler_PreservesSignedResponseAndClientValidators(t *testing.T) {
- body := " {\n \"payload\": \"signed bytes\",\n \"signatures\": []\n}\n"
- etag := `"homebrew-api-etag"`
- lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC)
- requests := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests++
- if r.Method != http.MethodGet {
- t.Errorf("upstream method = %s, want GET", r.Method)
- }
- if r.URL.Path != "/api/internal/packages.arm64_tahoe.jws.json" {
- t.Errorf("upstream path = %q", r.URL.Path)
- }
- if got := r.Header.Get("Authorization"); got != "" {
- t.Errorf("upstream Authorization = %q, want empty", got)
- }
- if got := r.Header.Get("Cookie"); got != "" {
- t.Errorf("upstream Cookie = %q, want empty", got)
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("ETag", etag)
- w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
- _, _ = io.WriteString(w, body)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- req := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
- req.Header.Set("Authorization", "Bearer client-secret")
- req.Header.Set("Cookie", "session=client-secret")
- w := httptest.NewRecorder()
- h.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if got := w.Body.String(); got != body {
- t.Errorf("body = %q, want byte-for-byte %q", got, body)
- }
- if got := w.Header().Get("Content-Type"); got != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", got)
- }
- wantContentLength := strconv.Itoa(len(body))
- if got := w.Header().Get("Content-Length"); got != wantContentLength {
- t.Errorf("Content-Length = %q, want %q", got, wantContentLength)
- }
- if got := w.Header().Get("ETag"); got != etag {
- t.Errorf("ETag = %q, want %q", got, etag)
- }
- if got := w.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
- t.Errorf("Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
- }
-
- conditionalRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
- conditionalRequest.Header.Set("If-None-Match", etag)
- conditional := httptest.NewRecorder()
- h.ServeHTTP(conditional, conditionalRequest)
- if conditional.Code != http.StatusNotModified {
- t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified)
- }
- if got := conditional.Header().Get("ETag"); got != etag {
- t.Errorf("conditional ETag = %q, want %q", got, etag)
- }
- if conditional.Body.Len() != 0 {
- t.Errorf("conditional body length = %d, want 0", conditional.Body.Len())
- }
-
- modifiedSinceRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil)
- modifiedSinceRequest.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat))
- modifiedSince := httptest.NewRecorder()
- h.ServeHTTP(modifiedSince, modifiedSinceRequest)
- if modifiedSince.Code != http.StatusNotModified {
- t.Fatalf("If-Modified-Since status = %d, want %d", modifiedSince.Code, http.StatusNotModified)
- }
- if got := modifiedSince.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
- t.Errorf("conditional Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
- }
- if requests != 1 {
- t.Errorf("upstream requests = %d, want 1", requests)
- }
-}
-
-func TestHomebrewHandler_HeadUsesMetadataCacheAndSurvivesOutage(t *testing.T) {
- body := `{"payload":"signed bytes","signatures":[]}`
- available := true
- requests := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests++
- if got := r.Header.Get("Authorization"); got != "" {
- t.Errorf("upstream Authorization = %q, want empty", got)
- }
- if !available {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("ETag", `"head-etag"`)
- _, _ = io.WriteString(w, body)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- coldRequest := httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil)
- coldRequest.Header.Set("Authorization", "Bearer client-secret")
- cold := httptest.NewRecorder()
- h.ServeHTTP(cold, coldRequest)
- if cold.Code != http.StatusOK {
- t.Fatalf("cold status = %d, want %d", cold.Code, http.StatusOK)
- }
- if cold.Body.Len() != 0 {
- t.Errorf("cold body length = %d, want 0", cold.Body.Len())
- }
- if got := cold.Header().Get("Content-Length"); got != strconv.Itoa(len(body)) {
- t.Errorf("Content-Length = %q, want %d", got, len(body))
- }
- if got := cold.Header().Get("ETag"); got != `"head-etag"` {
- t.Errorf("ETag = %q, want %q", got, `"head-etag"`)
- }
- if requests != 1 {
- t.Fatalf("cold upstream requests = %d, want 1", requests)
- }
-
- warm := httptest.NewRecorder()
- h.ServeHTTP(warm, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil))
- if warm.Code != http.StatusOK {
- t.Fatalf("warm status = %d, want %d", warm.Code, http.StatusOK)
- }
- if warm.Body.Len() != 0 {
- t.Errorf("warm body length = %d, want 0", warm.Body.Len())
- }
- if requests != 1 {
- t.Errorf("warm upstream requests = %d, want 1", requests)
- }
-
- proxy.MetadataTTL = time.Nanosecond
- available = false
- stale := httptest.NewRecorder()
- h.ServeHTTP(stale, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil))
- if stale.Code != http.StatusOK {
- t.Fatalf("stale status = %d, want %d; body: %s", stale.Code, http.StatusOK, stale.Body.String())
- }
- if stale.Body.Len() != 0 {
- t.Errorf("stale body length = %d, want 0", stale.Body.Len())
- }
- if got := stale.Header().Get("Warning"); got != containerStaleWarning {
- t.Errorf("Warning = %q, want %q", got, containerStaleWarning)
- }
-}
-
-func TestHomebrewHandler_HeadWithoutMetadataCachePreservesUpstreamMethod(t *testing.T) {
- upstreamMethod := ""
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- upstreamMethod = r.Method
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Length", "42")
- w.Header().Set("ETag", `"head-etag"`)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = false
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- head := httptest.NewRecorder()
- h.ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil))
- if head.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", head.Code, http.StatusOK)
- }
- if upstreamMethod != http.MethodHead {
- t.Errorf("upstream method = %q, want HEAD", upstreamMethod)
- }
- if head.Body.Len() != 0 {
- t.Errorf("body length = %d, want 0", head.Body.Len())
- }
- if got := head.Header().Get("Content-Length"); got != "42" {
- t.Errorf("Content-Length = %q, want 42", got)
- }
-}
-
-func TestHomebrewHandler_ServesStaleCachedResponseWhenUpstreamFails(t *testing.T) {
- body := `{"payload":"signed bytes","signatures":[]}`
- available := true
- requests := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests++
- if !available {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("ETag", `"stale-etag"`)
- _, _ = io.WriteString(w, body)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 5 * time.Millisecond
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- first := httptest.NewRecorder()
- h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil))
- if first.Code != http.StatusOK {
- t.Fatalf("warm status = %d, want %d", first.Code, http.StatusOK)
- }
-
- time.Sleep(10 * time.Millisecond)
- available = false
- stale := httptest.NewRecorder()
- h.ServeHTTP(stale, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil))
- if stale.Code != http.StatusOK {
- t.Fatalf("stale status = %d, want %d; body: %s", stale.Code, http.StatusOK, stale.Body.String())
- }
- if got := stale.Body.String(); got != body {
- t.Errorf("stale body = %q, want %q", got, body)
- }
- if got := stale.Header().Get("Warning"); got != containerStaleWarning {
- t.Errorf("Warning = %q, want %q", got, containerStaleWarning)
- }
- if requests != 2 {
- t.Errorf("upstream requests = %d, want 2", requests)
- }
-}
-
-func TestHomebrewHandler_ProxiesSupportedAPIPaths(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = io.WriteString(w, r.URL.RequestURI())
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- paths := []string{
- "/formula.jws.json",
- "/cask.jws.json",
- "/formula/jq.json",
- "/cask/firefox.json",
- "/internal/packages.arm64_tahoe.jws.json?download=1",
- }
- for _, requestPath := range paths {
- t.Run(requestPath, func(t *testing.T) {
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestPath, nil))
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
- if got, want := w.Body.String(), "/api"+requestPath; got != want {
- t.Errorf("upstream request = %q, want %q", got, want)
- }
- })
- }
-}
-
-func TestHomebrewHandler_RejectsUnsupportedRequests(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewHomebrewHandler(proxy, "https://example.test/api").Routes()
-
- method := httptest.NewRecorder()
- h.ServeHTTP(method, httptest.NewRequest(http.MethodPost, "/formula.jws.json", nil))
- if method.Code != http.StatusMethodNotAllowed {
- t.Errorf("POST status = %d, want %d", method.Code, http.StatusMethodNotAllowed)
- }
- if got := method.Header().Get("Allow"); got != "GET, HEAD" {
- t.Errorf("Allow = %q, want GET, HEAD", got)
- }
-
- root := httptest.NewRecorder()
- h.ServeHTTP(root, httptest.NewRequest(http.MethodGet, "/", nil))
- if root.Code != http.StatusNotFound {
- t.Errorf("root status = %d, want %d", root.Code, http.StatusNotFound)
- }
-
- traversal := httptest.NewRecorder()
- h.ServeHTTP(traversal, httptest.NewRequest(http.MethodGet, "/%2e%2e/secret", nil))
- if traversal.Code != http.StatusNotFound {
- t.Errorf("traversal status = %d, want %d", traversal.Code, http.StatusNotFound)
- }
-}
-
-func TestRegisterHomebrewArtifacts(t *testing.T) {
- h := &ContainerHandler{registryURL: dockerHubRegistry}
- artifactUpstream := "https://homebrew-proxy.example.com"
- RegisterHomebrewArtifacts(h, artifactUpstream+"/")
-
- if got := h.registryURLFor("homebrew/core/jq"); got != artifactUpstream {
- t.Errorf("homebrew/core registry = %q, want %q", got, artifactUpstream)
- }
- if got := h.registryURLFor("homebrew/cask/firefox"); got != "" {
- t.Errorf("other Homebrew registry = %q, want blocked", got)
- }
- if got := h.registryURLFor("library/nginx"); got != dockerHubRegistry {
- t.Errorf("unrelated registry = %q, want %q", got, dockerHubRegistry)
- }
-}
-
-func TestRegisterHomebrewArtifactsRejectsOtherHomebrewRoutes(t *testing.T) {
- upstreamRequests := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- upstreamRequests++
- _, _ = io.WriteString(w, "unexpected upstream response")
- }))
- defer upstream.Close()
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("unexpected upstream blob")),
- ContentType: "application/octet-stream",
- }
- h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
- RegisterHomebrewArtifacts(h, "https://ghcr.io")
-
- const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- paths := []string{
- "/homebrew/cask/firefox/blobs/" + digest,
- "/homebrew/cask/firefox/manifests/latest",
- "/homebrew/cask/firefox/tags/list",
- }
- for _, path := range paths {
- t.Run(path, func(t *testing.T) {
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
- }
- })
- }
-
- if fetcher.fetchCalled {
- t.Error("blocked Homebrew blob reached the artifact fetcher")
- }
- if upstreamRequests != 0 {
- t.Errorf("blocked Homebrew routes made %d upstream requests, want 0", upstreamRequests)
- }
-}
-
-// TestHomebrewHandler_RequestsGzipForAPIPaths covers #305's motivating case:
-// the JSON API files are fetched, cached and served gzip-compressed with
-// Content-Encoding: gzip (brew fetches them with --compressed), while the
-// analytics endpoints, which brew fetches without --compressed, stay identity.
-func TestHomebrewHandler_RequestsGzipForAPIPaths(t *testing.T) {
- plain := []byte(`{"payload":"signed bytes","signatures":[]}`)
- compressed := gzipPayload(t, plain)
-
- var available atomic.Bool
- available.Store(true)
- var requests atomic.Int32
- var sawAcceptEncoding atomic.Value // string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- sawAcceptEncoding.Store(r.Header.Get(headerAcceptEncoding))
- if !available.Load() {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set(headerContentType, "application/json")
- if strings.Contains(r.Header.Get(headerAcceptEncoding), "gzip") {
- w.Header().Set(headerContentEncoding, "gzip")
- _, _ = w.Write(compressed)
- return
- }
- _, _ = w.Write(plain)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes()
-
- get := func(path string) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- return w
- }
- lastAE := func() string {
- s, _ := sawAcceptEncoding.Load().(string)
- return s
- }
-
- first := get("/formula.jws.json")
- if first.Code != http.StatusOK {
- t.Fatalf("formula.jws.json: status = %d, want 200: %s", first.Code, first.Body.String())
- }
- if got := lastAE(); got != "gzip" {
- t.Errorf("formula.jws.json: upstream Accept-Encoding = %q, want %q", got, "gzip")
- }
- if !bytes.Equal(first.Body.Bytes(), compressed) {
- t.Errorf("formula.jws.json: body is not the compressed bytes (got %d, want %d)", first.Body.Len(), len(compressed))
- }
- if got := first.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("formula.jws.json: Content-Encoding = %q, want %q", got, "gzip")
- }
- if got := first.Header().Get(headerContentLength); got != strconv.Itoa(len(compressed)) {
- t.Errorf("formula.jws.json: Content-Length = %q, want %d", got, len(compressed))
- }
-
- // Replay from cache with the upstream down: same bytes and header, no refetch.
- before := requests.Load()
- available.Store(false)
- cached := get("/formula.jws.json")
- if cached.Code != http.StatusOK {
- t.Fatalf("cached formula.jws.json: status = %d, want 200: %s", cached.Code, cached.Body.String())
- }
- if !bytes.Equal(cached.Body.Bytes(), compressed) || cached.Header().Get(headerContentEncoding) != "gzip" {
- t.Errorf("cached formula.jws.json: body/header not replayed verbatim")
- }
- if requests.Load() != before {
- t.Errorf("cached formula.jws.json hit upstream: requests %d -> %d", before, requests.Load())
- }
- available.Store(true)
-
- // Analytics is fetched by brew without --compressed: stays identity, no header.
- analytics := get("/analytics/install/30d.json")
- if analytics.Code != http.StatusOK {
- t.Fatalf("analytics: status = %d, want 200: %s", analytics.Code, analytics.Body.String())
- }
- if got := lastAE(); got != "identity" {
- t.Errorf("analytics: upstream Accept-Encoding = %q, want %q", got, "identity")
- }
- if !bytes.Equal(analytics.Body.Bytes(), plain) {
- t.Errorf("analytics: body = %q, want plain %q", analytics.Body.Bytes(), plain)
- }
- if got := analytics.Header().Get(headerContentEncoding); got != "" {
- t.Errorf("analytics: Content-Encoding = %q, want empty", got)
- }
-}
diff --git a/internal/handler/hosted.go b/internal/handler/hosted.go
deleted file mode 100644
index 33f2191..0000000
--- a/internal/handler/hosted.go
+++ /dev/null
@@ -1,499 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha512"
- "database/sql"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "slices"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-var (
- ErrHostedUnsupported = errors.New("pushing packages is not supported for this ecosystem")
- ErrHostedInvalidName = errors.New("invalid package name or version")
- ErrHostedVersionNotFound = errors.New("no pushed artifacts for this version")
- ErrHostedFileNotFound = errors.New("no pushed file of this name for this version")
-
- // ErrHostedExists refuses a second push of the same file. Clients pin
- // pushed files by version and often by hash, so a published file is never
- // silently replaced; delete it first to push it again.
- ErrHostedExists = errors.New("this file was already pushed; delete it first to push it again")
-
- // ErrHostedChecksumMismatch reports a push whose bytes don't match the
- // sha256 the client sent along.
- ErrHostedChecksumMismatch = errors.New("pushed bytes don't match the expected sha256")
-
- // ErrHostedArtifactUnavailable and ErrHostedArtifactMissing report a
- // pushed artifact the cache can't serve. They stop the caller from
- // fetching the upstream copy, which would replace the pushed one.
- ErrHostedArtifactUnavailable = errors.New("pushed artifact is stored but can't be read")
- ErrHostedArtifactMissing = errors.New("pushed artifact is no longer stored")
-)
-
-// HostedTarget is where a pushed archive is stored: the package name and
-// version it is cached under and the filename clients request it by. Download
-// handlers look artifacts up by exactly these values, so a push under any
-// other name would never be served.
-type HostedTarget struct {
- Name string
- Version string
- Filename string
-}
-
-// HostedArtifact describes one pushed archive.
-type HostedArtifact struct {
- Version string `json:"version"`
- Filename string `json:"filename"`
- SHA256 string `json:"sha256"`
- Size int64 `json:"size"`
- PushedAt time.Time `json:"pushed_at"`
-}
-
-// hostedEcosystem is what an ecosystem defines for pushed packages. Storing,
-// listing, deleting and the push rules are shared; adding an ecosystem means
-// adding an entry here and, if its clients read version lists, listing pushed
-// versions in its metadata.
-type hostedEcosystem struct {
- // validName reports whether a package name, split on "/", is one of the
- // ecosystem's.
- validName func(segments []string) bool
-
- // filename is the name clients request the archive by, derived from the
- // package name and version. When nil, the filename is the last segment of
- // the push path, the package name must be a single segment so the path is
- // unambiguous, and a version can hold several files (see multiFile).
- filename func(name, version string) string
-
- // readManifest reads the package manifest out of a pushed archive and
- // checks that it describes target. When nil, pushes carry no manifest.
- readManifest func(archive io.Reader, target HostedTarget) (json.RawMessage, error)
-
- // hostedOnly reports whether a namespace (an npm scope, a Composer
- // vendor) is served from pushed packages alone. When nil, the ecosystem
- // has no such namespaces.
- hostedOnly func(p *Proxy, namespace string) bool
-}
-
-var hostedEcosystems = map[string]hostedEcosystem{
- "npm": {
- validName: func(segments []string) bool {
- return len(segments) == 1 || (len(segments) == 2 && strings.HasPrefix(segments[0], "@"))
- },
- filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".tgz" },
- readManifest: readNPMManifest,
- hostedOnly: func(p *Proxy, scope string) bool {
- return slices.Contains(p.HostedNPMScopes, scope)
- },
- },
- "composer": {
- validName: func(segments []string) bool { return len(segments) == vendorPackageParts },
- filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".zip" },
- readManifest: readComposerManifest,
- hostedOnly: func(p *Proxy, vendor string) bool {
- return slices.ContainsFunc(p.HostedComposerVendors, func(v string) bool {
- return strings.EqualFold(v, vendor)
- })
- },
- },
- // generic holds files that aren't packages, such as prebuilt binaries.
- // They are served at /generic/hosted/{name}/{version}/{file} and never
- // come from an upstream.
- genericEcosystem: {
- validName: func(segments []string) bool { return len(segments) == 1 },
- },
-}
-
-// multiFile reports whether a version can hold several files: those named in
-// the push path. Such versions get no version-level integrity, which could
-// only describe one of them.
-func (e hostedEcosystem) multiFile() bool {
- return e.filename == nil
-}
-
-// HostedSupported reports whether packages can be pushed for ecosystem.
-func HostedSupported(ecosystem string) bool {
- _, ok := hostedEcosystems[ecosystem]
- return ok
-}
-
-// HostedFileInPath reports whether pushes for ecosystem name the file in the
-// path, {name}/{version}/{file}, rather than deriving it from the name and
-// version.
-func HostedFileInPath(ecosystem string) bool {
- eco, ok := hostedEcosystems[ecosystem]
- return ok && eco.multiFile()
-}
-
-func lastSegment(name string) string {
- return name[strings.LastIndex(name, "/")+1:]
-}
-
-// HostedTargetFor validates a push of name@version to ecosystem and returns
-// where it is stored. filename is required for ecosystems whose pushes name
-// the file (see HostedFileInPath) and must be empty for the others.
-func HostedTargetFor(ecosystem, name, version, filename string) (HostedTarget, error) {
- if err := ValidateHostedPath(ecosystem, name, version, filename); err != nil {
- return HostedTarget{}, err
- }
- eco := hostedEcosystems[ecosystem]
- if eco.filename != nil {
- filename = eco.filename(name, version)
- } else if filename == "" {
- return HostedTarget{}, ErrHostedInvalidName
- }
- return HostedTarget{Name: name, Version: version, Filename: filename}, nil
-}
-
-// ValidateHostedPath checks the parts of a hosted API path: a supported
-// ecosystem, one of its package names, and path segments that are safe to
-// store. filename may be empty, as in a delete of a whole version, but is
-// only allowed for ecosystems whose pushes name the file.
-func ValidateHostedPath(ecosystem, name, version, filename string) error {
- eco, ok := hostedEcosystems[ecosystem]
- if !ok {
- return fmt.Errorf("%w: %s", ErrHostedUnsupported, ecosystem)
- }
- segments := strings.Split(name, "/")
- if !eco.validName(segments) {
- return ErrHostedInvalidName
- }
- checked := append(slices.Clone(segments), version)
- if filename != "" {
- if !eco.multiFile() {
- return ErrHostedInvalidName
- }
- checked = append(checked, filename)
- }
- for _, segment := range checked {
- if !validHostedSegment(segment) {
- return ErrHostedInvalidName
- }
- }
- return nil
-}
-
-func validHostedSegment(s string) bool {
- if s == "" || s == "." || s == ".." || strings.ContainsAny(s, `/\`) {
- return false
- }
- for _, c := range s {
- if c < ' ' || c == 0x7f {
- return false
- }
- }
- return true
-}
-
-// PushLocalArtifact stores body as the artifact for target and records it as
-// local, replacing any cached upstream copy. Download handlers serve it from
-// the cache without contacting the upstream, and eviction never removes it.
-// A file that was already pushed is refused with ErrHostedExists. When
-// wantSHA256 is set, the pushed bytes must hash to it. If the ecosystem has
-// manifests, the archive's manifest must name target; it is kept so the
-// ecosystem's metadata can list the version with its dependencies.
-func (p *Proxy) PushLocalArtifact(ctx context.Context, ecosystem string, target HostedTarget, body io.Reader, contentType, wantSHA256 string) (HostedArtifact, error) {
- if err := p.CheckPush(ecosystem, target); err != nil {
- return HostedArtifact{}, err
- }
- pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, target.Name, target.Version)
- if err != nil {
- return HostedArtifact{}, err
- }
-
- push := storedPush{
- storagePath: storage.FetchPath(ecosystem, target.Name, target.Version, storage.NewFetchID(), target.Filename),
- contentType: contentType,
- }
- sha512Hash := sha512.New()
- storeStart := time.Now()
- push.size, push.sha256, err = p.Storage.Store(ctx, push.storagePath, io.TeeReader(body, sha512Hash))
- metrics.RecordStorageOperation("write", time.Since(storeStart))
- if err != nil {
- metrics.RecordStorageError("write", metrics.FailureDetail{
- PURL: versionPURL, Filename: target.Filename, Path: push.storagePath, Err: err})
- return HostedArtifact{}, fmt.Errorf("storing artifact: %w", err)
- }
- push.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha512Hash.Sum(nil))
-
- if wantSHA256 != "" && !strings.EqualFold(wantSHA256, push.sha256) {
- p.discardStored(ctx, push.storagePath)
- return HostedArtifact{}, fmt.Errorf("%w: got %s", ErrHostedChecksumMismatch, push.sha256)
- }
-
- if err := p.checkPush(ctx, ecosystem, target, versionPURL, &push); err != nil {
- p.discardStored(ctx, push.storagePath)
- return HostedArtifact{}, err
- }
-
- push.pushedAt = time.Now()
- if err := p.recordLocalArtifact(ecosystem, target, pkgPURL, versionPURL, push); err != nil {
- p.discardStored(ctx, push.storagePath)
- return HostedArtifact{}, err
- }
-
- return HostedArtifact{
- Version: target.Version,
- Filename: target.Filename,
- SHA256: push.sha256,
- Size: push.size,
- PushedAt: push.pushedAt.UTC(),
- }, nil
-}
-
-// CheckPush refuses a push of a denied version or of a file that was already
-// pushed, before any bytes are read, so a client can be told without
-// uploading the file. For ecosystems with one file per version, any pushed
-// file of the version counts, so a differently cased alias of the name is
-// refused too. A cached upstream copy is fine to replace. Recording the push
-// checks again atomically, for concurrent pushes.
-func (p *Proxy) CheckPush(ecosystem string, target HostedTarget) error {
- _, versionPURL, err := packagePURLStrings(ecosystem, target.Name, target.Version)
- if err != nil {
- return err
- }
- if p.versionDenied(ecosystem, target.Name, target.Version) {
- return fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
- }
- var existing []database.Artifact
- if hostedEcosystems[ecosystem].multiFile() {
- record, err := p.DB.GetArtifact(versionPURL, target.Filename)
- if err != nil {
- return fmt.Errorf("checking existing artifact: %w", err)
- }
- if record != nil {
- existing = append(existing, *record)
- }
- } else if existing, err = p.DB.GetArtifactsByVersionPURL(versionPURL); err != nil {
- return fmt.Errorf("checking existing artifacts: %w", err)
- }
- for _, a := range existing {
- if a.Source == database.SourceLocal {
- return ErrHostedExists
- }
- }
- return nil
-}
-
-// storedPush is a pushed archive once it is in storage.
-type storedPush struct {
- storagePath string
- sha256 string
- integrity string
- size int64
- contentType string
- manifest json.RawMessage
- pushedAt time.Time
-}
-
-// checkPush reads the manifest of a stored push and runs the scanners on it.
-func (p *Proxy) checkPush(ctx context.Context, ecosystem string, target HostedTarget, versionPURL string, push *storedPush) error {
- if hostedEcosystems[ecosystem].readManifest != nil {
- stored, err := p.Storage.Open(ctx, push.storagePath)
- if err != nil {
- return fmt.Errorf("reading pushed archive: %w", err)
- }
- push.manifest, err = readHostedManifest(ecosystem, stored, target)
- _ = stored.Close()
- if err != nil {
- return err
- }
- }
-
- if p.Scanners != nil && p.Scanners.Enabled() {
- return p.runScan(ctx, ecosystem, target.Name, target.Version, target.Filename, versionPURL, push.storagePath, push.size, push.contentType)
- }
- return nil
-}
-
-func (p *Proxy) discardStored(ctx context.Context, storagePath string) {
- if err := p.Storage.Delete(context.WithoutCancel(ctx), storagePath); err != nil {
- p.Logger.Warn("failed to delete pushed artifact from storage", "path", storagePath, "error", err)
- }
-}
-
-// recordLocalArtifact writes the package, version and artifact records in
-// one transaction. An existing package record is kept as it is, since it may
-// describe the upstream package. For ecosystems with one file per version,
-// the version's integrity is set to the pushed bytes, so the cache's
-// integrity check verifies them rather than an upstream archive's digest.
-// Multi-file versions can't have one integrity, so an existing version record
-// is left alone and each file is checked against its own sha256.
-func (p *Proxy) recordLocalArtifact(ecosystem string, target HostedTarget, pkgPURL, versionPURL string, push storedPush) error {
- oneFilePerVersion := !hostedEcosystems[ecosystem].multiFile()
- err := p.DB.RecordLocalArtifact(
- &database.Package{PURL: pkgPURL, Ecosystem: ecosystem, Name: target.Name},
- &database.Version{
- PURL: versionPURL,
- PackagePURL: pkgPURL,
- Integrity: sql.NullString{String: push.integrity, Valid: oneFilePerVersion},
- },
- oneFilePerVersion,
- &database.Artifact{
- VersionPURL: versionPURL,
- Filename: target.Filename,
- StoragePath: sql.NullString{String: push.storagePath, Valid: true},
- ContentHash: sql.NullString{String: push.sha256, Valid: true},
- Size: sql.NullInt64{Int64: push.size, Valid: true},
- ContentType: sql.NullString{String: push.contentType, Valid: push.contentType != ""},
- FetchedAt: sql.NullTime{Time: push.pushedAt, Valid: true},
- Manifest: sql.NullString{String: string(push.manifest), Valid: push.manifest != nil},
- })
- if errors.Is(err, database.ErrLocalArtifactExists) {
- return ErrHostedExists
- }
- if err != nil {
- return fmt.Errorf("recording pushed artifact: %w", err)
- }
- return nil
-}
-
-// LocalArtifacts lists the archives pushed for a package.
-func (p *Proxy) LocalArtifacts(ecosystem, name string) ([]HostedArtifact, error) {
- pkgPURL := packageurl.MakeString(ecosystem, name, "")
- if pkgPURL == "" {
- return nil, fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
- }
- rows, err := p.DB.GetLocalArtifacts(pkgPURL)
- if err != nil {
- return nil, fmt.Errorf("listing local artifacts: %w", err)
- }
- listed := make([]HostedArtifact, 0, len(rows))
- for _, row := range rows {
- listed = append(listed, HostedArtifact{
- Version: database.VersionFromPURL(row.VersionPURL),
- Filename: row.Filename,
- SHA256: row.ContentHash.String,
- Size: row.Size.Int64,
- PushedAt: row.FetchedAt.Time.UTC(),
- })
- }
- return listed, nil
-}
-
-// hostedOnly reports whether a package is in an npm scope or Composer vendor
-// that is served from pushed packages alone. The upstream is never asked
-// about such a package, so nobody can mix in versions by publishing the same
-// name there.
-func (p *Proxy) hostedOnly(ecosystem, name string) bool {
- if !p.HostedEnabled {
- return false
- }
- namespace, _, namespaced := strings.Cut(name, "/")
- eco := hostedEcosystems[ecosystem]
- return namespaced && eco.hostedOnly != nil && eco.hostedOnly(p, namespace)
-}
-
-// localVersion is a pushed version as ecosystem metadata lists it.
-type localVersion struct {
- Version string
- Filename string
- SHA256 string
- Integrity string
- Manifest json.RawMessage
- PushedAt time.Time
-}
-
-// localVersions returns the versions pushed for a package, or nil when hosted
-// packages are disabled, so metadata requests cost no extra lookup then.
-// Denied versions are left out, so metadata never lists a version whose
-// download is refused.
-func (p *Proxy) localVersions(ecosystem, name string) ([]localVersion, error) {
- if !p.HostedEnabled {
- return nil, nil
- }
- pkgPURL := packageurl.MakeString(ecosystem, name, "")
- if pkgPURL == "" {
- return nil, nil
- }
- rows, err := p.DB.GetLocalArtifacts(pkgPURL)
- if err != nil {
- return nil, fmt.Errorf("listing local versions: %w", err)
- }
- versions := make([]localVersion, 0, len(rows))
- for _, row := range rows {
- version := database.VersionFromPURL(row.VersionPURL)
- if !row.Manifest.Valid || p.versionDenied(ecosystem, name, version) {
- continue
- }
- versions = append(versions, localVersion{
- Version: version,
- Filename: row.Filename,
- SHA256: row.ContentHash.String,
- Integrity: row.Integrity.String,
- Manifest: json.RawMessage(row.Manifest.String),
- PushedAt: row.FetchedAt.Time.UTC(),
- })
- }
- return versions, nil
-}
-
-// DeleteLocalVersion removes the archives pushed for name@version. If the
-// upstream has the version, clients get the upstream archive again.
-func (p *Proxy) DeleteLocalVersion(ecosystem, name, version string) error {
- return p.deleteLocal(ecosystem, name, version, "", ErrHostedVersionNotFound)
-}
-
-// DeleteLocalFile removes one pushed file of name@version.
-func (p *Proxy) DeleteLocalFile(ecosystem, name, version, filename string) error {
- return p.deleteLocal(ecosystem, name, version, filename, ErrHostedFileNotFound)
-}
-
-func (p *Proxy) deleteLocal(ecosystem, name, version, filename string, notFound error) error {
- _, versionPURL, err := packagePURLStrings(ecosystem, name, version)
- if err != nil {
- return err
- }
- deleted, err := p.DB.DeleteLocalArtifacts(versionPURL, filename)
- if err != nil {
- return fmt.Errorf("deleting local artifacts: %w", err)
- }
- if deleted == 0 {
- return notFound
- }
- return nil
-}
-
-// GetLocalArtifact serves a pushed artifact from storage, or returns nil
-// when the artifact was not pushed. Handlers that must consult the upstream
-// before serving a cached copy call it first, so pushed versions the upstream
-// does not list are still served. Pushed artifacts are not a cache, so they
-// are served even when StreamArtifacts is set.
-//
-// With hosted packages disabled it returns nil without a lookup, as
-// localVersions does: pushes are then left out of metadata, so they are not
-// served ahead of the upstream either.
-func (p *Proxy) GetLocalArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
- if !p.HostedEnabled {
- return nil, nil
- }
- pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
- if err != nil {
- return nil, err
- }
- record, err := p.DB.GetArtifact(versionPURL, filename)
- if err != nil {
- return nil, fmt.Errorf("checking artifact source: %w", err)
- }
- if record == nil || record.Source != database.SourceLocal {
- return nil, nil
- }
- if p.Denylist.Denied(versionPURL) {
- return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
- }
- result, err := p.readStored(ctx, pkgPURL, versionPURL, filename)
- if err != nil || result != nil {
- return result, err
- }
- return nil, p.localArtifactMiss(versionPURL, filename)
-}
diff --git a/internal/handler/hosted_manifest.go b/internal/handler/hosted_manifest.go
deleted file mode 100644
index cf90dcb..0000000
--- a/internal/handler/hosted_manifest.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "archive/zip"
- "bytes"
- "compress/gzip"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "path"
- "strings"
-)
-
-var (
- ErrHostedInvalidArchive = errors.New("archive has no readable package manifest")
- ErrHostedManifestMismatch = errors.New("package manifest does not match the pushed name and version")
-)
-
-// maxManifestSize bounds how much of a manifest file is read. Real manifests
-// are a few kilobytes; the bound keeps a hostile archive from claiming memory.
-const maxManifestSize = 1 << 20
-
-// readHostedManifest reads the package manifest out of a pushed archive and
-// checks that it describes target. The manifest is what the ecosystem's
-// metadata lists the version with, so its dependencies reach the client.
-// Ecosystems without manifests return nil.
-func readHostedManifest(ecosystem string, archive io.Reader, target HostedTarget) (json.RawMessage, error) {
- eco, ok := hostedEcosystems[ecosystem]
- if !ok {
- return nil, fmt.Errorf("%w: %s", ErrHostedUnsupported, ecosystem)
- }
- if eco.readManifest == nil {
- return nil, nil
- }
- return eco.readManifest(archive, target)
-}
-
-// readNPMManifest reads package.json from the top directory of an npm
-// tarball. npm packs it under package/, but published tarballs use other
-// directory names too.
-func readNPMManifest(archive io.Reader, target HostedTarget) (json.RawMessage, error) {
- gz, err := gzip.NewReader(archive)
- if err != nil {
- return nil, fmt.Errorf("%w: %w", ErrHostedInvalidArchive, err)
- }
- defer func() { _ = gz.Close() }()
-
- tr := tar.NewReader(gz)
- for {
- header, err := tr.Next()
- if err != nil {
- return nil, fmt.Errorf("%w: no package.json in tarball", ErrHostedInvalidArchive)
- }
- if header.Typeflag != tar.TypeReg || !isTopLevelManifest(header.Name, "package.json") {
- continue
- }
- manifest, fields, err := decodeManifest(tr)
- if err != nil {
- return nil, err
- }
- if fields.Name != target.Name || fields.Version != target.Version {
- return nil, fmt.Errorf("%w: package.json is %s@%s", ErrHostedManifestMismatch, fields.Name, fields.Version)
- }
- return manifest, nil
- }
-}
-
-// readComposerManifest reads composer.json from the root of a Composer dist
-// zip, or from its single top directory as GitHub zipballs have it. Package
-// names are case-insensitive, and composer.json only names a version when
-// the package pins one.
-func readComposerManifest(archive io.Reader, target HostedTarget) (json.RawMessage, error) {
- file, err := os.CreateTemp("", "hosted-composer-*.zip")
- if err != nil {
- return nil, err
- }
- defer func() {
- _ = file.Close()
- _ = os.Remove(file.Name())
- }()
- size, err := io.Copy(file, archive)
- if err != nil {
- return nil, err
- }
- zr, err := zip.NewReader(file, size)
- if err != nil {
- return nil, fmt.Errorf("%w: %w", ErrHostedInvalidArchive, err)
- }
-
- entry, err := composerManifestEntry(zr.File)
- if err != nil {
- return nil, err
- }
- rc, err := entry.Open()
- if err != nil {
- return nil, fmt.Errorf("%w: %w", ErrHostedInvalidArchive, err)
- }
- manifest, fields, err := decodeManifest(rc)
- _ = rc.Close()
- if err != nil {
- return nil, err
- }
- if !strings.EqualFold(fields.Name, target.Name) || (fields.Version != "" && fields.Version != target.Version) {
- return nil, fmt.Errorf("%w: composer.json is %s %s", ErrHostedManifestMismatch, fields.Name, fields.Version)
- }
- return manifest, nil
-}
-
-// composerManifestEntry picks the package's composer.json: the one at the
-// root, or else the only one directly inside a top directory. Archives keep
-// other composer.json files for tests and examples, in any order, so the
-// first one found isn't necessarily the package's.
-func composerManifestEntry(files []*zip.File) (*zip.File, error) {
- var topLevel []*zip.File
- for _, entry := range files {
- if entry.FileInfo().IsDir() {
- continue
- }
- if entry.Name == "composer.json" {
- return entry, nil
- }
- if isTopLevelManifest(entry.Name, "composer.json") {
- topLevel = append(topLevel, entry)
- }
- }
- switch len(topLevel) {
- case 0:
- return nil, fmt.Errorf("%w: no composer.json in zip", ErrHostedInvalidArchive)
- case 1:
- return topLevel[0], nil
- default:
- return nil, fmt.Errorf("%w: no composer.json at the root and %d in top directories", ErrHostedInvalidArchive, len(topLevel))
- }
-}
-
-// isTopLevelManifest reports whether name is filename inside one top
-// directory, such as package/package.json.
-func isTopLevelManifest(name, filename string) bool {
- dir, file := path.Split(path.Clean(name))
- return file == filename && dir != "" && !strings.Contains(strings.TrimSuffix(dir, "/"), "/")
-}
-
-type manifestFields struct {
- Name string `json:"name"`
- Version string `json:"version"`
-}
-
-// decodeManifest reads a JSON object manifest and returns it compacted along
-// with its name and version.
-func decodeManifest(r io.Reader) (json.RawMessage, manifestFields, error) {
- raw, err := io.ReadAll(io.LimitReader(r, maxManifestSize+1))
- if err != nil {
- return nil, manifestFields{}, fmt.Errorf("%w: %w", ErrHostedInvalidArchive, err)
- }
- if len(raw) > maxManifestSize {
- return nil, manifestFields{}, fmt.Errorf("%w: manifest exceeds %d bytes", ErrHostedInvalidArchive, maxManifestSize)
- }
- var fields manifestFields
- if err := json.Unmarshal(raw, &fields); err != nil {
- return nil, manifestFields{}, fmt.Errorf("%w: manifest is not a JSON object: %w", ErrHostedInvalidArchive, err)
- }
- var compact bytes.Buffer
- if err := json.Compact(&compact, raw); err != nil {
- return nil, manifestFields{}, fmt.Errorf("%w: %w", ErrHostedInvalidArchive, err)
- }
- return compact.Bytes(), fields, nil
-}
diff --git a/internal/handler/hosted_test.go b/internal/handler/hosted_test.go
deleted file mode 100644
index b0640be..0000000
--- a/internal/handler/hosted_test.go
+++ /dev/null
@@ -1,562 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "archive/zip"
- "bytes"
- "compress/gzip"
- "context"
- "errors"
- "fmt"
- "io"
- "strings"
- "sync"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestHostedTargetForFileInPath(t *testing.T) {
- got, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz")
- if err != nil || got != (HostedTarget{Name: "tools", Version: "1.2.0", Filename: "tool.tar.gz"}) {
- t.Errorf("generic target = %+v, %v", got, err)
- }
- for _, tt := range []struct{ ecosystem, name, version, file string }{
- {"generic", "tools", "1.2.0", ""},
- {"generic", "tools", "1.2.0", ".."},
- {"generic", "acme/tools", "1.2.0", "tool.tar.gz"},
- {"npm", "request", "2.88.3", "request-2.88.3.tgz"},
- } {
- if _, err := HostedTargetFor(tt.ecosystem, tt.name, tt.version, tt.file); !errors.Is(err, ErrHostedInvalidName) {
- t.Errorf("HostedTargetFor(%q, %q, %q, %q) error = %v, want ErrHostedInvalidName",
- tt.ecosystem, tt.name, tt.version, tt.file, err)
- }
- }
- if !HostedFileInPath("generic") || HostedFileInPath("npm") || HostedFileInPath("cargo") {
- t.Error("HostedFileInPath should hold for generic only")
- }
-}
-
-func TestHostedTargetFor(t *testing.T) {
- tests := []struct {
- name string
- ecosystem, pkg, version string
- want HostedTarget
- wantErr error
- }{
- {name: "npm", ecosystem: "npm", pkg: "request", version: "2.88.3",
- want: HostedTarget{Name: "request", Version: "2.88.3", Filename: "request-2.88.3.tgz"}},
- {name: "npm scoped", ecosystem: "npm", pkg: "@types/node", version: "20.1.0",
- want: HostedTarget{Name: "@types/node", Version: "20.1.0", Filename: "node-20.1.0.tgz"}},
- {name: "composer", ecosystem: "composer", pkg: "monolog/monolog", version: "3.5.0",
- want: HostedTarget{Name: "monolog/monolog", Version: "3.5.0", Filename: "monolog-3.5.0.zip"}},
- {name: "composer without vendor", ecosystem: "composer", pkg: "monolog", version: "3.5.0",
- wantErr: ErrHostedInvalidName},
- {name: "composer with extra segment", ecosystem: "composer", pkg: "a/b/c", version: "1.0.0",
- wantErr: ErrHostedInvalidName},
- {name: "unsupported ecosystem", ecosystem: "cargo", pkg: "serde", version: "1.0.200",
- wantErr: ErrHostedUnsupported},
- {name: "unknown ecosystem", ecosystem: "maven", pkg: "junit", version: "4.13",
- wantErr: ErrHostedUnsupported},
- {name: "npm scope without @", ecosystem: "npm", pkg: "types/node", version: "1.0.0",
- wantErr: ErrHostedInvalidName},
- {name: "dot-dot name segment", ecosystem: "npm", pkg: "@scope/..", version: "1.0.0",
- wantErr: ErrHostedInvalidName},
- {name: "dot-dot version", ecosystem: "npm", pkg: "request", version: "..",
- wantErr: ErrHostedInvalidName},
- {name: "control character", ecosystem: "npm", pkg: "request", version: "1.0.0\n",
- wantErr: ErrHostedInvalidName},
- {name: "empty version", ecosystem: "npm", pkg: "request", version: "",
- wantErr: ErrHostedInvalidName},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := HostedTargetFor(tt.ecosystem, tt.pkg, tt.version, "")
- if tt.wantErr != nil {
- if !errors.Is(err, tt.wantErr) {
- t.Fatalf("HostedTargetFor() error = %v, want %v", err, tt.wantErr)
- }
- return
- }
- if err != nil {
- t.Fatalf("HostedTargetFor() error = %v", err)
- }
- if got != tt.want {
- t.Errorf("HostedTargetFor() = %+v, want %+v", got, tt.want)
- }
- })
- }
-}
-
-// npmTarball builds an npm package tarball holding manifest as
-// package/package.json. Building an archive in memory cannot fail, so errors
-// panic.
-func npmTarball(manifest string) []byte {
- var buf bytes.Buffer
- gz := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gz)
- for _, f := range [][2]string{{"package/package.json", manifest}, {"package/index.js", "module.exports = 1\n"}} {
- must(tw.WriteHeader(&tar.Header{Name: f[0], Mode: 0o644, Size: int64(len(f[1]))}))
- _, err := io.WriteString(tw, f[1])
- must(err)
- }
- must(tw.Close())
- must(gz.Close())
- return buf.Bytes()
-}
-
-// composerZip builds a Composer dist archive holding manifest as composer.json
-// under dir, or at the root when dir is empty.
-func composerZip(dir, manifest string) []byte {
- var buf bytes.Buffer
- zw := zip.NewWriter(&buf)
- prefix := ""
- if dir != "" {
- prefix = dir + "/"
- }
- for _, f := range [][2]string{{prefix + "composer.json", manifest}, {prefix + "src/Logger.php", "=8.1"}}`)},
- {name: "composer manifest in a top directory", ecosystem: "composer", pkg: "monolog/monolog", version: "3.5.0",
- archive: composerZip("Seldaek-monolog-abc123", `{"name":"Monolog/Monolog","version":"3.5.0"}`)},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- target, err := HostedTargetFor(tt.ecosystem, tt.pkg, tt.version, "")
- if err != nil {
- t.Fatalf("HostedTargetFor: %v", err)
- }
- _, err = proxy.PushLocalArtifact(context.Background(), tt.ecosystem, target, bytes.NewReader(tt.archive), "", "")
- if tt.wantErr == nil {
- if err != nil {
- t.Fatalf("PushLocalArtifact: %v", err)
- }
- return
- }
- if !errors.Is(err, tt.wantErr) {
- t.Fatalf("PushLocalArtifact error = %v, want %v", err, tt.wantErr)
- }
- if listed, _ := proxy.LocalArtifacts(tt.ecosystem, tt.pkg); len(listed) != 0 {
- t.Errorf("rejected archive was recorded: %+v", listed)
- }
- if len(store.files) != 0 {
- t.Errorf("rejected archive left %d objects in storage", len(store.files))
- }
- })
- }
-}
-
-// removeStored deletes the stored object of an artifact behind the cache's back.
-func removeStored(t *testing.T, proxy *Proxy, versionPURL, filename string) {
- t.Helper()
- record, err := proxy.DB.GetArtifact(versionPURL, filename)
- if err != nil || record == nil {
- t.Fatalf("GetArtifact: %+v, %v", record, err)
- }
- if err := proxy.Storage.Delete(context.Background(), record.StoragePath.String); err != nil {
- t.Fatalf("deleting stored object: %v", err)
- }
-}
-
-func TestPushedArtifactIsNeverReplacedFromUpstream(t *testing.T) {
- const versionPURL, filename = "pkg:npm/request@2.88.3", "request-2.88.3.tgz"
- tests := []struct {
- name string
- damage func(t *testing.T, proxy *Proxy)
- wantErr error
- }{
- {name: "stored object missing", wantErr: ErrHostedArtifactUnavailable,
- damage: func(t *testing.T, proxy *Proxy) { removeStored(t, proxy, versionPURL, filename) }},
- {name: "record cleared after an integrity failure", wantErr: ErrHostedArtifactMissing,
- damage: func(t *testing.T, proxy *Proxy) {
- record, err := proxy.DB.GetArtifact(versionPURL, filename)
- if err != nil || record == nil {
- t.Fatalf("GetArtifact: %+v, %v", record, err)
- }
- if err := proxy.DB.DiscardArtifact(versionPURL, filename, record.StoragePath.String); err != nil {
- t.Fatalf("DiscardArtifact: %v", err)
- }
- }},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, db, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("upstream tarball")), ContentType: "application/gzip"}
- pushRequest(t, proxy)
- tt.damage(t, proxy)
-
- _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "npm", "request", "2.88.3", filename,
- "https://registry.npmjs.org/request/-/request-2.88.3.tgz")
- if !errors.Is(err, tt.wantErr) {
- t.Errorf("GetOrFetchArtifactFromURL error = %v, want %v", err, tt.wantErr)
- }
- if fetcher.fetchCalled {
- t.Error("a miss on a pushed artifact fetched the upstream copy")
- }
- local, err := db.GetLocalArtifacts("pkg:npm/request")
- if err != nil || len(local) != 1 || !local[0].Manifest.Valid {
- t.Errorf("pushed records after the miss = %+v, %v, want the pushed one with its manifest", local, err)
- }
- })
- }
-}
-
-// zipWith builds a zip holding files in the given order.
-func zipWith(files ...[2]string) []byte {
- var buf bytes.Buffer
- zw := zip.NewWriter(&buf)
- for _, f := range files {
- w, err := zw.Create(f[0])
- must(err)
- _, err = io.WriteString(w, f[1])
- must(err)
- }
- must(zw.Close())
- return buf.Bytes()
-}
-
-func TestComposerManifestLookup(t *testing.T) {
- const monolog = `{"name":"monolog/monolog"}`
- const fixture = `{"name":"acme/test-fixture"}`
- tests := []struct {
- name string
- archive []byte
- wantErr error
- }{
- {name: "root manifest after a nested one",
- archive: zipWith([2]string{"tests/composer.json", fixture}, [2]string{"composer.json", monolog})},
- {name: "top directory manifest next to a deeper one",
- archive: zipWith([2]string{"monolog-abc/tests/composer.json", fixture}, [2]string{"monolog-abc/composer.json", monolog})},
- {name: "two top directories with manifests",
- archive: zipWith([2]string{"tests/composer.json", fixture}, [2]string{"monolog-abc/composer.json", monolog}),
- wantErr: ErrHostedInvalidArchive},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- target, err := HostedTargetFor("composer", "monolog/monolog", "3.5.0", "")
- if err != nil {
- t.Fatalf("HostedTargetFor: %v", err)
- }
- _, err = readHostedManifest("composer", bytes.NewReader(tt.archive), target)
- if tt.wantErr == nil && err != nil {
- t.Errorf("readHostedManifest error = %v, want the root or single top directory manifest", err)
- }
- if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
- t.Errorf("readHostedManifest error = %v, want %v", err, tt.wantErr)
- }
- })
- }
-}
-
-func TestConcurrentPushesOfTheSameFileHaveOneWinner(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HostedEnabled = true
- target, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz")
- if err != nil {
- t.Fatalf("HostedTargetFor: %v", err)
- }
-
- const pushes = 8
- start := make(chan struct{})
- results := make([]error, pushes)
- pushed := make([]HostedArtifact, pushes)
- var wg sync.WaitGroup
- for i := range pushes {
- wg.Add(1)
- go func() {
- defer wg.Done()
- <-start
- body := strings.NewReader(fmt.Sprintf("build %d", i))
- pushed[i], results[i] = proxy.PushLocalArtifact(context.Background(), "generic", target, body, "", "")
- }()
- }
- close(start)
- wg.Wait()
-
- winner := -1
- for i, err := range results {
- switch {
- case err == nil && winner >= 0:
- t.Fatalf("pushes %d and %d both succeeded", winner, i)
- case err == nil:
- winner = i
- case !errors.Is(err, ErrHostedExists):
- t.Fatalf("push %d error = %v, want ErrHostedExists", i, err)
- }
- }
- if winner < 0 {
- t.Fatal("no push succeeded")
- }
-
- result, err := proxy.GetLocalArtifact(context.Background(), "generic", "tools", "1.2.0", "tool.tar.gz")
- if err != nil || result == nil {
- t.Fatalf("GetLocalArtifact = %v, %v", result, err)
- }
- served, _ := io.ReadAll(result.Reader)
- _ = result.Reader.Close()
- if string(served) != fmt.Sprintf("build %d", winner) {
- t.Errorf("served %q, want the winning push %q", served, fmt.Sprintf("build %d", winner))
- }
-}
-
-// A fetch that started before a push and records after it must not replace
-// the pushed artifact or its version's integrity.
-func TestCacheFillAfterPushKeepsThePush(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.HostedEnabled = true
- pushRequest(t, proxy)
-
- const fetchedPath = "npm/request/2.88.3/fetched/request-2.88.3.tgz"
- if _, _, err := store.Store(context.Background(), fetchedPath, strings.NewReader("upstream bytes")); err != nil {
- t.Fatalf("storing fetched copy: %v", err)
- }
- fetched := testArtifact("upstream bytes", "pkg:npm/request@2.88.3", "request-2.88.3.tgz", "application/gzip")
- err := proxy.updateCacheDB("npm", "request", "pkg:npm/request", "https://registry.npmjs.org/request/-/request-2.88.3.tgz",
- fetchedPath, fetched, "sha512-upstream")
- if !errors.Is(err, database.ErrLocalArtifactExists) {
- t.Fatalf("updateCacheDB over a push error = %v, want ErrLocalArtifactExists", err)
- }
-
- result, err := proxy.GetLocalArtifact(context.Background(), "npm", "request", "2.88.3", "request-2.88.3.tgz")
- if err != nil || result == nil {
- t.Fatalf("GetLocalArtifact = %v, %v, want the pushed copy", result, err)
- }
- served, err := io.ReadAll(result.Reader)
- _ = result.Reader.Close()
- if err != nil || string(served) != pushedTarball {
- t.Errorf("served %d bytes, %v, want the pushed tarball, integrity intact", len(served), err)
- }
-}
-
-func TestGenericPushDoesNotReadTheStoredFile(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.HostedEnabled = true
- target, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz")
- if err != nil {
- t.Fatalf("HostedTargetFor: %v", err)
- }
- store.openErr = errors.New("storage opened")
- if _, err := proxy.PushLocalArtifact(context.Background(), "generic", target, strings.NewReader("bytes"), "", ""); err != nil {
- t.Fatalf("PushLocalArtifact: %v", err)
- }
-}
diff --git a/internal/handler/if_none_match_test.go b/internal/handler/if_none_match_test.go
deleted file mode 100644
index b659254..0000000
--- a/internal/handler/if_none_match_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package handler
-
-import "testing"
-
-func TestIfNoneMatchHits(t *testing.T) {
- tests := []struct {
- header string
- etag string
- want bool
- }{
- {`"abc"`, `"abc"`, true},
- {`"abc"`, `"def"`, false},
- {"", `"abc"`, false},
- {`"abc"`, "", false},
- {"*", `"abc"`, true},
- {"*", "", false},
- {`W/"abc"`, `"abc"`, true},
- {`"abc"`, `W/"abc"`, true},
- {`W/"abc"`, `W/"abc"`, true},
- {`"abc", "def"`, `"def"`, true},
- {`"abc","def"`, `"def"`, true},
- {` "abc" , W/"def" `, `"def"`, true},
- {`"abc", "def"`, `"ghi"`, false},
- }
-
- for _, tt := range tests {
- if got := ifNoneMatchHits(tt.header, tt.etag); got != tt.want {
- t.Errorf("ifNoneMatchHits(%q, %q) = %v, want %v", tt.header, tt.etag, got, tt.want)
- }
- }
-}
diff --git a/internal/handler/integrity.go b/internal/handler/integrity.go
deleted file mode 100644
index 1bbd406..0000000
--- a/internal/handler/integrity.go
+++ /dev/null
@@ -1,297 +0,0 @@
-package handler
-
-import (
- "fmt"
- "io"
-
- "github.com/git-pkgs/integrity"
-)
-
-type integrityChecks struct {
- contentHash integrity.SRI
- native integrity.SRI
- // checksum is native metadata given as a single algorithm-prefixed hex
- // checksum, such as a registry's SHA-1 shasum, which is not SRI.
- checksum integrity.Digest
- hasChecksum bool
- algorithms []integrity.Algorithm
-}
-
-func newIntegrityChecks(contentHash, native string) (integrityChecks, error) {
- checks := integrityChecks{}
-
- if contentHash != "" {
- digest, err := integrity.ParseHex(integrity.SHA256, contentHash)
- if err != nil {
- return integrityChecks{}, fmt.Errorf("parse content_hash: %w", err)
- }
- checks.contentHash = integrity.SRI{digest}
- checks.algorithms = append(checks.algorithms, integrity.SHA256)
- }
-
- if native != "" {
- digests, err := integrity.ParseSRI(native)
- if err != nil {
- checksum, checksumErr := integrity.ParseChecksum(native)
- if checksumErr != nil {
- return integrityChecks{}, fmt.Errorf("parse integrity: %w", err)
- }
- checks.checksum, checks.hasChecksum = checksum, true
- checks.algorithms = append(checks.algorithms, checksum.Algorithm())
- return checks, nil
- }
- checks.native = digests
- for _, digest := range digests {
- checks.algorithms = append(checks.algorithms, digest.Algorithm())
- }
- }
-
- return checks, nil
-}
-
-// forStoredObject returns the checks to run when serving a stored object.
-// When the content hash is recorded it alone is checked. It was computed
-// from the bytes that were verified against the native integrity before
-// they were stored, so a match binds the bytes just as well, and serving a
-// cache hit costs one hash instead of two (SHA-256 plus SHA-512 for npm).
-// Without a content hash the native digests are all there is to check.
-func (c integrityChecks) forStoredObject() integrityChecks {
- if len(c.contentHash) == 0 {
- return c
- }
- return integrityChecks{contentHash: c.contentHash, algorithms: []integrity.Algorithm{integrity.SHA256}}
-}
-
-// verifyIntegrity wraps body to check it against the declared integrity as
-// it is read. verified, called once body has been read, reports a mismatch as
-// ErrArtifactDigestMismatch, and also a body that was not read to its end,
-// since only a complete body can be checked. An empty declared checks
-// nothing.
-func verifyIntegrity(body io.ReadCloser, declared string) (_ io.Reader, verified func() error, _ error) {
- if declared == "" {
- return body, func() error { return nil }, nil
- }
- checks, err := newIntegrityChecks("", declared)
- if err != nil {
- return nil, nil, err
- }
- var mismatch string
- reader, err := checks.newVerifyingReader(body, func(reason string) { mismatch = reason }, false)
- if err != nil {
- return nil, nil, err
- }
- return reader, func() error {
- if !reader.(*verifyingReader).verified {
- return fmt.Errorf("%w: body was not read to its end", ErrArtifactDigestMismatch)
- }
- if mismatch != "" {
- return fmt.Errorf("%w: %s", ErrArtifactDigestMismatch, mismatch)
- }
- return nil
- }, nil
-}
-
-func (c integrityChecks) wrap(source io.ReadCloser, onMismatch func(string)) (io.ReadCloser, error) {
- return c.wrapSeekable(source, onMismatch, false)
-}
-
-// wrapFailOnMismatch is wrap for bytes that are served to a client: the
-// final chunk is held back until the digest is checked, and a mismatch drops
-// it and is returned from Read as ErrArtifactDigestMismatch in place of
-// io.EOF, so the caller can abort rather than complete the response.
-func (c integrityChecks) wrapFailOnMismatch(source io.ReadCloser, onMismatch func(string)) (io.ReadCloser, error) {
- return c.wrapSeekable(source, onMismatch, true)
-}
-
-func (c integrityChecks) wrapSeekable(source io.ReadCloser, onMismatch func(string), failOnMismatch bool) (io.ReadCloser, error) {
- if len(c.algorithms) == 0 {
- return source, nil
- }
- verified, err := c.newVerifyingReader(source, onMismatch, failOnMismatch)
- if err != nil {
- return nil, err
- }
- seeker, ok := source.(io.Seeker)
- if !ok {
- return verified, nil
- }
- return &seekableVerifyingReader{
- source: source,
- verified: verified,
- seeker: seeker,
- }, nil
-}
-
-func (c integrityChecks) newVerifyingReader(source io.ReadCloser, onMismatch func(string), failOnMismatch bool) (io.ReadCloser, error) {
- if len(c.algorithms) == 0 {
- return source, nil
- }
- reader, err := integrity.NewReader(source, c.algorithms...)
- if err != nil {
- return nil, fmt.Errorf("create integrity reader: %w", err)
- }
- return &verifyingReader{
- source: source,
- reader: reader,
- checks: c,
- onMismatch: onMismatch,
- failOnMismatch: failOnMismatch,
- }, nil
-}
-
-// verifyingReader forwards Close to its source and reports completed digest
-// mismatches after its shared integrity reader observes EOF.
-type verifyingReader struct {
- source io.ReadCloser
- reader *integrity.Reader
- checks integrityChecks
- onMismatch func(reason string)
- verified bool
-
- failOnMismatch bool
- mismatched bool
-
- // With failOnMismatch, held (backed by buf) is read but not yet
- // returned, and only its first releasable bytes are known not to end the
- // body. The rest is the latest chunk, returned once the digest matches.
- buf []byte
- held []byte
- releasable int
- eof bool
-}
-
-// seekableVerifyingReader verifies ordinary reads until a successful seek,
-// after which reads bypass whole-object verification for range responses.
-type seekableVerifyingReader struct {
- source io.ReadCloser
- verified io.ReadCloser
- seeker io.Seeker
- bypass bool
-}
-
-func (r *seekableVerifyingReader) Read(p []byte) (int, error) {
- if r.bypass {
- return r.source.Read(p)
- }
- return r.verified.Read(p)
-}
-
-func (r *seekableVerifyingReader) Seek(offset int64, whence int) (int64, error) {
- position, err := r.seeker.Seek(offset, whence)
- if err == nil {
- r.bypass = true
- }
- return position, err
-}
-
-func (r *seekableVerifyingReader) Close() error {
- return r.verified.Close()
-}
-
-func (r *verifyingReader) Read(p []byte) (int, error) {
- if r.failOnMismatch {
- return r.readHoldingBack(p)
- }
- n, err := r.reader.Read(p)
- if err == io.EOF {
- r.verify()
- }
- return n, err
-}
-
-// readHoldingBack returns bytes one chunk behind the source, so the final
-// chunk is released only after the digest over the whole body has matched.
-func (r *verifyingReader) readHoldingBack(p []byte) (int, error) {
- if len(p) == 0 {
- return 0, nil
- }
- for r.releasable == 0 && !r.eof {
- // Move the held chunk to the front of the buffer and read the next
- // chunk after it; once that arrives, the held chunk is not the last.
- var pending int
- if cap(r.buf) < len(r.held)+len(p) {
- buf := make([]byte, len(r.held)+len(p))
- pending = copy(buf, r.held)
- r.buf = buf
- } else {
- pending = copy(r.buf[:cap(r.buf)], r.held)
- }
- n, err := r.reader.Read(r.buf[pending : pending+len(p)])
- r.held = r.buf[:pending+n]
- if n > 0 {
- r.releasable = pending
- }
- switch {
- case err == io.EOF:
- r.verify()
- r.eof = true
- case err != nil:
- r.held, r.releasable = nil, 0
- return 0, err
- case n == 0:
- return 0, nil
- }
- }
- if r.eof {
- if r.mismatched {
- r.held, r.releasable = nil, 0
- return 0, ErrArtifactDigestMismatch
- }
- r.releasable = len(r.held)
- if r.releasable == 0 {
- return 0, io.EOF
- }
- }
- n := copy(p, r.held[:r.releasable])
- r.held = r.held[n:]
- r.releasable -= n
- return n, nil
-}
-
-func (r *verifyingReader) Close() error {
- return r.source.Close()
-}
-
-func (r *verifyingReader) verify() {
- if r.verified {
- return
- }
- r.verified = true
- result := r.reader.Result()
- if !result.Complete {
- return
- }
-
- if len(r.checks.contentHash) > 0 {
- if err := result.Verify(r.checks.contentHash); err != nil {
- r.mismatched = true
- r.onMismatch("content_hash: " + err.Error())
- }
- }
- if len(r.checks.native) > 0 {
- if err := result.Verify(r.checks.native); err != nil {
- r.mismatched = true
- r.onMismatch("integrity: " + err.Error())
- }
- }
- if r.checks.hasChecksum {
- if err := result.VerifyDigest(r.checks.checksum); err != nil {
- r.mismatched = true
- r.onMismatch("integrity: " + err.Error())
- }
- }
-}
-
-// sha1Checksum returns a registry's hexadecimal SHA-1 shasum as the
-// "sha1-" checksum newIntegrityChecks accepts, or "" when it is empty or
-// not a SHA-1 digest.
-func sha1Checksum(shasum string) string {
- if shasum == "" {
- return ""
- }
- digest, err := integrity.ParseHex(integrity.SHA1, shasum)
- if err != nil {
- return ""
- }
- return digest.Checksum()
-}
diff --git a/internal/handler/integrity_test.go b/internal/handler/integrity_test.go
deleted file mode 100644
index 84ce34d..0000000
--- a/internal/handler/integrity_test.go
+++ /dev/null
@@ -1,550 +0,0 @@
-package handler
-
-import (
- "bytes"
- "crypto/sha256"
- "crypto/sha512"
- "encoding/base64"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "slices"
- "strings"
- "testing"
- "testing/iotest"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-func sha256Hex(data string) string {
- sum := sha256.Sum256([]byte(data))
- return hex.EncodeToString(sum[:])
-}
-
-func sha256SRI(data string) string {
- sum := sha256.Sum256([]byte(data))
- return "sha256-" + base64.StdEncoding.EncodeToString(sum[:])
-}
-
-func sha384SRI(data string) string {
- sum := sha512.Sum384([]byte(data))
- return "sha384-" + base64.StdEncoding.EncodeToString(sum[:])
-}
-
-func sha512SRI(data string) string {
- sum := sha512.Sum512([]byte(data))
- return "sha512-" + base64.StdEncoding.EncodeToString(sum[:])
-}
-
-func wrapIntegrityReader(t *testing.T, source io.ReadCloser, contentHash, native string, onMismatch func(string)) io.ReadCloser {
- t.Helper()
- checks, err := newIntegrityChecks(contentHash, native)
- if err != nil {
- t.Fatalf("newIntegrityChecks: %v", err)
- }
- reader, err := checks.wrap(source, onMismatch)
- if err != nil {
- t.Fatalf("wrap: %v", err)
- }
- return reader
-}
-
-func TestNewIntegrityChecksCollectsAlgorithms(t *testing.T) {
- checks, err := newIntegrityChecks(
- sha256Hex("hello"),
- strings.Join([]string{sha256SRI("first"), sha512SRI("second"), sha384SRI("third"), sha512SRI("alternative")}, " "),
- )
- if err != nil {
- t.Fatal(err)
- }
- if len(checks.algorithms) != 5 {
- t.Fatalf("algorithms = %v, want 5 entries", checks.algorithms)
- }
- if len(checks.native) != 4 {
- t.Errorf("native digests = %d, want 4", len(checks.native))
- }
-}
-
-func TestNewIntegrityChecksRejectsMalformedMetadata(t *testing.T) {
- tests := []struct {
- name string
- contentHash string
- native string
- }{
- {name: "short content hash", contentHash: "abc123"},
- {name: "non-hex content hash", contentHash: strings.Repeat("z", sha256.Size*2)},
- {name: "missing SRI separator", native: "sha512"},
- {name: "malformed SRI base64", native: "sha512-not!base64"},
- {name: "wrong SRI length", native: "sha512-" + base64.StdEncoding.EncodeToString([]byte("short"))},
- {name: "unsupported SRI algorithm", native: "md5-1B2M2Y8AsgTpgAmY7PhCfg=="},
- {name: "invalid SRI alternative", native: sha512SRI("valid") + " sha384-nope"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- if _, err := newIntegrityChecks(test.contentHash, test.native); err == nil {
- t.Fatal("newIntegrityChecks returned nil error")
- }
- })
- }
-}
-
-// TestIntegrityChecksVerifyRegistryChecksum checks native metadata given as
-// an algorithm-prefixed hex checksum, the form recorded for a registry's SHA-1
-// shasum, which is not SRI.
-func TestIntegrityChecksVerifyRegistryChecksum(t *testing.T) {
- const data = "hello world"
- checksum := "sha1-" + sha1Hex(data)
- checks, err := newIntegrityChecks(sha256Hex(data), checksum)
- if err != nil {
- t.Fatalf("newIntegrityChecks(%q): %v", checksum, err)
- }
- for _, tt := range []struct {
- name, body string
- wantMismatch bool
- }{
- {"matching", data, false},
- {"mismatched", "other bytes", true},
- } {
- t.Run(tt.name, func(t *testing.T) {
- var reasons []string
- reader, err := checks.wrapFailOnMismatch(io.NopCloser(strings.NewReader(tt.body)), func(reason string) { reasons = append(reasons, reason) })
- if err != nil {
- t.Fatal(err)
- }
- _, _ = io.ReadAll(reader)
- gotNative := slices.ContainsFunc(reasons, func(r string) bool { return strings.HasPrefix(r, "integrity: ") })
- if gotNative != tt.wantMismatch {
- t.Errorf("checksum mismatch reported = %v, want %v (reasons %q)", gotNative, tt.wantMismatch, reasons)
- }
- })
- }
-}
-
-func TestVerifyingReader(t *testing.T) {
- const data = "hello world"
- goodSHA := sha256Hex(data)
- goodSRI := sha512SRI(data)
-
- tests := []struct {
- name string
- hash string
- sri string
- wantCalls int
- }{
- {name: "both match", hash: goodSHA, sri: goodSRI},
- {name: "SHA-256 only match", hash: goodSHA},
- {name: "SRI only match", sri: goodSRI},
- {name: "SHA-256 mismatch", hash: sha256Hex("other"), wantCalls: 1},
- {name: "SRI mismatch", sri: sha512SRI("other"), wantCalls: 1},
- {name: "both mismatch", hash: sha256Hex("other"), sri: sha512SRI("other"), wantCalls: 2},
- {name: "no checks"},
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- var calls []string
- reader := wrapIntegrityReader(t, io.NopCloser(strings.NewReader(data)), test.hash, test.sri,
- func(reason string) { calls = append(calls, reason) })
-
- got, err := io.ReadAll(reader)
- if err != nil {
- t.Fatalf("ReadAll: %v", err)
- }
- if string(got) != data {
- t.Errorf("data corrupted: got %q", got)
- }
- if err := reader.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
- if len(calls) != test.wantCalls {
- t.Errorf("onMismatch called %d times, want %d: %v", len(calls), test.wantCalls, calls)
- }
- })
- }
-}
-
-func TestVerifyingReaderPreservesSeekCapability(t *testing.T) {
- const data = "hello world"
- t.Run("ordinary reads stay verified", func(t *testing.T) {
- source := &seekableCloseTrackingReader{Reader: bytes.NewReader([]byte(data))}
- var calls int
- reader := wrapIntegrityReader(t, source, sha256Hex("different"), "", func(string) { calls++ })
-
- got, err := io.ReadAll(reader)
- if err != nil {
- t.Fatalf("ReadAll: %v", err)
- }
- if string(got) != data {
- t.Errorf("data = %q, want %q", got, data)
- }
- if calls != 1 {
- t.Errorf("onMismatch called %d times, want 1", calls)
- }
- if err := reader.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
- if source.closeCount != 1 {
- t.Errorf("source closed %d times, want 1", source.closeCount)
- }
- })
-
- t.Run("seek bypasses whole-object verification", func(t *testing.T) {
- source := &seekableCloseTrackingReader{Reader: bytes.NewReader([]byte(data))}
- var calls int
- reader := wrapIntegrityReader(t, source, sha256Hex("different"), "", func(string) { calls++ })
- seeker, ok := reader.(io.Seeker)
- if !ok {
- t.Fatal("seekable source did not retain io.Seeker")
- }
- if _, err := seeker.Seek(6, io.SeekStart); err != nil {
- t.Fatalf("Seek: %v", err)
- }
-
- got, err := io.ReadAll(reader)
- if err != nil {
- t.Fatalf("ReadAll: %v", err)
- }
- if string(got) != "world" {
- t.Errorf("data after seek = %q, want %q", got, "world")
- }
- if calls != 0 {
- t.Errorf("onMismatch called %d times for a partial read, want 0", calls)
- }
- if err := reader.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
- if source.closeCount != 1 {
- t.Errorf("source closed %d times, want 1", source.closeCount)
- }
- })
-}
-
-type seekableCloseTrackingReader struct {
- *bytes.Reader
- closeCount int
-}
-
-func (r *seekableCloseTrackingReader) Close() error {
- r.closeCount++
- return nil
-}
-
-func TestVerifyingReaderUsesStrongestNativeAlgorithm(t *testing.T) {
- const data = "artifact"
- tests := []struct {
- name string
- native string
- wantCalls int
- }{
- {
- name: "weaker match does not override stronger mismatch",
- native: sha256SRI(data) + " " + sha512SRI("other"),
- wantCalls: 1,
- },
- {
- name: "stronger match ignores weaker mismatch",
- native: sha256SRI("other") + " " + sha512SRI(data),
- },
- {
- name: "same algorithm alternative matches",
- native: sha512SRI("other") + " " + sha512SRI(data),
- },
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- var calls int
- reader := wrapIntegrityReader(t, io.NopCloser(strings.NewReader(data)), "", test.native, func(string) { calls++ })
- if _, err := io.Copy(io.Discard, reader); err != nil {
- t.Fatal(err)
- }
- if calls != test.wantCalls {
- t.Errorf("onMismatch called %d times, want %d", calls, test.wantCalls)
- }
- })
- }
-}
-
-func TestVerifyingReaderMismatchMessages(t *testing.T) {
- const data = "actual"
- wantHash := sha256Hex("expected")
- wantSRI := sha512SRI("expected")
- var reasons []string
- reader := wrapIntegrityReader(t, io.NopCloser(strings.NewReader(data)), wantHash, wantSRI,
- func(reason string) { reasons = append(reasons, reason) })
- if _, err := io.Copy(io.Discard, reader); err != nil {
- t.Fatal(err)
- }
- if len(reasons) != 2 {
- t.Fatalf("reasons = %v, want two", reasons)
- }
- wantContentReason := "content_hash: integrity mismatch: expected " + sha256SRI("expected") + ", calculated " + sha256SRI(data)
- if reasons[0] != wantContentReason {
- t.Errorf("content reason = %q, want %q", reasons[0], wantContentReason)
- }
- wantNativeReason := "integrity: integrity mismatch: expected " + wantSRI + ", calculated " + sha512SRI(data)
- if reasons[1] != wantNativeReason {
- t.Errorf("native reason = %q, want %q", reasons[1], wantNativeReason)
- }
-}
-
-func TestVerifyingReaderPassthrough(t *testing.T) {
- source := io.NopCloser(strings.NewReader("x"))
- reader := wrapIntegrityReader(t, source, "", "", func(string) { t.Fatal("should not be called") })
- if reader != source {
- t.Error("expected passthrough when no hashes were provided")
- }
-}
-
-type closeTrackingReader struct {
- io.Reader
- closed bool
-}
-
-func (r *closeTrackingReader) Close() error {
- r.closed = true
- return nil
-}
-
-func TestVerifyingReaderPartialRead(t *testing.T) {
- source := &closeTrackingReader{Reader: strings.NewReader("hello world")}
- var calls int
- reader := wrapIntegrityReader(t, source, sha256Hex("other"), "", func(string) { calls++ })
-
- buffer := make([]byte, 5)
- _, _ = reader.Read(buffer)
- _ = reader.Close()
-
- if calls != 0 {
- t.Errorf("onMismatch called %d times for partial read, want 0", calls)
- }
- if !source.closed {
- t.Error("Close was not forwarded to the source")
- }
-}
-
-func TestVerifyingReaderNonEOFError(t *testing.T) {
- var calls int
- reader := wrapIntegrityReader(t, io.NopCloser(errorFixtureReader{}), sha256Hex("data"), "", func(string) { calls++ })
- if _, err := io.ReadAll(reader); !errors.Is(err, errIntegrityReadFixture) {
- t.Fatalf("ReadAll error = %v", err)
- }
- if calls != 0 {
- t.Errorf("onMismatch called %d times after non-EOF error", calls)
- }
-}
-
-var errIntegrityReadFixture = errors.New("integrity read fixture")
-
-type errorFixtureReader struct{}
-
-func (errorFixtureReader) Read(p []byte) (int, error) {
- return copy(p, "data"), errIntegrityReadFixture
-}
-
-func TestVerifyingReaderVerifyOnce(t *testing.T) {
- var calls int
- reader := wrapIntegrityReader(t, io.NopCloser(strings.NewReader("x")), sha256Hex("y"), "", func(string) { calls++ })
- _, _ = io.ReadAll(reader)
- _ = reader.Close()
- _ = reader.Close()
- if calls != 1 {
- t.Errorf("onMismatch called %d times, want 1", calls)
- }
-}
-
-// TestCorruptCachedArtifactAbortsResponse serves a cached artifact whose
-// stored bytes no longer match its digest through a real server and client.
-// The client must see a failed, short body rather than a complete response.
-func TestCorruptCachedArtifactAbortsResponse(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- content := strings.Repeat("cached content ", 10000)
- seedPackage(t, db, store, "npm", "corrupt", "1.0.0", "corrupt-1.0.0.tgz", content)
- seeded, err := db.GetArtifact("pkg:npm/corrupt@1.0.0", "corrupt-1.0.0.tgz")
- if err != nil || seeded == nil || !seeded.StoragePath.Valid {
- t.Fatalf("seeded record = %+v (err %v), want a storage path", seeded, err)
- }
- tampered := []byte(content)
- tampered[len(tampered)-1] = '!'
- store.files[seeded.StoragePath.String] = tampered
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- result, err := proxy.GetCachedArtifact(r.Context(), "npm", "corrupt", "1.0.0", "corrupt-1.0.0.tgz")
- if err != nil || result == nil {
- http.Error(w, "not cached", http.StatusInternalServerError)
- return
- }
- ServeArtifactRequest(w, r, result)
- }))
- t.Cleanup(server.Close)
-
- resp, err := server.Client().Get(server.URL)
- if err != nil {
- t.Fatalf("GET: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if resp.ContentLength != int64(len(content)) {
- t.Fatalf("Content-Length = %d, want %d", resp.ContentLength, len(content))
- }
- body, err := io.ReadAll(resp.Body)
- if err == nil {
- t.Error("reading a corrupted cached artifact succeeded, want an error")
- }
- if int64(len(body)) >= resp.ContentLength {
- t.Errorf("read %d bytes, want fewer than Content-Length %d", len(body), resp.ContentLength)
- }
- if !bytes.HasPrefix(tampered, body) {
- t.Error("body is not a prefix of the stored bytes")
- }
-
- artifact, err := db.GetArtifact("pkg:npm/corrupt@1.0.0", "corrupt-1.0.0.tgz")
- if err != nil || artifact == nil || artifact.StoragePath.Valid {
- t.Errorf("record = %+v (err %v), want it cleared", artifact, err)
- }
-}
-
-func TestFailOnMismatchHoldsBackFinalChunk(t *testing.T) {
- data := strings.Repeat("0123456789", 1000)
- for _, tt := range []struct {
- name string
- source func(string) io.Reader
- }{
- {"single reads", func(s string) io.Reader { return strings.NewReader(s) }},
- {"one byte reads", func(s string) io.Reader { return iotest.OneByteReader(strings.NewReader(s)) }},
- {"data with EOF", func(s string) io.Reader { return iotest.DataErrReader(strings.NewReader(s)) }},
- } {
- t.Run(tt.name, func(t *testing.T) {
- checks, err := newIntegrityChecks(sha256Hex(data), "")
- if err != nil {
- t.Fatal(err)
- }
-
- reader, err := checks.wrapFailOnMismatch(io.NopCloser(tt.source(data)), func(string) {
- t.Error("onMismatch called for matching bytes")
- })
- if err != nil {
- t.Fatal(err)
- }
- got, err := io.ReadAll(reader)
- if err != nil || string(got) != data {
- t.Errorf("matching read = %d bytes, %v; want %d bytes, nil", len(got), err, len(data))
- }
-
- var calls int
- tampered := data[:len(data)-1] + "!"
- reader, err = checks.wrapFailOnMismatch(io.NopCloser(tt.source(tampered)), func(string) { calls++ })
- if err != nil {
- t.Fatal(err)
- }
- got, err = io.ReadAll(reader)
- if !errors.Is(err, ErrArtifactDigestMismatch) {
- t.Errorf("mismatched read error = %v, want ErrArtifactDigestMismatch", err)
- }
- if len(got) >= len(tampered) || !strings.HasPrefix(tampered, string(got)) {
- t.Errorf("mismatched read returned %d of %d bytes, want a strict prefix", len(got), len(tampered))
- }
- if calls != 1 {
- t.Errorf("onMismatch called %d times, want 1", calls)
- }
- })
- }
-}
-
-// TestFetchWithDigestVerifiesEveryAlgorithm checks that a sha512 digest is
-// verified before the artifact is cached, like a sha256 one, and that a digest
-// whose algorithm can't be verified is refused without fetching.
-func TestFetchWithDigestVerifiesEveryAlgorithm(t *testing.T) {
- const content = "blob content"
- sum512 := sha512.Sum512([]byte(content))
- tests := []struct {
- name, digest string
- wantErr error
- }{
- {"sha256", "sha256:" + sha256Hex(content), nil},
- {"sha512", "sha512:" + hex.EncodeToString(sum512[:]), nil},
- {"mismatched sha256", "sha256:" + sha256Hex("other"), ErrArtifactDigestMismatch},
- {"mismatched sha512", "sha512:" + strings.Repeat("a", 128), ErrArtifactDigestMismatch},
- {"unsupported algorithm", "sha1:" + strings.Repeat("a", 40), ErrUnsupportedDigest},
- {"malformed sha512", "sha512:abc", ErrUnsupportedDigest},
- }
- for _, stream := range []bool{false, true} {
- for _, tt := range tests {
- t.Run(fmt.Sprintf("%s/stream=%t", tt.name, stream), func(t *testing.T) {
- checkFetchWithDigest(t, stream, content, tt.digest, tt.wantErr)
- })
- }
- }
-}
-
-func checkFetchWithDigest(t *testing.T, stream bool, content, digest string, wantErr error) {
- t.Helper()
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.StreamArtifacts = stream
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader(content))}
-
- result, err := proxy.GetOrFetchArtifactFromURLWithDigest(t.Context(), "oci", "library/demo", digest, digest,
- "https://registry.example/v2/library/demo/blobs/"+digest, digest)
- if err == nil {
- var body []byte
- body, err = io.ReadAll(result.Reader)
- _ = result.Reader.Close()
- if err == nil && string(body) != content {
- t.Errorf("body = %q, want %q", body, content)
- }
- }
- if !errors.Is(err, wantErr) {
- t.Fatalf("error = %v, want %v", err, wantErr)
- }
- if errors.Is(wantErr, ErrUnsupportedDigest) && fetcher.fetchCalled {
- t.Error("fetched an artifact whose digest can't be verified")
- }
- cached, err := proxy.GetCachedArtifact(t.Context(), "oci", "library/demo", digest, digest)
- if err != nil {
- t.Fatal(err)
- }
- if cached != nil {
- _ = cached.Reader.Close()
- }
- if wantCached := wantErr == nil && !stream; (cached != nil) != wantCached {
- t.Errorf("cached = %v, want %v", cached != nil, wantCached)
- }
-}
-
-// BenchmarkStoredObjectVerification compares verifying a cache hit with
-// every recorded digest (SHA-256 content hash plus npm's SHA-512) against
-// the content hash alone.
-func BenchmarkStoredObjectVerification(b *testing.B) {
- data := strings.Repeat("x", 8<<20)
- checks, err := newIntegrityChecks(sha256Hex(data), sha512SRI(data))
- if err != nil {
- b.Fatal(err)
- }
- for _, bench := range []struct {
- name string
- checks integrityChecks
- }{
- {"all digests", checks},
- {"content hash", checks.forStoredObject()},
- } {
- b.Run(bench.name, func(b *testing.B) {
- b.SetBytes(int64(len(data)))
- buf := make([]byte, 32<<10)
- for b.Loop() {
- reader, err := bench.checks.wrapFailOnMismatch(io.NopCloser(strings.NewReader(data)), func(reason string) {
- b.Fatal(reason)
- })
- if err != nil {
- b.Fatal(err)
- }
- if _, err := io.CopyBuffer(io.Discard, reader, buf); err != nil {
- b.Fatal(err)
- }
- }
- })
- }
-}
diff --git a/internal/handler/julia.go b/internal/handler/julia.go
deleted file mode 100644
index dbac313..0000000
--- a/internal/handler/julia.go
+++ /dev/null
@@ -1,434 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "bufio"
- "bytes"
- "compress/gzip"
- "context"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strings"
- "sync"
- "time"
-
- "github.com/BurntSushi/toml"
- "github.com/git-pkgs/proxy/internal/retention"
-)
-
-const (
- juliaUpstream = "https://pkg.julialang.org"
- juliaGeneralRegistryUUID = "23338594-aafe-5451-b93e-139f81909106"
- juliaArtifactName = "_artifact"
- juliaRegistryName = "_registry"
-
- // juliaRefreshTimeout bounds a background reload of the name map.
- juliaRefreshTimeout = 5 * time.Minute
- // maxJuliaRegistryTomlSize bounds Registry.toml. General's is a few MB.
- maxJuliaRegistryTomlSize = 64 << 20
-)
-
-var (
- juliaHexPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`)
- juliaUUIDPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
-)
-
-// Julia takes no package rules: its PURLs need a uuid qualifier the cache
-// doesn't store. The julia rule and the default cover package tarballs,
-// artifacts and registry snapshots alike; all are content-addressed, so an
-// evicted one comes back unchanged.
-func init() {
- retention.Default.Register(retention.Spec{Key: "julia"})
-}
-
-// JuliaHandler handles Julia Pkg server protocol requests.
-//
-// See https://pkgdocs.julialang.org/v1/registries/ and the PkgServer.jl
-// reference implementation. The protocol is content-addressed: registry,
-// package and artifact resources are all identified by git tree hashes
-// and are immutable once published.
-type JuliaHandler struct {
- proxy *Proxy
- upstreamURL string
-
- mu sync.RWMutex
- names map[string]string
- namesHash string
- loadMu sync.Mutex
-}
-
-// NewJuliaHandler creates a new Julia Pkg server handler.
-func NewJuliaHandler(proxy *Proxy, _ string) *JuliaHandler {
- return &JuliaHandler{
- proxy: proxy,
- upstreamURL: juliaUpstream,
- names: make(map[string]string),
- }
-}
-
-// NewJuliaHandlerWithUpstream creates a Julia handler with a custom upstream.
-func NewJuliaHandlerWithUpstream(proxy *Proxy, upstreamURL string) *JuliaHandler {
- h := NewJuliaHandler(proxy, "")
- h.upstreamURL = configuredUpstreamURL(upstreamURL, juliaUpstream)
- return h
-}
-
-// Routes returns the HTTP handler for Julia requests.
-func (h *JuliaHandler) Routes() http.Handler {
- mux := http.NewServeMux()
-
- mux.HandleFunc("GET /registries", h.handleRegistries)
- mux.HandleFunc("GET /registries.eager", h.handleRegistries)
- mux.HandleFunc("GET /registries.conservative", h.handleRegistries)
- mux.HandleFunc("GET /registry/{uuid}/{hash}", h.handleRegistry)
- mux.HandleFunc("GET /package/{uuid}/{hash}", h.handlePackage)
- mux.HandleFunc("GET /artifact/{hash}", h.handleArtifact)
- mux.HandleFunc("GET /meta", h.proxyUpstream)
-
- return mux
-}
-
-// handleRegistries serves the list of available registries. This is the only
-// mutable endpoint in the protocol so it goes through the metadata cache.
-func (h *JuliaHandler) handleRegistries(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "julia", cacheKey, "*/*")
-}
-
-// handleRegistry serves an immutable registry tarball and refreshes the
-// UUID→name map from its Registry.toml.
-func (h *JuliaHandler) handleRegistry(w http.ResponseWriter, r *http.Request) {
- uuid := r.PathValue("uuid")
- hash := r.PathValue("hash")
- if !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid registry reference", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Debug("julia registry request", "uuid", uuid, "hash", hash)
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaRegistryName, hash, juliaTarball(hash), upstreamURL)
- if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch registry")
- return
- }
-
- go h.refreshNamesFromRegistry(context.WithoutCancel(r.Context()), uuid, hash)
-
- ServeArtifactRequest(w, r, result)
-}
-
-// handlePackage serves an immutable package source tarball.
-func (h *JuliaHandler) handlePackage(w http.ResponseWriter, r *http.Request) {
- uuid := r.PathValue("uuid")
- hash := r.PathValue("hash")
- if !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid package reference", http.StatusBadRequest)
- return
- }
-
- if err := h.ensureNames(r.Context()); err != nil {
- h.proxy.Logger.Warn("julia name map unavailable, using uuid", "error", err)
- }
- name := h.resolveName(uuid)
-
- h.proxy.Logger.Info("julia package request", "name", name, "uuid", uuid, "hash", hash)
-
- if name == uuid {
- if result := h.cachedPackage(r.Context(), hash); result != nil {
- ServeArtifactRequest(w, r, result)
- return
- }
- }
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", name, hash, juliaTarball(hash), upstreamURL)
- if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch package")
- return
- }
-
- ServeArtifactRequest(w, r, result)
-}
-
-// cachedPackage returns the package tarball with tree hash hash from the
-// cache, under whatever name it was cached as, or nil. Without the name map,
-// which needs the registry from upstream, a request can't be matched to the
-// name its package was cached under, but the tarball is identified by its
-// tree hash alone. The registry and artifact tarballs cached under the same
-// file name are skipped.
-func (h *JuliaHandler) cachedPackage(ctx context.Context, hash string) *CacheResult {
- if h.proxy.DB == nil {
- return nil
- }
- filename := juliaTarball(hash)
- names, err := h.proxy.DB.CachedArtifactNames("julia", filename)
- if err != nil {
- h.proxy.Logger.Warn("looking up cached julia package", "hash", hash, "error", err)
- return nil
- }
- for _, name := range names {
- if name == juliaRegistryName || name == juliaArtifactName {
- continue
- }
- result, err := h.proxy.GetCachedArtifact(ctx, "julia", name, hash, filename)
- if err == nil && result != nil {
- return result
- }
- }
- return nil
-}
-
-// JuliaPackageDownload returns the URL of a package's tarball on the Pkg
-// server at upstreamURL, or the default one when empty, and the file name the
-// Julia handler caches it under.
-func JuliaPackageDownload(upstreamURL, uuid, treeHash string) (url, filename string, err error) {
- if !validJuliaUUID(uuid) {
- return "", "", fmt.Errorf("invalid julia package uuid %q", uuid)
- }
- if !juliaHexPattern.MatchString(treeHash) {
- return "", "", fmt.Errorf("invalid julia tree hash %q", treeHash)
- }
- base := configuredUpstreamURL(upstreamURL, juliaUpstream)
- return base + "/package/" + uuid + "/" + treeHash, juliaTarball(treeHash), nil
-}
-
-// juliaTarball is the file name a registry, package or artifact tarball is
-// cached under.
-func juliaTarball(hash string) string {
- return hash + ".tar.gz"
-}
-
-// handleArtifact serves an immutable binary artifact tarball. Artifacts are
-// anonymous content-addressed blobs with no associated package name.
-func (h *JuliaHandler) handleArtifact(w http.ResponseWriter, r *http.Request) {
- hash := r.PathValue("hash")
- if !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid artifact hash", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Debug("julia artifact request", "hash", hash)
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaArtifactName, hash, juliaTarball(hash), upstreamURL)
- if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
- return
- }
-
- ServeArtifactRequest(w, r, result)
-}
-
-// proxyUpstream forwards a request to the upstream Pkg server without caching.
-func (h *JuliaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, nil)
-}
-
-// resolveName returns the human-readable package name for a UUID, falling
-// back to the UUID itself if it is not present in the loaded registry.
-func (h *JuliaHandler) resolveName(uuid string) string {
- h.mu.RLock()
- defer h.mu.RUnlock()
- if name, ok := h.names[uuid]; ok {
- return name
- }
- return uuid
-}
-
-// ensureNames lazily populates the UUID→name map from the General registry.
-// Returns immediately if the map is already populated; otherwise blocks until
-// a single in-flight load completes. Failed loads are retried on the next call.
-func (h *JuliaHandler) ensureNames(ctx context.Context) error {
- if h.namesLoaded() {
- return nil
- }
-
- h.loadMu.Lock()
- defer h.loadMu.Unlock()
-
- if h.namesLoaded() {
- return nil
- }
- return h.loadNamesFromUpstream(ctx)
-}
-
-func (h *JuliaHandler) namesLoaded() bool {
- h.mu.RLock()
- defer h.mu.RUnlock()
- return len(h.names) > 0
-}
-
-// loadNamesFromUpstream fetches the current /registries listing, downloads the
-// General registry tarball at its current hash, and parses Registry.toml.
-func (h *JuliaHandler) loadNamesFromUpstream(ctx context.Context) error {
- hash, err := h.fetchGeneralRegistryHash(ctx)
- if err != nil {
- return err
- }
- return h.loadRegistryTarball(ctx, juliaGeneralRegistryUUID, hash)
-}
-
-// fetchGeneralRegistryHash reads /registries, through the metadata cache that
-// also serves it to clients, and returns the current tree hash for the
-// General registry.
-func (h *JuliaHandler) fetchGeneralRegistryHash(ctx context.Context) (string, error) {
- body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "julia", "registries", h.upstreamURL+"/registries", "*/*")
- if err != nil {
- return "", fmt.Errorf("reading /registries: %w", err)
- }
-
- scanner := bufio.NewScanner(bytes.NewReader(body))
- for scanner.Scan() {
- uuid, hash, ok := parseRegistryLine(scanner.Text())
- if ok && uuid == juliaGeneralRegistryUUID {
- return hash, nil
- }
- }
- if err := scanner.Err(); err != nil {
- return "", err
- }
- return "", fmt.Errorf("general registry not listed in /registries")
-}
-
-// refreshNamesFromRegistry reloads the UUID→name map after a General registry
-// tarball has been served. Only the hash /registries currently lists replaces
-// the map, so requests for older snapshots neither roll it back nor trigger a
-// download each. One reload runs at a time; a request that finds one running
-// skips its own. Errors are logged but do not affect the response.
-func (h *JuliaHandler) refreshNamesFromRegistry(ctx context.Context, uuid, hash string) {
- if uuid != juliaGeneralRegistryUUID || h.currentNamesHash() == hash {
- return
- }
- if !h.loadMu.TryLock() {
- return
- }
- defer h.loadMu.Unlock()
-
- ctx, cancel := context.WithTimeout(ctx, juliaRefreshTimeout)
- defer cancel()
- advertised, err := h.fetchGeneralRegistryHash(ctx)
- if err != nil {
- h.proxy.Logger.Warn("failed to refresh julia name map", "error", err)
- return
- }
- if advertised != hash || h.currentNamesHash() == hash {
- return
- }
- if err := h.loadRegistryTarball(ctx, uuid, hash); err != nil {
- h.proxy.Logger.Warn("failed to refresh julia name map", "error", err)
- }
-}
-
-func (h *JuliaHandler) currentNamesHash() string {
- h.mu.RLock()
- defer h.mu.RUnlock()
- return h.namesHash
-}
-
-// loadRegistryTarball downloads a registry tarball and replaces the name map
-// with the contents of its Registry.toml.
-func (h *JuliaHandler) loadRegistryTarball(ctx context.Context, uuid, hash string) error {
- url := fmt.Sprintf("%s/registry/%s/%s", h.upstreamURL, uuid, hash)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return err
- }
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return err
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("upstream registry returned %d", resp.StatusCode)
- }
-
- names, err := extractRegistryNames(resp.Body)
- if err != nil {
- return err
- }
-
- h.mu.Lock()
- h.names = names
- h.namesHash = hash
- h.mu.Unlock()
-
- h.proxy.Logger.Info("loaded julia registry name map", "packages", len(names), "hash", hash)
- return nil
-}
-
-// extractRegistryNames reads a gzipped registry tarball, finds Registry.toml
-// at the root, and returns its [packages] table as a UUID→name map.
-func extractRegistryNames(r io.Reader) (map[string]string, error) {
- gz, err := gzip.NewReader(r)
- if err != nil {
- return nil, fmt.Errorf("opening gzip stream: %w", err)
- }
- defer func() { _ = gz.Close() }()
-
- tr := tar.NewReader(gz)
- for {
- hdr, err := tr.Next()
- if err == io.EOF {
- return nil, fmt.Errorf("no Registry.toml in tarball")
- }
- if err != nil {
- return nil, err
- }
- if strings.TrimPrefix(hdr.Name, "./") != "Registry.toml" {
- continue
- }
-
- data, err := io.ReadAll(io.LimitReader(tr, maxJuliaRegistryTomlSize+1))
- if err != nil {
- return nil, err
- }
- if len(data) > maxJuliaRegistryTomlSize {
- return nil, fmt.Errorf("registry file Registry.toml exceeds %d bytes", maxJuliaRegistryTomlSize)
- }
- return parseRegistryToml(data)
- }
-}
-
-type juliaRegistryFile struct {
- Packages map[string]struct {
- Name string `toml:"name"`
- } `toml:"packages"`
-}
-
-// parseRegistryToml decodes the [packages] table of a Registry.toml file.
-func parseRegistryToml(data []byte) (map[string]string, error) {
- var reg juliaRegistryFile
- if _, err := toml.NewDecoder(bytes.NewReader(data)).Decode(®); err != nil {
- return nil, fmt.Errorf("parsing Registry.toml: %w", err)
- }
-
- names := make(map[string]string, len(reg.Packages))
- for uuid, pkg := range reg.Packages {
- if pkg.Name != "" {
- names[uuid] = pkg.Name
- }
- }
- return names, nil
-}
-
-// parseRegistryLine parses a single line from /registries of the form
-// "/registry/{uuid}/{hash}" and returns the uuid and hash.
-func parseRegistryLine(line string) (uuid, hash string, ok bool) {
- line = strings.TrimSpace(line)
- line = strings.TrimPrefix(line, "/registry/")
- uuid, hash, found := strings.Cut(line, "/")
- if !found || !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- return "", "", false
- }
- return uuid, hash, true
-}
-
-// validJuliaUUID reports whether s looks like a lowercase RFC 4122 UUID.
-func validJuliaUUID(s string) bool {
- return juliaUUIDPattern.MatchString(s)
-}
diff --git a/internal/handler/julia_refresh_test.go b/internal/handler/julia_refresh_test.go
deleted file mode 100644
index 4ee080b..0000000
--- a/internal/handler/julia_refresh_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-func juliaRegistryTarball(t *testing.T, packageName string) []byte {
- t.Helper()
- body := "name = \"General\"\n[packages]\n295af30f-e4ad-537b-8983-00126c2a3abe = { name = \"" + packageName + "\", path = \"R/X\" }\n"
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gw)
- if err := tw.WriteHeader(&tar.Header{Name: "Registry.toml", Mode: 0o644, Size: int64(len(body))}); err != nil {
- t.Fatal(err)
- }
- _, _ = tw.Write([]byte(body))
- _ = tw.Close()
- _ = gw.Close()
- return buf.Bytes()
-}
-
-type fakeJuliaUpstream struct {
- mu sync.Mutex
- advertised string
- tarballs map[string][]byte
- downloads map[string]int
-}
-
-func (u *fakeJuliaUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- u.mu.Lock()
- defer u.mu.Unlock()
- if r.URL.Path == "/registries" {
- _, _ = w.Write([]byte("/registry/" + juliaGeneralRegistryUUID + "/" + u.advertised + "\n"))
- return
- }
- hash := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
- body, ok := u.tarballs[hash]
- if !ok {
- http.NotFound(w, r)
- return
- }
- u.downloads[hash]++
- _, _ = w.Write(body)
-}
-
-func (u *fakeJuliaUpstream) count(hash string) int {
- u.mu.Lock()
- defer u.mu.Unlock()
- return u.downloads[hash]
-}
-
-func TestJuliaRefreshOnlyLoadsAdvertisedRegistry(t *testing.T) {
- oldHash, newHash := strings.Repeat("a", 40), strings.Repeat("b", 40)
- up := &fakeJuliaUpstream{
- advertised: newHash,
- tarballs: map[string][]byte{
- oldHash: juliaRegistryTarball(t, "OldName"),
- newHash: juliaRegistryTarball(t, "NewName"),
- },
- downloads: map[string]int{},
- }
- server := httptest.NewServer(up)
- defer server.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- h := NewJuliaHandlerWithUpstream(proxy, server.URL)
- ctx := context.Background()
-
- // An older snapshot neither replaces the map nor is downloaded for it.
- for range 5 {
- h.refreshNamesFromRegistry(ctx, juliaGeneralRegistryUUID, oldHash)
- }
- if n := up.count(oldHash); n != 0 {
- t.Fatalf("old registry downloaded %d times for the name map", n)
- }
-
- h.refreshNamesFromRegistry(ctx, juliaGeneralRegistryUUID, newHash)
- if got := h.resolveName("295af30f-e4ad-537b-8983-00126c2a3abe"); got != "NewName" {
- t.Fatalf("name = %q, want NewName", got)
- }
-
- // The loaded hash is not fetched again, and an old one doesn't roll back.
- h.refreshNamesFromRegistry(ctx, juliaGeneralRegistryUUID, newHash)
- h.refreshNamesFromRegistry(ctx, juliaGeneralRegistryUUID, oldHash)
- if n := up.count(newHash); n != 1 {
- t.Errorf("current registry downloaded %d times, want 1", n)
- }
- if got := h.resolveName("295af30f-e4ad-537b-8983-00126c2a3abe"); got != "NewName" {
- t.Errorf("name rolled back to %q", got)
- }
-}
-
-func TestJuliaRefreshSkipsWhileLoading(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewJuliaHandlerWithUpstream(proxy, "http://127.0.0.1:1")
- h.loadMu.Lock()
- done := make(chan struct{})
- go func() {
- h.refreshNamesFromRegistry(context.Background(), juliaGeneralRegistryUUID, strings.Repeat("c", 40))
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("refresh waited for a running load instead of skipping")
- }
- h.loadMu.Unlock()
-}
-
-func TestJuliaExtractRegistryNamesLimitsRegistryToml(t *testing.T) {
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gw)
- size := int64(maxJuliaRegistryTomlSize + 1)
- if err := tw.WriteHeader(&tar.Header{Name: "Registry.toml", Mode: 0o644, Size: size}); err != nil {
- t.Fatal(err)
- }
- if _, err := tw.Write(bytes.Repeat([]byte("#"), int(size))); err != nil {
- t.Fatal(err)
- }
- _ = tw.Close()
- _ = gw.Close()
- if _, err := extractRegistryNames(&buf); err == nil {
- t.Fatal("oversized Registry.toml was accepted")
- }
-}
diff --git a/internal/handler/julia_test.go b/internal/handler/julia_test.go
deleted file mode 100644
index 68fb975..0000000
--- a/internal/handler/julia_test.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestJuliaParseRegistryLine(t *testing.T) {
- tests := []struct {
- line string
- wantUUID string
- wantHash string
- wantOK bool
- }{
- {
- "/registry/23338594-aafe-5451-b93e-139f81909106/342327538ed6c1ec54c69fa145e7b6bf5934201e",
- "23338594-aafe-5451-b93e-139f81909106",
- "342327538ed6c1ec54c69fa145e7b6bf5934201e",
- true,
- },
- {
- " /registry/23338594-aafe-5451-b93e-139f81909106/342327538ed6c1ec54c69fa145e7b6bf5934201e\n",
- "23338594-aafe-5451-b93e-139f81909106",
- "342327538ed6c1ec54c69fa145e7b6bf5934201e",
- true,
- },
- {"/registry/not-a-uuid/0000", "", "", false},
- {"junk", "", "", false},
- {"", "", "", false},
- }
-
- for _, tt := range tests {
- uuid, hash, ok := parseRegistryLine(tt.line)
- if uuid != tt.wantUUID || hash != tt.wantHash || ok != tt.wantOK {
- t.Errorf("parseRegistryLine(%q) = (%q, %q, %v), want (%q, %q, %v)",
- tt.line, uuid, hash, ok, tt.wantUUID, tt.wantHash, tt.wantOK)
- }
- }
-}
-
-func TestJuliaValidUUID(t *testing.T) {
- tests := []struct {
- s string
- want bool
- }{
- {"23338594-aafe-5451-b93e-139f81909106", true},
- {"295af30f-e4ad-537b-8983-00126c2a3abe", true},
- {"23338594-AAFE-5451-b93e-139f81909106", false},
- {"23338594aafe5451b93e139f81909106", false},
- {"23338594-aafe-5451-b93e-139f8190910", false},
- {"23338594-aafe-5451-b93e-139f81909106-", false},
- {"23338594-gafe-5451-b93e-139f81909106", false},
- {"", false},
- }
-
- for _, tt := range tests {
- if got := validJuliaUUID(tt.s); got != tt.want {
- t.Errorf("validJuliaUUID(%q) = %v, want %v", tt.s, got, tt.want)
- }
- }
-}
-
-func TestJuliaParseRegistryToml(t *testing.T) {
- data := []byte(`name = "General"
-uuid = "23338594-aafe-5451-b93e-139f81909106"
-
-[packages]
-295af30f-e4ad-537b-8983-00126c2a3abe = { name = "Revise", path = "R/Revise" }
-91a5bcdd-55d7-5caf-9e0b-520d859cae80 = { name = "Plots", path = "P/Plots" }
-`)
-
- names, err := parseRegistryToml(data)
- if err != nil {
- t.Fatalf("parseRegistryToml: %v", err)
- }
- if got := names["295af30f-e4ad-537b-8983-00126c2a3abe"]; got != "Revise" {
- t.Errorf("names[Revise uuid] = %q, want Revise", got)
- }
- if got := names["91a5bcdd-55d7-5caf-9e0b-520d859cae80"]; got != "Plots" {
- t.Errorf("names[Plots uuid] = %q, want Plots", got)
- }
- if len(names) != 2 {
- t.Errorf("len(names) = %d, want 2", len(names))
- }
-}
-
-func TestJuliaExtractRegistryNames(t *testing.T) {
- registryToml := `name = "General"
-[packages]
-295af30f-e4ad-537b-8983-00126c2a3abe = { name = "Revise", path = "R/Revise" }
-`
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gw)
-
- for _, f := range []struct{ name, body string }{
- {"R/Revise/Package.toml", "name = \"Revise\"\n"},
- {"Registry.toml", registryToml},
- } {
- if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o644, Size: int64(len(f.body))}); err != nil {
- t.Fatalf("WriteHeader: %v", err)
- }
- if _, err := tw.Write([]byte(f.body)); err != nil {
- t.Fatalf("Write: %v", err)
- }
- }
- if err := tw.Close(); err != nil {
- t.Fatalf("tar Close: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("gzip Close: %v", err)
- }
-
- names, err := extractRegistryNames(bytes.NewReader(buf.Bytes()))
- if err != nil {
- t.Fatalf("extractRegistryNames: %v", err)
- }
- if got := names["295af30f-e4ad-537b-8983-00126c2a3abe"]; got != "Revise" {
- t.Errorf("names[Revise uuid] = %q, want Revise", got)
- }
-}
-
-func TestJuliaResolveName(t *testing.T) {
- h := &JuliaHandler{
- proxy: &Proxy{Logger: slog.Default()},
- names: map[string]string{
- "295af30f-e4ad-537b-8983-00126c2a3abe": "Revise",
- },
- }
-
- if got := h.resolveName("295af30f-e4ad-537b-8983-00126c2a3abe"); got != "Revise" {
- t.Errorf("resolveName(known) = %q, want Revise", got)
- }
- if got := h.resolveName("00000000-0000-0000-0000-000000000000"); got != "00000000-0000-0000-0000-000000000000" {
- t.Errorf("resolveName(unknown) = %q, want uuid fallback", got)
- }
-}
-
-func TestJuliaRoutesValidation(t *testing.T) {
- h := NewJuliaHandler(&Proxy{Logger: slog.Default()}, "")
- routes := h.Routes()
-
- tests := []struct {
- path string
- want int
- }{
- {"/package/not-a-uuid/342327538ed6c1ec54c69fa145e7b6bf5934201e", http.StatusBadRequest},
- {"/package/295af30f-e4ad-537b-8983-00126c2a3abe/short", http.StatusBadRequest},
- {"/registry/295af30f-e4ad-537b-8983-00126c2a3abe/zzzz", http.StatusBadRequest},
- {"/artifact/nothex", http.StatusBadRequest},
- {"/nope", http.StatusNotFound},
- }
-
- for _, tt := range tests {
- req := httptest.NewRequest(http.MethodGet, tt.path, nil)
- rr := httptest.NewRecorder()
- routes.ServeHTTP(rr, req)
- if rr.Code != tt.want {
- t.Errorf("GET %s = %d, want %d", tt.path, rr.Code, tt.want)
- }
- }
-}
diff --git a/internal/handler/maven.go b/internal/handler/maven.go
index 50d3046..5c8f949 100644
--- a/internal/handler/maven.go
+++ b/internal/handler/maven.go
@@ -1,98 +1,30 @@
package handler
import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
"fmt"
"io"
"net/http"
"path"
"strings"
-
- "github.com/git-pkgs/proxy/internal/retention"
- "github.com/git-pkgs/purl"
)
const (
- mavenCentralUpstream = "https://repo1.maven.org/maven2"
- gradlePluginPortalUpstream = "https://plugins.gradle.org/m2"
- minMavenParts = 4 // group path segments + artifact + version + filename
+ mavenUpstream = "https://repo1.maven.org/maven2"
)
-// Maven files are stored under pkg:maven/{group}/{artifact}, built from the
-// group:artifact name, which is what the default key mapping produces from
-// a package rule. Names keep their case, as Maven coordinates do.
-func init() {
- retention.Default.Register(retention.Spec{Key: "maven", CanonicalPackage: mavenRetentionKey})
-}
-
-// mavenRetentionKey maps a package rule to the stored package PURL. A key
-// without a group, or with the group written as a path (com/google/guava)
-// instead of dotted, parses fine but can never match a stored package, so
-// it is refused rather than accepted as a rule that does nothing.
-func mavenRetentionKey(p *purl.PURL) (string, bool) {
- if p == nil || p.Namespace == "" || strings.Contains(p.Namespace, "/") {
- return "", false
- }
- return retention.DefaultCanonical("maven")(p)
-}
-
-// MavenHandler handles Maven repository protocol requests. The default
-// repository is served at /maven/ and falls back to the Gradle Plugin Portal;
-// each named repository is served at /maven/{name}/ from its own upstream
-// alone.
+// MavenHandler handles Maven repository protocol requests.
type MavenHandler struct {
- proxy *Proxy
- upstreamURL string
- pluginPortalUpstreamURL string
- proxyURL string
- repositories map[string]mavenSource
-}
-
-// mavenSource is the repository a request is served from.
-type mavenSource struct {
- // name is the repository name, or "" for the default repository.
- name string
+ proxy *Proxy
upstreamURL string
- // fallbackURL is tried when upstreamURL doesn't have a file. Only the
- // default repository has one, the Gradle Plugin Portal.
- fallbackURL string
- // cachePrefix is prepended to cached filenames: empty for the default
- // repository, {name}/{hash of upstreamURL}/ for a named one.
- cachePrefix string
-}
-
-// WithRepositories adds named repositories, served at /maven/{name}/.
-func (h *MavenHandler) WithRepositories(repositories map[string]string) *MavenHandler {
- h.repositories = make(map[string]mavenSource, len(repositories))
- for name, upstreamURL := range repositories {
- upstreamURL = strings.TrimSuffix(upstreamURL, "/")
- digest := sha256.Sum256([]byte(upstreamURL))
- h.repositories[name] = mavenSource{
- name: name,
- upstreamURL: upstreamURL,
- cachePrefix: name + "/" + hex.EncodeToString(digest[:mavenUpstreamHashBytes]) + "/",
- }
- }
- return h
+ proxyURL string
}
// NewMavenHandler creates a new Maven repository handler.
-func NewMavenHandler(proxy *Proxy, proxyURL, upstreamURL, pluginPortalUpstreamURL string) *MavenHandler {
- if strings.TrimSpace(upstreamURL) == "" {
- upstreamURL = mavenCentralUpstream
- }
- if strings.TrimSpace(pluginPortalUpstreamURL) == "" {
- pluginPortalUpstreamURL = gradlePluginPortalUpstream
- }
-
+func NewMavenHandler(proxy *Proxy, proxyURL string) *MavenHandler {
return &MavenHandler{
- proxy: proxy,
- upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
- pluginPortalUpstreamURL: strings.TrimSuffix(pluginPortalUpstreamURL, "/"),
- proxyURL: strings.TrimSuffix(proxyURL, "/"),
+ proxy: proxy,
+ upstreamURL: mavenUpstream,
+ proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
@@ -104,7 +36,7 @@ func (h *MavenHandler) Routes() http.Handler {
// e.g., /com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar
mux.HandleFunc("GET /", h.handleRequest)
- return rejectQueryChars(mux)
+ return mux
}
// handleRequest routes Maven requests based on the path.
@@ -115,196 +47,59 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
return
}
- source := mavenSource{upstreamURL: h.upstreamURL, fallbackURL: h.pluginPortalUpstreamURL}
- if name, rest, ok := strings.Cut(urlPath, "/"); ok {
- if named, found := h.repositories[name]; found {
- source = named
- urlPath = rest
- }
- } else if _, found := h.repositories[urlPath]; found {
- http.NotFound(w, r)
- return
- }
- if urlPath == "" {
- http.NotFound(w, r)
- return
- }
-
+ // Check if this is an artifact file or metadata
filename := path.Base(urlPath)
- if h.isVersionListingFile(filename) {
- h.handleMetadata(w, r, source, urlPath)
+ if h.isMetadataFile(filename) {
+ // Proxy metadata without caching
+ h.proxyUpstream(w, r)
return
}
- if h.isArtifactFile(filename) || h.isChecksumFile(filename) || h.isCoordinateFile(urlPath) {
- h.handleDownload(w, r, source, urlPath)
+ if h.isArtifactFile(filename) {
+ // Cache artifact files
+ h.handleDownload(w, r, urlPath)
return
}
- // Proxy everything else (directory listings, catalogs etc.)
- h.proxyUpstream(w, r, source, urlPath)
-}
-
-// metadataCacheKey is the metadata cache key of urlPath. The default
-// repository's keys are hashed from its upstream URLs and the path. A named
-// repository's keys are hashed from its name, upstream URL and the path, so
-// repositories never share entries and repointing one drops what it cached.
-func (s mavenSource) metadataCacheKey(urlPath string) string {
- if s.name == "" {
- return hashedCacheKey(s.upstreamURL, s.fallbackURL, urlPath)
- }
- digest := sha256.Sum256([]byte(s.name + "\x00" + s.upstreamURL + "\x00" + urlPath))
- return "repo_" + hex.EncodeToString(digest[:])
-}
-
-// cacheFilename is the name a file is cached under. A named repository's
-// files are prefixed with its name and a hash of its upstream URL, so the
-// same coordinates in two repositories, which may hold different bytes, are
-// cached apart, and repointing a repository stops serving what the old
-// upstream sent.
-func (s mavenSource) cacheFilename(filename string) string {
- return s.cachePrefix + filename
-}
-
-// mavenUpstreamHashBytes is how much of the upstream URL's hash a named
-// repository's cache filenames carry: enough to tell its upstreams apart.
-const mavenUpstreamHashBytes = 8
-
-func (h *MavenHandler) handleMetadata(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath string) {
- if h.cooldownEnabled() && h.serveCooldownMetadata(w, r, source, urlPath) {
- return
- }
-
- // The checksums of maven-metadata.xml are computed from the cached
- // metadata, as with cooldown, so they never describe another deploy.
- if suffix, ok := strings.CutPrefix(path.Base(urlPath), mavenMetadataFile); ok && mavenChecksums[suffix] != nil {
- res, _ := h.fetchMetadata(r.Context(), source, path.Join(path.Dir(urlPath), mavenMetadataFile))
- var body []byte
- if res.err == nil {
- body, res.err = h.proxy.decodedMetadataBody(res)
- }
- if res.err != nil {
- h.writeMetadataError(w, res.err)
- return
- }
- sum := mavenChecksums[suffix]()
- _, _ = sum.Write(body)
- w.Header().Set(headerContentType, "text/plain")
- markStale(w, res.stale)
- _, _ = io.WriteString(w, hex.EncodeToString(sum.Sum(nil)))
- return
- }
-
- res, _ := h.fetchMetadata(r.Context(), source, urlPath)
- if res.err != nil {
- h.writeMetadataError(w, res.err)
- return
- }
- writeMetadataResult(w, r, res)
-}
-
-// fetchMetadata fetches a metadata file from the source's upstream, or from
-// its fallback when the upstream doesn't have it, and returns it with the
-// key it is cached under.
-func (h *MavenHandler) fetchMetadata(ctx context.Context, source mavenSource, urlPath string) (metadataResult, string) {
- cacheKey := source.metadataCacheKey(urlPath)
- upstreamURL := fmt.Sprintf("%s/%s", source.upstreamURL, urlPath)
-
- res := h.proxy.lookupMetadata(ctx, "maven", cacheKey, upstreamURL, "*/*", "", nil)
- if res.err != nil && source.fallbackURL != "" {
- if errors.Is(res.err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", source.fallbackURL, urlPath)
- h.proxy.Logger.Info("maven metadata unavailable in primary upstream, trying Gradle Plugin Portal",
- "path", urlPath)
- res = h.proxy.lookupMetadata(ctx, "maven", cacheKey, pluginPortalURL, "*/*", "", nil)
- }
- }
- return res, cacheKey
-}
-
-func (h *MavenHandler) writeMetadataError(w http.ResponseWriter, err error) {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("metadata fetch failed", "error", err)
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ // Proxy everything else (directory listings, checksums, etc.)
+ h.proxyUpstream(w, r)
}
// handleDownload serves an artifact file, fetching and caching from upstream if needed.
-func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath string) {
+func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, urlPath string) {
// Parse Maven path: group/artifact/version/filename
// e.g., com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar
group, artifact, version, filename := h.parsePath(urlPath)
if artifact == "" {
- if strings.Count(urlPath, "/") >= minMavenParts-1 {
- // A dot or colon in a group directory, or a colon in the
- // artifact, which the Maven layout never has. Proxied, the
- // file would skip the denylist, scan and cooldown.
- http.NotFound(w, r)
- return
- }
- h.proxyUpstream(w, r, source, urlPath)
- return
- }
-
- // The .pom of a version whose time isn't known yet carries it in its
- // own Last-Modified, so it is checked once fetched, without a request
- // of its own. Maven fetches the .pom before a version's other files.
- ctx, checkAfter := h.cooldownFromDownload(r.Context(), source, urlPath, version)
- if !checkAfter && h.checkCooldown(w, r, source, urlPath, version) {
+ h.proxyUpstream(w, r)
return
}
// Maven uses group:artifact as the package name
name := fmt.Sprintf("%s:%s", group, artifact)
- h.proxy.Logger.Debug("maven download request", "repository", source.name,
+ h.proxy.Logger.Info("maven download request",
"group", group, "artifact", artifact, "version", version, "filename", filename)
- upstreamURL := fmt.Sprintf("%s/%s", source.upstreamURL, urlPath)
- cacheFilename := source.cacheFilename(filename)
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, urlPath)
- // Each build replaces a SNAPSHOT file named without a timestamp, so a
- // GET fetches it again rather than serving the cached copy. HEAD is
- // answered from cache.
- get := h.proxy.GetOrFetchArtifactFromURL
- if h.isMutableSnapshotFile(urlPath) && r.Method != http.MethodHead {
- get = h.proxy.refetchArtifactFromURL
- }
-
- result, err := get(ctx, "maven", name, version, cacheFilename, upstreamURL)
- if err != nil && source.fallbackURL != "" {
- if errors.Is(err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", source.fallbackURL, urlPath)
- h.proxy.Logger.Info("maven artifact not found in primary upstream, trying Gradle Plugin Portal",
- "group", group, "artifact", artifact, "version", version, "filename", filename)
- result, err = get(ctx, "maven", name, version, cacheFilename, pluginPortalURL)
- }
- }
+ result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
- return
- }
- if checkAfter && h.checkCooldown(w, r, source, urlPath, version) {
- _ = result.Reader.Close()
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
return
}
- ServeArtifactRequest(w, r, result)
+ ServeArtifact(w, result)
}
// parsePath extracts Maven coordinates from a URL path.
// e.g., "com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar"
// -> ("com.google.guava", "guava", "32.1.3-jre", "guava-32.1.3-jre.jar")
-// A path that isn't in the Maven layout returns empty strings.
func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, filename string) {
parts := strings.Split(urlPath, "/")
- if len(parts) < minMavenParts {
+ if len(parts) < 4 {
return "", "", "", ""
}
@@ -312,18 +107,6 @@ func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, file
version = parts[len(parts)-2]
artifact = parts[len(parts)-3]
groupParts := parts[:len(parts)-3]
- // The Maven layout splits a group at its dots, so a group directory
- // never holds a dot or is empty. Such a path would spell the same group
- // as another and share its cache entries, so it is not parsed. Nor is a
- // colon in the group or artifact, which are cached as group:artifact.
- for _, part := range groupParts {
- if part == "" || strings.ContainsAny(part, ".:") {
- return "", "", "", ""
- }
- }
- if strings.Contains(artifact, ":") {
- return "", "", "", ""
- }
group = strings.Join(groupParts, ".")
return group, artifact, version, filename
@@ -331,12 +114,8 @@ func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, file
// isArtifactFile returns true if the filename looks like a Maven artifact.
func (h *MavenHandler) isArtifactFile(filename string) bool {
- // Common artifact extensions, and the archives distributions such as
- // apache-maven-{version}-bin.tar.gz are published as.
- extensions := []string{
- ".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module",
- ".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz",
- }
+ // Common artifact extensions
+ extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib"}
for _, ext := range extensions {
if strings.HasSuffix(filename, ext) {
return true
@@ -345,68 +124,42 @@ func (h *MavenHandler) isArtifactFile(filename string) bool {
return false
}
-// isCoordinateFile reports whether urlPath is a file named after the
-// artifact and version of its directory, {artifact}-{version}..., as Maven
-// names every file it publishes, whatever the extension. A SNAPSHOT's
-// files carry a timestamp in place of "SNAPSHOT".
-//
-// The group isn't checked, so a file under a group directory outside the
-// Maven layout still goes to handleDownload, which answers 404.
-func (h *MavenHandler) isCoordinateFile(urlPath string) bool {
- parts := strings.Split(urlPath, "/")
- if len(parts) < minMavenParts {
- return false
- }
- filename, version, artifact := parts[len(parts)-1], parts[len(parts)-2], parts[len(parts)-3]
- if artifact == "" || version == "" {
- return false
- }
- return strings.HasPrefix(filename, artifact+"-"+strings.TrimSuffix(version, "SNAPSHOT"))
+// isMetadataFile returns true if the filename is Maven metadata.
+func (h *MavenHandler) isMetadataFile(filename string) bool {
+ return filename == "maven-metadata.xml" ||
+ strings.HasSuffix(filename, ".sha1") ||
+ strings.HasSuffix(filename, ".sha256") ||
+ strings.HasSuffix(filename, ".sha512") ||
+ strings.HasSuffix(filename, ".md5") ||
+ strings.HasSuffix(filename, ".asc")
}
-// isMutableSnapshotFile reports whether urlPath is a SNAPSHOT file named
-// with "SNAPSHOT" rather than a build timestamp (lib-1.0-SNAPSHOT.jar), which
-// each new build replaces. Timestamped SNAPSHOT files never change.
-func (h *MavenHandler) isMutableSnapshotFile(urlPath string) bool {
- _, artifact, version, filename := h.parsePath(urlPath)
- return artifact != "" && strings.HasSuffix(version, mavenSnapshot) &&
- strings.HasPrefix(filename, artifact+"-"+version)
-}
+// proxyUpstream forwards a request to Maven Central without caching.
+func (h *MavenHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
+ upstreamURL := h.upstreamURL + r.URL.Path
-// isVersionListingFile matches maven-metadata.xml and its checksums. The
-// listing changes with every release, so it is cached for the metadata TTL
-// rather than kept like an artifact.
-func (h *MavenHandler) isVersionListingFile(filename string) bool {
- return strings.HasPrefix(filename, "maven-metadata.xml")
-}
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
-// isChecksumFile matches the checksum and signature files published beside
-// an artifact. They are as immutable as the artifact, so they are cached
-// with it rather than refetched on every request.
-func (h *MavenHandler) isChecksumFile(filename string) bool {
- for _, ext := range []string{".sha1", ".sha256", ".sha512", ".md5", ".asc"} {
- if strings.HasSuffix(filename, ext) {
- return true
- }
- }
- return false
-}
-
-// mavenForwardHeaders lets clients resume artifacts proxied without caching.
-var mavenForwardHeaders = []string{"Range", "If-Range"}
-
-// proxyUpstream forwards a request to the source's upstream without caching.
-func (h *MavenHandler) proxyUpstream(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath string) {
- if source.fallbackURL == "" {
- h.proxy.ProxyUpstream(w, r, source.upstreamURL+"/"+urlPath, mavenForwardHeaders)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
- // As for cached files, a file the primary upstream lacks is looked for
- // on the Gradle Plugin Portal.
- resp, err := h.proxy.upstreamRequest(r, source.upstreamURL+"/"+urlPath, mavenForwardHeaders)
- if err == nil && resp.StatusCode == http.StatusNotFound {
- _ = resp.Body.Close()
- resp, err = h.proxy.upstreamRequest(r, source.fallbackURL+"/"+urlPath, mavenForwardHeaders)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("upstream request failed", "error", err)
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
}
- h.proxy.relayUpstream(w, r, resp, err)
+ defer func() { _ = resp.Body.Close() }()
+
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
diff --git a/internal/handler/maven_consistency_test.go b/internal/handler/maven_consistency_test.go
deleted file mode 100644
index e548447..0000000
--- a/internal/handler/maven_consistency_test.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package handler
-
-import (
- "crypto/sha1" //nolint:gosec // Maven publishes SHA-1 checksums.
- "encoding/hex"
- "io"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-// mavenGenerations serves every path with the current generation's bytes,
-// and a matching .sha1 for maven-metadata.xml, counting requests per path.
-type mavenGenerations struct {
- gen atomic.Int32
- requests map[string]*atomic.Int32
- // down makes every request fail with 502.
- down atomic.Bool
-}
-
-func (m *mavenGenerations) body(path string) string {
- return path + " generation " + string(rune('0'+m.gen.Load()))
-}
-
-func newMavenGenerations(t *testing.T, paths ...string) (*mavenGenerations, http.Handler) {
- t.Helper()
- m := &mavenGenerations{requests: map[string]*atomic.Int32{}}
- for _, p := range paths {
- m.requests[p] = &atomic.Int32{}
- }
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if n := m.requests[r.URL.Path]; n != nil {
- n.Add(1)
- }
- if m.down.Load() {
- http.Error(w, "unavailable", http.StatusBadGateway)
- return
- }
- const metadata = "/com/example/lib/maven-metadata.xml"
- if r.URL.Path == metadata+".sha1" {
- sum := sha1.Sum([]byte(m.body(metadata))) //nolint:gosec // as above
- _, _ = io.WriteString(w, hex.EncodeToString(sum[:]))
- return
- }
- _, _ = io.WriteString(w, m.body(r.URL.Path))
- }))
- t.Cleanup(srv.Close)
-
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = srv.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- return m, NewMavenHandler(proxy, "http://localhost", srv.URL, srv.URL).Routes()
-}
-
-// TestMavenMetadataChecksumMatchesServedMetadata checks that the checksum
-// files of maven-metadata.xml describe the metadata the proxy serves, even
-// when upstream has deployed since it was cached.
-func TestMavenMetadataChecksumMatchesServedMetadata(t *testing.T) {
- m, h := newMavenGenerations(t)
- const metadata = "/com/example/lib/maven-metadata.xml"
-
- served := getMaven(h, metadata).Body.String()
- m.gen.Store(1)
- w := getMaven(h, metadata+".sha1")
- sum := sha1.Sum([]byte(served)) //nolint:gosec // as above
- if w.Code != http.StatusOK || w.Body.String() != hex.EncodeToString(sum[:]) {
- t.Errorf(".sha1 = %d %q, want the sha1 of the served metadata %x", w.Code, w.Body, sum)
- }
-}
-
-// TestMavenNonUniqueSnapshotNotKept checks that a SNAPSHOT file without a
-// timestamp, which each build replaces, is not served from cache for good,
-// while a timestamped one is.
-func TestMavenNonUniqueSnapshotNotKept(t *testing.T) {
- const (
- snapshot = "/com/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.jar"
- timestamped = "/com/example/lib/1.0-SNAPSHOT/lib-1.0-20240101.120000-1.jar"
- )
- m, h := newMavenGenerations(t, timestamped)
-
- getMaven(h, snapshot)
- getMaven(h, snapshot+".sha1")
- getMaven(h, timestamped)
- m.gen.Store(1)
- if w := getMaven(h, snapshot); w.Body.String() != m.body(snapshot) {
- t.Errorf("SNAPSHOT jar = %q, want the new build %q", w.Body, m.body(snapshot))
- }
- if w := getMaven(h, snapshot+".sha1"); w.Body.String() != m.body(snapshot+".sha1") {
- t.Errorf("SNAPSHOT jar checksum = %q, want the new build's", w.Body)
- }
- if w := getMaven(h, timestamped); w.Code != http.StatusOK || m.requests[timestamped].Load() != 1 {
- t.Errorf("timestamped SNAPSHOT jar fetched %d times, want once", m.requests[timestamped].Load())
- }
-}
-
-// TestMavenMutableSnapshotKeepsPolicyAndFallback checks that a SNAPSHOT file
-// fetched again for each request still goes through the denylist, and falls
-// back to the Gradle Plugin Portal when the primary upstream lacks it.
-func TestMavenMutableSnapshotKeepsPolicyAndFallback(t *testing.T) {
- const snapshot = "/com/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.jar"
- primary := httptest.NewServer(http.NotFoundHandler())
- t.Cleanup(primary.Close)
- portal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = io.WriteString(w, "portal build")
- }))
- t.Cleanup(portal.Close)
-
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.HTTPClient = primary.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(primary.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- h := NewMavenHandler(proxy, "http://localhost", primary.URL, portal.URL).Routes()
-
- if w := getMaven(h, snapshot); w.Code != http.StatusOK || w.Body.String() != "portal build" {
- t.Errorf("SNAPSHOT from the fallback = %d %q, want 200 portal build", w.Code, w.Body)
- }
- setTestDenylist(t, proxy, "pkg:maven/com.example/lib@1.0-SNAPSHOT")
- if w := getMaven(h, snapshot); w.Code != http.StatusForbidden {
- t.Errorf("denied SNAPSHOT = %d, want 403", w.Code)
- }
-}
-
-// TestMavenMutableSnapshotServesCacheWhenUpstreamFails checks that a
-// SNAPSHOT file fetched again for each request is still served from cache
-// while upstream is down, and that HEAD does not download it again.
-func TestMavenMutableSnapshotServesCacheWhenUpstreamFails(t *testing.T) {
- const snapshot = "/com/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.jar"
- m, h := newMavenGenerations(t, snapshot)
-
- built := getMaven(h, snapshot).Body.String()
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodHead, snapshot, nil))
- if w.Code != http.StatusOK || m.requests[snapshot].Load() != 1 {
- t.Errorf("HEAD = %d after %d upstream requests, want 200 from cache", w.Code, m.requests[snapshot].Load())
- }
-
- m.down.Store(true)
- if w := getMaven(h, snapshot); w.Code != http.StatusOK || w.Body.String() != built {
- t.Errorf("SNAPSHOT while upstream is down = %d %q, want the cached build %q", w.Code, w.Body, built)
- }
- m.down.Store(false)
- m.gen.Store(1)
- if w := getMaven(h, snapshot); w.Body.String() != m.body(snapshot) {
- t.Errorf("SNAPSHOT once upstream is back = %q, want the new build", w.Body)
- }
-}
diff --git a/internal/handler/maven_cooldown.go b/internal/handler/maven_cooldown.go
deleted file mode 100644
index 6155e0d..0000000
--- a/internal/handler/maven_cooldown.go
+++ /dev/null
@@ -1,476 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "crypto/md5"
- "crypto/sha1"
- "crypto/sha256"
- "crypto/sha512"
- "encoding/hex"
- "encoding/xml"
- "errors"
- "fmt"
- "hash"
- "io"
- "net/http"
- "net/url"
- "path"
- "slices"
- "strings"
- "time"
-)
-
-// Maven cooldown measures a version by the Last-Modified header of its .pom.
-// On Maven Central that is the time the version was deployed. The header is
-// read once per version and stored in the versions table, since release
-// files don't change.
-//
-// maven-metadata.xml has a single for the whole artifact, so
-// the proxy only looks at versions that can still be in cooldown: none when
-// is older than the window, and otherwise the versions from
-// the end of the list back to the first one that is old enough. Maven
-// Central blocks clients that send bursts of requests, so the proxy doesn't
-// read every version's time.
-//
-// SNAPSHOT versions are left alone: their files change after publishing.
-
-const errMavenCooldown = "version is in cooldown"
-
-// mavenTimeUserAgent is the User-Agent of publish-time requests, the one
-// the artifact fetcher sends.
-const mavenTimeUserAgent = "git-pkgs-proxy/1.0"
-
-const (
- mavenMetadataFile = "maven-metadata.xml"
- mavenSnapshot = "-SNAPSHOT"
- // mavenLastUpdatedLayout is the UTC timestamp format of .
- mavenLastUpdatedLayout = "20060102150405"
-)
-
-// mavenChecksums are the checksum files served beside maven-metadata.xml.
-// With cooldown on they are computed from the metadata as served.
-var mavenChecksums = map[string]func() hash.Hash{
- ".md5": md5.New,
- ".sha1": sha1.New,
- ".sha256": sha256.New,
- ".sha512": sha512.New,
-}
-
-func (h *MavenHandler) cooldownEnabled() bool {
- return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
-}
-
-func mavenPackagePURL(group, artifact string) string {
- return canonicalPackagePURL("maven", group+":"+artifact)
-}
-
-// mavenCoordinates returns the group and artifact of an artifact directory
-// such as com/google/guava/guava.
-func mavenCoordinates(artifactDir string) (group, artifact string, ok bool) {
- i := strings.LastIndex(artifactDir, "/")
- if i <= 0 || i == len(artifactDir)-1 {
- return "", "", false
- }
- return strings.ReplaceAll(artifactDir[:i], "/", "."), artifactDir[i+1:], true
-}
-
-// usableMavenVersion reports whether version can be put in a URL path as
-// one segment. Versions come from upstream metadata.
-func usableMavenVersion(version string) bool {
- return version != "" && version != "." && version != ".." && !strings.ContainsAny(version, "/\\?#%")
-}
-
-// mavenTimeKey returns the versions-table PURLs a version's time is stored
-// under. A named repository's keys carry its upstream as repository_url:
-// the same coordinates there can have been published at another time than
-// in the default repository.
-func (h *MavenHandler) mavenTimeKey(source mavenSource, group, artifact, version string) (versionPURL, packagePURL string, ok bool) {
- if h.proxy.DB == nil {
- return "", "", false
- }
- name := group + ":" + artifact
- versionPURL, packagePURL = canonicalVersionPURL("maven", name, version), canonicalPackagePURL("maven", name)
- if source.name != "" {
- qualifier := "?repository_url=" + url.QueryEscape(source.upstreamURL)
- versionPURL += qualifier
- packagePURL += qualifier
- }
- return versionPURL, packagePURL, true
-}
-
-// publishTime returns a version's publish time from the versions table, or
-// from the Last-Modified of its .pom, which is then stored. A version
-// without a .pom, or whose .pom has no Last-Modified, gets the zero time and
-// is let through, as in the other ecosystems. A failed request is an error,
-// so a rate-limited upstream can't let a version through.
-func (h *MavenHandler) publishTime(ctx context.Context, source mavenSource, artifactDir, version string) (time.Time, error) {
- group, artifact, ok := mavenCoordinates(artifactDir)
- if !ok || !usableMavenVersion(version) {
- return time.Time{}, nil
- }
- versionPURL, packagePURL, store := h.mavenTimeKey(source, group, artifact, version)
- if store {
- if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
- return ver.PublishedAt.Time, nil
- }
- }
-
- pomPath := artifactDir + "/" + version + "/" + artifact + "-" + version + ".pom"
- published, found, err := h.pomLastModified(ctx, source.upstreamURL+"/"+pomPath)
- if err == nil && !found && source.fallbackURL != "" {
- published, _, err = h.pomLastModified(ctx, source.fallbackURL+"/"+pomPath)
- }
- if err != nil {
- return time.Time{}, err
- }
- if store && !published.IsZero() {
- if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, published); err != nil {
- h.proxy.Logger.Warn("cooldown: could not store maven publish time",
- "group", group, "artifact", artifact, "version", version, "error", err)
- }
- }
- return published, nil
-}
-
-// cooldownFromDownload returns a context that stores the publish time of a
-// version from the download of its .pom or Gradle .module, and whether the
-// cooldown check is to run after the download. It does so only for those
-// files of a version whose time is not stored yet; for anything else the
-// check runs before. Both files are deployed with the version, and
-// whichever a client fetches first gives its time.
-func (h *MavenHandler) cooldownFromDownload(ctx context.Context, source mavenSource, urlPath, version string) (context.Context, bool) {
- if !h.cooldownEnabled() || strings.HasSuffix(version, mavenSnapshot) {
- return ctx, false
- }
- artifactDir := path.Dir(path.Dir(urlPath))
- group, artifact, ok := mavenCoordinates(artifactDir)
- if base := artifact + "-" + version; !ok || !usableMavenVersion(version) ||
- (path.Base(urlPath) != base+".pom" && path.Base(urlPath) != base+".module") {
- return ctx, false
- }
- versionPURL, packagePURL, store := h.mavenTimeKey(source, group, artifact, version)
- if !store {
- return ctx, false
- }
- if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
- return ctx, false
- }
- return WithUpstreamLastModified(ctx, func(published time.Time) {
- if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, published); err != nil {
- h.proxy.Logger.Warn("cooldown: could not store maven publish time",
- "group", group, "artifact", artifact, "version", version, "error", err)
- }
- }), true
-}
-
-// pomLastModified sends a HEAD request for a .pom and returns its
-// Last-Modified. found is false when upstream doesn't have the file. Only a
-// 2xx response's header is used: an error page's date says nothing about the
-// version.
-func (h *MavenHandler) pomLastModified(ctx context.Context, pomURL string) (published time.Time, found bool, err error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodHead, pomURL, nil)
- if err != nil {
- return time.Time{}, false, err
- }
- // Name the proxy as its artifact downloads do; Maven Central asks
- // tools not to send an HTTP library's default.
- req.Header.Set("User-Agent", mavenTimeUserAgent)
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return time.Time{}, false, fmt.Errorf("reading pom time: %w", err)
- }
- _ = resp.Body.Close()
- switch {
- case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
- return time.Time{}, false, nil
- case resp.StatusCode == http.StatusTooManyRequests && h.proxy.RateLimits != nil:
- // The gate has just noted the upstream's Retry-After; pass it on.
- if err := h.proxy.RateLimits.Held(pomURL); err != nil {
- return time.Time{}, false, err
- }
- return time.Time{}, false, fmt.Errorf("reading pom time: upstream returned %d", resp.StatusCode)
- case resp.StatusCode < 200 || resp.StatusCode > 299:
- return time.Time{}, false, fmt.Errorf("reading pom time: upstream returned %d", resp.StatusCode)
- }
- published, err = http.ParseTime(resp.Header.Get(headerLastModified))
- if err != nil {
- return time.Time{}, true, nil
- }
- return published, true, nil
-}
-
-// checkCooldown answers a request for a file of a version in cooldown, or
-// whose time couldn't be read, and reports whether it did. The answer is
-// 403, as for a version on the denylist.
-func (h *MavenHandler) checkCooldown(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath, version string) bool {
- if !h.cooldownEnabled() || strings.HasSuffix(version, mavenSnapshot) {
- return false
- }
- artifactDir := path.Dir(path.Dir(urlPath))
- group, artifact, ok := mavenCoordinates(artifactDir)
- if !ok || h.proxy.Cooldown.For("maven", mavenPackagePURL(group, artifact)) == 0 {
- return false
- }
- published, err := h.publishTime(r.Context(), source, artifactDir, version)
- if err != nil {
- if writeUpstreamHeld(w, err) {
- return true
- }
- h.proxy.Logger.Warn("cooldown: could not read maven version time",
- "group", group, "artifact", artifact, "version", version, "error", err)
- http.Error(w, "failed to check cooldown", http.StatusBadGateway)
- return true
- }
- if !h.proxy.cooldown().IsAllowed("maven", mavenPackagePURL(group, artifact), published) {
- h.proxy.Logger.Info("cooldown: withholding maven version",
- "group", group, "artifact", artifact, "version", version)
- http.Error(w, errMavenCooldown+": "+canonicalVersionPURL("maven", group+":"+artifact, version), http.StatusForbidden)
- return true
- }
- return false
-}
-
-// serveCooldownMetadata serves maven-metadata.xml without versions in
-// cooldown, and its checksum files computed from what is served. It reports
-// false for other files beside the metadata, such as a signature, when the
-// metadata is served unchanged, so they are proxied as usual.
-func (h *MavenHandler) serveCooldownMetadata(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath string) bool {
- filename := path.Base(urlPath)
- suffix, ok := strings.CutPrefix(filename, mavenMetadataFile)
- if !ok {
- return false
- }
- metadataPath := path.Join(path.Dir(urlPath), mavenMetadataFile)
- res, cacheKey := h.fetchMetadata(r.Context(), source, metadataPath)
- if res.err != nil {
- h.writeMetadataError(w, res.err)
- return true
- }
- body, contentType := res.body, res.contentType
-
- m, err := h.proxy.cachedRewrite(r.Context(), "maven", h.proxyURL, cacheKey, body, "", func(in []byte) ([]byte, []string, error) {
- // The rewrite is shared by requests waiting on it and cached, so it
- // finishes even if the request that started it goes away.
- out, err := h.filterMetadata(context.WithoutCancel(r.Context()), source, path.Dir(urlPath), in)
- return out, nil, err
- })
- if err != nil {
- if r.Context().Err() != nil {
- return true
- }
- if writeUpstreamHeld(w, err) {
- return true
- }
- h.proxy.Logger.Warn("cooldown: could not filter maven metadata", "path", metadataPath, "error", err)
- http.Error(w, "failed to check cooldown", http.StatusBadGateway)
- return true
- }
-
- switch {
- case suffix == "":
- // The metadata cache records JSON when an upstream sent no type,
- // and maven-metadata.xml is always XML.
- if contentType == "" || contentType == contentTypeJSON {
- contentType = "text/xml"
- }
- markStale(w, res.stale)
- serveRewrittenMetadataAs(w, r, m, contentType)
- case mavenChecksums[suffix] != nil:
- sum := mavenChecksums[suffix]()
- _, _ = sum.Write(m.content())
- w.Header().Set(headerContentType, "text/plain")
- _, _ = io.WriteString(w, hex.EncodeToString(sum.Sum(nil)))
- case bytes.Equal(m.content(), body):
- return false
- default:
- // A signature of the upstream metadata doesn't match what is served.
- http.Error(w, "not found", http.StatusNotFound)
- }
- return true
-}
-
-// filterMetadata removes versions in cooldown from an artifact's
-// maven-metadata.xml and points and at the newest
-// versions left. Metadata without a version list, such as a group's plugin
-// list or a SNAPSHOT's build list, is returned unchanged. The document is
-// edited in place rather than encoded again, so everything else in it stays
-// as upstream sent it.
-func (h *MavenHandler) filterMetadata(ctx context.Context, source mavenSource, artifactDir string, body []byte) ([]byte, error) {
- doc, err := parseMavenMetadata(body)
- if err != nil {
- return nil, err
- }
- group, artifact, ok := mavenCoordinates(artifactDir)
- if !ok || len(doc.versions) == 0 {
- return body, nil
- }
- packagePURL := mavenPackagePURL(group, artifact)
- // No version can be newer than the metadata listing it.
- if lastUpdated, err := time.Parse(mavenLastUpdatedLayout, doc.lastUpdated); err == nil &&
- h.proxy.cooldown().IsAllowed("maven", packagePURL, lastUpdated) {
- return body, nil
- }
-
- removed := map[string]bool{}
- for i := len(doc.versions) - 1; i >= 0; i-- {
- v := doc.versions[i].value
- if strings.HasSuffix(v, mavenSnapshot) {
- continue
- }
- published, err := h.publishTime(ctx, source, artifactDir, v)
- if err != nil {
- return nil, fmt.Errorf("reading time of %s: %w", v, err)
- }
- if published.IsZero() {
- continue
- }
- if h.proxy.cooldown().IsAllowed("maven", packagePURL, published) {
- break
- }
- h.proxy.Logger.Info("cooldown: filtering maven version", "group", group, "artifact", artifact, "version", v)
- removed[v] = true
- }
- if len(removed) == 0 {
- return body, nil
- }
- return doc.without(body, removed), nil
-}
-
-// mavenElement is an element of maven-metadata.xml: its text and where it
-// starts and ends in the document.
-type mavenElement struct {
- value string
- start, end int
-}
-
-type mavenMetadata struct {
- versions []mavenElement
- latest, release *mavenElement
- lastUpdated string
-}
-
-// parseMavenMetadata reads the version list, , and
-// of a maven-metadata.xml, with their offsets.
-func parseMavenMetadata(body []byte) (*mavenMetadata, error) {
- dec := xml.NewDecoder(bytes.NewReader(body))
- // Versions are ASCII, so any declared charset can be read as bytes.
- dec.CharsetReader = func(_ string, in io.Reader) (io.Reader, error) { return in, nil }
-
- doc := &mavenMetadata{}
- var stack []string
- var current *mavenElement
- for {
- offset := int(dec.InputOffset())
- tok, err := dec.Token()
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil {
- return nil, fmt.Errorf("parsing maven metadata: %w", err)
- }
- switch t := tok.(type) {
- case xml.StartElement:
- stack = append(stack, t.Name.Local)
- switch strings.Join(stack, "/") {
- case "metadata/versioning/versions/version",
- "metadata/versioning/latest",
- "metadata/versioning/release",
- "metadata/versioning/lastUpdated":
- current = &mavenElement{start: offset}
- }
- case xml.CharData:
- if current != nil {
- current.value += string(t)
- }
- case xml.EndElement:
- if current != nil {
- current.value = strings.TrimSpace(current.value)
- current.end = int(dec.InputOffset())
- switch t.Name.Local {
- case "version":
- doc.versions = append(doc.versions, *current)
- case "latest":
- doc.latest = current
- case "release":
- doc.release = current
- case "lastUpdated":
- doc.lastUpdated = current.value
- }
- current = nil
- }
- if len(stack) > 0 {
- stack = stack[:len(stack)-1]
- }
- }
- }
- return doc, nil
-}
-
-// without returns body with the removed versions taken out of the version
-// list, and and set to the last versions left in list
-// order, which is how the repository chose them. Either is dropped when no
-// version is left for it.
-func (doc *mavenMetadata) without(body []byte, removed map[string]bool) []byte {
- type edit struct {
- start, end int
- text string
- }
- var edits []edit
- drop := func(e mavenElement) {
- edits = append(edits, edit{start: lineStart(body, e.start), end: e.end})
- }
- var latest, release string
- for _, v := range doc.versions {
- if removed[v.value] {
- drop(v)
- continue
- }
- latest = v.value
- if !strings.HasSuffix(v.value, mavenSnapshot) {
- release = v.value
- }
- }
- for _, field := range []struct {
- el *mavenElement
- name string
- next string
- }{{doc.latest, "latest", latest}, {doc.release, "release", release}} {
- if field.el == nil || !removed[field.el.value] {
- continue
- }
- if field.next == "" {
- drop(*field.el)
- continue
- }
- edits = append(edits, edit{field.el.start, field.el.end, "<" + field.name + ">" + field.next + "" + field.name + ">"})
- }
-
- // Edits don't overlap; apply them in document order.
- slices.SortFunc(edits, func(a, b edit) int { return a.start - b.start })
- out := make([]byte, 0, len(body))
- pos := 0
- for _, e := range edits {
- out = append(out, body[pos:e.start]...)
- out = append(out, e.text...)
- pos = e.end
- }
- return append(out, body[pos:]...)
-}
-
-// lineStart moves an element's start back over the indentation and line
-// break before it, so removing the element removes its line.
-func lineStart(body []byte, start int) int {
- i := start
- for i > 0 && (body[i-1] == ' ' || body[i-1] == '\t') {
- i--
- }
- if i > 0 && body[i-1] == '\n' {
- i--
- if i > 0 && body[i-1] == '\r' {
- i--
- }
- return i
- }
- return start
-}
diff --git a/internal/handler/maven_cooldown_test.go b/internal/handler/maven_cooldown_test.go
deleted file mode 100644
index 828d1ff..0000000
--- a/internal/handler/maven_cooldown_test.go
+++ /dev/null
@@ -1,530 +0,0 @@
-package handler
-
-import (
- "compress/gzip"
- "crypto/md5"
- "crypto/sha1"
- "crypto/sha256"
- "crypto/sha512"
- "encoding/hex"
- "fmt"
- "hash"
- "io"
- "net/http"
- "net/http/httptest"
- "slices"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- registryclient "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
-)
-
-const mavenTestDir = "/com/example/lib/"
-
-// mavenCooldownUpstream serves com.example:lib. Each version's .pom has the
-// Last-Modified in times; pomStatus overrides the status of a version's
-// .pom. Requests for .pom files are counted.
-type mavenCooldownUpstream struct {
- server *httptest.Server
- versions []string
- latest string
- release string
- lastUpdated time.Time
- times map[string]time.Time
- pomStatus map[string]int
-
- mu sync.Mutex
- poms map[string]int
- heads int
- userAgents []string
-}
-
-func newMavenCooldownUpstream(t *testing.T) *mavenCooldownUpstream {
- t.Helper()
- now := time.Now()
- u := &mavenCooldownUpstream{
- versions: []string{"1.0", "1.1", "2.0", "2.1", "3.0-SNAPSHOT"},
- latest: "3.0-SNAPSHOT",
- release: "2.1",
- lastUpdated: now.Add(-time.Hour),
- times: map[string]time.Time{
- "1.0": now.Add(-30 * 24 * time.Hour),
- "1.1": now.Add(-10 * 24 * time.Hour),
- "2.0": now.Add(-2 * time.Hour),
- "2.1": now.Add(-time.Hour),
- "3.0-SNAPSHOT": now.Add(-time.Minute),
- },
- pomStatus: map[string]int{},
- poms: map[string]int{},
- }
- u.server = httptest.NewServer(http.HandlerFunc(u.serve))
- t.Cleanup(u.server.Close)
- return u
-}
-
-// serve answers for the default upstream and, under /portal, for the
-// Gradle Plugin Portal fallback alike.
-func (u *mavenCooldownUpstream) serve(w http.ResponseWriter, r *http.Request) {
- rest, ok := strings.CutPrefix(strings.TrimPrefix(r.URL.Path, "/portal"), mavenTestDir)
- if !ok {
- http.NotFound(w, r)
- return
- }
- switch rest {
- case "maven-metadata.xml":
- _, _ = w.Write([]byte(u.metadata()))
- return
- case "maven-metadata.xml.asc":
- _, _ = w.Write([]byte("signature"))
- return
- }
- version, file, _ := strings.Cut(rest, "/")
- published, known := u.times[version]
- if !known {
- http.NotFound(w, r)
- return
- }
- if strings.HasSuffix(file, ".module") {
- w.Header().Set(headerLastModified, published.UTC().Format(http.TimeFormat))
- }
- if strings.HasSuffix(file, ".pom") {
- u.mu.Lock()
- u.poms[version]++
- if r.Method == http.MethodHead {
- u.heads++
- u.userAgents = append(u.userAgents, r.UserAgent())
- }
- u.mu.Unlock()
- w.Header().Set(headerLastModified, published.UTC().Format(http.TimeFormat))
- if status := u.pomStatus[version]; status != 0 {
- // An error page dated long ago must not count as an old version.
- w.Header().Set(headerLastModified, time.Unix(0, 0).UTC().Format(http.TimeFormat))
- if status == http.StatusTooManyRequests {
- w.Header().Set("Retry-After", "60")
- }
- w.WriteHeader(status)
- return
- }
- }
- _, _ = w.Write([]byte(file + " of " + version))
-}
-
-func (u *mavenCooldownUpstream) metadata() string {
- var b strings.Builder
- b.WriteString("\n\n com.example\n lib\n \n")
- b.WriteString(" " + u.latest + "\n " + u.release + "\n \n")
- for _, v := range u.versions {
- b.WriteString(" " + v + "\n")
- }
- b.WriteString(" \n " + u.lastUpdated.UTC().Format(mavenLastUpdatedLayout) + "\n \n\n")
- return b.String()
-}
-
-// pomRequests returns how many requests each version's .pom got.
-func (u *mavenCooldownUpstream) pomRequests() map[string]int {
- u.mu.Lock()
- defer u.mu.Unlock()
- out := make(map[string]int, len(u.poms))
- for v, n := range u.poms {
- out[v] = n
- }
- return out
-}
-
-func (u *mavenCooldownUpstream) totalPOMRequests() int {
- total := 0
- for _, n := range u.pomRequests() {
- total += n
- }
- return total
-}
-
-func newMavenCooldownHandler(t *testing.T, u *mavenCooldownUpstream) (http.Handler, *Proxy) {
- t.Helper()
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.HTTPClient = u.server.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(u.server.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- return NewMavenHandler(proxy, "http://localhost", u.server.URL, u.server.URL+"/portal").Routes(), proxy
-}
-
-func mavenMetadataVersions(t *testing.T, body string) []string {
- t.Helper()
- doc, err := parseMavenMetadata([]byte(body))
- if err != nil {
- t.Fatalf("served metadata does not parse: %v\n%s", err, body)
- }
- var versions []string
- for _, v := range doc.versions {
- versions = append(versions, v.value)
- }
- return versions
-}
-
-func TestMavenCooldownFiltersMetadata(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, proxy := newMavenCooldownHandler(t, u)
-
- w := getMaven(h, mavenTestDir+"maven-metadata.xml")
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d: %s", w.Code, w.Body)
- }
- body := w.Body.String()
- if got := strings.Join(mavenMetadataVersions(t, body), " "); got != "1.0 1.1 3.0-SNAPSHOT" {
- t.Errorf("versions = %s, want 1.0 1.1 3.0-SNAPSHOT", got)
- }
- if !strings.Contains(body, "1.1") || !strings.Contains(body, "3.0-SNAPSHOT") {
- t.Errorf("release or latest not recomputed:\n%s", body)
- }
- if strings.Contains(body, "\n\n") {
- t.Errorf("removed versions left blank lines:\n%s", body)
- }
-
- // The walk stops at 1.1, the first version old enough, and skips the
- // SNAPSHOT.
- want := map[string]int{"2.1": 1, "2.0": 1, "1.1": 1}
- if got := u.pomRequests(); len(got) != len(want) || got["2.1"] != 1 || got["2.0"] != 1 || got["1.1"] != 1 {
- t.Errorf("pom requests = %v, want %v", got, want)
- }
-
- // Times are stored, so the next request makes no .pom requests.
- ver, err := proxy.DB.GetVersionByPURL(canonicalVersionPURL("maven", "com.example:lib", "2.1"))
- if err != nil || ver == nil || !ver.PublishedAt.Valid {
- t.Fatalf("time of 2.1 not stored: %v %v", ver, err)
- }
- if w := getMaven(h, mavenTestDir+"maven-metadata.xml"); w.Body.String() != body {
- t.Errorf("second response differs:\n%s", w.Body)
- }
- if n := u.totalPOMRequests(); n != 3 {
- t.Errorf("pom requests after second metadata request = %d, want 3", n)
- }
-}
-
-func TestMavenCooldownRecomputesLatest(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- u.versions = []string{"1.0", "1.1", "2.0", "2.1"}
- u.latest = "2.1"
- h, _ := newMavenCooldownHandler(t, u)
-
- body := getMaven(h, mavenTestDir+"maven-metadata.xml").Body.String()
- if !strings.Contains(body, "1.1") || !strings.Contains(body, "1.1") {
- t.Errorf("latest and release should be 1.1:\n%s", body)
- }
-}
-
-func TestMavenCooldownDropsLatestWhenNothingIsLeft(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- u.versions = []string{"2.0", "2.1"}
- u.latest = "2.1"
- h, _ := newMavenCooldownHandler(t, u)
-
- body := getMaven(h, mavenTestDir+"maven-metadata.xml").Body.String()
- if strings.Contains(body, "") || strings.Contains(body, "") || strings.Contains(body, "") {
- t.Errorf("no version is out of cooldown:\n%s", body)
- }
- if !strings.Contains(body, "\n ") {
- t.Errorf("version list not emptied in place:\n%s", body)
- }
-}
-
-func TestMavenCooldownChecksumsMatchMetadata(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, _ := newMavenCooldownHandler(t, u)
-
- body := getMaven(h, mavenTestDir+"maven-metadata.xml").Body.Bytes()
- for suffix, newHash := range map[string]func() hash.Hash{
- ".md5": md5.New, ".sha1": sha1.New, ".sha256": sha256.New, ".sha512": sha512.New,
- } {
- sum := newHash()
- _, _ = sum.Write(body)
- w := getMaven(h, mavenTestDir+"maven-metadata.xml"+suffix)
- if w.Code != http.StatusOK || w.Body.String() != hex.EncodeToString(sum.Sum(nil)) {
- t.Errorf("%s: status = %d body %q, want checksum of served metadata", suffix, w.Code, w.Body)
- }
- }
- // A signature of the upstream metadata would not match.
- if w := getMaven(h, mavenTestDir+"maven-metadata.xml.asc"); w.Code != http.StatusNotFound {
- t.Errorf(".asc status = %d, want 404", w.Code)
- }
-
- // Unchanged metadata keeps its signature.
- u.lastUpdated = time.Now().Add(-4 * 24 * time.Hour)
- if w := getMaven(h, mavenTestDir+"maven-metadata.xml.asc"); w.Code != http.StatusOK || w.Body.String() != "signature" {
- t.Errorf("unchanged .asc: status = %d body %q, want upstream's", w.Code, w.Body)
- }
-}
-
-func TestMavenCooldownRefusesFiles(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, _ := newMavenCooldownHandler(t, u)
-
- for _, file := range []string{"2.1/lib-2.1.pom", "2.1/lib-2.1.jar", "2.1/lib-2.1.jar.sha1", "2.0/lib-2.0-sources.jar"} {
- w := getMaven(h, mavenTestDir+file)
- if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), errMavenCooldown) {
- t.Errorf("%s: status = %d body %q, want 403 cooldown", file, w.Code, w.Body)
- }
- }
- for _, file := range []string{"1.1/lib-1.1.jar", "3.0-SNAPSHOT/lib-3.0-SNAPSHOT.jar"} {
- if w := getMaven(h, mavenTestDir+file); w.Code != http.StatusOK {
- t.Errorf("%s: status = %d, want 200", file, w.Code)
- }
- }
- // One .pom request per version, none for the SNAPSHOT.
- if got := u.pomRequests(); got["2.1"] != 1 || got["2.0"] != 1 || got["1.1"] != 1 || got["3.0-SNAPSHOT"] != 0 {
- t.Errorf("pom requests = %v", got)
- }
-}
-
-// TestMavenCooldownPOMDownloadCarriesItsTime checks that a build fetching
-// pinned versions, which never reads maven-metadata.xml, costs no requests
-// beyond its downloads: the .pom's own Last-Modified gives the version's
-// time, and Maven fetches the .pom before the version's other files.
-func TestMavenCooldownPOMDownloadCarriesItsTime(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, proxy := newMavenCooldownHandler(t, u)
-
- for _, c := range []struct {
- file string
- want int
- }{
- {"2.1/lib-2.1.pom", http.StatusForbidden},
- {"2.1/lib-2.1.jar", http.StatusForbidden},
- {"1.0/lib-1.0.pom", http.StatusOK},
- {"1.0/lib-1.0.jar", http.StatusOK},
- } {
- if w := getMaven(h, mavenTestDir+c.file); w.Code != c.want {
- t.Errorf("%s: status = %d, want %d", c.file, w.Code, c.want)
- }
- }
- u.mu.Lock()
- heads := u.heads
- u.mu.Unlock()
- if heads != 0 {
- t.Errorf("HEAD requests = %d, want 0", heads)
- }
- if got := u.pomRequests(); got["2.1"] != 1 || got["1.0"] != 1 {
- t.Errorf("pom requests = %v, want one GET each", got)
- }
- ver, _ := proxy.DB.GetVersionByPURL(canonicalVersionPURL("maven", "com.example:lib", "2.1"))
- if ver == nil || !ver.PublishedAt.Valid || !ver.PublishedAt.Time.Equal(u.times["2.1"].Truncate(time.Second)) {
- t.Errorf("stored time of 2.1 = %v, want %v", ver, u.times["2.1"])
- }
-}
-
-// TestMavenCooldownModuleDownloadCarriesItsTime checks that a Gradle
-// .module fetched before the .pom gives the version's time as the .pom
-// would.
-func TestMavenCooldownModuleDownloadCarriesItsTime(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, _ := newMavenCooldownHandler(t, u)
-
- if w := getMaven(h, mavenTestDir+"2.1/lib-2.1.module"); w.Code != http.StatusForbidden {
- t.Errorf("2.1 .module: status = %d, want 403", w.Code)
- }
- if w := getMaven(h, mavenTestDir+"1.0/lib-1.0.module"); w.Code != http.StatusOK {
- t.Errorf("1.0 .module: status = %d, want 200", w.Code)
- }
- if w := getMaven(h, mavenTestDir+"1.0/lib-1.0.jar"); w.Code != http.StatusOK {
- t.Errorf("1.0 .jar: status = %d, want 200", w.Code)
- }
- if n := u.totalPOMRequests(); n != 0 {
- t.Errorf("pom requests = %d, want 0", n)
- }
-}
-
-// TestMavenCooldownTimeRequestsNameTheProxy checks the User-Agent of the
-// HEAD requests for publish times: Maven Central asks tools to name
-// themselves rather than send an HTTP library's default.
-func TestMavenCooldownTimeRequestsNameTheProxy(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, _ := newMavenCooldownHandler(t, u)
-
- getMaven(h, mavenTestDir+"maven-metadata.xml")
- u.mu.Lock()
- defer u.mu.Unlock()
- if len(u.userAgents) == 0 {
- t.Fatal("no HEAD requests")
- }
- for _, ua := range u.userAgents {
- if ua != mavenTimeUserAgent {
- t.Errorf("User-Agent = %q, want %q", ua, mavenTimeUserAgent)
- }
- }
-}
-
-// TestMavenCooldownMetadataServedLikeOtherRewrites checks that filtered
-// metadata is gzip-compressed for clients that accept it, as the other
-// ecosystems' rewritten metadata is.
-func TestMavenCooldownMetadataServedLikeOtherRewrites(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- // Enough versions that the document compresses.
- for i := range 50 {
- v := fmt.Sprintf("0.%d", i)
- u.versions = append([]string{v}, u.versions...)
- u.times[v] = time.Now().Add(-100 * 24 * time.Hour)
- }
- h, proxy := newMavenCooldownHandler(t, u)
- // The server always sets up the rewrite cache, which keeps the gzip copy.
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- req := httptest.NewRequest(http.MethodGet, mavenTestDir+"maven-metadata.xml", nil)
- req.Header.Set(headerAcceptEncoding, "gzip")
- w := httptest.NewRecorder()
- h.ServeHTTP(w, req)
- if w.Code != http.StatusOK || w.Header().Get(headerContentEncoding) != "gzip" {
- t.Fatalf("status = %d, Content-Encoding %q; want gzip", w.Code, w.Header().Get(headerContentEncoding))
- }
- if ct := w.Header().Get(headerContentType); !strings.Contains(ct, "xml") {
- t.Errorf("Content-Type = %q, want XML", ct)
- }
- zr, err := gzip.NewReader(w.Body)
- if err != nil {
- t.Fatal(err)
- }
- body, _ := io.ReadAll(zr)
- if versions := mavenMetadataVersions(t, string(body)); slices.Contains(versions, "2.1") {
- t.Errorf("2.1 listed in the gzip body: %v", versions)
- }
-}
-
-func TestMavenCooldownOldLastUpdatedSkipsPOMs(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- u.lastUpdated = time.Now().Add(-4 * 24 * time.Hour)
- // The times would filter 2.0 and 2.1 if they were read.
- h, _ := newMavenCooldownHandler(t, u)
-
- w := getMaven(h, mavenTestDir+"maven-metadata.xml")
- if w.Body.String() != u.metadata() {
- t.Errorf("metadata changed:\n%s", w.Body)
- }
- if n := u.totalPOMRequests(); n != 0 {
- t.Errorf("pom requests = %d, want 0", n)
- }
-}
-
-func TestMavenCooldownIgnoresNon2xxPOMResponses(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- u.pomStatus["2.1"] = http.StatusNotFound
- h, proxy := newMavenCooldownHandler(t, u)
-
- // A missing .pom gives no time: 2.1 stays listed, and the walk goes on
- // to 2.0 rather than stopping at the error page's old date.
- versions := mavenMetadataVersions(t, getMaven(h, mavenTestDir+"maven-metadata.xml").Body.String())
- if got := strings.Join(versions, " "); got != "1.0 1.1 2.1 3.0-SNAPSHOT" {
- t.Errorf("versions = %s, want 1.0 1.1 2.1 3.0-SNAPSHOT", got)
- }
- if ver, _ := proxy.DB.GetVersionByPURL(canonicalVersionPURL("maven", "com.example:lib", "2.1")); ver != nil && ver.PublishedAt.Valid {
- t.Errorf("time of a 404 stored: %v", ver.PublishedAt.Time)
- }
-
- // Any other error fails closed.
- u.pomStatus["2.1"] = http.StatusTooManyRequests
- for _, p := range []string{"maven-metadata.xml", "maven-metadata.xml.sha1", "2.1/lib-2.1.jar"} {
- if w := getMaven(h, mavenTestDir+p); w.Code != http.StatusBadGateway {
- t.Errorf("%s: status = %d, want 502", p, w.Code)
- }
- }
- if ver, _ := proxy.DB.GetVersionByPURL(canonicalVersionPURL("maven", "com.example:lib", "2.1")); ver != nil && ver.PublishedAt.Valid {
- t.Errorf("time of a 429 stored: %v", ver.PublishedAt.Time)
- }
-}
-
-// TestMavenCooldownRateLimitedPOMIsHeld checks that a 429 on a .pom time
-// request is answered like any held upstream request, with 503 and the
-// upstream's Retry-After, so clients wait instead of failing the build.
-func TestMavenCooldownRateLimitedPOMIsHeld(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- u.pomStatus["2.0"] = http.StatusTooManyRequests
- h, proxy := newMavenCooldownHandler(t, u)
- gate := registryclient.NewRateLimitGate(u.server.Client().Transport)
- proxy.HTTPClient = &http.Client{Transport: gate}
- proxy.RateLimits = gate
-
- // Not the .pom, so its time is read with a HEAD.
- w := getMaven(h, mavenTestDir+"2.0/lib-2.0.jar")
- if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
- t.Errorf("status = %d, Retry-After %q; want 503 with Retry-After", w.Code, w.Header().Get("Retry-After"))
- }
-}
-
-func TestMavenCooldownDisabledPassesThrough(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, proxy := newMavenCooldownHandler(t, u)
- proxy.Cooldown = nil
-
- if w := getMaven(h, mavenTestDir+"maven-metadata.xml"); w.Body.String() != u.metadata() {
- t.Errorf("metadata changed:\n%s", w.Body)
- }
- if w := getMaven(h, mavenTestDir+"2.1/lib-2.1.jar"); w.Code != http.StatusOK {
- t.Errorf("jar status = %d, want 200", w.Code)
- }
- if n := u.totalPOMRequests(); n != 0 {
- t.Errorf("pom requests = %d, want 0", n)
- }
-}
-
-func TestMavenCooldownPackageOverride(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- h, proxy := newMavenCooldownHandler(t, u)
- proxy.Cooldown = &cooldown.Config{Default: "3d", Packages: map[string]string{"pkg:maven/com.example/lib": "0"}}
-
- if w := getMaven(h, mavenTestDir+"maven-metadata.xml"); w.Body.String() != u.metadata() {
- t.Errorf("metadata changed:\n%s", w.Body)
- }
- if w := getMaven(h, mavenTestDir+"2.1/lib-2.1.jar"); w.Code != http.StatusOK {
- t.Errorf("jar status = %d, want 200", w.Code)
- }
- if n := u.totalPOMRequests(); n != 0 {
- t.Errorf("pom requests = %d, want 0", n)
- }
-}
-
-func TestMavenCooldownNamedRepositoryStoresItsOwnTimes(t *testing.T) {
- u := newMavenCooldownUpstream(t)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = u.server.Client()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- // The named repository is the same server under /portal, which serves
- // the same files.
- h := NewMavenHandler(proxy, "http://localhost", u.server.URL, u.server.URL+"/portal").
- WithRepositories(map[string]string{"mirror": u.server.URL + "/portal"}).Routes()
-
- for range 2 {
- versions := mavenMetadataVersions(t, getMaven(h, "/mirror"+mavenTestDir+"maven-metadata.xml").Body.String())
- if got := strings.Join(versions, " "); got != "1.0 1.1 3.0-SNAPSHOT" {
- t.Fatalf("versions = %s, want 1.0 1.1 3.0-SNAPSHOT", got)
- }
- }
- if w := getMaven(h, "/mirror"+mavenTestDir+"2.1/lib-2.1.jar"); w.Code != http.StatusForbidden {
- t.Errorf("jar status = %d, want 403", w.Code)
- }
- if n := u.totalPOMRequests(); n != 3 {
- t.Errorf("pom requests = %d, want 3", n)
- }
-
- // The default repository doesn't use the named one's times.
- _ = getMaven(h, mavenTestDir+"maven-metadata.xml")
- if n := u.totalPOMRequests(); n != 6 {
- t.Errorf("pom requests after default metadata = %d, want 6", n)
- }
-}
-
-func TestMavenMetadataWithoutCompact(t *testing.T) {
- in := `2212`
- doc, err := parseMavenMetadata([]byte(in))
- if err != nil {
- t.Fatal(err)
- }
- got := string(doc.without([]byte(in), map[string]bool{"2": true}))
- want := `111`
- if got != want {
- t.Errorf("got %s\nwant %s", got, want)
- }
-}
diff --git a/internal/handler/maven_repositories_test.go b/internal/handler/maven_repositories_test.go
deleted file mode 100644
index e9b2fcf..0000000
--- a/internal/handler/maven_repositories_test.go
+++ /dev/null
@@ -1,225 +0,0 @@
-package handler
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-// mavenRepoUpstream serves label-specific bytes for every path it knows and
-// counts its requests.
-func mavenRepoUpstream(t *testing.T, label string, known ...string) (*httptest.Server, *atomic.Int32) {
- t.Helper()
- var requests atomic.Int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- for _, p := range known {
- if r.URL.Path == p {
- _, _ = io.WriteString(w, label+" "+r.URL.Path)
- return
- }
- }
- http.NotFound(w, r)
- }))
- t.Cleanup(srv.Close)
- return srv, &requests
-}
-
-func newMavenRepositoriesHandler(t *testing.T, central, portal, grails *httptest.Server) http.Handler {
- t.Helper()
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.HTTPClient = central.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(central.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- return NewMavenHandler(proxy, "http://localhost", central.URL, portal.URL).
- WithRepositories(map[string]string{"grails": grails.URL + "/restricted/"}).Routes()
-}
-
-func getMaven(h http.Handler, path string) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- return w
-}
-
-const (
- mavenJarPath = "/org/grails/grails-core/6.0.0/grails-core-6.0.0.jar"
- mavenMetadataPath = "/org/grails/grails-core/maven-metadata.xml"
-)
-
-func TestMavenRepositoriesAreServedFromTheirOwnUpstream(t *testing.T) {
- central, _ := mavenRepoUpstream(t, "central", mavenJarPath, mavenMetadataPath)
- portal, _ := mavenRepoUpstream(t, "portal")
- grails, _ := mavenRepoUpstream(t, "grails", "/restricted"+mavenJarPath, "/restricted"+mavenMetadataPath)
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- // The same coordinates in both repositories are cached apart.
- for range 2 {
- if w := getMaven(h, "/grails"+mavenJarPath); w.Code != http.StatusOK || w.Body.String() != "grails /restricted"+mavenJarPath {
- t.Fatalf("named jar: status = %d, body = %q", w.Code, w.Body)
- }
- if w := getMaven(h, mavenJarPath); w.Code != http.StatusOK || w.Body.String() != "central "+mavenJarPath {
- t.Fatalf("default jar: status = %d, body = %q", w.Code, w.Body)
- }
- }
-
- if w := getMaven(h, "/grails"+mavenMetadataPath); w.Code != http.StatusOK || !strings.HasPrefix(w.Body.String(), "grails ") {
- t.Errorf("named metadata: status = %d, body = %q", w.Code, w.Body)
- }
- if w := getMaven(h, mavenMetadataPath); w.Code != http.StatusOK || !strings.HasPrefix(w.Body.String(), "central ") {
- t.Errorf("default metadata: status = %d, body = %q", w.Code, w.Body)
- }
-}
-
-func TestMavenRepositoriesDoNotFallBackToThePluginPortal(t *testing.T) {
- const pluginPath = "/org/example/plugin/1.0/plugin-1.0.jar"
- central, _ := mavenRepoUpstream(t, "central")
- portal, portalRequests := mavenRepoUpstream(t, "portal", pluginPath, "/org/example/plugin/maven-metadata.xml")
- grails, _ := mavenRepoUpstream(t, "grails")
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- for _, path := range []string{"/grails" + pluginPath, "/grails/org/example/plugin/maven-metadata.xml"} {
- if w := getMaven(h, path); w.Code != http.StatusNotFound {
- t.Errorf("GET %s status = %d, want 404", path, w.Code)
- }
- }
- if n := portalRequests.Load(); n != 0 {
- t.Errorf("named repository asked the plugin portal %d times", n)
- }
- if w := getMaven(h, pluginPath); w.Code != http.StatusOK || !strings.HasPrefix(w.Body.String(), "portal ") {
- t.Errorf("default repository: status = %d, body = %q, want the plugin portal's copy", w.Code, w.Body)
- }
-}
-
-func TestMavenRepositoryRootIsNotFound(t *testing.T) {
- central, centralRequests := mavenRepoUpstream(t, "central")
- portal, _ := mavenRepoUpstream(t, "portal")
- grails, _ := mavenRepoUpstream(t, "grails")
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- for _, path := range []string{"/grails", "/grails/"} {
- if w := getMaven(h, path); w.Code != http.StatusNotFound {
- t.Errorf("GET %s status = %d, want 404", path, w.Code)
- }
- }
- if n := centralRequests.Load(); n != 0 {
- t.Errorf("repository root went to the default upstream %d times", n)
- }
-}
-
-func TestMavenRepositoryRepointedServesTheNewUpstream(t *testing.T) {
- central, _ := mavenRepoUpstream(t, "central")
- portal, _ := mavenRepoUpstream(t, "portal")
- staging, _ := mavenRepoUpstream(t, "staging", mavenJarPath)
- production, _ := mavenRepoUpstream(t, "production", mavenJarPath)
-
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.HTTPClient = central.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(central.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
-
- for _, upstream := range []struct {
- srv *httptest.Server
- label string
- }{{staging, "staging"}, {production, "production"}} {
- h := NewMavenHandler(proxy, "http://localhost", central.URL, portal.URL).
- WithRepositories(map[string]string{"grails": upstream.srv.URL}).Routes()
- if w := getMaven(h, "/grails"+mavenJarPath); w.Code != http.StatusOK || w.Body.String() != upstream.label+" "+mavenJarPath {
- t.Errorf("%s: status = %d, body = %q", upstream.label, w.Code, w.Body)
- }
- }
-}
-
-// TestMavenDottedGroupDirectoryNotCachedAsGroup checks that a file whose
-// group directories contain dots or colons, which the Maven layout never
-// has, is not served: it would either share the cache entry of the group
-// the dots spell or be proxied past the denylist, scan and cooldown.
-func TestMavenDottedGroupDirectoryNotCachedAsGroup(t *testing.T) {
- const standard = "/org/foo/bar/1.0/bar-1.0.jar"
- odd := []string{
- "/org.foo/bar/1.0/bar-1.0.jar", "/org.foo/bar/1.0/bar-1.0-linux.exe",
- "/org/foo:x/bar/1.0/bar-1.0.jar", "/org/foo/b:ar/1.0/b:ar-1.0.jar",
- }
- central, requests := mavenRepoUpstream(t, "central", append([]string{standard}, odd...)...)
- portal, _ := mavenRepoUpstream(t, "portal")
- var restricted []string
- for _, p := range odd {
- restricted = append(restricted, "/restricted"+p)
- }
- grails, grailsRequests := mavenRepoUpstream(t, "grails", restricted...)
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- if w := getMaven(h, standard); w.Code != http.StatusOK || w.Body.String() != "central "+standard {
- t.Errorf("GET %s = %d %q", standard, w.Code, w.Body.String())
- }
- before := requests.Load() + grailsRequests.Load()
- for _, p := range odd {
- for _, path := range []string{p, "/grails" + p} {
- if w := getMaven(h, path); w.Code != http.StatusNotFound {
- t.Errorf("GET %s = %d %q, want 404", path, w.Code, w.Body.String())
- }
- }
- }
- if n := requests.Load() + grailsRequests.Load() - before; n != 0 {
- t.Errorf("%d upstream requests for paths outside the Maven layout", n)
- }
-}
-
-// TestMavenUncachedPathFallsBackToPluginPortal checks that a path proxied
-// without caching, such as a catalog, still tries the Gradle Plugin Portal
-// when the primary upstream lacks it.
-func TestMavenUncachedPathFallsBackToPluginPortal(t *testing.T) {
- const uncached = "/org/foo/archetype-catalog.xml"
- central, _ := mavenRepoUpstream(t, "central")
- portal, _ := mavenRepoUpstream(t, "portal", uncached)
- grails, _ := mavenRepoUpstream(t, "grails")
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- if w := getMaven(h, uncached); w.Code != http.StatusOK || w.Body.String() != "portal "+uncached {
- t.Errorf("GET %s = %d %q, want the Plugin Portal's copy", uncached, w.Code, w.Body.String())
- }
-}
-
-func TestMavenUncachedPathKeepsByteRanges(t *testing.T) {
- const uncached = "/org/foo/archetype-catalog.xml"
- ranged := func() *httptest.Server {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("ETag", `"v2"`)
- http.ServeContent(w, r, "archetype-catalog.xml", time.Time{}, strings.NewReader("0123456789"))
- }))
- t.Cleanup(srv.Close)
- return srv
- }
- central, grails := ranged(), ranged()
- portal, _ := mavenRepoUpstream(t, "portal")
- h := newMavenRepositoriesHandler(t, central, portal, grails)
-
- for _, p := range []string{uncached, "/grails" + uncached} {
- req := httptest.NewRequest(http.MethodGet, p, nil)
- req.Header.Set("Range", "bytes=2-4")
- w := httptest.NewRecorder()
- h.ServeHTTP(w, req)
- if w.Code != http.StatusPartialContent || w.Body.String() != "234" {
- t.Errorf("GET %s with Range = %d %q, want 206 %q", p, w.Code, w.Body.String(), "234")
- }
-
- // A Range for an older copy gets the whole current file.
- req = httptest.NewRequest(http.MethodGet, p, nil)
- req.Header.Set("Range", "bytes=2-4")
- req.Header.Set("If-Range", `"v1"`)
- w = httptest.NewRecorder()
- h.ServeHTTP(w, req)
- if w.Code != http.StatusOK || w.Body.String() != "0123456789" {
- t.Errorf("GET %s with stale If-Range = %d %q, want 200 with the whole file", p, w.Code, w.Body.String())
- }
- }
-}
diff --git a/internal/handler/maven_test.go b/internal/handler/maven_test.go
index 14cb49f..df6917c 100644
--- a/internal/handler/maven_test.go
+++ b/internal/handler/maven_test.go
@@ -31,15 +31,6 @@ func TestMavenParsePath(t *testing.T) {
"short/path",
"", "", "", "",
},
- // Group directories never hold dots or are empty in the Maven
- // layout; these would spell the same group as org/foo.
- {"org.foo/bar/1.0/bar-1.0.jar", "", "", "", ""},
- {"org/.foo/bar/1.0/bar-1.0.jar", "", "", "", ""},
- {"org//foo/bar/1.0/bar-1.0.jar", "", "", "", ""},
- // Cache identity is group:artifact, so a colon in either would
- // let org:foo/bar and org/foo:bar share one entry.
- {"org:foo/bar/1.0/bar-1.0.jar", "", "", "", ""},
- {"org/foo:bar/1.0/bar-1.0.jar", "", "", "", ""},
}
for _, tt := range tests {
@@ -61,15 +52,10 @@ func TestMavenIsArtifactFile(t *testing.T) {
}{
{"guava-32.1.3-jre.jar", true},
{"guava-32.1.3-jre.pom", true},
- {"guava-32.1.3-jre.module", true},
{"app-1.0.war", true},
{"lib-1.0.aar", true},
- {"apache-maven-3.9.9-bin.tar.gz", true},
- {"apache-maven-3.9.9-bin.zip", true},
- {"maven-mvnd-1.0.6-linux-amd64.tgz", true},
{"maven-metadata.xml", false},
{"guava-32.1.3-jre.jar.sha1", false},
- {"apache-maven-3.9.9-bin.tar.gz.sha512", false},
}
for _, tt := range tests {
@@ -79,75 +65,3 @@ func TestMavenIsArtifactFile(t *testing.T) {
}
}
}
-
-func TestMavenIsVersionListingFile(t *testing.T) {
- h := &MavenHandler{}
-
- tests := []struct {
- filename string
- want bool
- }{
- {"maven-metadata.xml", true},
- {"maven-metadata.xml.sha1", true},
- {"maven-metadata.xml.sha256", true},
- {"guava-32.1.3-jre.jar", false},
- {"guava-32.1.3-jre.jar.sha1", false},
- {"com.diffplug.spotless.gradle.plugin-8.4.0.pom", false},
- }
-
- for _, tt := range tests {
- if got := h.isVersionListingFile(tt.filename); got != tt.want {
- t.Errorf("isVersionListingFile(%q) = %v, want %v", tt.filename, got, tt.want)
- }
- }
-}
-
-func TestMavenIsChecksumFile(t *testing.T) {
- h := &MavenHandler{}
-
- tests := []struct {
- filename string
- want bool
- }{
- {"guava-32.1.3-jre.jar.sha1", true},
- {"guava-32.1.3-jre.jar.md5", true},
- {"guava-32.1.3-jre.pom.sha1", true},
- {"guava-32.1.3-jre.jar.asc", true},
- {"apache-maven-3.9.9-bin.tar.gz.sha512", true},
- {"com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1", true},
- {"guava-32.1.3-jre.jar", false},
- {"guava-32.1.3-jre.pom", false},
- {"maven-metadata.xml", false},
- }
-
- for _, tt := range tests {
- if got := h.isChecksumFile(tt.filename); got != tt.want {
- t.Errorf("isChecksumFile(%q) = %v, want %v", tt.filename, got, tt.want)
- }
- }
-}
-
-func TestMavenIsCoordinateFile(t *testing.T) {
- h := &MavenHandler{}
-
- tests := []struct {
- path string
- want bool
- }{
- {"com/google/protobuf/protoc/3.21.0/protoc-3.21.0-linux-x86_64.exe", true},
- {"com/google/protobuf/protoc/3.21.0/protoc-3.21.0-linux-x86_64.exe.sha1", true},
- {"org/example/plugin/1.0/plugin-1.0.hpi", true},
- {"org/example/lib/1.0-SNAPSHOT/lib-1.0-20240101.120000-1.exe", true},
- {"org/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.exe", true},
- {"com/google/protobuf/protoc/3.21.0/", false},
- {"com/google/protobuf/protoc/3.21.0/other-3.21.0.exe", false},
- {"com/google/protobuf/protoc/3.21.0/protoc-3.20.0.exe", false},
- {"archetype-catalog.xml", false},
- }
-
- for _, tt := range tests {
- if got := h.isCoordinateFile(tt.path); got != tt.want {
- t.Errorf("isCoordinateFile(%q) = %v, want %v", tt.path, got, tt.want)
- }
- }
-}
diff --git a/internal/handler/metadata_access_test.go b/internal/handler/metadata_access_test.go
deleted file mode 100644
index 7b5e1cf..0000000
--- a/internal/handler/metadata_access_test.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package handler
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// ageMetadataAccess moves a cache row's last access an hour back and returns
-// that time.
-func ageMetadataAccess(t *testing.T, db *database.DB, ecosystem, key string) time.Time {
- t.Helper()
- old := time.Now().Add(-time.Hour).UTC()
- query := db.Rebind(`UPDATE metadata_cache SET last_accessed_at = ? WHERE ecosystem = ? AND name = ?`)
- if _, err := db.Exec(query, old, ecosystem, key); err != nil {
- t.Fatalf("ageing last access: %v", err)
- }
- return old
-}
-
-func assertMetadataAccessedAfter(t *testing.T, db *database.DB, ecosystem, key string, old time.Time) {
- t.Helper()
- entry, err := db.GetMetadataCache(ecosystem, key)
- if err != nil || entry == nil {
- t.Fatalf("GetMetadataCache: %v, %v", entry, err)
- }
- if !entry.LastAccessedAt.Valid || !entry.LastAccessedAt.Time.After(old.Add(time.Minute)) {
- t.Errorf("last_accessed_at = %v, want the time it was served from cache", entry.LastAccessedAt)
- }
-}
-
-func TestFetchOrCacheMetadata_CacheHitMarksAccess(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"v":1}`))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- ctx := context.Background()
-
- if _, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "pkg", upstream.URL+"/pkg"); err != nil {
- t.Fatalf("first fetch: %v", err)
- }
- old := ageMetadataAccess(t, db, "test", "pkg")
- if _, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "pkg", upstream.URL+"/pkg"); err != nil {
- t.Fatalf("cached fetch: %v", err)
- }
- assertMetadataAccessedAfter(t, db, "test", "pkg", old)
-}
-
-func TestProxyCached_NotModifiedMarksAccess(t *testing.T) {
- const body = `{"v":1}`
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("ETag", `"v1"`)
- if r.Header.Get("If-None-Match") == `"v1"` {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- _, _ = w.Write([]byte(body))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Nanosecond
- proxy.HTTPClient = upstream.Client()
-
- for range 2 {
- response := httptest.NewRecorder()
- proxy.ProxyCached(response, httptest.NewRequest(http.MethodGet, "/pkg", nil), upstream.URL+"/pkg", "test", "pkg")
- if response.Code != http.StatusOK || response.Body.String() != body {
- t.Fatalf("response = %d %q, want 200 %q", response.Code, response.Body.String(), body)
- }
- if store.stores != 1 {
- t.Fatalf("document stored %d times, want once", store.stores)
- }
- assertMetadataAccessedAfter(t, db, "test", "pkg", time.Now().Add(-time.Hour))
- ageMetadataAccess(t, db, "test", "pkg")
- }
-}
-
-func TestLoadContainerMetadata_MarksAccess(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", "")
- ctx := context.Background()
-
- if _, err := h.storeContainerMetadata(ctx, "oci-test", "key", []byte("{}"), "", "", "application/json", "", time.Time{}, time.Now()); err != nil {
- t.Fatalf("storing: %v", err)
- }
- old := ageMetadataAccess(t, db, "oci-test", "key")
- if entry, _, err := h.loadContainerMetadata(ctx, "oci-test", "key"); err != nil || entry == nil {
- t.Fatalf("loading: %v, %v", entry, err)
- }
- assertMetadataAccessedAfter(t, db, "oci-test", "key", old)
-}
-
-func TestLoadContainerManifest_MarksAccess(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", "")
- ctx := context.Background()
-
- manifest := &cachedContainerManifest{body: []byte("{}"), contentType: "application/json", fetchedAt: time.Now()}
- if err := h.storeContainerManifest(ctx, "key", manifest); err != nil {
- t.Fatalf("storing: %v", err)
- }
- old := ageMetadataAccess(t, db, containerManifestCacheEcosystem, "key")
- if got, err := h.loadContainerManifest(ctx, "key"); err != nil || got == nil {
- t.Fatalf("loading: %v, %v", got, err)
- }
- assertMetadataAccessedAfter(t, db, containerManifestCacheEcosystem, "key", old)
-}
-
-func TestLoadContainerTags_MarksAccess(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", "")
- ctx := context.Background()
-
- tags := &cachedContainerTags{body: []byte(`{"tags":[]}`), contentType: "application/json", fetchedAt: time.Now()}
- if err := h.storeContainerTags(ctx, "key", tags); err != nil {
- t.Fatalf("storing: %v", err)
- }
- old := ageMetadataAccess(t, db, containerTagsCacheEcosystem, "key")
- if got, err := h.loadContainerTags(ctx, "key"); err != nil || got == nil {
- t.Fatalf("loading: %v, %v", got, err)
- }
- assertMetadataAccessedAfter(t, db, containerTagsCacheEcosystem, "key", old)
-}
-
-func TestStoredRewrite_MarksAccess(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- body := []byte(`{}`)
- proxy.cacheMetadataBlob(t.Context(), "npm", "demo", metadataStoragePath("npm", "demo"), &upstreamMetadata{body: body})
- key := rewriteCacheKey("npm", "http://proxy", "demo", body)
- if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil {
- t.Fatal(err)
- }
- old := ageMetadataAccess(t, db, "npm", "demo")
- if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); !ok {
- t.Fatal("expected a stored rewrite")
- }
- assertMetadataAccessedAfter(t, db, "npm", "demo", old)
-}
-
-// TestArchHandler_RefreshRestoresEvictedBlob covers a database row whose blob
-// size eviction removed while a refresh re-created the row: the next refresh
-// with the same bytes stores the blob again, so the stale fallback works.
-func TestArchHandler_RefreshRestoresEvictedBlob(t *testing.T) {
- db := []byte("core-db-bytes")
- upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": db})
- proxy := newArchTestProxy(t, upstream)
- h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
- path := "/archlinux/core/os/x86_64/core.db"
-
- assertArchBody(t, serveArchRequest(h, http.MethodGet, path, nil), "seed", db)
- entry, err := proxy.DB.GetMetadataCache(archEcosystem, h.metadataCacheKey("archlinux", upstream.URL, "core/os/x86_64/core.db"))
- if err != nil || entry == nil {
- t.Fatalf("metadata entry: %v, %v", entry, err)
- }
- if err := proxy.Storage.Delete(t.Context(), entry.StoragePath); err != nil {
- t.Fatalf("deleting blob: %v", err)
- }
-
- ageMetadata(t, proxy, h, "archlinux", upstream.URL, "core/os/x86_64/core.db")
- assertArchBody(t, serveArchRequest(h, http.MethodGet, path, nil), "refresh", db)
-
- ageMetadata(t, proxy, h, "archlinux", upstream.URL, "core/os/x86_64/core.db")
- upstream.available.Store(false)
- assertArchBody(t, serveArchRequest(h, http.MethodGet, path, nil), "stale", db)
-}
diff --git a/internal/handler/metadata_coalesce_test.go b/internal/handler/metadata_coalesce_test.go
deleted file mode 100644
index af8e2eb..0000000
--- a/internal/handler/metadata_coalesce_test.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// metadataUpstream is a stub registry that counts metadata requests. It
-// signals entered when the first request arrives and holds every request until
-// release is closed, so a test can keep the first fetch in flight while others
-// arrive.
-type metadataUpstream struct {
- *httptest.Server
- calls atomic.Int64
- entered chan struct{}
- release chan struct{}
- once sync.Once
-}
-
-func newMetadataUpstream(t *testing.T, status int) *metadataUpstream {
- t.Helper()
- u := &metadataUpstream{entered: make(chan struct{}), release: make(chan struct{})}
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.calls.Add(1)
- u.once.Do(func() { close(u.entered) })
- <-u.release
- if status != http.StatusOK {
- w.WriteHeader(status)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- // Echo the Accept header so a test can tell which variant it got.
- _, _ = w.Write([]byte(`{"accept":"` + r.Header.Get("Accept") + `"}`))
- }))
- t.Cleanup(u.Close)
- return u
-}
-
-func metadataTestProxy(t *testing.T, u *metadataUpstream) *Proxy {
- t.Helper()
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = u.Client()
- return proxy
-}
-
-// waitForMetadataWaiters returns once n callers are waiting on the fetch for
-// key, so a test can release upstream knowing every caller joined it.
-func waitForMetadataWaiters(t *testing.T, p *Proxy, key string, n int) {
- t.Helper()
- deadline := time.Now().Add(5 * time.Second)
- for time.Now().Before(deadline) {
- p.metaMu.Lock()
- f := p.inFlightMeta[key]
- joined := 0
- if f != nil {
- joined = f.waiters
- }
- p.metaMu.Unlock()
- if joined >= n {
- return
- }
- time.Sleep(time.Millisecond)
- }
- t.Fatalf("timed out waiting for %d callers to join the metadata fetch", n)
-}
-
-type metadataCall struct {
- body []byte
- err error
-}
-
-// startMetadataCalls starts n FetchOrCacheMetadata calls for one package.
-func startMetadataCalls(p *Proxy, ctx context.Context, n int, url, accept string) []chan metadataCall {
- out := make([]chan metadataCall, n)
- for i := range out {
- out[i] = make(chan metadataCall, 1)
- go func(ch chan metadataCall) {
- body, _, err := p.FetchOrCacheMetadata(ctx, "npm", "left-pad", url, accept)
- ch <- metadataCall{body, err}
- }(out[i])
- }
- return out
-}
-
-// TestFetchOrCacheMetadata_ConcurrentMissesCoalesce asserts that concurrent
-// misses for one package make a single upstream request: the CI shape, where
-// parallel jobs resolve the same Composer or npm dependencies at once.
-func TestFetchOrCacheMetadata_ConcurrentMissesCoalesce(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- p := metadataTestProxy(t, u)
- const n = 10
-
- calls := startMetadataCalls(p, context.Background(), 1, u.URL, contentTypeJSON)
- <-u.entered
- calls = append(calls, startMetadataCalls(p, context.Background(), n-1, u.URL, contentTypeJSON)...)
- key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false)
- waitForMetadataWaiters(t, p, key, n-1)
- close(u.release)
-
- for _, ch := range calls {
- c := <-ch
- if c.err != nil {
- t.Fatalf("FetchOrCacheMetadata: %v", c.err)
- }
- if string(c.body) != `{"accept":"application/json"}` {
- t.Errorf("body = %s", c.body)
- }
- }
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
-}
-
-// TestFetchOrCacheMetadata_DifferentAcceptDoNotShare asserts that requests
-// for different variants of one package, such as npm's abbreviated and full
-// documents, each get their own fetch and their own bytes.
-func TestFetchOrCacheMetadata_DifferentAcceptDoNotShare(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- p := metadataTestProxy(t, u)
- const abbreviated = "application/vnd.npm.install-v1+json"
-
- full := startMetadataCalls(p, context.Background(), 1, u.URL, contentTypeJSON)
- <-u.entered
- abbr := startMetadataCalls(p, context.Background(), 1, u.URL, abbreviated)
- deadline := time.Now().Add(5 * time.Second)
- for u.calls.Load() < 2 && time.Now().Before(deadline) {
- time.Sleep(time.Millisecond)
- }
- close(u.release)
-
- if c := <-full[0]; c.err != nil || string(c.body) != `{"accept":"application/json"}` {
- t.Errorf("full document: body %s, err %v", c.body, c.err)
- }
- if c := <-abbr[0]; c.err != nil || string(c.body) != `{"accept":"`+abbreviated+`"}` {
- t.Errorf("abbreviated document: body %s, err %v", c.body, c.err)
- }
- if got := u.calls.Load(); got != 2 {
- t.Errorf("upstream requests = %d, want 2", got)
- }
-}
-
-// TestFetchOrCacheMetadata_FirstCallerLeavingDoesNotFailOthers asserts that
-// when the caller running the shared fetch disconnects, the fetch continues
-// and everyone waiting on it still gets the metadata.
-func TestFetchOrCacheMetadata_FirstCallerLeavingDoesNotFailOthers(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- p := metadataTestProxy(t, u)
- const n = 5
-
- leaderCtx, cancelLeader := context.WithCancel(context.Background())
- leader := startMetadataCalls(p, leaderCtx, 1, u.URL, contentTypeJSON)
- <-u.entered
- waiters := startMetadataCalls(p, context.Background(), n, u.URL, contentTypeJSON)
- key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false)
- waitForMetadataWaiters(t, p, key, n)
- cancelLeader()
- close(u.release)
-
- <-leader[0]
- for _, ch := range waiters {
- if c := <-ch; c.err != nil {
- t.Errorf("waiter failed after the first caller left: %v", c.err)
- }
- }
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
-}
-
-// TestFetchOrCacheMetadata_WaiterLeavingKeepsFetch asserts that a waiter whose
-// client disconnects returns its own context error without disturbing the
-// fetch the others are waiting on.
-func TestFetchOrCacheMetadata_WaiterLeavingKeepsFetch(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- p := metadataTestProxy(t, u)
-
- leader := startMetadataCalls(p, context.Background(), 1, u.URL, contentTypeJSON)
- <-u.entered
- waiterCtx, cancelWaiter := context.WithCancel(context.Background())
- leaving := startMetadataCalls(p, waiterCtx, 1, u.URL, contentTypeJSON)
- key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false)
- waitForMetadataWaiters(t, p, key, 1)
- cancelWaiter()
-
- if c := <-leaving[0]; !errors.Is(c.err, context.Canceled) {
- t.Errorf("leaving waiter err = %v, want context.Canceled", c.err)
- }
- close(u.release)
- if c := <-leader[0]; c.err != nil {
- t.Errorf("first caller failed: %v", c.err)
- }
-}
-
-// TestFetchOrCacheMetadata_SharedNotFound asserts that an upstream 404 reaches
-// every caller sharing the fetch as ErrUpstreamNotFound, from one request.
-func TestFetchOrCacheMetadata_SharedNotFound(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusNotFound)
- p := metadataTestProxy(t, u)
- const n = 5
-
- calls := startMetadataCalls(p, context.Background(), 1, u.URL, contentTypeJSON)
- <-u.entered
- calls = append(calls, startMetadataCalls(p, context.Background(), n-1, u.URL, contentTypeJSON)...)
- key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false)
- waitForMetadataWaiters(t, p, key, n-1)
- close(u.release)
-
- for _, ch := range calls {
- if c := <-ch; !errors.Is(c.err, ErrUpstreamNotFound) {
- t.Errorf("err = %v, want ErrUpstreamNotFound", c.err)
- }
- }
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
-}
-
-// TestCoalescedMetadataMiss_ServesRowCommittedSinceLookup asserts that a
-// caller which missed the cache, but finds the row fresh once it takes the
-// key, serves that row instead of fetching again. That is a fetch finishing
-// between a caller's lookup and its turn at the key.
-func TestCoalescedMetadataMiss_ServesRowCommittedSinceLookup(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- close(u.release)
- p := metadataTestProxy(t, u)
- p.CacheMetadata = true
- p.MetadataTTL = time.Hour
-
- if _, _, err := p.FetchOrCacheMetadata(context.Background(), "npm", "left-pad", u.URL); err != nil {
- t.Fatalf("priming fetch: %v", err)
- }
-
- res := p.coalescedMetadataMiss(context.Background(), "npm", "left-pad", u.URL, contentTypeJSON, "", nil)
- if res.err != nil {
- t.Fatalf("coalescedMetadataMiss: %v", res.err)
- }
- if string(res.body) != `{"accept":"application/json"}` {
- t.Errorf("body = %s", res.body)
- }
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1 (the priming fetch only)", got)
- }
-}
diff --git a/internal/handler/metadata_consistency_test.go b/internal/handler/metadata_consistency_test.go
deleted file mode 100644
index 3e0220a..0000000
--- a/internal/handler/metadata_consistency_test.go
+++ /dev/null
@@ -1,324 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "context"
- "database/sql"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// proxyCachedRequest runs one ProxyCached request for key "pkg".
-func proxyCachedRequest(proxy *Proxy, upstreamURL string, header http.Header) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- r := httptest.NewRequest(http.MethodGet, "/pkg", nil)
- for k, v := range header {
- r.Header[k] = v
- }
- proxy.ProxyCached(w, r, upstreamURL+"/pkg", "test", "pkg")
- return w
-}
-
-// TestFetchedMetadataValidatorsDescribeBody checks that a document fetched
-// fresh but not stored is served with upstream's validators, not those of the
-// older row still in the cache.
-func TestFetchedMetadataValidatorsDescribeBody(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set(headerContentType, contentTypeJSON)
- if requests.Add(1) == 1 {
- w.Header().Set(headerETag, `"v1"`)
- _, _ = w.Write([]byte(`{"v":1}`))
- return
- }
- w.Header().Set(headerETag, `"v2"`)
- _, _ = w.Write([]byte(`{"v":2}`))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Millisecond
- proxy.HTTPClient = upstream.Client()
-
- if w := proxyCachedRequest(proxy, upstream.URL, nil); w.Code != http.StatusOK {
- t.Fatalf("first request: %d", w.Code)
- }
- time.Sleep(5 * time.Millisecond)
- store.mu.Lock()
- store.storeErr = errors.New("disk full")
- store.mu.Unlock()
-
- w := proxyCachedRequest(proxy, upstream.URL, nil)
- if w.Code != http.StatusOK || w.Body.String() != `{"v":2}` {
- t.Fatalf("second request = %d %q, want v2", w.Code, w.Body)
- }
- if got := w.Header().Get(headerETag); got != `"v2"` {
- t.Errorf("ETag = %q, want %q", got, `"v2"`)
- }
- if got := w.Header().Get("Warning"); got != "" {
- t.Errorf("Warning = %q on a fresh document", got)
- }
-
- // A client holding v1 must get v2, not a 304.
- w = proxyCachedRequest(proxy, upstream.URL, http.Header{"If-None-Match": {`"v1"`}})
- if w.Code != http.StatusOK || w.Body.String() != `{"v":2}` {
- t.Errorf("conditional request = %d %q, want 200 v2", w.Code, w.Body)
- }
-}
-
-// TestStaleMetadataMarkedWithoutTTL checks that the stale fallback carries
-// the Warning header with metadata_ttl 0, where every request revalidates.
-func TestStaleMetadataMarkedWithoutTTL(t *testing.T) {
- upstream := upstreamThen(t, `{"v":1}`, http.StatusBadGateway)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0
- proxy.HTTPClient = upstream.Client()
-
- if w := proxyCachedRequest(proxy, upstream.URL, nil); w.Code != http.StatusOK {
- t.Fatalf("first request: %d", w.Code)
- }
- w := proxyCachedRequest(proxy, upstream.URL, nil)
- if w.Code != http.StatusOK || w.Body.String() != `{"v":1}` {
- t.Fatalf("fallback = %d %q", w.Code, w.Body)
- }
- if got := w.Header().Get("Warning"); got != staleWarning {
- t.Errorf("Warning = %q, want %q", got, staleWarning)
- }
-}
-
-// seedMetadataRow stores {"v":1} for key "pkg" with ETag "v1" and a row past
-// its TTL.
-func seedMetadataRow(t *testing.T, db *database.DB, store *mockStorage) string {
- t.Helper()
- const body, etag = `{"v":1}`, `"v1"`
- path := metadataStoragePath("test", "pkg")
- store.mu.Lock()
- store.files[path] = []byte(body)
- store.mu.Unlock()
- if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "test",
- Name: "pkg",
- StoragePath: path,
- ETag: sql.NullString{String: etag, Valid: true},
- ContentType: sql.NullString{String: contentTypeJSON, Valid: true},
- ContentDigest: sql.NullString{String: "sha256:" + sha256Hex(body), Valid: true},
- Size: sql.NullInt64{Int64: int64(len(body)), Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
- }); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-// v2Upstream answers If-None-Match "v1" with 304 after running onNotModified,
-// and any other request with v2.
-func v2Upstream(t *testing.T, onNotModified func()) *httptest.Server {
- t.Helper()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("If-None-Match") == `"v1"` {
- onNotModified()
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- w.Header().Set(headerETag, `"v2"`)
- _, _ = w.Write([]byte(`{"v":2}`))
- }))
- t.Cleanup(srv.Close)
- return srv
-}
-
-// TestNotModifiedChecksStoredBytes checks that a 304 for a row whose blob has
-// meanwhile been replaced (by another instance sharing the store) does not
-// serve the new bytes under the old ETag.
-func TestNotModifiedChecksStoredBytes(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Minute
- path := seedMetadataRow(t, db, store)
- upstream := v2Upstream(t, func() {
- // The other instance has written v2 but not yet its row.
- store.mu.Lock()
- store.files[path] = []byte(`{"v":2}`)
- store.mu.Unlock()
- })
- proxy.HTTPClient = upstream.Client()
-
- w := proxyCachedRequest(proxy, upstream.URL, nil)
- if w.Code != http.StatusOK || w.Body.String() != `{"v":2}` {
- t.Fatalf("response = %d %q", w.Code, w.Body)
- }
- if got := w.Header().Get(headerETag); got != `"v2"` {
- t.Errorf("ETag = %q, want %q", got, `"v2"`)
- }
- row, err := db.GetMetadataCache("test", "pkg")
- if err != nil || row == nil {
- t.Fatal(row, err)
- }
- if row.ETag.String != `"v2"` || row.ContentDigest.String != "sha256:"+sha256Hex(`{"v":2}`) {
- t.Errorf("row = etag %q digest %q, want v2's", row.ETag.String, row.ContentDigest.String)
- }
-}
-
-// TestNotModifiedAfterRowReplaced checks that a 304 for a row another
-// instance has replaced since it was read does not store the old document
-// back over the new one.
-func TestNotModifiedAfterRowReplaced(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Minute
- path := seedMetadataRow(t, db, store)
- upstream := v2Upstream(t, func() {
- // The other instance has recorded v2's row; its blob is not written
- // yet.
- _ = db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "test",
- Name: "pkg",
- StoragePath: path,
- ETag: sql.NullString{String: `"v2"`, Valid: true},
- ContentType: sql.NullString{String: contentTypeJSON, Valid: true},
- ContentDigest: sql.NullString{String: "sha256:" + sha256Hex(`{"v":2}`), Valid: true},
- Size: sql.NullInt64{Int64: 7, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- })
- })
- proxy.HTTPClient = upstream.Client()
-
- res := proxy.fetchMetadataDocument(context.Background(), "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
- if res.err != nil || string(res.body) != `{"v":2}` {
- t.Fatalf("fetch = %q, %v; want v2", res.body, res.err)
- }
- row, err := db.GetMetadataCache("test", "pkg")
- if err != nil || row == nil {
- t.Fatal(row, err)
- }
- if row.ETag.String != `"v2"` {
- t.Errorf("row etag = %q, want %q", row.ETag.String, `"v2"`)
- }
-}
-
-// TestNotModifiedRejectedBodyNotRefreshed checks that a 304 for a stored
-// document validate rejects does not mark that document fresh.
-func TestNotModifiedRejectedBodyNotRefreshed(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Minute
- seedMetadataRow(t, db, store)
- before, err := db.GetMetadataCache("test", "pkg")
- if err != nil || before == nil {
- t.Fatal(before, err)
- }
- upstream := v2Upstream(t, func() {})
- proxy.HTTPClient = upstream.Client()
-
- reject := func(body []byte) error {
- if string(body) == `{"v":1}` {
- return errors.New("damaged")
- }
- return nil
- }
- proxy.fetchMetadataDocument(context.Background(), "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, reject)
- row, err := db.GetMetadataCache("test", "pkg")
- if err != nil || row == nil {
- t.Fatal(row, err)
- }
- if !row.FetchedAt.Time.Equal(before.FetchedAt.Time) {
- t.Errorf("rejected v1 row fetched_at moved from %v to %v", before.FetchedAt.Time, row.FetchedAt.Time)
- }
-}
-
-// openHookStorage runs onOpen before the first Open.
-type openHookStorage struct {
- *mockStorage
- once sync.Once
- onOpen func()
-}
-
-func (s *openHookStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- s.once.Do(s.onOpen)
- return s.mockStorage.Open(ctx, path)
-}
-
-// TestStaleMetadataChecksStoredBytes checks that the stale fallback does not
-// serve a blob another instance replaced after the row was read under that
-// row's ETag.
-func TestStaleMetadataChecksStoredBytes(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusBadGateway)
- }))
- t.Cleanup(upstream.Close)
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0
- proxy.HTTPClient = upstream.Client()
- path := seedMetadataRow(t, db, store)
- proxy.Storage = &openHookStorage{mockStorage: store, onOpen: func() {
- // The other instance has written v2 but not yet its row.
- store.mu.Lock()
- store.files[path] = []byte(`{"v":2}`)
- store.mu.Unlock()
- }}
-
- // No row describes v2 yet, so its encoding is unknown: nothing is served.
- w := proxyCachedRequest(proxy, upstream.URL, http.Header{"If-None-Match": {`"v1"`}})
- if w.Code < http.StatusInternalServerError || w.Header().Get(headerETag) == `"v1"` {
- t.Errorf("response = %d ETag %q for v2's bytes, want an error without v1 validators", w.Code, w.Header().Get(headerETag))
- }
-}
-
-// TestStaleMetadataRereadsReplacedRow checks that when the stored bytes no
-// longer match the row, the stale fallback reads the row again and serves
-// the blob with the encoding and validators of the row that describes it.
-func TestStaleMetadataRereadsReplacedRow(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusBadGateway)
- }))
- t.Cleanup(upstream.Close)
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0
- proxy.HTTPClient = upstream.Client()
- path := seedMetadataRow(t, db, store)
- var gz bytes.Buffer
- zw := gzip.NewWriter(&gz)
- _, _ = zw.Write([]byte(`{"v":2}`))
- _ = zw.Close()
- v2 := gz.Bytes()
- proxy.Storage = &openHookStorage{mockStorage: store, onOpen: func() {
- // The other instance stores v2 gzipped, then its row.
- store.mu.Lock()
- store.files[path] = v2
- store.mu.Unlock()
- if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "test",
- Name: "pkg",
- StoragePath: path,
- ETag: sql.NullString{String: `"v2"`, Valid: true},
- ContentType: sql.NullString{String: contentTypeJSON, Valid: true},
- ContentEncoding: sql.NullString{String: "gzip", Valid: true},
- ContentDigest: sql.NullString{String: "sha256:" + sha256Hex(string(v2)), Valid: true},
- Size: sql.NullInt64{Int64: int64(len(v2)), Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
- }); err != nil {
- t.Error(err)
- }
- }}
-
- w := proxyCachedRequest(proxy, upstream.URL, http.Header{"Accept-Encoding": {"gzip"}})
- if w.Code != http.StatusOK || w.Header().Get("Content-Encoding") != "gzip" ||
- w.Header().Get(headerETag) != `"v2"` || !bytes.Equal(w.Body.Bytes(), v2) {
- t.Errorf("response = %d, Content-Encoding %q, ETag %q; want v2 gzipped under its own row",
- w.Code, w.Header().Get("Content-Encoding"), w.Header().Get(headerETag))
- }
-}
diff --git a/internal/handler/metadata_dates.go b/internal/handler/metadata_dates.go
deleted file mode 100644
index fca5a1f..0000000
--- a/internal/handler/metadata_dates.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package handler
-
-import (
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// metadataDateAttempts bounds how often metadataDate retries when other
-// replicas keep changing a package's date between its read and its write.
-const metadataDateAttempts = 3
-
-// metadataDate returns the Last-Modified to serve a package's metadata with,
-// as rewritten for proxyURL into the representation with the given ETag, or
-// zero for none.
-//
-// The date is the second the package started serving that representation,
-// recorded in the database so that every replica sharing it serves the same
-// date. A switch to another representation is dated only in a second later
-// than the recorded date, and then with that second, so a date is never given
-// to two representations of a package: a client holding one never gets 304
-// for another, whichever replica it asks. Until such a second the
-// representation goes undated, and If-Modified-Since is not evaluated.
-//
-// A package's first date is likewise later than the second this process
-// started, as the process it replaced may have dated the package then without
-// recording it. A date ahead of this process's clock, recorded by a replica
-// whose clock is ahead, is not served until the clock catches up. Without a
-// database, or when it fails, nothing is dated.
-func (p *Proxy) metadataDate(ecosystem, proxyURL, name, etag string) time.Time {
- if p.DB == nil || etag == "" {
- return time.Time{}
- }
- now := p.clock().Truncate(time.Second)
- for range metadataDateAttempts {
- date, settled, err := p.claimMetadataDate(ecosystem, proxyURL, name, etag, now)
- if err != nil {
- p.Logger.Warn("failed to date rewritten metadata", "ecosystem", ecosystem, "name", name, "error", err)
- return time.Time{}
- }
- if settled {
- if date.After(now) {
- return time.Time{}
- }
- return date
- }
- }
- return time.Time{}
-}
-
-// claimMetadataDate makes one attempt at recording the date of etag for a
-// package at now. It reports false when another replica changed the date
-// first, and the attempt should be made again.
-func (p *Proxy) claimMetadataDate(ecosystem, proxyURL, name, etag string, now time.Time) (time.Time, bool, error) {
- current, err := p.DB.GetMetadataDate(ecosystem, proxyURL, name)
- if err != nil {
- return time.Time{}, false, err
- }
- next := database.MetadataDate{ETag: etag, LastModified: now}
- switch {
- case current == nil:
- if !now.After(p.started.Truncate(time.Second)) {
- return time.Time{}, true, nil
- }
- inserted, err := p.DB.InsertMetadataDate(ecosystem, proxyURL, name, next)
- return now, inserted, err
- case current.ETag == etag:
- return current.LastModified, true, nil
- case !now.After(current.LastModified):
- return time.Time{}, true, nil
- default:
- swapped, err := p.DB.SwapMetadataDate(ecosystem, proxyURL, name, *current, next)
- return now, swapped, err
- }
-}
-
-// clock reads the time metadata is dated by.
-func (p *Proxy) clock() time.Time {
- if p.now == nil {
- return time.Now()
- }
- return p.now()
-}
diff --git a/internal/handler/metadata_dates_upgrade_test.go b/internal/handler/metadata_dates_upgrade_test.go
deleted file mode 100644
index 4d1ca74..0000000
--- a/internal/handler/metadata_dates_upgrade_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// upgradeDatabases opens an empty database with the current schema for each
-// dialect the proxy supports. A nil database means the dialect is unavailable.
-var upgradeDatabases = []struct {
- dialect string
- open func(t *testing.T) *database.DB
-}{
- {"sqlite", func(t *testing.T) *database.DB {
- db, err := database.Create(t.TempDir() + "/test.db")
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
- return db
- }},
- {"postgres", openTestPostgres},
-}
-
-// openTestPostgres creates a database of its own on the server at
-// PROXY_DATABASE_URL, so the test shares no tables with tests elsewhere that
-// use the same server, and drops it afterwards.
-func openTestPostgres(t *testing.T) *database.DB {
- t.Helper()
- serverURL := os.Getenv("PROXY_DATABASE_URL")
- if serverURL == "" {
- return nil
- }
- server, err := database.OpenPostgres(serverURL)
- if err != nil {
- t.Fatalf("OpenPostgres failed: %v", err)
- }
- t.Cleanup(func() { _ = server.Close() })
-
- suffix := make([]byte, 8)
- _, _ = rand.Read(suffix)
- name := "proxy_upgrade_" + hex.EncodeToString(suffix)
- if _, err := server.Exec("CREATE DATABASE " + name); err != nil {
- t.Fatalf("creating database %s: %v", name, err)
- }
- t.Cleanup(func() { _, _ = server.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") })
-
- dbURL, err := url.Parse(serverURL)
- if err != nil {
- t.Fatal(err)
- }
- dbURL.Path = "/" + name
- db, err := database.OpenPostgres(dbURL.String())
- if err != nil {
- t.Fatalf("OpenPostgres(%s) failed: %v", name, err)
- }
- t.Cleanup(func() { _ = db.Close() })
- if err := db.CreateSchema(); err != nil {
- t.Fatalf("CreateSchema failed: %v", err)
- }
- return db
-}
-
-// rollBackMetadataDates returns db to the schema from before migration 011,
-// which only added the metadata_dates table.
-func rollBackMetadataDates(t *testing.T, db *database.DB) {
- t.Helper()
- if _, err := db.Exec("DROP TABLE metadata_dates"); err != nil {
- t.Fatal(err)
- }
- if _, err := db.Exec("DELETE FROM migrations WHERE name = '011_add_metadata_dates'"); err != nil {
- t.Fatal(err)
- }
-}
-
-// upgradeProxy returns a proxy on db that dates metadata by a clock the test
-// moves, a second after the process start it models.
-func upgradeProxy(t *testing.T, db *database.DB, u *validatorUpstream) (*Proxy, *testClock) {
- t.Helper()
- proxy := NewProxy(db, newMockStorage(), &mockFetcher{}, fetch.NewResolver(), slog.New(slog.NewTextHandler(io.Discard, nil)))
- proxy.HTTPClient = u.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
- restartProxy(proxy, clock)
- clock.advance(time.Second)
- return proxy, clock
-}
-
-// cachedMetadata is a metadata cache row and the bytes stored for it.
-type cachedMetadata struct {
- entry database.MetadataCacheEntry
- body string
-}
-
-func readCachedMetadata(t *testing.T, proxy *Proxy, ecosystem, name string) cachedMetadata {
- t.Helper()
- entry, err := proxy.DB.GetMetadataCache(ecosystem, name)
- if err != nil || entry == nil {
- t.Fatalf("metadata row %s/%s = %v, %v", ecosystem, name, entry, err)
- }
- r, err := proxy.Storage.Open(context.Background(), entry.StoragePath)
- if err != nil {
- t.Fatalf("opening stored metadata %s: %v", entry.StoragePath, err)
- }
- defer func() { _ = r.Close() }()
- body, err := io.ReadAll(r)
- if err != nil {
- t.Fatal(err)
- }
- return cachedMetadata{entry: *entry, body: string(body)}
-}
-
-// TestRewrittenMetadataAfterMetadataDatesMigration upgrades a database from
-// before migration 011 that holds cached npm and Composer metadata. The cached
-// rows and stored bytes must come through unchanged, and two replicas sharing
-// the upgraded database must serve the cached document with validators that
-// revalidate, then date a changed document alike.
-func TestRewrittenMetadataAfterMetadataDatesMigration(t *testing.T) {
- for _, d := range upgradeDatabases {
- for _, c := range validatorCases {
- t.Run(d.dialect+"/"+c.ecosystem, func(t *testing.T) {
- db := d.open(t)
- if db == nil {
- t.Skip("PROXY_DATABASE_URL not set, skipping postgres test")
- }
- u := newValidatorUpstream(t)
- a, clock := upgradeProxy(t, db, u)
- upgradeWithCachedMetadata(t, a, u, c)
-
- replicas := map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(validatorReplica(a, clock), u)}
- cached := serve(replicas["A"], c.path, nil)
- if cached.Code != http.StatusOK || strings.Contains(cached.Body.String(), "2.0.0") {
- t.Fatalf("first request after the upgrade: status %d, body %s; want 200 with the document cached before it", cached.Code, cached.Body)
- }
- expectRevalidates(t, replicas, c.path, cached)
-
- expireMetadata(t, db, c.ecosystem, c.name)
- clock.advance(time.Second)
- changed := serve(replicas["A"], c.path, nil)
- expectChangedDocument(t, cached, changed)
- expectChangedForCachedValidators(t, replicas, c.path, cached, changed)
- expectRevalidates(t, replicas, c.path, changed)
- })
- }
- }
-}
-
-// upgradeWithCachedMetadata rolls proxy's database back to before migration
-// 011, caches c's document as the proxy did then, moves upstream on to version
-// 2.0.0 and migrates. The cached row and stored bytes must survive.
-func upgradeWithCachedMetadata(t *testing.T, proxy *Proxy, u *validatorUpstream, c validatorCase) {
- t.Helper()
- rollBackMetadataDates(t, proxy.DB)
- if _, _, err := proxy.FetchOrCacheMetadata(context.Background(), c.ecosystem, c.name, u.URL+c.path); err != nil {
- t.Fatalf("seeding metadata before the upgrade: %v", err)
- }
- seeded := readCachedMetadata(t, proxy, c.ecosystem, c.name)
- u.set(npmV("2.0.0"), composerV("2.0.0"))
-
- if err := proxy.DB.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema() error = %v", err)
- }
- if upgraded := readCachedMetadata(t, proxy, c.ecosystem, c.name); upgraded != seeded {
- t.Fatalf("cached metadata after the upgrade = %+v, want %+v", upgraded, seeded)
- }
-}
-
-// expectChangedDocument checks that changed is the new upstream document,
-// with an ETag of its own and a later date than cached.
-func expectChangedDocument(t *testing.T, cached, changed *httptest.ResponseRecorder) {
- t.Helper()
- if changed.Code != http.StatusOK || !strings.Contains(changed.Body.String(), "2.0.0") {
- t.Fatalf("after upstream changed: status %d, body %s; want 200 with the new document", changed.Code, changed.Body)
- }
- if changed.Header().Get("ETag") == cached.Header().Get("ETag") {
- t.Error("the changed document has the cached document's ETag")
- }
- was, _ := http.ParseTime(cached.Header().Get("Last-Modified"))
- if now, err := http.ParseTime(changed.Header().Get("Last-Modified")); err != nil || !now.After(was) {
- t.Errorf("changed document Last-Modified %q, want later than %q", changed.Header().Get("Last-Modified"), cached.Header().Get("Last-Modified"))
- }
-}
-
-// expectChangedForCachedValidators checks that no replica answers a client
-// holding the cached document's validators with 304 once it changed.
-func expectChangedForCachedValidators(t *testing.T, replicas map[string]http.Handler, path string, cached, changed *httptest.ResponseRecorder) {
- t.Helper()
- held := []http.Header{
- {"If-None-Match": {cached.Header().Get("ETag")}},
- {"If-Modified-Since": {cached.Header().Get("Last-Modified")}},
- }
- for name, handler := range replicas {
- for _, header := range held {
- if rec := serve(handler, path, header); rec.Code != http.StatusOK || rec.Body.String() != changed.Body.String() {
- t.Errorf("replica %s, %v of the cached document: status %d; want 200 with the changed document", name, header, rec.Code)
- }
- }
- }
-}
-
-// expectRevalidates checks that every replica serves want's validators and
-// answers a conditional request with either of them with 304.
-func expectRevalidates(t *testing.T, replicas map[string]http.Handler, path string, want *httptest.ResponseRecorder) {
- t.Helper()
- etag, lastModified := want.Header().Get("ETag"), want.Header().Get("Last-Modified")
- if etag == "" || lastModified == "" {
- t.Fatalf("ETag %q, Last-Modified %q; want both", etag, lastModified)
- }
- for name, handler := range replicas {
- rec := serve(handler, path, nil)
- if rec.Header().Get("ETag") != etag || rec.Header().Get("Last-Modified") != lastModified {
- t.Errorf("replica %s: ETag %q, Last-Modified %q; want %q, %q", name, rec.Header().Get("ETag"), rec.Header().Get("Last-Modified"), etag, lastModified)
- }
- for _, header := range []http.Header{{"If-None-Match": {etag}}, {"If-Modified-Since": {lastModified}}} {
- if rec := serve(handler, path, header); rec.Code != http.StatusNotModified {
- t.Errorf("replica %s, %v: status %d, want 304", name, header, rec.Code)
- }
- }
- }
-}
diff --git a/internal/handler/metadata_encoding_test.go b/internal/handler/metadata_encoding_test.go
deleted file mode 100644
index 10aab10..0000000
--- a/internal/handler/metadata_encoding_test.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// gzipPayload returns a gzip-compressed copy of data, simulating an origin
-// that stores pre-compressed index files.
-func gzipPayload(t *testing.T, data []byte) []byte {
- t.Helper()
- var buf bytes.Buffer
- zw := gzip.NewWriter(&buf)
- if _, err := zw.Write(data); err != nil {
- t.Fatalf("compressing payload: %v", err)
- }
- if err := zw.Close(); err != nil {
- t.Fatalf("closing gzip writer: %v", err)
- }
- return buf.Bytes()
-}
-
-// TestProxyCached_PreservesContentEncodedBytes covers issue #300: an upstream
-// that serves a signed index with Content-Encoding: gzip must have its bytes
-// cached and re-served verbatim, with the encoding header replayed, instead of
-// being transparently decompressed by the HTTP client.
-func TestProxyCached_PreservesContentEncodedBytes(t *testing.T) {
- raw := gzipPayload(t, []byte("signed index payload"))
-
- var available atomic.Bool
- available.Store(true)
- var sawAcceptEncoding atomic.Value
- var upstreamRequests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !available.Load() {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- upstreamRequests.Add(1)
- sawAcceptEncoding.Store(r.Header.Get(headerAcceptEncoding))
- w.Header().Set(headerContentType, "application/octet-stream")
- w.Header().Set(headerContentEncoding, "gzip")
- _, _ = w.Write(raw)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
-
- serve := func() *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- r := httptest.NewRequest(http.MethodGet, "/index", nil)
- proxy.ProxyCached(w, r, upstream.URL+"/index", "apk", "index-key", "*/*")
- return w
- }
-
- first := serve()
- if first.Code != http.StatusOK {
- t.Fatalf("first response status = %d, want 200: %s", first.Code, first.Body.String())
- }
- if got, _ := sawAcceptEncoding.Load().(string); got != "identity" {
- t.Errorf("upstream saw Accept-Encoding %q, want %q", got, "identity")
- }
- if !bytes.Equal(first.Body.Bytes(), raw) {
- t.Errorf("first response altered the upstream bytes: got %d bytes, want %d", first.Body.Len(), len(raw))
- }
- if got := first.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("first response Content-Encoding = %q, want %q", got, "gzip")
- }
-
- // Within the TTL and with the upstream down, the cached copy must be
- // served with the same bytes and encoding.
- available.Store(false)
- second := serve()
- if second.Code != http.StatusOK {
- t.Fatalf("cached response status = %d, want 200: %s", second.Code, second.Body.String())
- }
- if !bytes.Equal(second.Body.Bytes(), raw) {
- t.Errorf("cached response altered the stored bytes")
- }
- if got := second.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("cached response Content-Encoding = %q, want %q", got, "gzip")
- }
- if got := upstreamRequests.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
-}
-
-// TestProxyCached_NoEncodingHeaderForIdentityResponses pins that ordinary
-// responses do not grow a spurious Content-Encoding header.
-func TestProxyCached_NoEncodingHeaderForIdentityResponses(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set(headerContentType, contentTypeJSON)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
-
- w := httptest.NewRecorder()
- r := httptest.NewRequest(http.MethodGet, "/meta", nil)
- proxy.ProxyCached(w, r, upstream.URL+"/meta", "npm", "meta-key", contentTypeJSON)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- if got := w.Header().Get(headerContentEncoding); got != "" {
- t.Errorf("Content-Encoding = %q, want empty", got)
- }
-}
-
-// TestProxyMetadataStream_PreservesSignedBytesWithoutClientEncoding pins the
-// uncached streaming path (the default, since cache_metadata is off) for the
-// realistic client that sends no Accept-Encoding: the proxy must request
-// identity upstream so Go does not transparently decompress a signed index,
-// and the raw bytes plus the Content-Encoding header must reach the client.
-func TestProxyMetadataStream_PreservesSignedBytesWithoutClientEncoding(t *testing.T) {
- raw := gzipPayload(t, []byte("streamed index payload"))
- var sawAcceptEncoding string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sawAcceptEncoding = r.Header.Get(headerAcceptEncoding)
- w.Header().Set(headerContentType, "application/octet-stream")
- w.Header().Set(headerContentEncoding, "gzip")
- _, _ = w.Write(raw)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = false
- proxy.HTTPClient = upstream.Client()
-
- w := httptest.NewRecorder()
- // No Accept-Encoding on the client request -- the apk/apt/dnf case.
- r := httptest.NewRequest(http.MethodGet, "/index", nil)
- proxy.ProxyCached(w, r, upstream.URL+"/index", "apk", "stream-key", "*/*")
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- if sawAcceptEncoding != "identity" {
- t.Errorf("upstream saw Accept-Encoding %q, want %q", sawAcceptEncoding, "identity")
- }
- if !bytes.Equal(w.Body.Bytes(), raw) {
- t.Errorf("streamed response altered the upstream bytes: got %d bytes, want %d", w.Body.Len(), len(raw))
- }
- if got := w.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("Content-Encoding = %q, want %q", got, "gzip")
- }
-}
-
-// TestFetchOrCacheMetadata_DirectCallersKeepTransparentCompression pins that
-// the parsing/rewriting ecosystems (npm, pypi, cargo, helm, ...) that call
-// FetchOrCacheMetadata directly are NOT forced to identity: they keep Go's
-// transparent transfer compression and receive decoded bytes, so a gzip-only
-// upstream does not regress them (no wire-size blowup, no parse failures).
-func TestFetchOrCacheMetadata_DirectCallersKeepTransparentCompression(t *testing.T) {
- plaintext := []byte(`{"name":"demo","versions":{"1.0.0":{}}}`)
- var sawAcceptEncoding string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sawAcceptEncoding = r.Header.Get(headerAcceptEncoding)
- // Serve gzip only when the client accepts it, like a real CDN.
- if strings.Contains(r.Header.Get(headerAcceptEncoding), "gzip") {
- w.Header().Set(headerContentType, contentTypeJSON)
- w.Header().Set(headerContentEncoding, "gzip")
- _, _ = w.Write(gzipPayload(t, plaintext))
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- _, _ = w.Write(plaintext)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
-
- body, _, err := proxy.FetchOrCacheMetadata(t.Context(), "npm", "demo", upstream.URL+"/demo", contentTypeJSON)
- if err != nil {
- t.Fatalf("FetchOrCacheMetadata() error = %v", err)
- }
- // The default transport adds Accept-Encoding: gzip and transparently
- // decompresses, so the caller sees decoded JSON regardless of the wire form.
- if sawAcceptEncoding == "identity" {
- t.Errorf("direct caller forced identity; want transparent compression")
- }
- if !bytes.Equal(body, plaintext) {
- t.Errorf("direct caller got %q, want decoded %q", body, plaintext)
- }
-}
diff --git a/internal/handler/metadata_gzip_test.go b/internal/handler/metadata_gzip_test.go
deleted file mode 100644
index 57d20cc..0000000
--- a/internal/handler/metadata_gzip_test.go
+++ /dev/null
@@ -1,237 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// compressibleDocuments sets upstream documents with enough versions that
-// gzip makes them smaller.
-func compressibleDocuments(u *validatorUpstream) {
- var npm, composer []string
- for i := range 50 {
- v := fmt.Sprintf("1.0.%d", i)
- npm = append(npm, `"`+v+`":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-`+v+`.tgz"}}`)
- composer = append(composer, `{"version":"`+v+`","dist":{"type":"zip","url":"https://example.com/pkg-`+v+`.zip"}}`)
- }
- u.set(
- `{"name":"left-pad","versions":{`+strings.Join(npm, ",")+`}}`,
- `{"packages":{"vendor/pkg":[`+strings.Join(composer, ",")+`]}}`,
- )
-}
-
-func gunzip(t *testing.T, rec *httptest.ResponseRecorder) string {
- t.Helper()
- zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
- if err != nil {
- t.Fatalf("body is not gzip: %v", err)
- }
- body, err := io.ReadAll(zr)
- if err != nil {
- t.Fatalf("reading gzip body: %v", err)
- }
- return string(body)
-}
-
-var acceptGzip = http.Header{"Accept-Encoding": {"gzip, deflate, br"}}
-
-// TestRewrittenMetadataGzip checks that a client accepting gzip gets the
-// cached compressed copy of rewritten metadata, on the first fetch and on
-// the rewrite fast path, with an ETag of its own so it never matches the
-// uncompressed representation.
-func TestRewrittenMetadataGzip(t *testing.T) {
- for _, c := range validatorCases {
- for _, ttl := range []time.Duration{time.Hour, 0} {
- t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
- u := newValidatorUpstream(t)
- compressibleDocuments(u)
- handler := c.handler(validatorProxy(t, u, ttl), u)
-
- identity := serve(handler, c.path, nil)
- for range 2 {
- expectGzipOf(t, serve(handler, c.path, acceptGzip), identity)
- }
- })
- }
- }
-}
-
-// expectGzipOf checks that rec is the gzip representation of the
-// uncompressed response identity.
-func expectGzipOf(t *testing.T, rec, identity *httptest.ResponseRecorder) {
- t.Helper()
- if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "gzip" {
- t.Fatalf("status %d, Content-Encoding %q; want 200 with gzip", rec.Code, rec.Header().Get("Content-Encoding"))
- }
- if rec.Body.Len() >= identity.Body.Len() {
- t.Errorf("gzip body is %d bytes, uncompressed %d", rec.Body.Len(), identity.Body.Len())
- }
- if got := gunzip(t, rec); got != identity.Body.String() {
- t.Errorf("decompressed body differs from the uncompressed response")
- }
- if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
- t.Errorf("Vary = %q, want Accept-Encoding", got)
- }
- if etag := rec.Header().Get("ETag"); etag == "" || etag == identity.Header().Get("ETag") {
- t.Errorf("gzip ETag %q must differ from the uncompressed ETag %q", etag, identity.Header().Get("ETag"))
- }
- if rec.Header().Get("Last-Modified") != identity.Header().Get("Last-Modified") {
- t.Errorf("Last-Modified %q, want %q", rec.Header().Get("Last-Modified"), identity.Header().Get("Last-Modified"))
- }
-}
-
-// TestRewrittenMetadataGzipRevalidation checks that each representation
-// revalidates with its own ETag, also when a reverse proxy weakened it.
-func TestRewrittenMetadataGzipRevalidation(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- compressibleDocuments(u)
- handler := c.handler(validatorProxy(t, u, time.Hour), u)
- gzipETag := serve(handler, c.path, acceptGzip).Header().Get("ETag")
- identityETag := serve(handler, c.path, nil).Header().Get("ETag")
-
- for _, ifNoneMatch := range []string{gzipETag, "W/" + gzipETag} {
- rec := serve(handler, c.path, http.Header{"Accept-Encoding": {"gzip"}, "If-None-Match": {ifNoneMatch}})
- expectNotModified(t, rec, gzipETag)
- if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
- t.Errorf("304 Vary = %q, want Accept-Encoding", got)
- }
- }
- if rec := serve(handler, c.path, http.Header{"Accept-Encoding": {"gzip"}, "If-None-Match": {identityETag}}); rec.Code != http.StatusOK {
- t.Errorf("gzip request revalidating the uncompressed ETag: status %d, want 200", rec.Code)
- }
- if rec := serve(handler, c.path, http.Header{"If-None-Match": {gzipETag}}); rec.Code != http.StatusOK {
- t.Errorf("uncompressed request revalidating the gzip ETag: status %d, want 200", rec.Code)
- }
- })
- }
-}
-
-// TestRewrittenMetadataWithoutGzip checks that clients that do not accept
-// gzip get the uncompressed document, which still varies on Accept-Encoding.
-func TestRewrittenMetadataWithoutGzip(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- compressibleDocuments(u)
- handler := c.handler(validatorProxy(t, u, time.Hour), u)
-
- for _, acceptEncoding := range []string{"", "identity", "gzip;q=0", "br"} {
- header := http.Header{}
- if acceptEncoding != "" {
- header.Set("Accept-Encoding", acceptEncoding)
- }
- rec := serve(handler, c.path, header)
- if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "" {
- t.Errorf("Accept-Encoding %q: status %d, Content-Encoding %q; want 200 uncompressed", acceptEncoding, rec.Code, rec.Header().Get("Content-Encoding"))
- }
- if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
- t.Errorf("Accept-Encoding %q: Vary = %q, want Accept-Encoding", acceptEncoding, got)
- }
- }
- })
- }
-}
-
-// TestRewrittenMetadataGzipOnlyFromRewriteCache checks that rewrites made
-// outside the cache, with it disabled or with cooldown on, stay uncompressed:
-// compressing them on every request is better left to a reverse proxy.
-func TestRewrittenMetadataGzipOnlyFromRewriteCache(t *testing.T) {
- setups := map[string]func(*Proxy){
- "cache disabled": func(p *Proxy) { p.SetMetadataRewriteCacheSize(0) },
- "cooldown without cache": func(p *Proxy) {
- p.SetMetadataRewriteCacheSize(0)
- p.Cooldown = &cooldown.Config{Default: "3d"}
- },
- }
- for _, c := range validatorCases {
- for name, setup := range setups {
- t.Run(c.ecosystem+"/"+name, func(t *testing.T) {
- u := newValidatorUpstream(t)
- compressibleDocuments(u)
- proxy := validatorProxy(t, u, time.Hour)
- setup(proxy)
- handler := c.handler(proxy, u)
-
- for range 2 {
- if rec := serve(handler, c.path, acceptGzip); rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "" {
- t.Errorf("status %d, Content-Encoding %q; want 200 uncompressed", rec.Code, rec.Header().Get("Content-Encoding"))
- }
- }
- })
- }
- }
-}
-
-// TestRewriteCache_KeepsGzipCopyOnlyWhenSmaller checks that the cache keeps
-// only a compressed copy of a rewrite, counted against its size, unless
-// compressing does not make the rewrite smaller, and that the plain document
-// is still there for callers that need it.
-func TestRewriteCache_KeepsGzipCopyOnlyWhenSmaller(t *testing.T) {
- c := newRewriteCache(1 << 20)
- identity := func(b []byte) ([]byte, error) { return b, nil }
- large := []byte(strings.Repeat(`{"version":"1.0.0"},`, 100))
-
- for _, doc := range [][]byte{large, []byte("abc")} {
- key := rewriteCacheKey("npm", "http://proxy", string(doc[:3]), doc)
- if _, err := c.rewrite(t.Context(), key, doc, nil, identity); err != nil {
- t.Fatal(err)
- }
- }
-
- m, ok := c.get(rewriteCacheKey("npm", "http://proxy", string(large[:3]), large))
- if !ok || m.gzipped == nil {
- t.Fatalf("large rewrite: cached %v, gzip copy %d bytes; want a gzip copy", ok, len(m.gzipped))
- }
- if m.plain != nil {
- t.Errorf("large rewrite: cached %d plain bytes besides the gzip copy, want none", len(m.plain))
- }
- if c.size != int64(len(m.gzipped)+3) {
- t.Errorf("cache size = %d, want the gzip copy and the small rewrite (%d)", c.size, len(m.gzipped)+3)
- }
- if !bytes.Equal(m.content(), large) || m.size != len(large) {
- t.Errorf("large rewrite content = %d bytes, size %d; want the %d bytes rewritten", len(m.content()), m.size, len(large))
- }
- if small, ok := c.get(rewriteCacheKey("npm", "http://proxy", "abc", []byte("abc"))); !ok || small.gzipped != nil || string(small.content()) != "abc" {
- t.Errorf("small rewrite: cached %v, gzip copy %d bytes, content %q; want abc uncompressed", ok, len(small.gzipped), small.content())
- }
-}
-
-func TestAcceptsGzip(t *testing.T) {
- cases := map[string]bool{
- "": false,
- "identity": false,
- "br, deflate": false,
- "gzip": true,
- "GZIP": true,
- "x-gzip": true,
- "deflate, gzip;q=1.0": true,
- "gzip; q=0.5": true,
- "gzip;q=0": false,
- "gzip;q=0.000": false,
- "*": true,
- "*;q=0": false,
- "br;q=1, *;q=0.5": true,
- "gzip;q=0, *": false,
- "gzip;q=invalid, deflate": false,
- }
- for header, want := range cases {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- if header != "" {
- r.Header.Set("Accept-Encoding", header)
- }
- if got := acceptsGzip(r); got != want {
- t.Errorf("acceptsGzip(%q) = %v, want %v", header, got, want)
- }
- }
-}
diff --git a/internal/handler/metadata_immutable.go b/internal/handler/metadata_immutable.go
deleted file mode 100644
index 214077c..0000000
--- a/internal/handler/metadata_immutable.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "net/http"
-
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// Some metadata documents never change once published: a Go module's .mod
-// and .info for a canonical version, or an RPM repodata file named after its
-// own checksum. Revalidating them every metadata TTL, or fetching them again
-// on every request without cache_metadata, only costs upstream requests. They
-// are kept in the metadata cache like other metadata, but served whatever
-// their age and stored whether or not cache_metadata is set, the way
-// artifacts are.
-
-// errImmutableDigest is returned when a stored immutable document doesn't
-// match the digest recorded for it.
-var errImmutableDigest = errors.New("stored metadata does not match its digest")
-
-// proxyImmutable answers a request for an immutable metadata document: from
-// the metadata cache when it is stored, otherwise fetched once and stored. A
-// document over the metadata size limit is streamed from upstream instead,
-// uncached.
-func (p *Proxy) proxyImmutable(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey, accept string) {
- if p.serveStoredMetadata(w, r, ecosystem, cacheKey, true) {
- return
- }
- res := p.fetchImmutableMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, accept)
- if errors.Is(res.err, ErrMetadataTooLarge) {
- p.proxyMetadataStream(w, r, upstreamURL, "identity", accept)
- return
- }
- if res.err != nil {
- p.writeMetadataFetchError(w, res.err)
- return
- }
- res.stale = false
- writeMetadataResult(w, r, res)
-}
-
-// fetchImmutableMetadata returns an immutable metadata document from the
-// metadata cache whatever its age, or fetches and stores it. Upstream's bytes
-// are kept as sent (Accept-Encoding: identity). Without a database or storage
-// it falls back to the TTL lookup.
-func (p *Proxy) fetchImmutableMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string) metadataResult {
- if p.DB == nil || p.Storage == nil {
- return p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, "identity", nil)
- }
- if containsPathTraversal(cacheKey) {
- return metadataResult{err: fmt.Errorf("invalid cache key: %q", cacheKey)}
- }
- if hit := p.storedImmutable(ctx, ecosystem, cacheKey); hit != nil {
- metrics.RecordMetadataCacheHit(ecosystem, len(hit.body))
- return *hit
- }
- metrics.RecordMetadataCacheMiss(ecosystem)
-
- // Kept apart from TTL lookups of the same key, which don't store the
- // document without cache_metadata.
- key := metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, "identity", false) + "\x00immutable"
- res, _ := p.coalesceMetadata(ctx, key, func(fetchCtx context.Context) metadataResult {
- if hit := p.storedImmutable(fetchCtx, ecosystem, cacheKey); hit != nil {
- return *hit
- }
- meta, err := p.fetchUpstreamMetadata(fetchCtx, upstreamURL, nil, accept, "identity")
- if err != nil {
- return metadataResult{err: err}
- }
- digest := p.cacheMetadataBlob(fetchCtx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta)
- return fetchedResult(meta, digest)
- })
- return res
-}
-
-// storedImmutable reads an immutable document from the metadata cache, or
-// returns nil. Since the copy is never revalidated, it is checked against the
-// digest recorded when it was stored; a row without one, or whose bytes don't
-// match, is a miss, and the fetch that follows replaces it.
-func (p *Proxy) storedImmutable(ctx context.Context, ecosystem, cacheKey string) *metadataResult {
- entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || entry == nil {
- return nil
- }
- entry = p.migrateLegacyMetadataEntry(ctx, ecosystem, cacheKey, entry)
- if entry == nil || !entry.ContentDigest.Valid {
- return nil
- }
- want := entry.ContentDigest.String
- body, contentType, err := p.readCachedMetadata(ctx, entry, func(b []byte) error {
- sum := sha256.Sum256(b)
- if "sha256:"+hex.EncodeToString(sum[:]) != want {
- return errImmutableDigest
- }
- return nil
- })
- if errors.Is(err, errImmutableDigest) {
- p.Logger.Error("cached metadata failed integrity check",
- "ecosystem", ecosystem, "key", cacheKey, "path", entry.StoragePath)
- metrics.RecordIntegrityFailure(ecosystem, metrics.FailureDetail{Path: entry.StoragePath, Err: err})
- if err := p.DB.DiscardMetadataCache(ecosystem, cacheKey, entry.StoragePath, want); err != nil {
- p.Logger.Warn("failed to clear corrupt metadata from cache", "error", err)
- }
- }
- if err != nil {
- return nil
- }
- res := metadataResult{body: body, contentType: contentType, contentEncoding: entry.ContentEncoding.String}.describedBy(entry)
- return &res
-}
diff --git a/internal/handler/metadata_immutable_test.go b/internal/handler/metadata_immutable_test.go
deleted file mode 100644
index 95b1a17..0000000
--- a/internal/handler/metadata_immutable_test.go
+++ /dev/null
@@ -1,270 +0,0 @@
-package handler
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// countingUpstream answers every path with body and counts requests per path.
-type countingUpstream struct {
- server *httptest.Server
- mu sync.Mutex
- hits map[string]int
- body func(path string) (string, bool)
-}
-
-func newCountingUpstream(t *testing.T, body func(path string) (string, bool)) *countingUpstream {
- t.Helper()
- u := &countingUpstream{hits: map[string]int{}, body: body}
- u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.mu.Lock()
- u.hits[r.URL.Path]++
- u.mu.Unlock()
- b, ok := u.body(r.URL.Path)
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write([]byte(b))
- }))
- t.Cleanup(u.server.Close)
- return u
-}
-
-func (u *countingUpstream) count(path string) int {
- u.mu.Lock()
- defer u.mu.Unlock()
- return u.hits[path]
-}
-
-const goPseudoVersion = "v0.0.0-20240102150405-abcdefabcdef"
-
-func goImmutableUpstream(t *testing.T) *countingUpstream {
- return newCountingUpstream(t, func(path string) (string, bool) {
- rest, ok := strings.CutPrefix(path, "/example.com/!mod/")
- if !ok {
- return "", false
- }
- switch {
- case rest == "@v/list":
- return "v1.0.0\n", true
- case rest == "@latest", rest == "@v/main.info":
- return `{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`, true
- case rest == "@v/v1.0.0.info":
- return `{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`, true
- case rest == "@v/"+goPseudoVersion+".info":
- return `{"Version":"` + goPseudoVersion + `","Time":"2024-01-02T15:04:05Z"}`, true
- case strings.HasSuffix(rest, ".mod"):
- return "module example.com/Mod\n", true
- }
- return "", false
- })
-}
-
-// TestGoVersionFilesAreNotRevalidated checks that a canonical version's .mod
-// and .info are fetched once and then served from the cache, with or without
-// cache_metadata and whatever the metadata TTL, while queries, @v/list and
-// @latest still go upstream.
-func TestGoVersionFilesAreNotRevalidated(t *testing.T) {
- for _, tt := range []struct {
- name string
- cacheMetadata, cooldown bool
- }{
- {"cache_metadata off", false, false},
- {"cache_metadata on", true, false},
- {"cache_metadata off, cooldown", false, true},
- {"cache_metadata on, cooldown", true, true},
- } {
- t.Run(tt.name, func(t *testing.T) {
- testGoVersionFilesAreNotRevalidated(t, tt.cacheMetadata, tt.cooldown)
- })
- }
-}
-
-func testGoVersionFilesAreNotRevalidated(t *testing.T, cacheMetadata, withCooldown bool) {
- u := goImmutableUpstream(t)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = cacheMetadata
- proxy.MetadataTTL = time.Nanosecond // every TTL-bound entry is already expired
- if withCooldown {
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- }
- h := NewGoHandlerWithUpstream(proxy, "http://localhost:8080", u.server.URL).Routes()
-
- once := []string{
- "/example.com/!mod/@v/v1.0.0.mod",
- "/example.com/!mod/@v/v1.0.0.info",
- "/example.com/!mod/@v/" + goPseudoVersion + ".mod",
- "/example.com/!mod/@v/" + goPseudoVersion + ".info",
- }
- every := []string{
- "/example.com/!mod/@v/main.info",
- "/example.com/!mod/@v/list",
- "/example.com/!mod/@latest",
- }
- for range 3 {
- for _, p := range append(append([]string{}, once...), every...) {
- if w := goGet(h, p); w.Code != http.StatusOK {
- t.Fatalf("GET %s = %d: %s", p, w.Code, w.Body.String())
- }
- }
- }
- for _, p := range once {
- if w := goGet(h, p); w.Header().Get("Warning") != "" {
- t.Errorf("GET %s marked stale", p)
- }
- if got := u.count(p); got != 1 {
- t.Errorf("upstream requests for %s = %d, want 1", p, got)
- }
- }
- for _, p := range every {
- if got := u.count(p); got < 2 {
- t.Errorf("upstream requests for %s = %d, want one per request", p, got)
- }
- }
-}
-
-func TestGoVersionImmutable(t *testing.T) {
- tests := map[string]bool{
- "v1.0.0": true,
- "v1.0.0-!r!c1": true, // v1.0.0-RC1
- "v2.0.0+incompatible": true,
- goPseudoVersion: true,
- "v1.2": false, // resolves to v1.2.0
- "v1.0.0+build": false,
- "main": false,
- "latest": false,
- "v1.0.0-RC1": false, // not a valid escaped version
- "": false,
- "master-20240102150405-x": false,
- }
- for v, want := range tests {
- if got := goVersionImmutable(v); got != want {
- t.Errorf("goVersionImmutable(%q) = %v, want %v", v, got, want)
- }
- }
-}
-
-// TestImmutableMetadataRefetchedWhenCorrupt checks that a stored immutable
-// document whose bytes no longer match their digest is fetched again rather
-// than returned for good. (A streamed hit of such a document aborts, as
-// TestCachedMetadataHitAbortsOnDigestMismatch checks.)
-func TestImmutableMetadataRefetchedWhenCorrupt(t *testing.T) {
- u := goImmutableUpstream(t)
- proxy, db, store, _ := setupTestProxy(t)
- const (
- path = "/example.com/!mod/@v/v1.0.0.info"
- key = "example.com/Mod/@v/v1.0.0.info"
- want = `{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`
- )
- fetch := func() string {
- t.Helper()
- res := proxy.fetchImmutableMetadata(context.Background(), "golang", key, u.server.URL+path, "*/*")
- if res.err != nil {
- t.Fatalf("fetch: %v", res.err)
- }
- return string(res.body)
- }
-
- fetch()
- entry, err := db.GetMetadataCache("golang", key)
- if err != nil || entry == nil {
- t.Fatalf("document not stored: %v", err)
- }
- if _, _, err := store.Store(context.Background(), entry.StoragePath, strings.NewReader(`{"Version":"v6.6.6"}`)); err != nil {
- t.Fatal(err)
- }
-
- if got := fetch(); got != want {
- t.Fatalf("got %q, want the upstream document", got)
- }
- if got := u.count(path); got != 2 {
- t.Errorf("upstream requests = %d, want 2", got)
- }
- if got := fetch(); got != want {
- t.Fatalf("got %q after refetch, want the upstream document", got)
- }
- if got := u.count(path); got != 2 {
- t.Errorf("upstream requests after refetch = %d, want 2", got)
- }
-}
-
-// TestRPMContentAddressedRepodataIsNotRevalidated checks that repodata files
-// named after their checksum are fetched once, while repomd.xml and plainly
-// named files follow the TTL.
-func TestRPMContentAddressedRepodataIsNotRevalidated(t *testing.T) {
- const (
- sum = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
- primary = "/releases/40/Everything/x86_64/os/repodata/" + sum + "-primary.xml.zst"
- repomd = "/releases/40/Everything/x86_64/os/repodata/repomd.xml"
- plain = "/releases/40/Everything/x86_64/os/repodata/primary.xml.gz"
- )
- for _, cacheMetadata := range []bool{false, true} {
- u := newCountingUpstream(t, func(string) (string, bool) { return "data", true })
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = cacheMetadata
- proxy.MetadataTTL = time.Nanosecond
- h := NewRPMHandlerWithUpstream(proxy, "http://localhost:8080", u.server.URL).Routes()
-
- for range 3 {
- for _, p := range []string{primary, repomd, plain} {
- if w := goGet(h, p); w.Code != http.StatusOK || w.Body.String() != "data" {
- t.Fatalf("GET %s = %d %q", p, w.Code, w.Body.String())
- }
- }
- }
- if got := u.count(primary); got != 1 {
- t.Errorf("cache_metadata=%v: upstream requests for checksum-named file = %d, want 1", cacheMetadata, got)
- }
- for _, p := range []string{repomd, plain} {
- if got := u.count(p); got != 3 {
- t.Errorf("cache_metadata=%v: upstream requests for %s = %d, want 3", cacheMetadata, p, got)
- }
- }
- }
-}
-
-func TestRPMContentAddressed(t *testing.T) {
- sha256 := strings.Repeat("a", 64)
- tests := map[string]bool{
- "os/repodata/" + sha256 + "-primary.xml.gz": true,
- "os/repodata/" + strings.Repeat("0", 40) + "-other.xml.gz": true,
- "os/repodata/" + strings.Repeat("f", 128) + "-comps.xml": true,
- "os/repodata/repomd.xml": false,
- "os/repodata/repomd.xml.asc": false,
- "os/repodata/primary.xml.gz": false,
- "os/repodata/" + strings.Repeat("a", 63) + "-primary.xml.gz": false,
- "os/repodata/" + strings.Repeat("A", 64) + "-primary.xml.gz": false,
- "os/repodata/" + sha256: false,
- }
- for p, want := range tests {
- if got := rpmContentAddressed(p); got != want {
- t.Errorf("rpmContentAddressed(%q) = %v, want %v", p, got, want)
- }
- }
-}
-
-// TestImmutableMetadataOverSizeLimitIsStreamed checks that a checksum-named
-// repodata file too large to buffer is streamed from upstream rather than
-// refused.
-func TestImmutableMetadataOverSizeLimitIsStreamed(t *testing.T) {
- const path = "/os/repodata/" + "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" + "-filelists.xml.gz"
- u := newCountingUpstream(t, func(string) (string, bool) { return "large document", true })
- proxy, db, _, _ := setupTestProxy(t)
- proxy.MetadataMaxSize = 4
- h := NewRPMHandlerWithUpstream(proxy, "http://localhost:8080", u.server.URL).Routes()
-
- if w := goGet(h, path); w.Code != http.StatusOK || w.Body.String() != "large document" {
- t.Fatalf("got %d %q, want the upstream document", w.Code, w.Body.String())
- }
- if entry, _ := db.GetMetadataCache("rpm", hashedCacheKey(u.server.URL, strings.TrimPrefix(path, "/"))); entry != nil {
- t.Error("stored a document over the size limit")
- }
-}
diff --git a/internal/handler/metadata_not_modified_test.go b/internal/handler/metadata_not_modified_test.go
deleted file mode 100644
index fd690df..0000000
--- a/internal/handler/metadata_not_modified_test.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package handler
-
-import (
- "context"
- "database/sql"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// TestMetadataNotModifiedKeepsStoredDocument checks that a 304 from upstream
-// refreshes the cache row without writing the unchanged document to storage
-// again, and still serves it.
-func TestMetadataNotModifiedKeepsStoredDocument(t *testing.T) {
- const body = `{"v":1}`
- var notModified atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set(headerETag, `"v1"`)
- if r.Header.Get("If-None-Match") == `"v1"` {
- notModified.Add(1)
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- _, _ = w.Write([]byte(body))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Nanosecond // revalidate on every request
- proxy.HTTPClient = upstream.Client()
-
- ctx := context.Background()
- first := proxy.fetchMetadataDocument(ctx, "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
- if first.err != nil {
- t.Fatal(first.err)
- }
- stored, err := db.GetMetadataCache("test", "pkg")
- if err != nil || stored == nil {
- t.Fatalf("cache row after first fetch: %v, %v", stored, err)
- }
-
- for range 2 {
- res := proxy.fetchMetadataDocument(ctx, "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
- if res.err != nil || string(res.body) != body || res.digest != first.digest {
- t.Fatalf("revalidated fetch = %q, digest %q, err %v; want %q, digest %q", res.body, res.digest, res.err, body, first.digest)
- }
- }
- if n := notModified.Load(); n != 2 {
- t.Fatalf("upstream answered 304 %d times, want 2", n)
- }
- if store.stores != 1 {
- t.Errorf("document stored %d times, want once", store.stores)
- }
- refreshed, err := db.GetMetadataCache("test", "pkg")
- if err != nil {
- t.Fatal(err)
- }
- if !refreshed.FetchedAt.Time.After(stored.FetchedAt.Time) {
- t.Errorf("fetched_at = %v, want later than %v", refreshed.FetchedAt.Time, stored.FetchedAt.Time)
- }
- if refreshed.ContentDigest != stored.ContentDigest || refreshed.ETag != stored.ETag {
- t.Errorf("row validators changed: %+v, want %+v", refreshed, stored)
- }
-}
-
-// TestMetadataNotModifiedStoresRowWithoutDigest checks that a 304 for a row
-// written before digests were recorded stores the document as before, so the
-// row gains its digest.
-func TestMetadataNotModifiedStoresRowWithoutDigest(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("If-None-Match") == `"v1"` {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- t.Errorf("unconditional upstream request")
- }))
- t.Cleanup(upstream.Close)
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Nanosecond
- proxy.HTTPClient = upstream.Client()
-
- path := metadataStoragePath("test", "pkg")
- store.files[path] = []byte(`{"v":1}`)
- if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "test",
- Name: "pkg",
- StoragePath: path,
- ETag: sql.NullString{String: `"v1"`, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
- }); err != nil {
- t.Fatal(err)
- }
-
- res := proxy.fetchMetadataDocument(context.Background(), "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
- if res.err != nil || string(res.body) != `{"v":1}` {
- t.Fatalf("fetch = %q, %v", res.body, res.err)
- }
- if want := "sha256:" + sha256Hex(`{"v":1}`); res.digest != want {
- t.Errorf("digest = %q, want %q", res.digest, want)
- }
- if store.stores != 1 {
- t.Errorf("document stored %d times, want once", store.stores)
- }
-}
diff --git a/internal/handler/metadata_path.go b/internal/handler/metadata_path.go
deleted file mode 100644
index 4637909..0000000
--- a/internal/handler/metadata_path.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "io"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// metadataStoragePath builds the storage path for cached metadata. Cache
-// keys are case-sensitive, but on a case-insensitive filesystem (APFS, NTFS)
-// keys that differ only in case, such as Go's github.com/Sirupsen/logrus and
-// github.com/sirupsen/logrus, would share one file. Keys may also hold names
-// Windows reserves or end in a dot. So the path carries a hash of the exact
-// key. Keys that already are lowercase SHA-256 hex digests are used as they
-// are, so their existing entries stay valid.
-func metadataStoragePath(ecosystem, cacheKey string) string {
- if !isSHA256Hex(cacheKey) {
- digest := sha256.Sum256([]byte(cacheKey))
- cacheKey = hex.EncodeToString(digest[:])
- }
- return legacyMetadataStoragePath(ecosystem, cacheKey)
-}
-
-// legacyMetadataStoragePath is where metadata was stored before paths were
-// hashed: under the cache key itself.
-func legacyMetadataStoragePath(ecosystem, cacheKey string) string {
- return "_metadata/" + ecosystem + "/" + cacheKey + "/metadata"
-}
-
-func isSHA256Hex(s string) bool {
- if len(s) != 2*sha256.Size {
- return false
- }
- for i := range len(s) {
- c := s[i]
- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
- return false
- }
- }
- return true
-}
-
-// migrateLegacyMetadataEntry handles a cache row whose blob is still at its
-// legacy, unhashed path. On a case-insensitive filesystem keys differing only
-// in case shared that blob, so it is served only if it matches the row's digest.
-func (p *Proxy) migrateLegacyMetadataEntry(ctx context.Context, ecosystem, cacheKey string, entry *database.MetadataCacheEntry) *database.MetadataCacheEntry {
- if entry == nil || p.Storage == nil {
- return entry
- }
- legacy := legacyMetadataStoragePath(ecosystem, cacheKey)
- hashed := metadataStoragePath(ecosystem, cacheKey)
- if entry.StoragePath != legacy || legacy == hashed {
- return entry
- }
- var matches bool
- if entry.ContentDigest.Valid {
- digest, err := p.storedDigest(ctx, legacy)
- if err != nil {
- p.Logger.Warn("failed to read legacy metadata blob", "ecosystem", ecosystem, "key", cacheKey, "error", err)
- return nil
- }
- matches = digest == entry.ContentDigest.String
- }
- if !matches {
- if err := p.DB.ForgetMetadataDocument(ecosystem, cacheKey, legacy, hashed); err != nil {
- p.Logger.Warn("failed to drop legacy metadata row", "ecosystem", ecosystem, "key", cacheKey, "error", err)
- return nil
- }
- p.queueLegacyMetadataBlob(ecosystem, cacheKey, legacy)
- return nil
- }
- if !p.moveLegacyMetadataBlob(ctx, entry, legacy, hashed) {
- return entry
- }
- p.queueLegacyMetadataBlob(ecosystem, cacheKey, legacy)
- moved := *entry
- moved.StoragePath = hashed
- return &moved
-}
-
-// storedDigest hashes the blob at path as cacheMetadataBlob records it.
-func (p *Proxy) storedDigest(ctx context.Context, path string) (string, error) {
- r, err := p.Storage.Open(ctx, path)
- if err != nil {
- return "", err
- }
- defer func() { _ = r.Close() }()
- h := sha256.New()
- if _, err := io.Copy(h, r); err != nil {
- return "", err
- }
- return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
-}
-
-// moveLegacyMetadataBlob copies entry's verified blob from legacy to hashed
-// and points the row there, reporting whether it did.
-func (p *Proxy) moveLegacyMetadataBlob(ctx context.Context, entry *database.MetadataCacheEntry, legacy, hashed string) bool {
- logFailure := func(err error) {
- p.Logger.Warn("failed to move legacy metadata blob", "ecosystem", entry.Ecosystem, "key", entry.Name, "error", err)
- }
- r, err := p.Storage.Open(ctx, legacy)
- if err != nil {
- logFailure(err)
- return false
- }
- defer func() { _ = r.Close() }()
- _, hash, err := p.Storage.Store(ctx, hashed, r)
- if err != nil {
- logFailure(err)
- return false
- }
- if "sha256:"+hash != entry.ContentDigest.String {
- logFailure(fmt.Errorf("blob changed while being copied"))
- return false
- }
- moved, err := p.DB.MoveMetadataCache(entry.Ecosystem, entry.Name, legacy, hashed, entry.ContentDigest.String)
- if err != nil {
- logFailure(err)
- return false
- }
- return moved
-}
-
-// queueLegacyMetadataBlob queues a row's legacy blob for deletion once the row
-// no longer reads it. Reclaim skips it while another row still points there.
-func (p *Proxy) queueLegacyMetadataBlob(ecosystem, cacheKey, legacy string) {
- if err := p.DB.QueuePendingDelete(legacy); err != nil {
- p.Logger.Warn("failed to queue legacy metadata blob for deletion", "ecosystem", ecosystem, "key", cacheKey, "error", err)
- }
-}
diff --git a/internal/handler/metadata_path_test.go b/internal/handler/metadata_path_test.go
deleted file mode 100644
index b1f040c..0000000
--- a/internal/handler/metadata_path_test.go
+++ /dev/null
@@ -1,351 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "database/sql"
- "encoding/hex"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "slices"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestMetadataStoragePathKeepsCaseApart(t *testing.T) {
- for _, pair := range [][2]string{
- {"github.com/Sirupsen/logrus", "github.com/sirupsen/logrus"},
- {"JSONStream", "jsonstream"},
- } {
- a, b := metadataStoragePath("go", pair[0]), metadataStoragePath("go", pair[1])
- if strings.EqualFold(a, b) {
- t.Errorf("%q and %q map to paths %q and %q, which a case-insensitive filesystem treats as one", pair[0], pair[1], a, b)
- }
- }
- for _, key := range []string{"CON", "nul", "trailing.", "a/../b"} {
- p := metadataStoragePath("npm", key)
- if strings.Contains(p, key) {
- t.Errorf("path %q contains the raw key %q", p, key)
- }
- }
- // Keys that already are digests keep their path, so existing entries
- // stay valid.
- sum := sha256.Sum256([]byte("generic"))
- digest := hex.EncodeToString(sum[:])
- if got, want := metadataStoragePath("generic", digest), "_metadata/generic/"+digest+"/metadata"; got != want {
- t.Errorf("digest key path = %q, want %q", got, want)
- }
-}
-
-// fileStorageProxy returns a proxy whose metadata is cached on the local
-// filesystem, as with file:// storage.
-func fileStorageProxy(t *testing.T, client *http.Client) (*Proxy, *database.DB) {
- t.Helper()
- dir := t.TempDir()
- db, err := database.Create(filepath.Join(dir, "test.db"))
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = db.Close() })
- store, err := storage.OpenBucket(context.Background(), "file://"+filepath.Join(dir, "cache"))
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = store.Close() })
- proxy := NewProxy(db, store, &mockFetcher{}, fetch.NewResolver(), slog.New(slog.NewTextHandler(io.Discard, nil)))
- proxy.HTTPClient = client
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- return proxy, db
-}
-
-// TestMetadataKeysDifferingInCaseCachedApart caches metadata for two keys
-// that differ only in case on file storage. On a case-insensitive filesystem
-// they shared one file, and the first key was served the second's document.
-func TestMetadataKeysDifferingInCaseCachedApart(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- _, _ = io.WriteString(w, "document for "+r.URL.Path)
- }))
- defer upstream.Close()
- proxy, _ := fileStorageProxy(t, upstream.Client())
-
- keys := []string{"github.com/Sirupsen/logrus", "github.com/sirupsen/logrus"}
- for range 2 {
- for _, key := range keys {
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/"+key)
- if err != nil {
- t.Fatal(err)
- }
- if want := "document for /" + key; string(body) != want {
- t.Errorf("%s: served %q, want %q", key, body, want)
- }
- }
- }
- if got := requests.Load(); got != 2 {
- t.Errorf("upstream requests = %d, want 2", got)
- }
-}
-
-// seedLegacyMetadata stores blob at key's legacy path and a row pointing at
-// it, fetched at fetchedAt. The row's digest is that of document, or unset
-// when document is empty.
-func seedLegacyMetadata(t *testing.T, proxy *Proxy, db *database.DB, key, blob, document string, fetchedAt time.Time) string {
- t.Helper()
- legacy := legacyMetadataStoragePath("go", key)
- if _, _, err := proxy.Storage.Store(context.Background(), legacy, strings.NewReader(blob)); err != nil {
- t.Fatal(err)
- }
- entry := &database.MetadataCacheEntry{
- Ecosystem: "go",
- Name: key,
- StoragePath: legacy,
- ETag: sql.NullString{String: `"v1"`, Valid: true},
- Size: sql.NullInt64{Int64: int64(len(blob)), Valid: true},
- FetchedAt: sql.NullTime{Time: fetchedAt, Valid: true},
- }
- if document != "" {
- sum := sha256.Sum256([]byte(document))
- entry.ContentDigest = sql.NullString{String: "sha256:" + hex.EncodeToString(sum[:]), Valid: true}
- }
- if err := db.UpsertMetadataCache(entry); err != nil {
- t.Fatal(err)
- }
- return legacy
-}
-
-func pendingDeletes(t *testing.T, db *database.DB) []string {
- t.Helper()
- due, err := db.GetDuePendingDeletes(time.Now().Add(time.Hour), 100)
- if err != nil {
- t.Fatal(err)
- }
- return due
-}
-
-func metadataRowPath(t *testing.T, db *database.DB, key string) string {
- t.Helper()
- entry, err := db.GetMetadataCache("go", key)
- if err != nil || entry == nil {
- t.Fatalf("cache row for %s: %v %v", key, entry, err)
- }
- return entry.StoragePath
-}
-
-// TestLegacyMetadataMatchingBlobServedWhenUpstreamDown checks that a row at
-// its legacy path whose blob matches its digest is still served as a stale
-// fallback, and is moved to the hashed path.
-func TestLegacyMetadataMatchingBlobServedWhenUpstreamDown(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusBadGateway)
- }))
- defer upstream.Close()
- proxy, db := fileStorageProxy(t, upstream.Client())
-
- const key = "github.com/Sirupsen/logrus"
- legacy := seedLegacyMetadata(t, proxy, db, key, "old", "old", time.Now().Add(-2*time.Hour))
-
- for range 2 {
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/x")
- if err != nil {
- t.Fatalf("legacy document not served while upstream is down: %v", err)
- }
- if string(body) != "old" {
- t.Fatalf("served %q, want %q", body, "old")
- }
- }
- if got, want := metadataRowPath(t, db, key), metadataStoragePath("go", key); got != want {
- t.Errorf("storage path = %q, want %q", got, want)
- }
- if due := pendingDeletes(t, db); !slices.Contains(due, legacy) {
- t.Errorf("pending deletes = %v, want %q", due, legacy)
- }
-}
-
-// TestLegacyMetadataMatchingBlobServedFromCache checks that a fresh row at
-// its legacy path whose blob matches is served without asking upstream.
-func TestLegacyMetadataMatchingBlobServedFromCache(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- _, _ = io.WriteString(w, "fresh")
- }))
- defer upstream.Close()
- proxy, db := fileStorageProxy(t, upstream.Client())
-
- const key = "github.com/Sirupsen/logrus"
- seedLegacyMetadata(t, proxy, db, key, "old", "old", time.Now())
-
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/x")
- if err != nil {
- t.Fatal(err)
- }
- if string(body) != "old" || requests.Load() != 0 {
- t.Errorf("served %q after %d upstream requests, want the cached document", body, requests.Load())
- }
-}
-
-// TestLegacyMetadataMismatchedBlobNotServed checks that a legacy blob that
-// does not match the row's digest, as when another key's document
-// overwrote it, is neither served nor used as a fallback.
-func TestLegacyMetadataMismatchedBlobNotServed(t *testing.T) {
- var down atomic.Bool
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- if down.Load() {
- w.WriteHeader(http.StatusBadGateway)
- return
- }
- if r.Header.Get("If-None-Match") != "" {
- t.Errorf("revalidated a document that was dropped")
- }
- _, _ = io.WriteString(w, "fresh")
- }))
- defer upstream.Close()
- proxy, db := fileStorageProxy(t, upstream.Client())
-
- const key = "github.com/Sirupsen/logrus"
- seedLegacyMetadata(t, proxy, db, key, "other key's document", "old", time.Now())
- down.Store(true)
- if body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/x"); err == nil {
- t.Fatalf("served %q, want an error while upstream is down", body)
- }
-
- down.Store(false)
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/x")
- if err != nil {
- t.Fatal(err)
- }
- if string(body) != "fresh" {
- t.Errorf("served %q, want %q", body, "fresh")
- }
- if got, want := metadataRowPath(t, db, key), metadataStoragePath("go", key); got != want {
- t.Errorf("storage path = %q, want %q", got, want)
- }
-}
-
-// caseFoldingStorage stores paths differing only in case as one file, as a
-// case-insensitive filesystem does.
-type caseFoldingStorage struct{ storage.Storage }
-
-func (s caseFoldingStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- return s.Storage.Store(ctx, strings.ToLower(path), r)
-}
-
-func (s caseFoldingStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- return s.Storage.Open(ctx, strings.ToLower(path))
-}
-
-func (s caseFoldingStorage) Exists(ctx context.Context, path string) (bool, error) {
- return s.Storage.Exists(ctx, strings.ToLower(path))
-}
-
-func (s caseFoldingStorage) Delete(ctx context.Context, path string) error {
- return s.Storage.Delete(ctx, strings.ToLower(path))
-}
-
-func (s caseFoldingStorage) Size(ctx context.Context, path string) (int64, error) {
- return s.Storage.Size(ctx, strings.ToLower(path))
-}
-
-// TestLegacyMetadataCaseVariantRefetchedAfterReclaim checks that on
-// case-insensitive storage, a key whose legacy blob was shared with a case
-// variant and deleted after that variant moved is fetched again rather than
-// served.
-func TestLegacyMetadataCaseVariantRefetchedAfterReclaim(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- _, _ = io.WriteString(w, "fresh")
- }))
- defer upstream.Close()
- proxy, db := fileStorageProxy(t, upstream.Client())
- proxy.Storage = caseFoldingStorage{proxy.Storage}
-
- const upper, lower = "github.com/Sirupsen/logrus", "github.com/sirupsen/logrus"
- seedLegacyMetadata(t, proxy, db, upper, "doc", "doc", time.Now())
- seedLegacyMetadata(t, proxy, db, lower, "doc", "doc", time.Now())
-
- if body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", upper, upstream.URL+"/x"); err != nil || string(body) != "doc" {
- t.Fatalf("FetchOrCacheMetadata(%s) = %q, %v; want the migrated document", upper, body, err)
- }
- // Reclaim deletes the queued legacy blob, which is also lower's.
- for _, path := range pendingDeletes(t, db) {
- if err := proxy.Storage.Delete(context.Background(), path); err != nil {
- t.Fatal(err)
- }
- }
-
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", lower, upstream.URL+"/x")
- if err != nil {
- t.Fatal(err)
- }
- if string(body) != "fresh" || requests.Load() != 1 {
- t.Errorf("served %q after %d upstream requests, want a fresh fetch", body, requests.Load())
- }
- if got, want := metadataRowPath(t, db, lower), metadataStoragePath("go", lower); got != want {
- t.Errorf("storage path = %q, want %q", got, want)
- }
-}
-
-// TestLegacyMetadataPathRefetched checks that a row at its legacy path with
-// no digest to check its blob against is fetched again, moved to the hashed
-// path, and its old blob queued for deletion.
-func TestLegacyMetadataPathRefetched(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- _, _ = io.WriteString(w, "fresh")
- }))
- defer upstream.Close()
- proxy, db := fileStorageProxy(t, upstream.Client())
-
- const key = "github.com/Sirupsen/logrus"
- legacy := legacyMetadataStoragePath("go", key)
- if _, _, err := proxy.Storage.Store(context.Background(), legacy, bytes.NewReader([]byte("old"))); err != nil {
- t.Fatal(err)
- }
- if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "go",
- Name: key,
- StoragePath: legacy,
- Size: sql.NullInt64{Int64: 3, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }); err != nil {
- t.Fatal(err)
- }
-
- body, _, err := proxy.FetchOrCacheMetadata(context.Background(), "go", key, upstream.URL+"/x")
- if err != nil {
- t.Fatal(err)
- }
- if string(body) != "fresh" || requests.Load() != 1 {
- t.Fatalf("served %q after %d upstream requests, want a fresh fetch", body, requests.Load())
- }
- entry, err := db.GetMetadataCache("go", key)
- if err != nil || entry == nil {
- t.Fatalf("cache row: %v %v", entry, err)
- }
- if entry.StoragePath != metadataStoragePath("go", key) {
- t.Errorf("storage path = %q, want %q", entry.StoragePath, metadataStoragePath("go", key))
- }
- due, err := db.GetDuePendingDeletes(time.Now().Add(time.Hour), 10)
- if err != nil {
- t.Fatal(err)
- }
- if !slices.Contains(due, legacy) {
- t.Errorf("pending deletes = %v, want %q", due, legacy)
- }
-}
diff --git a/internal/handler/metadata_stored.go b/internal/handler/metadata_stored.go
deleted file mode 100644
index fc16d43..0000000
--- a/internal/handler/metadata_stored.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "hash"
- "io"
- "net/http"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// A metadata document can be rewritten from storage instead of from memory:
-// storedMetadataDocument streams it from upstream into storage, and the
-// rewrite reads it back as often as it needs. Nothing holds the whole
-// document, so its size is not limited by metadata_max_size or by memory.
-
-// errMetadataNotStored is returned when metadata is not cached in storage, so
-// a document has to be read into memory.
-var errMetadataNotStored = errors.New("metadata is not cached in storage")
-
-// errStoredMetadataChanged is returned when a stored document no longer has
-// the digest its cache row records: a fetch replaced it while it was read.
-var errStoredMetadataChanged = errors.New("stored metadata changed while it was read")
-
-// storedMetadataDocument returns the cache row of a metadata document stored
-// uncompressed with its digest: the stored copy while it is within its TTL,
-// or else the document fetched from upstream and stored, or the stored copy
-// when upstream fails. The document is streamed from upstream into storage
-// without being held in memory, so a fetch takes no metadata_max_size limit.
-// Concurrent calls for one document share a fetch, and instances sharing the
-// database take turns as for other metadata.
-//
-// validate, when not nil, checks a fetched document before it replaces the
-// stored one; a document it rejects is not stored, and the stored copy is
-// served as when upstream fails. It does not check a stored copy within its
-// TTL.
-//
-// It returns errMetadataNotStored when the proxy doesn't cache metadata or
-// storing the document failed, and the caller reads it into memory instead.
-func (p *Proxy) storedMetadataDocument(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string, validate func(io.Reader) error) (*database.MetadataCacheEntry, error) {
- res := p.storedMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, validate, "")
- return res.entry, res.err
-}
-
-// storedMetadata implements storedMetadataDocument, and reports in the
-// result whether the row was read from cache or served stale. A row with
-// the digest gone, one whose stored copy was missing or out of date when
-// read, is not served or revalidated, within its TTL or not, and the
-// document is fetched again.
-func (p *Proxy) storedMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string, validate func(io.Reader) error, gone string) metadataResult {
- if !p.CacheMetadata || p.DB == nil || p.Storage == nil || p.MetadataTTL <= 0 {
- return metadataResult{err: errMetadataNotStored}
- }
- if containsPathTraversal(cacheKey) {
- return metadataResult{err: fmt.Errorf("invalid cache key: %q", cacheKey)}
- }
- if entry, fresh := p.storedMetadataEntryState(ctx, ecosystem, cacheKey, gone); fresh {
- metrics.RecordMetadataCacheHit(ecosystem, int(entry.Size.Int64))
- p.recordMetadataRead(entry)
- return metadataResult{entry: entry, fromCache: true}
- }
- p.recordMetadataCacheMiss(ecosystem)
-
- key := metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, "", false) + "\x00stored"
- res, _ := p.coalesceMetadata(ctx, key, func(fetchCtx context.Context) metadataResult {
- entry, fresh := p.storedMetadataEntryState(fetchCtx, ecosystem, cacheKey, gone)
- if fresh {
- return metadataResult{entry: entry}
- }
- release, outcome, err := p.awaitFetchLease(fetchCtx, metrics.CacheKindMetadata, key, func() bool {
- entry, fresh = p.storedMetadataEntryState(fetchCtx, ecosystem, cacheKey, gone)
- return fresh
- })
- if err != nil {
- return metadataResult{err: err}
- }
- switch outcome {
- case leaseStored:
- return metadataResult{entry: entry}
- case leaseFailed:
- // Serve the stored copy, as the holder did, rather than trying
- // the failing upstream again.
- p.reloadUpstreamHolds()
- if usableStoredMetadata(entry) {
- return p.staleStoredMetadata(ecosystem, cacheKey, entry, errFetchFailedElsewhere)
- }
- case leaseFetch:
- }
- res := metadataResult{err: errSharedFetchAbandoned}
- defer func() { release(res.err != nil || res.stale) }()
- res = p.fetchStoredMetadata(fetchCtx, ecosystem, cacheKey, upstreamURL, accept, validate, entry)
- return res
- })
- return res
-}
-
-// storedMetadataEntryState reads the cache row of a document and reports
-// whether it is within its TTL and usable by storedMetadataDocument. A row
-// with the digest gone is returned as nil.
-func (p *Proxy) storedMetadataEntryState(ctx context.Context, ecosystem, cacheKey, gone string) (*database.MetadataCacheEntry, bool) {
- entry, _ := p.DB.GetMetadataCache(ecosystem, cacheKey)
- entry = p.migrateLegacyMetadataEntry(ctx, ecosystem, cacheKey, entry)
- if gone != "" && entry != nil && entry.ContentDigest.String == gone {
- return nil, false
- }
- fresh := usableStoredMetadata(entry) && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) < p.MetadataTTL
- return entry, fresh
-}
-
-// usableStoredMetadata reports whether entry describes a document stored
-// uncompressed with its digest, which a rewrite can read back and check.
-func usableStoredMetadata(entry *database.MetadataCacheEntry) bool {
- return entry != nil && entry.ContentDigest.Valid && entry.ContentEncoding.String == ""
-}
-
-// fetchStoredMetadata fetches a document from upstream into storage,
-// revalidating entry when there is one, and falls back to entry when
-// upstream fails or validate rejects what it sent. A failure to store is
-// returned as it is, for the caller to read the document into memory.
-func (p *Proxy) fetchStoredMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string, validate func(io.Reader) error, entry *database.MetadataCacheEntry) metadataResult {
- revalidate := entry
- if !usableStoredMetadata(entry) {
- revalidate = nil
- }
- stored, err := p.storeUpstreamMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, validate, revalidate)
- if errors.Is(err, errStale304) {
- stored, err = p.storeUpstreamMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, validate, nil)
- }
- if err == nil {
- return metadataResult{entry: stored}
- }
- // As in fetchMetadataFromUpstream, any upstream failure, a 404 included,
- // falls back to the stored copy.
- if !usableStoredMetadata(entry) || errors.Is(err, errMetadataNotStored) {
- return metadataResult{err: err}
- }
- return p.staleStoredMetadata(ecosystem, cacheKey, entry, err)
-}
-
-// staleStoredMetadata falls back to the stored document entry describes after
-// the upstream fetch failed with err.
-func (p *Proxy) staleStoredMetadata(ecosystem, cacheKey string, entry *database.MetadataCacheEntry, err error) metadataResult {
- p.Logger.Warn("upstream metadata fetch failed, serving cached copy",
- "ecosystem", ecosystem, "key", cacheKey, "error", err)
- entry = p.currentMetadataEntry(ecosystem, cacheKey, entry)
- if !usableStoredMetadata(entry) {
- return metadataResult{err: fmt.Errorf("upstream failed and no usable cached metadata: %w", err)}
- }
- return metadataResult{entry: entry, stale: true, upstreamErr: err}
-}
-
-// storeUpstreamMetadata fetches a document and streams it into storage, and
-// returns its new cache row. On a 304 for entry it only moves the row's
-// fetched_at on; errStale304 means the stored copy is gone or the row was
-// replaced meanwhile, and the caller should fetch without validators.
-//
-// With validate, the document is staged under a path of its own and checked
-// there first, so one validate rejects never replaces the stored copy.
-func (p *Proxy) storeUpstreamMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string, validate func(io.Reader) error, entry *database.MetadataCacheEntry) (*database.MetadataCacheEntry, error) {
- resp, err := p.requestUpstreamMetadata(ctx, upstreamURL, entry, accept, "")
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode == http.StatusNotModified && entry != nil {
- if ok, err := p.Storage.Exists(ctx, entry.StoragePath); err != nil || !ok {
- return nil, errStale304
- }
- refreshed, err := p.DB.RefreshMetadataCache(ecosystem, cacheKey, entry.ContentDigest.String, time.Now())
- if err != nil || !refreshed {
- return nil, errStale304
- }
- return p.currentMetadataEntry(ecosystem, cacheKey, entry), nil
- }
- if err := upstreamMetadataStatus(resp); err != nil {
- return nil, err
- }
-
- meta := &upstreamMetadata{
- contentType: resp.Header.Get(headerContentType),
- etag: resp.Header.Get(headerETag),
- }
- if meta.contentType == "" {
- meta.contentType = contentTypeJSON
- }
- if lm := resp.Header.Get(headerLastModified); lm != "" {
- meta.lastModified, _ = http.ParseTime(lm)
- }
- limit := p.MetadataStreamMaxSize
- if limit <= 0 {
- limit = defaultMetadataStreamMaxSize
- }
- // Go decompresses the body, since no Accept-Encoding was set, so it is
- // stored uncompressed.
- var body io.Reader = &limitedBody{r: upstreamBodyReader{resp.Body}, left: limit}
- if validate != nil {
- staged, err := p.stageMetadata(ctx, ecosystem, cacheKey, body, validate)
- if err != nil {
- return nil, err
- }
- defer func() { _ = staged.Close() }()
- body = staged
- }
- if _, err := p.storeMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), body, meta); err != nil {
- return nil, notStored("", err)
- }
- stored, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || !usableStoredMetadata(stored) {
- return nil, fmt.Errorf("reading the stored metadata row: %w", errors.Join(err, errMetadataNotStored))
- }
- return stored, nil
-}
-
-// stageMetadata stores body under a staging path of its own, checks it with
-// validate, and returns it opened for reading, to be stored in place of the
-// cached copy. The staged copy is deleted when the reader is closed. A
-// failure to store it is returned as errMetadataNotStored.
-func (p *Proxy) stageMetadata(ctx context.Context, ecosystem, cacheKey string, body io.Reader, validate func(io.Reader) error) (io.ReadCloser, error) {
- path := metadataStoragePath(ecosystem, cacheKey) + ".staged-" + rand.Text()
- discard := func() {
- if err := p.Storage.Delete(context.WithoutCancel(ctx), path); err != nil {
- p.Logger.Warn("failed to delete staged metadata", "ecosystem", ecosystem, "key", cacheKey, "error", err)
- }
- }
- if _, _, err := p.Storage.Store(ctx, path, body); err != nil {
- discard()
- return nil, notStored("staging: ", err)
- }
- if err := readStored(func() (io.ReadCloser, error) { return p.Storage.Open(ctx, path) }, validate); err != nil {
- discard()
- return nil, err
- }
- staged, err := p.Storage.Open(ctx, path)
- if err != nil {
- discard()
- return nil, fmt.Errorf("%w: reading staged copy: %w", errMetadataNotStored, err)
- }
- return &stagedReader{ReadCloser: staged, discard: discard}, nil
-}
-
-// limitedBody fails with ErrMetadataTooLarge, as an upstream failure, once
-// r has sent more than left bytes.
-type limitedBody struct {
- r io.Reader
- left int64
-}
-
-func (l *limitedBody) Read(b []byte) (int, error) {
- n, err := l.r.Read(b)
- if l.left -= int64(n); l.left < 0 {
- return 0, &upstreamBodyError{ErrMetadataTooLarge}
- }
- return n, err
-}
-
-// notStored returns a failure to store a document as errMetadataNotStored,
-// unless reading it from upstream failed.
-func notStored(what string, err error) error {
- if body := (*upstreamBodyError)(nil); errors.As(err, &body) {
- return body
- }
- return fmt.Errorf("%w: %s%w", errMetadataNotStored, what, err)
-}
-
-// stagedReader deletes the staged copy it reads when closed.
-type stagedReader struct {
- io.ReadCloser
- discard func()
-}
-
-func (s *stagedReader) Close() error {
- err := s.ReadCloser.Close()
- s.discard()
- return err
-}
-
-// openStoredMetadata opens the document entry describes. Reading it to the
-// end fails with errStoredMetadataChanged when its bytes don't have the
-// digest entry records.
-func (p *Proxy) openStoredMetadata(ctx context.Context, entry *database.MetadataCacheEntry) (io.ReadCloser, error) {
- r, err := p.Storage.Open(ctx, entry.StoragePath)
- if err != nil {
- return nil, err
- }
- return &digestReader{r: r, h: sha256.New(), want: entry.ContentDigest.String}, nil
-}
-
-// digestReader checks at the end of its reader that what it read has the
-// digest want, in the form the metadata cache records it.
-type digestReader struct {
- r io.ReadCloser
- h hash.Hash
- want string
-}
-
-func (d *digestReader) Read(b []byte) (int, error) {
- n, err := d.r.Read(b)
- d.h.Write(b[:n])
- if errors.Is(err, io.EOF) && "sha256:"+hex.EncodeToString(d.h.Sum(nil)) != d.want {
- return n, errStoredMetadataChanged
- }
- return n, err
-}
-
-func (d *digestReader) Close() error { return d.r.Close() }
diff --git a/internal/handler/metadata_stream.go b/internal/handler/metadata_stream.go
deleted file mode 100644
index c165b81..0000000
--- a/internal/handler/metadata_stream.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// serveCachedMetadata answers a ProxyCached request from a fresh metadata
-// cache row by streaming the stored document, rather than reading it into
-// memory first, so concurrent hits on a large document don't each hold a copy
-// of it. The bytes are checked against the row's digest as they are sent, and
-// a mismatch aborts the response and drops the row, the way a corrupt cached
-// artifact is handled.
-//
-// It reports false, having written nothing, when the request should take the
-// buffered path instead: no fresh row, a row without a digest or size, a
-// document over the metadata size limit, or one that can't be opened.
-func (p *Proxy) serveCachedMetadata(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string) bool {
- return p.serveStoredMetadata(w, r, ecosystem, cacheKey, false)
-}
-
-// serveStoredMetadata is serveCachedMetadata. With immutable set the row is
-// served whatever its age and never marked stale, for documents that can't
-// change upstream (see proxyImmutable).
-func (p *Proxy) serveStoredMetadata(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, immutable bool) bool {
- entry := p.storedMetadataEntry(ecosystem, cacheKey, immutable)
- if entry == nil {
- return false
- }
- return p.serveMetadataEntry(w, r, ecosystem, cacheKey, entry, immutable)
-}
-
-// serveMetadataEntry is serveStoredMetadata for entry, a row
-// storedMetadataEntry or streamableMetadata accepted.
-func (p *Proxy) serveMetadataEntry(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, entry *database.MetadataCacheEntry, immutable bool) bool {
- body, err := p.openMetadataEntry(r.Context(), ecosystem, cacheKey, entry)
- if err != nil {
- return false
- }
- defer func() { _ = body.Close() }()
-
- p.recordStreamedMetadataHit(ecosystem, entry)
-
- cm := p.cachedMetaFor(entry)
- if immutable {
- cm.stale = false
- }
- if writeMetadataValidators(w, r, cm) {
- return true
- }
- contentType := contentTypeJSON
- if entry.ContentType.Valid {
- contentType = entry.ContentType.String
- }
- w.Header().Set(headerContentType, contentType)
- w.Header().Set(headerContentLength, strconv.FormatInt(entry.Size.Int64, 10))
- if entry.ContentEncoding.String != "" {
- w.Header().Set(headerContentEncoding, entry.ContentEncoding.String)
- }
- markStale(w, cm.stale)
- w.WriteHeader(http.StatusOK)
- if r.Method != http.MethodHead {
- copyArtifactBody(w, body, entry.Size.Int64, false)
- }
- return true
-}
-
-// freshMetadataEntry returns the metadata cache row for ecosystem and
-// cacheKey when its document can be streamed: the row is within its TTL and
-// records a sha256 digest and a size within the metadata size limit. It
-// returns nil otherwise.
-func (p *Proxy) freshMetadataEntry(ecosystem, cacheKey string) *database.MetadataCacheEntry {
- return p.storedMetadataEntry(ecosystem, cacheKey, false)
-}
-
-// storedMetadataEntry is freshMetadataEntry, ignoring the row's age when
-// immutable is set.
-func (p *Proxy) storedMetadataEntry(ecosystem, cacheKey string, immutable bool) *database.MetadataCacheEntry {
- if p.DB == nil || p.Storage == nil || (!immutable && p.MetadataTTL <= 0) || containsPathTraversal(cacheKey) {
- return nil
- }
- entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || entry == nil || !entry.FetchedAt.Valid {
- return nil
- }
- if !immutable && time.Since(entry.FetchedAt.Time) >= p.MetadataTTL {
- return nil
- }
- if !p.streamableMetadata(entry) {
- return nil
- }
- return entry
-}
-
-// streamableMetadata reports whether entry records a sha256 digest and a
-// size within the metadata size limit, so its document can be streamed.
-func (p *Proxy) streamableMetadata(entry *database.MetadataCacheEntry) bool {
- limit := p.MetadataMaxSize
- if limit <= 0 {
- limit = defaultMetadataMaxSize
- }
- return entry.ContentDigest.Valid && strings.HasPrefix(entry.ContentDigest.String, "sha256:") && entry.Size.Valid && entry.Size.Int64 <= limit
-}
-
-// openMetadataEntry opens the stored document of a row freshMetadataEntry
-// returned. Its bytes are checked against the row's digest as they are read:
-// a mismatch fails the read at the end and drops the row, so the next
-// request fetches the document again.
-func (p *Proxy) openMetadataEntry(ctx context.Context, ecosystem, cacheKey string, entry *database.MetadataCacheEntry) (io.ReadCloser, error) {
- checks, err := newIntegrityChecks(strings.TrimPrefix(entry.ContentDigest.String, "sha256:"), "")
- if err != nil {
- return nil, err
- }
- stored, err := p.Storage.Open(ctx, entry.StoragePath)
- if err != nil {
- return nil, err
- }
- body, err := checks.wrapFailOnMismatch(stored, func(reason string) {
- p.Logger.Error("cached metadata failed integrity check",
- "ecosystem", ecosystem, "key", cacheKey, "path", entry.StoragePath, "reason", reason)
- metrics.RecordIntegrityFailure(ecosystem, metrics.FailureDetail{
- Path: entry.StoragePath, Err: errors.New(reason)})
- if err := p.DB.DiscardMetadataCache(ecosystem, cacheKey, entry.StoragePath, entry.ContentDigest.String); err != nil {
- p.Logger.Warn("failed to clear corrupt metadata from cache", "error", err)
- }
- })
- if err != nil {
- _ = stored.Close()
- return nil, err
- }
- return body, nil
-}
-
-// recordStreamedMetadataHit counts a hit on a row served by streaming.
-func (p *Proxy) recordStreamedMetadataHit(ecosystem string, entry *database.MetadataCacheEntry) {
- p.recordMetadataRead(entry)
- metrics.RecordMetadataCacheHit(ecosystem, int(entry.Size.Int64))
-}
diff --git a/internal/handler/metadata_stream_test.go b/internal/handler/metadata_stream_test.go
deleted file mode 100644
index 702eea8..0000000
--- a/internal/handler/metadata_stream_test.go
+++ /dev/null
@@ -1,223 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "slices"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-// stallingStorage hands out cached metadata readers that return the first
-// head bytes and then block until release closes, so a test can see whether a
-// response starts before the whole document has been read.
-type stallingStorage struct {
- storage.Storage
- head int
- release chan struct{}
-}
-
-func (s *stallingStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- rc, err := s.Storage.Open(ctx, path)
- if err != nil || !strings.HasPrefix(path, "_metadata/") {
- return rc, err
- }
- return &stallingReader{ReadCloser: rc, left: s.head, release: s.release}, nil
-}
-
-type stallingReader struct {
- io.ReadCloser
- left int
- release chan struct{}
-}
-
-func (r *stallingReader) Read(p []byte) (int, error) {
- if r.left == 0 {
- <-r.release
- return r.ReadCloser.Read(p)
- }
- if len(p) > r.left {
- p = p[:r.left]
- }
- n, err := r.ReadCloser.Read(p)
- r.left -= n
- return n, err
-}
-
-// cachedMetadataServer serves doc through ProxyCached and caches it with one
-// request. upstreamHits counts the requests that reached the upstream.
-func cachedMetadataServer(t *testing.T, doc []byte) (proxy *Proxy, store *mockStorage, srv *httptest.Server, upstreamHits *atomic.Int32) {
- t.Helper()
- upstreamHits = &atomic.Int32{}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- upstreamHits.Add(1)
- w.Header().Set(headerContentType, "application/x-test")
- w.Header().Set(headerETag, `"v1"`)
- _, _ = w.Write(doc)
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, store, _ = setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- proxy.ProxyCached(w, r, upstream.URL+"/doc", "test", "doc", "*/*")
- }))
- t.Cleanup(srv.Close)
-
- resp, err := http.Get(srv.URL)
- if err != nil {
- t.Fatalf("populating cache: %v", err)
- }
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("populating cache: status %d", resp.StatusCode)
- }
- return proxy, store, srv, upstreamHits
-}
-
-func TestConcurrentMetadataCacheHitsStream(t *testing.T) {
- const callers = 10
- doc := bytes.Repeat([]byte("0123456789abcdef"), 1<<18) // 4 MB
- proxy, store, srv, _ := cachedMetadataServer(t, doc)
-
- gate := &stallingStorage{Storage: store, head: 64 << 10, release: make(chan struct{})}
- proxy.Storage = gate
- var releaseOnce sync.Once
- release := func() { releaseOnce.Do(func() { close(gate.release) }) }
- t.Cleanup(release)
-
- // Every hit has to start its response while its reader is stalled after
- // the first 64 KB. A hit that reads the document into memory first can't
- // send anything until release.
- type result struct {
- resp *http.Response
- first []byte
- err error
- }
- started := make(chan result, callers)
- for range callers {
- go func() {
- resp, err := http.Get(srv.URL)
- if err != nil {
- started <- result{err: err}
- return
- }
- first := make([]byte, 1024)
- _, err = io.ReadFull(resp.Body, first)
- started <- result{resp: resp, first: first, err: err}
- }()
- }
- results := make([]result, 0, callers)
- timeout := time.After(5 * time.Second)
- streamed := 0
- for len(results) < callers {
- select {
- case res := <-started:
- results = append(results, res)
- case <-timeout:
- // Let the stalled hits finish, so the server can shut down.
- release()
- timeout = nil
- streamed = len(results)
- }
- }
- release()
- t.Cleanup(func() {
- for _, res := range results {
- if res.resp != nil {
- _ = res.resp.Body.Close()
- }
- }
- })
- if timeout == nil {
- t.Fatalf("%d of %d concurrent cache hits started their response before the document was read to its end", streamed, callers)
- }
-
- for _, res := range results {
- if res.err != nil {
- t.Fatalf("cache hit: %v", res.err)
- }
- rest, err := io.ReadAll(res.resp.Body)
- _ = res.resp.Body.Close()
- if err != nil {
- t.Fatalf("reading cache hit: %v", err)
- }
- if body := slices.Concat(res.first, rest); !bytes.Equal(body, doc) {
- t.Fatalf("cache hit returned %d bytes, want the %d-byte document", len(body), len(doc))
- }
- if got := res.resp.Header.Get(headerContentType); got != "application/x-test" {
- t.Errorf("Content-Type = %q, want application/x-test", got)
- }
- if got := res.resp.Header.Get(headerContentLength); got != strconv.Itoa(len(doc)) {
- t.Errorf("Content-Length = %q, want %d", got, len(doc))
- }
- if got := res.resp.Header.Get(headerETag); got != `"v1"` {
- t.Errorf("ETag = %q, want %q", got, `"v1"`)
- }
- }
-
- req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
- req.Header.Set("If-None-Match", `"v1"`)
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("conditional cache hit: %v", err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusNotModified {
- t.Errorf("conditional cache hit: status %d, want 304", resp.StatusCode)
- }
-}
-
-func TestCachedMetadataHitAbortsOnDigestMismatch(t *testing.T) {
- doc := bytes.Repeat([]byte("metadata "), 1<<14)
- _, store, srv, upstreamHits := cachedMetadataServer(t, doc)
-
- corrupt := bytes.Clone(doc)
- corrupt[len(corrupt)-1] ^= 0xff
- store.mu.Lock()
- store.files[metadataStoragePath("test", "doc")] = corrupt
- store.mu.Unlock()
-
- resp, err := http.Get(srv.URL)
- if err != nil {
- t.Fatalf("cache hit: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err == nil {
- t.Fatalf("cache hit completed with %d bytes of a document that doesn't match its digest, want the response aborted", len(body))
- }
- if len(body) >= len(doc) {
- t.Errorf("aborted response still delivered %d of %d bytes", len(body), len(doc))
- }
-
- // The corrupt row is dropped, so the next request fetches the document
- // from upstream again instead of aborting until the TTL runs out.
- resp, err = http.Get(srv.URL)
- if err != nil {
- t.Fatalf("request after abort: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err = io.ReadAll(resp.Body)
- if err != nil {
- t.Fatalf("request after abort: %v", err)
- }
- if !bytes.Equal(body, doc) {
- t.Errorf("request after abort got %d bytes, want the upstream document (%d bytes)", len(body), len(doc))
- }
- if got := upstreamHits.Load(); got != 2 {
- t.Errorf("upstream requests = %d, want 2 (populate, then refetch after the corrupt hit)", got)
- }
-}
diff --git a/internal/handler/metadata_truncated_test.go b/internal/handler/metadata_truncated_test.go
deleted file mode 100644
index 5044374..0000000
--- a/internal/handler/metadata_truncated_test.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package handler
-
-import (
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// cutMetadataUpstream serves one npm packument and, when cut is set, closes
-// the connection halfway through the document it announced.
-type cutMetadataUpstream struct {
- *httptest.Server
- mu sync.Mutex
- body string
- cut bool
-}
-
-func newCutMetadataUpstream(t *testing.T, body string) *cutMetadataUpstream {
- t.Helper()
- u := &cutMetadataUpstream{body: body}
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.mu.Lock()
- body, cut := u.body, u.cut
- u.mu.Unlock()
- if r.URL.Path != "/pkg" {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Length", fmt.Sprint(len(body)))
- if cut {
- _, _ = io.WriteString(w, body[:len(body)/2])
- return
- }
- _, _ = io.WriteString(w, body)
- }))
- t.Cleanup(u.Close)
- return u
-}
-
-func (u *cutMetadataUpstream) set(body string, cut bool) {
- u.mu.Lock()
- defer u.mu.Unlock()
- u.body, u.cut = body, cut
-}
-
-// truncPackument is an npm packument with n versions published long ago.
-func truncPackument(n int) string {
- var versions, times []string
- for i := range n {
- v := fmt.Sprintf("1.0.%d", i)
- versions = append(versions, fmt.Sprintf(`%q:{"name":"pkg","version":%q,"dist":{"tarball":"https://registry.npmjs.org/pkg/-/pkg-%s.tgz"}}`, v, v, v))
- times = append(times, fmt.Sprintf(`%q:"2020-01-01T00:00:00Z"`, v))
- }
- return `{"name":"pkg","dist-tags":{"latest":"1.0.0"},"versions":{` + strings.Join(versions, ",") +
- `},"time":{` + strings.Join(times, ",") + `}}`
-}
-
-// TestMetadataTruncatedUpstream checks that a document upstream cuts off
-// before its Content-Length is neither served nor cached: the cached copy is
-// served instead, with and without cooldown.
-func TestMetadataTruncatedUpstream(t *testing.T) {
- for _, withCooldown := range []bool{false, true} {
- t.Run(map[bool]string{false: "no cooldown", true: "cooldown"}[withCooldown], func(t *testing.T) {
- u := newCutMetadataUpstream(t, truncPackument(50))
- proxy, db, _, _ := setupTestProxy(t)
- proxy.HTTPClient = u.Client()
- proxy.CacheMetadata = true
- if withCooldown {
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- }
- handler := NewNPMHandler(proxy, "http://proxy.example", u.URL).Routes()
-
- first := serve(handler, "/pkg", nil)
- if first.Code != http.StatusOK {
- t.Fatalf("first request: status %d", first.Code)
- }
- before, err := db.GetMetadataCache("npm", "pkg")
- if err != nil || before == nil {
- t.Fatalf("no metadata row after the first request: %v", err)
- }
-
- u.set(truncPackument(51), true)
- expireMetadata(t, db, "npm", "pkg")
- proxy.SetMetadataRewriteCacheSize(0)
-
- rec := serve(handler, "/pkg", nil)
- if rec.Code != http.StatusOK || rec.Body.String() != first.Body.String() {
- t.Errorf("status %d, want 200 with the cached document", rec.Code)
- }
- after, err := db.GetMetadataCache("npm", "pkg")
- if err != nil || after == nil || after.ContentDigest != before.ContentDigest {
- t.Error("the cut-off document replaced the cached copy")
- }
- })
- }
-}
-
-// TestMetadataTruncatedUpstreamUncached checks that a cut-off document with
-// nothing cached fails the request and is not cached.
-func TestMetadataTruncatedUpstreamUncached(t *testing.T) {
- u := newCutMetadataUpstream(t, truncPackument(50))
- u.set(truncPackument(50), true)
- proxy, db, _, _ := setupTestProxy(t)
- proxy.HTTPClient = u.Client()
- proxy.CacheMetadata = true
- handler := NewNPMHandler(proxy, "http://proxy.example", u.URL).Routes()
-
- if rec := serve(handler, "/pkg", nil); rec.Code != http.StatusBadGateway {
- t.Errorf("status %d, want 502", rec.Code)
- }
- if entry, _ := db.GetMetadataCache("npm", "pkg"); entry != nil {
- t.Error("cached the cut-off document")
- }
-}
diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go
deleted file mode 100644
index 340e3db..0000000
--- a/internal/handler/metadata_validators_test.go
+++ /dev/null
@@ -1,782 +0,0 @@
-package handler
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// validatorUpstream serves one npm packument and one Composer document whose
-// bodies a test can change, with a fixed Last-Modified.
-type validatorUpstream struct {
- *httptest.Server
- mu sync.Mutex
- npm string
- composer string
- lastModified time.Time
-}
-
-func newValidatorUpstream(t *testing.T) *validatorUpstream {
- t.Helper()
- u := &validatorUpstream{
- npm: `{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`,
- composer: `{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`,
- lastModified: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC),
- }
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.mu.Lock()
- defer u.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Last-Modified", u.lastModified.Format(http.TimeFormat))
- switch r.URL.Path {
- case "/left-pad":
- _, _ = w.Write([]byte(u.npm))
- case "/p2/vendor/pkg.json":
- _, _ = w.Write([]byte(u.composer))
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(u.Close)
- return u
-}
-
-func (u *validatorUpstream) set(npm, composer string) {
- u.mu.Lock()
- defer u.mu.Unlock()
- u.npm, u.composer = npm, composer
-}
-
-func (u *validatorUpstream) setLastModified(lastModified time.Time) {
- u.mu.Lock()
- defer u.mu.Unlock()
- u.lastModified = lastModified
-}
-
-type validatorCase struct {
- ecosystem, name, path string
- handler func(*Proxy, *validatorUpstream) http.Handler
-}
-
-var validatorCases = []validatorCase{
- {"npm", "left-pad", "/left-pad", func(p *Proxy, u *validatorUpstream) http.Handler {
- return NewNPMHandler(p, "http://proxy.example", u.URL).Routes()
- }},
- {"composer", "vendor/pkg", "/p2/vendor/pkg.json", func(p *Proxy, u *validatorUpstream) http.Handler {
- return NewComposerHandlerWithUpstreams(p, "http://proxy.example", u.URL, u.URL).Routes()
- }},
-}
-
-func validatorProxy(t *testing.T, u *validatorUpstream, ttl time.Duration) *Proxy {
- t.Helper()
- proxy, _, _ := validatorProxyWithDB(t, u, ttl)
- return proxy
-}
-
-// validatorProxyWithDB returns a proxy that dates metadata by a clock the
-// test moves, starting a second after the process start it models.
-func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) (*Proxy, *database.DB, *testClock) {
- t.Helper()
- proxy, db, _, _ := setupTestProxy(t)
- proxy.HTTPClient = u.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = ttl
- clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
- restartProxy(proxy, clock)
- clock.advance(time.Second)
- return proxy, db, clock
-}
-
-// restartProxy gives proxy a new, empty rewrite cache and makes it date
-// metadata by clock from the clock's current time on, as a restart of the
-// process would. What the database holds survives.
-func restartProxy(proxy *Proxy, clock *testClock) {
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- proxy.now = clock.now
- proxy.started = clock.now()
-}
-
-// validatorReplica returns a second proxy process sharing proxy's database
-// and storage, as another replica behind the same load balancer would, with
-// its own rewrite cache and its own clock.
-func validatorReplica(proxy *Proxy, clock *testClock) *Proxy {
- replica := NewProxy(proxy.DB, proxy.Storage, proxy.Fetcher, proxy.Resolver, proxy.Logger)
- replica.HTTPClient = proxy.HTTPClient
- replica.CacheMetadata = proxy.CacheMetadata
- replica.MetadataTTL = proxy.MetadataTTL
- replica.SetMetadataRewriteCacheSize(1 << 20)
- replica.now = clock.now
- replica.started = proxy.started
- return replica
-}
-
-type testClock struct {
- mu sync.Mutex
- t time.Time
-}
-
-func (c *testClock) now() time.Time {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.t
-}
-
-func (c *testClock) advance(d time.Duration) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.t = c.t.Add(d)
-}
-
-// npmV and composerV are upstream documents with a single version v.
-func npmV(v string) string {
- return `{"name":"left-pad","versions":{"` + v + `":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-` + v + `.tgz"}}}}`
-}
-
-func composerV(v string) string {
- return `{"packages":{"vendor/pkg":[{"version":"` + v + `","dist":{"type":"zip","url":"https://example.com/pkg-` + v + `.zip"}}]}}`
-}
-
-// expireMetadata ages a metadata cache row past any TTL, so the next request
-// refreshes it from upstream.
-func expireMetadata(t *testing.T, db *database.DB, ecosystem, name string) {
- t.Helper()
- entry, err := db.GetMetadataCache(ecosystem, name)
- if err != nil || entry == nil {
- t.Fatalf("metadata row %s/%s = %v, %v", ecosystem, name, entry, err)
- }
- entry.FetchedAt.Time = time.Now().Add(-48 * time.Hour)
- if err := db.UpsertMetadataCache(entry); err != nil {
- t.Fatal(err)
- }
-}
-
-func serve(handler http.Handler, path string, header http.Header) *httptest.ResponseRecorder {
- req := httptest.NewRequest(http.MethodGet, path, nil)
- for k, v := range header {
- req.Header[k] = v
- }
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, req)
- return rec
-}
-
-// TestRewrittenMetadataETag checks that rewritten metadata carries an ETag
-// that clients can revalidate with, on the first fetch, on the rewrite fast
-// path and when the TTL forces a revalidation.
-func TestRewrittenMetadataETag(t *testing.T) {
- for _, c := range validatorCases {
- for _, ttl := range []time.Duration{time.Hour, 0} {
- t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
- u := newValidatorUpstream(t)
- handler := c.handler(validatorProxy(t, u, ttl), u)
-
- first := serve(handler, c.path, nil)
- etag := first.Header().Get("ETag")
- if first.Code != http.StatusOK || etag == "" {
- t.Fatalf("first response: status %d, ETag %q; want 200 with an ETag", first.Code, etag)
- }
- for _, ifNoneMatch := range []string{etag, "W/" + etag, `"other", ` + etag} {
- expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {ifNoneMatch}}), etag)
- }
- if rec := serve(handler, c.path, nil); rec.Header().Get("ETag") != etag || rec.Body.String() != first.Body.String() {
- t.Errorf("repeat response: ETag %q, want %q, and the same body", rec.Header().Get("ETag"), etag)
- }
- })
- }
- }
-}
-
-func expectNotModified(t *testing.T, rec *httptest.ResponseRecorder, etag string) {
- t.Helper()
- if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
- t.Errorf("status %d with %d bytes, want an empty 304", rec.Code, rec.Body.Len())
- }
- if got := rec.Header().Get("ETag"); got != etag {
- t.Errorf("304 ETag = %q, want %q", got, etag)
- }
-}
-
-// TestRewrittenMetadataETagFollowsUpstream checks that a new upstream
-// document gets a new ETag, so a client's old copy no longer matches.
-func TestRewrittenMetadataETagFollowsUpstream(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- handler := c.handler(validatorProxy(t, u, 0), u)
- etag := serve(handler, c.path, nil).Header().Get("ETag")
-
- u.set(
- `{"name":"left-pad","versions":{"1.3.1":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.1.tgz"}}}}`,
- `{"packages":{"vendor/pkg":[{"version":"1.0.1","dist":{"type":"zip","url":"https://example.com/pkg-1.0.1.zip"}}]}}`,
- )
- rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}})
- if rec.Code != http.StatusOK || rec.Header().Get("ETag") == etag {
- t.Errorf("status %d, ETag %q; want 200 with a new ETag", rec.Code, rec.Header().Get("ETag"))
- }
- })
- }
-}
-
-// TestRewrittenMetadataLastModified checks the validator Composer revalidates
-// with. It is when this process started serving the rewrite, not upstream's
-// date: the rewrite also depends on configuration read at startup, so the
-// same upstream document can be rewritten differently after a restart. It
-// stays the same while the rewrite does.
-func TestRewrittenMetadataLastModified(t *testing.T) {
- for _, c := range validatorCases {
- for _, ttl := range []time.Duration{time.Hour, 0} {
- t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, _, clock := validatorProxyWithDB(t, u, ttl)
- handler := c.handler(proxy, u)
-
- clock.advance(400 * time.Millisecond)
- first := serve(handler, c.path, nil)
- lastModified, err := http.ParseTime(first.Header().Get("Last-Modified"))
- if err != nil {
- t.Fatalf("Last-Modified %q: %v", first.Header().Get("Last-Modified"), err)
- }
- if want := clock.now().Truncate(time.Second); !lastModified.Equal(want) {
- t.Errorf("Last-Modified %v, want %v, the second of the rewrite", lastModified, want)
- }
- clock.advance(time.Hour)
-
- current := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}})
- if current.Code != http.StatusNotModified || current.Body.Len() != 0 {
- t.Errorf("If-Modified-Since Last-Modified: status %d with %d bytes, want an empty 304", current.Code, current.Body.Len())
- }
-
- older := lastModified.Add(-time.Hour).Format(http.TimeFormat)
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {older}}); rec.Code != http.StatusOK {
- t.Errorf("If-Modified-Since an hour earlier: status %d, want 200", rec.Code)
- }
-
- // If-None-Match takes precedence over If-Modified-Since.
- rec := serve(handler, c.path, http.Header{
- "If-None-Match": {`"other"`},
- "If-Modified-Since": {first.Header().Get("Last-Modified")},
- })
- if rec.Code != http.StatusOK {
- t.Errorf("mismatched If-None-Match with a current If-Modified-Since: status %d, want 200", rec.Code)
- }
- })
- }
- }
-}
-
-// TestRewrittenMetadataLastModifiedFollowsRefresh is a regression test for a
-// false 304. Upstream dates both documents before this process started, the
-// cached one and the newer one a refresh fetches, and neither may give the
-// newer document a Last-Modified a client holding the older one already has:
-// If-Modified-Since from the first response must fetch the new body.
-func TestRewrittenMetadataLastModifiedFollowsRefresh(t *testing.T) {
- for _, c := range validatorCases {
- for _, ttl := range []time.Duration{time.Hour, 0} {
- t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, db, clock := validatorProxyWithDB(t, u, ttl)
- handler := c.handler(proxy, u)
-
- first := serve(handler, c.path, nil)
- firstModified := first.Header().Get("Last-Modified")
- if first.Code != http.StatusOK || firstModified == "" {
- t.Fatalf("first response: status %d, Last-Modified %q; want 200 with a Last-Modified", first.Code, firstModified)
- }
-
- clock.advance(time.Minute)
- u.set(npmV("1.3.1"), composerV("1.0.1"))
- u.setLastModified(u.lastModified.Add(24 * time.Hour))
- expireMetadata(t, db, c.ecosystem, c.name)
-
- rec := serve(handler, c.path, http.Header{"If-Modified-Since": {firstModified}})
- if rec.Code != http.StatusOK || rec.Body.String() == first.Body.String() {
- t.Fatalf("If-Modified-Since from before the refresh: status %d, body changed %t; want 200 with the new body",
- rec.Code, rec.Body.String() != first.Body.String())
- }
- was, _ := http.ParseTime(firstModified)
- now, err := http.ParseTime(rec.Header().Get("Last-Modified"))
- if err != nil || !now.After(was) {
- t.Errorf("Last-Modified after the refresh = %q, want later than %q", rec.Header().Get("Last-Modified"), firstModified)
- }
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {rec.Header().Get("Last-Modified")}}); rec.Code != http.StatusNotModified {
- t.Errorf("If-Modified-Since the new Last-Modified: status %d, want 304", rec.Code)
- }
- })
- }
- }
-}
-
-// TestRewrittenMetadataLastModifiedReturningDocument is a regression test
-// for a false 304. Upstream serves A, then B, then A again; A's cached
-// rewrite must not bring back the date it was first served with, which is
-// earlier than B's, or a client holding B gets 304 for A.
-func TestRewrittenMetadataLastModifiedReturningDocument(t *testing.T) {
- for _, c := range validatorCases {
- for _, ttl := range []time.Duration{time.Hour, 0} {
- t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, db, clock := validatorProxyWithDB(t, u, ttl)
- handler := c.handler(proxy, u)
- change := func(v string) {
- clock.advance(10 * time.Second)
- u.set(npmV(v), composerV(v))
- if ttl > 0 {
- expireMetadata(t, db, c.ecosystem, c.name)
- }
- }
-
- u.set(npmV("1.0.0"), composerV("1.0.0"))
- a := serve(handler, c.path, nil)
- change("2.0.0")
- b := serve(handler, c.path, nil)
- if b.Body.String() == a.Body.String() || b.Header().Get("Last-Modified") == "" {
- t.Fatalf("B: Last-Modified %q, body changed %t; want a new body with a date", b.Header().Get("Last-Modified"), b.Body.String() != a.Body.String())
- }
- change("1.0.0")
-
- rec := serve(handler, c.path, http.Header{"If-Modified-Since": {b.Header().Get("Last-Modified")}})
- if rec.Code != http.StatusOK || rec.Body.String() != a.Body.String() {
- t.Fatalf("A again, If-Modified-Since B's date: status %d, body is A %t; want 200 with A", rec.Code, rec.Body.String() == a.Body.String())
- }
- was, _ := http.ParseTime(b.Header().Get("Last-Modified"))
- if now, err := http.ParseTime(rec.Header().Get("Last-Modified")); err != nil || !now.After(was) {
- t.Errorf("A again: Last-Modified %q, want later than B's %q", rec.Header().Get("Last-Modified"), b.Header().Get("Last-Modified"))
- }
- })
- }
- }
-}
-
-// TestRewrittenMetadataLastModifiedSameSecond checks that a document that
-// replaces another within the second the other was dated gets no date of
-// its own until a later second, rather than one ahead of the clock. The
-// clock-time date it would share is one a client may already hold.
-func TestRewrittenMetadataLastModifiedSameSecond(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, _, clock := validatorProxyWithDB(t, u, 0)
- handler := c.handler(proxy, u)
-
- a := serve(handler, c.path, nil)
- aModified := a.Header().Get("Last-Modified")
- u.set(npmV("2.0.0"), composerV("2.0.0"))
- for i, header := range []http.Header{nil, {"If-Modified-Since": {aModified}}} {
- rec := serve(handler, c.path, header)
- if rec.Code != http.StatusOK || rec.Body.String() == a.Body.String() {
- t.Fatalf("request %d for B: status %d; want 200 with B", i, rec.Code)
- }
- if lm := rec.Header().Get("Last-Modified"); lm != "" {
- t.Errorf("request %d for B in A's second: Last-Modified %q, want none", i, lm)
- }
- }
-
- clock.advance(time.Second)
- rec := serve(handler, c.path, nil)
- lastModified, err := http.ParseTime(rec.Header().Get("Last-Modified"))
- if want := clock.now().Truncate(time.Second); err != nil || !lastModified.Equal(want) {
- t.Errorf("B a second later: Last-Modified %q, want %v", rec.Header().Get("Last-Modified"), want)
- }
- })
- }
-}
-
-// TestRewrittenMetadataLastModifiedAcrossRestart is a regression test for a
-// false 304 after a restart. Dates are never ahead of the clock, and the
-// restarted process dates nothing in the second it starts, the last in which
-// the previous process may have dated a document.
-func TestRewrittenMetadataLastModifiedAcrossRestart(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, _, clock := validatorProxyWithDB(t, u, 0)
- handler := c.handler(proxy, u)
-
- // Two documents in one second; the client keeps the latest date.
- latest := serve(handler, c.path, nil).Header().Get("Last-Modified")
- u.set(npmV("2.0.0"), composerV("2.0.0"))
- if lm := serve(handler, c.path, nil).Header().Get("Last-Modified"); lm != "" {
- latest = lm
- }
- if held, _ := http.ParseTime(latest); held.After(clock.now()) {
- t.Errorf("Last-Modified %q is ahead of the clock at %v", latest, clock.now())
- }
-
- restartProxy(proxy, clock)
- u.set(npmV("3.0.0"), composerV("3.0.0"))
- rec := serve(handler, c.path, http.Header{"If-Modified-Since": {latest}})
- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "3.0.0") {
- t.Fatalf("changed document after a restart, If-Modified-Since %q: status %d; want 200 with it", latest, rec.Code)
- }
- if lm := rec.Header().Get("Last-Modified"); lm != "" {
- t.Errorf("Last-Modified %q in the second the process started, want none", lm)
- }
-
- clock.advance(time.Second)
- rec = serve(handler, c.path, http.Header{"If-Modified-Since": {latest}})
- if rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") == "" {
- t.Errorf("a second after the restart: status %d, Last-Modified %q; want 200 with a date", rec.Code, rec.Header().Get("Last-Modified"))
- }
- })
- }
-}
-
-// TestRewrittenMetadataValidatorsWithCooldown checks that with cooldown on,
-// rewritten metadata from the rewrite cache has an ETag and a Last-Modified,
-// so clients that only send If-Modified-Since, such as Composer, get 304.
-func TestRewrittenMetadataValidatorsWithCooldown(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy := validatorProxy(t, u, time.Hour)
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- handler := c.handler(proxy, u)
-
- first := serve(handler, c.path, nil)
- etag := first.Header().Get("ETag")
- if etag == "" {
- t.Fatal("no ETag with cooldown on")
- }
- lm := first.Header().Get("Last-Modified")
- if lm == "" {
- t.Fatal("no Last-Modified with cooldown on")
- }
- if rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}}); rec.Code != http.StatusNotModified {
- t.Errorf("If-None-Match: status %d, want 304", rec.Code)
- }
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {lm}}); rec.Code != http.StatusNotModified {
- t.Errorf("If-Modified-Since: status %d, want 304", rec.Code)
- }
- })
- }
-}
-
-// TestRewrittenMetadataWithCooldownIsGzipped checks that with cooldown on,
-// clients accepting gzip get the cached compressed copy.
-func TestRewrittenMetadataWithCooldownIsGzipped(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- compressibleDocuments(u)
- proxy := validatorProxy(t, u, time.Hour)
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- handler := c.handler(proxy, u)
- for range 2 {
- if rec := serve(handler, c.path, http.Header{"Accept-Encoding": {"gzip"}}); rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "gzip" {
- t.Errorf("status %d, Content-Encoding %q; want 200 gzip", rec.Code, rec.Header().Get("Content-Encoding"))
- }
- }
- })
- }
-}
-
-// TestUnrewrittenMetadataHasNoValidators checks that metadata proxied as it
-// is, because rewriting it failed, does not claim the rewrite's validators.
-func TestUnrewrittenMetadataHasNoValidators(t *testing.T) {
- u := newValidatorUpstream(t)
- u.set(`{"name":"left-pad","versions":`, `{"packages":`)
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- rec := serve(c.handler(validatorProxy(t, u, time.Hour), u), c.path, nil)
- if rec.Code != http.StatusOK {
- t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
- }
- if etag, lm := rec.Header().Get("ETag"), rec.Header().Get("Last-Modified"); etag != "" || lm != "" {
- t.Errorf("ETag %q, Last-Modified %q; want neither on unrewritten metadata", etag, lm)
- }
- })
- }
-}
-
-func TestRewriteCacheETag(t *testing.T) {
- c := newRewriteCache(1 << 20)
- exclaim := func(b []byte) ([]byte, error) { return []byte(string(b) + "!"), nil }
- key := rewriteCacheKey("npm", "http://proxy", "demo", []byte("doc"))
-
- m, err := c.rewrite(t.Context(), key, []byte("doc"), nil, exclaim)
- if err != nil || string(m.content()) != "doc!" {
- t.Fatalf("rewrite = %q, %v", m.content(), err)
- }
- if want := metadataETag([]byte("doc!")); m.etag != want {
- t.Errorf("rewrite ETag = %q, want %q", m.etag, want)
- }
- if cached, ok := c.get(key); !ok || cached.metadataValidators != m.metadataValidators {
- t.Errorf("get validators = %+v, %v; want %+v", cached.metadataValidators, ok, m.metadataValidators)
- }
- if metadataETag([]byte("other")) == m.etag {
- t.Error("different output has the same ETag")
- }
-}
-
-// TestRewrittenMetadataLastModifiedWithoutRewriteCache checks that dates do
-// not depend on the rewrite cache: the database records them.
-func TestRewrittenMetadataLastModifiedWithoutRewriteCache(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- proxy, _, clock := validatorProxyWithDB(t, u, time.Hour)
- proxy.SetMetadataRewriteCacheSize(0)
- handler := c.handler(proxy, u)
-
- first := serve(handler, c.path, nil)
- if first.Code != http.StatusOK || first.Header().Get("ETag") == "" {
- t.Fatalf("status %d, ETag %q; want 200 with an ETag", first.Code, first.Header().Get("ETag"))
- }
- lastModified, err := http.ParseTime(first.Header().Get("Last-Modified"))
- if want := clock.now().Truncate(time.Second); err != nil || !lastModified.Equal(want) {
- t.Errorf("Last-Modified %q without the rewrite cache, want %v", first.Header().Get("Last-Modified"), want)
- }
- clock.advance(time.Minute)
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}}); rec.Code != http.StatusNotModified {
- t.Errorf("If-Modified-Since Last-Modified: status %d, want 304", rec.Code)
- }
- })
- }
-}
-
-// TestRewrittenMetadataLastModifiedAcrossReplicas is a regression test for a
-// false 304 between replicas sharing a database and storage. One replica
-// dates a document, upstream changes in that second, and a client holding
-// the date asks another replica: the date must not suppress the new body.
-// Once a second has passed, both replicas date the new document alike.
-func TestRewrittenMetadataLastModifiedAcrossReplicas(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- a, _, clock := validatorProxyWithDB(t, u, 0)
- b := validatorReplica(a, clock)
- replicas := map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)}
-
- first := serve(replicas["A"], c.path, nil)
- held := first.Header().Get("Last-Modified")
- if first.Code != http.StatusOK || held == "" {
- t.Fatalf("replica A: status %d, Last-Modified %q; want 200 with a date", first.Code, held)
- }
- u.set(npmV("2.0.0"), composerV("2.0.0"))
- for _, name := range []string{"B", "A"} {
- expectChangedUndated(t, name, serve(replicas[name], c.path, http.Header{"If-Modified-Since": {held}}), first)
- }
-
- clock.advance(time.Second)
- shared := expectSharedDate(t, replicas, c.path, held)
- for name, handler := range replicas {
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {shared}}); rec.Code != http.StatusNotModified {
- t.Errorf("replica %s, If-Modified-Since the shared date: status %d, want 304", name, rec.Code)
- }
- if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}}); rec.Code != http.StatusOK {
- t.Errorf("replica %s, If-Modified-Since the old date: status %d, want 200", name, rec.Code)
- }
- }
- })
- }
-}
-
-// expectChangedUndated checks that a replica answered a conditional request
-// with a body and ETag other than first's, and no date: the second first was
-// dated in may not date another representation.
-func expectChangedUndated(t *testing.T, replica string, rec *httptest.ResponseRecorder, first *httptest.ResponseRecorder) {
- t.Helper()
- if rec.Code != http.StatusOK || rec.Body.String() == first.Body.String() {
- t.Fatalf("replica %s after upstream changed: status %d; want 200 with the new body", replica, rec.Code)
- }
- if rec.Header().Get("ETag") == first.Header().Get("ETag") {
- t.Errorf("replica %s: the new body has the old ETag", replica)
- }
- if lm := rec.Header().Get("Last-Modified"); lm != "" {
- t.Errorf("replica %s: Last-Modified %q in the second the old body was dated, want none", replica, lm)
- }
-}
-
-// expectSharedDate checks that every replica serves the same Last-Modified,
-// later than held, and returns it.
-func expectSharedDate(t *testing.T, replicas map[string]http.Handler, path, held string) string {
- t.Helper()
- was, _ := http.ParseTime(held)
- shared := ""
- for name, handler := range replicas {
- lm := serve(handler, path, nil).Header().Get("Last-Modified")
- if now, err := http.ParseTime(lm); err != nil || !now.After(was) {
- t.Fatalf("replica %s: Last-Modified %q, want later than %q", name, lm, held)
- }
- if shared != "" && lm != shared {
- t.Fatalf("replica %s: Last-Modified %q, another replica serves %q", name, lm, shared)
- }
- shared = lm
- }
- return shared
-}
-
-// TestRewrittenMetadataLastModifiedFromBeforeTheFix checks that dates clients
-// hold from before dates were shared cannot suppress a body. Those were never
-// later than the clock of the process that gave them, which stopped before
-// this one started, so no date is given in the second this process started
-// and every later one is later than any of them.
-func TestRewrittenMetadataLastModifiedFromBeforeTheFix(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- a, _, clock := validatorProxyWithDB(t, u, 0)
- restartProxy(a, clock)
- b := validatorReplica(a, clock)
- held := clock.now().Truncate(time.Second).Format(http.TimeFormat)
-
- for name, handler := range map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)} {
- rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}})
- if rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") != "" {
- t.Errorf("replica %s in its first second, If-Modified-Since %q: status %d, Last-Modified %q; want 200 without a date",
- name, held, rec.Code, rec.Header().Get("Last-Modified"))
- }
- }
-
- clock.advance(time.Second)
- for name, handler := range map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)} {
- rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}})
- was, _ := http.ParseTime(held)
- now, err := http.ParseTime(rec.Header().Get("Last-Modified"))
- if rec.Code != http.StatusOK || err != nil || !now.After(was) {
- t.Errorf("replica %s a second later, If-Modified-Since %q: status %d, Last-Modified %q; want 200 with a later date",
- name, held, rec.Code, rec.Header().Get("Last-Modified"))
- }
- }
- })
- }
-}
-
-// TestRewrittenMetadataLastModifiedWithClockSkew checks that a replica whose
-// clock is behind gives no date ahead of its own clock, neither for a new
-// document nor for one another replica dated.
-func TestRewrittenMetadataLastModifiedWithClockSkew(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := newValidatorUpstream(t)
- a, _, clock := validatorProxyWithDB(t, u, 0)
- behind := &testClock{t: clock.now().Add(-2 * time.Second)}
- b := validatorReplica(a, behind)
- handlerA, handlerB := c.handler(a, u), c.handler(b, u)
-
- held := serve(handlerA, c.path, nil).Header().Get("Last-Modified")
- u.set(npmV("2.0.0"), composerV("2.0.0"))
- if rec := serve(handlerB, c.path, http.Header{"If-Modified-Since": {held}}); rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") != "" {
- t.Fatalf("lagging replica, new document: status %d, Last-Modified %q; want 200 without a date", rec.Code, rec.Header().Get("Last-Modified"))
- }
-
- clock.advance(time.Second)
- dated := serve(handlerA, c.path, nil).Header().Get("Last-Modified")
- if dated == "" {
- t.Fatal("replica A gave no date a second later")
- }
- if lm := serve(handlerB, c.path, nil).Header().Get("Last-Modified"); lm != "" {
- t.Errorf("lagging replica: Last-Modified %q ahead of its clock at %v, want none", lm, behind.now())
- }
- behind.advance(3 * time.Second)
- if lm := serve(handlerB, c.path, nil).Header().Get("Last-Modified"); lm != dated {
- t.Errorf("lagging replica once its clock caught up: Last-Modified %q, want %q", lm, dated)
- }
- })
- }
-}
-
-// cooldownUpstream sets documents with one version published long ago and
-// one published two hours ago, so a cooldown of a day hides the recent one
-// and a cooldown of an hour keeps it.
-func cooldownUpstream(t *testing.T) *validatorUpstream {
- t.Helper()
- recent := time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)
- u := newValidatorUpstream(t)
- u.set(
- `{"name":"left-pad","dist-tags":{"latest":"1.4.0"},"versions":{`+
- `"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}},`+
- `"1.4.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.4.0.tgz"}}},`+
- `"time":{"1.3.0":"2016-01-01T00:00:00Z","1.4.0":"`+recent+`"}}`,
- `{"packages":{"vendor/pkg":[`+
- `{"version":"1.0.0","time":"2016-01-01T00:00:00Z","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}},`+
- `{"version":"1.1.0","time":"`+recent+`","dist":{"type":"zip","url":"https://example.com/pkg-1.1.0.zip"}}]}}`,
- )
- return u
-}
-
-// TestRewrittenMetadataETagWithCooldownFollowsKeptVersions checks that with
-// cooldown on, the ETag changes when a version leaves cooldown although the
-// upstream document stays the same, once the cached rewrite has expired, and
-// comes back when the same versions do.
-func TestRewrittenMetadataETagWithCooldownFollowsKeptVersions(t *testing.T) {
- for _, c := range validatorCases {
- t.Run(c.ecosystem, func(t *testing.T) {
- u := cooldownUpstream(t)
- proxy, _, clock := validatorProxyWithDB(t, u, time.Hour)
- handler := c.handler(proxy, u)
-
- proxy.Cooldown = &cooldown.Config{Default: "1d"}
- etag := serve(handler, c.path, nil).Header().Get("ETag")
-
- proxy.Cooldown = &cooldown.Config{Default: "1h"}
- if rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}}); rec.Code != http.StatusNotModified {
- t.Errorf("within the rewrite's lifetime: status %d, want 304 from the cached rewrite", rec.Code)
- }
- clock.advance(maxCooldownRewriteTTL)
- rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}})
- if rec.Code != http.StatusOK || rec.Header().Get("ETag") == etag {
- t.Errorf("after a version left cooldown: status %d, ETag %q; want 200 with a new ETag", rec.Code, rec.Header().Get("ETag"))
- }
-
- proxy.Cooldown = &cooldown.Config{Default: "1d"}
- clock.advance(maxCooldownRewriteTTL)
- expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {etag}}), etag)
- })
- }
-}
-
-// TestRewrittenMetadataETagWithCooldownComesFromInputs checks that with
-// cooldown on and no rewrite cache, the ETag is derived from what fixes the
-// rewrite rather than from the rewritten body, when the metadata cache knows
-// the upstream digest, and falls back to the body when it does not.
-func TestRewrittenMetadataETagWithCooldownComesFromInputs(t *testing.T) {
- for _, c := range validatorCases {
- for _, cacheMetadata := range []bool{true, false} {
- t.Run(fmt.Sprintf("%s/cacheMetadata=%t", c.ecosystem, cacheMetadata), func(t *testing.T) {
- u := cooldownUpstream(t)
- proxy := validatorProxy(t, u, time.Hour)
- proxy.CacheMetadata = cacheMetadata
- proxy.SetMetadataRewriteCacheSize(0)
- proxy.Cooldown = &cooldown.Config{Default: "1d"}
- handler := c.handler(proxy, u)
-
- rec := serve(handler, c.path, nil)
- bodyETag := metadataETag(rec.Body.Bytes())
- if got := rec.Header().Get("ETag"); (got == bodyETag) == cacheMetadata {
- t.Errorf("ETag %q, body hash %q; want them to differ only with a known digest", got, bodyETag)
- }
- etag := rec.Header().Get("ETag")
- expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {etag}}), etag)
- })
- }
- }
-}
-
-func TestKeptVersionsETag(t *testing.T) {
- base := keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0", "1.1.0"})
- if got := keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.1.0", "1.0.0"}); got != base {
- t.Errorf("kept versions in another order: ETag %q, want %q", got, base)
- }
- for name, etag := range map[string]string{
- "digest": keptVersionsETag("sha256:abd", "http://proxy", "left-pad", []string{"1.0.0", "1.1.0"}),
- "proxy URL": keptVersionsETag("sha256:abc", "http://other", "left-pad", []string{"1.0.0", "1.1.0"}),
- "name": keptVersionsETag("sha256:abc", "http://proxy", "right-pad", []string{"1.0.0", "1.1.0"}),
- "kept versions": keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0"}),
- "field bounds": keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0\x001.1.0"}),
- } {
- if etag == base {
- t.Errorf("a different %s gives the same ETag", name)
- }
- }
- if !strings.HasPrefix(base, `"`) || !strings.HasSuffix(base, `"`) {
- t.Errorf("ETag %q is not a quoted strong validator", base)
- }
-}
diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go
deleted file mode 100644
index cc61928..0000000
--- a/internal/handler/notfound_ecosystems_test.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package handler
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) {
- tests := []struct {
- name string
- path string
- handler func(p *Proxy) http.Handler
- }{
- {"debian", "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb",
- func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost", "", nil).Routes() }},
- {"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm",
- func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }},
- {"apk", "/alpine/v3.22/main/x86_64/busybox-1.37.0-r12.apk",
- func(p *Proxy) http.Handler { return NewAPKHandler(p, "http://localhost", nil).Routes() }},
- {"arch", "/archlinux/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst",
- func(p *Proxy) http.Handler { return NewArchHandler(p, "http://localhost", nil).Routes() }},
- {"freebsd", "/freebsd/FreeBSD:14:amd64/latest/All/Hashed/zsh-5.9_5~2$3rwh1rgp.pkg",
- func(p *Proxy) http.Handler { return NewFreeBSDHandler(p, "http://localhost", nil).Routes() }},
- {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg",
- func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }},
- {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl",
- func(p *Proxy) http.Handler {
- return NewPyPIHandlerWithUpstreams(p, "http://localhost", "", "").Routes()
- }},
- {"cran", "/src/contrib/ggplot2_3.4.4.tar.gz",
- func(p *Proxy) http.Handler { return NewCRANHandler(p, "http://localhost").Routes() }},
- {"conda", "/conda-forge/linux-64/numpy-1.26.0-py311_0.tar.bz2",
- func(p *Proxy) http.Handler { return NewCondaHandler(p, "http://localhost").Routes() }},
- {"conan", "/v2/conans/zlib/1.3.1/_/_/revisions/0/files/conan_sources.tgz",
- func(p *Proxy) http.Handler { return NewConanHandler(p, "http://localhost").Routes() }},
- {"gem", "/gems/rails-7.1.0.gem",
- func(p *Proxy) http.Handler { return NewGemHandler(p, "http://localhost").Routes() }},
- {"hex", "/tarballs/phoenix-1.7.10.tar",
- func(p *Proxy) http.Handler { return NewHexHandler(p, "http://localhost").Routes() }},
- {"chef", "/api/v1/cookbooks/apt/versions/7.5.22/download",
- func(p *Proxy) http.Handler { return NewChefHandler(p, "http://localhost", "http://localhost").Routes() }},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- srv := httptest.NewServer(tt.handler(proxy))
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + tt.path)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
- }
- })
- }
-}
-
-func TestJuliaPackageUpstreamNotFoundReturns404(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- dead := httptest.NewServer(http.NotFoundHandler())
- defer dead.Close()
-
- h := NewJuliaHandler(proxy, "http://localhost")
- h.upstreamURL = dead.URL
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL +
- "/package/7876af07-990d-54b4-ab0e-23690620f79a/0123456789abcdef0123456789abcdef01234567")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("want 404 for missing upstream package, got %d", resp.StatusCode)
- }
-}
-
-func TestComposerDownloadUpstreamNotFoundReturns404(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- meta := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/p2/monolog/monolog.json" {
- _, _ = w.Write([]byte(`{
- "packages": {
- "monolog/monolog": [
- {"version": "2.9.1", "dist": {"url": "https://example.com/monolog-2.9.1.zip", "type": "zip"}}
- ]
- }
- }`))
- return
- }
- http.NotFound(w, r)
- }))
- defer meta.Close()
-
- h := &ComposerHandler{proxy: proxy, repoURL: meta.URL, proxyURL: "http://localhost"}
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/files/monolog/monolog/2.9.1/monolog-2.9.1.zip")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("want 404 for missing upstream dist, got %d", resp.StatusCode)
- }
-}
-
-func TestContainerBlobUpstreamNotFoundReturns404(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- h := &ContainerHandler{
- proxy: proxy,
- registryURL: "https://registry-1.docker.io",
- proxyURL: "http://localhost:8080",
- }
-
- req := httptest.NewRequest(http.MethodGet,
- "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("want 404 for missing upstream blob, got %d; body: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "BLOB_UNKNOWN") {
- t.Errorf("want BLOB_UNKNOWN error code in body, got: %s", w.Body.String())
- }
-}
diff --git a/internal/handler/notfound_test.go b/internal/handler/notfound_test.go
deleted file mode 100644
index 9ea38ac..0000000
--- a/internal/handler/notfound_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestErrUpstreamNotFoundWrapsFetchErrNotFound(t *testing.T) {
- if !errors.Is(ErrUpstreamNotFound, fetch.ErrNotFound) {
- t.Fatal("ErrUpstreamNotFound does not wrap fetch.ErrNotFound")
- }
-}
-
-func TestGetOrFetchArtifactFromURL_NotFound(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- _, err := proxy.GetOrFetchArtifactFromURL(context.Background(),
- "maven", "org.example:missing", "1.0", "missing-1.0.jar",
- "http://upstream.test/org/example/missing/1.0/missing-1.0.jar")
-
- if !errors.Is(err, ErrUpstreamNotFound) {
- t.Fatalf("want ErrUpstreamNotFound, got %v", err)
- }
-}
-
-func TestMavenHandler_UpstreamNotFoundReturns404(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = fetch.ErrNotFound
-
- h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/org/example/missing/1.0/missing-1.0.jar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
- }
-}
-
-func TestMavenHandler_PluginPortalFallback(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErrByURL = map[string]error{
- "http://upstream.test/org/example/plugin/1.0/plugin-1.0.jar": fetch.ErrNotFound,
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("portal artifact")),
- ContentType: "application/java-archive",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/org/example/plugin/1.0/plugin-1.0.jar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("want 200 via plugin portal fallback, got %d", resp.StatusCode)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "portal artifact" {
- t.Errorf("want portal artifact body, got %q", body)
- }
- if fetcher.fetchedURL != "http://portal.test/org/example/plugin/1.0/plugin-1.0.jar" {
- t.Errorf("fallback did not hit plugin portal, last URL: %s", fetcher.fetchedURL)
- }
-}
diff --git a/internal/handler/npm.go b/internal/handler/npm.go
index 95576b5..b2625f1 100644
--- a/internal/handler/npm.go
+++ b/internal/handler/npm.go
@@ -1,136 +1,43 @@
package handler
import (
- "bytes"
- "context"
- "errors"
+ "encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
- "sync"
- "time"
-
- "github.com/git-pkgs/integrity"
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/jsonscan"
- "github.com/git-pkgs/proxy/internal/metadata/npm"
- "github.com/git-pkgs/proxy/internal/retention"
- "github.com/git-pkgs/proxy/internal/storage"
)
const (
- npmUpstream = "https://registry.npmjs.org"
- npmAcceptDefault = "application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.8"
- scopedParts = 2 // scope + name in scoped packages
-
- // npmSecurityPrefix covers the audit endpoints: /-/npm/v1/security/audits,
- // .../audits/quick and .../advisories/bulk.
- npmSecurityPrefix = "/-/npm/v1/security/"
-
- // npmKeysPath serves the registry signing keys `npm audit signatures` reads.
- npmKeysPath = "/-/npm/v1/keys"
-
- // npmAttestationsPrefix serves a version's provenance and publish
- // attestations, as /-/npm/v1/attestations/{name}@{version}.
- npmAttestationsPrefix = "/-/npm/v1/attestations/"
-
- // npmSearchPath is what `npm search` queries, with the terms in the query
- // string. npmPingPath is what `npm ping` checks.
- npmSearchPath = "/-/v1/search"
- npmPingPath = "/-/ping"
-
- // npmSecurityMaxBody caps the audit payload. Buffering it lets an oversized
- // body be refused before the upstream request starts.
- npmSecurityMaxBody = 16 << 20
+ npmUpstream = "https://registry.npmjs.org"
)
-// npm tarballs are stored under pkg:npm/{name} as purl builds it, so package
-// rules use the default key mapping.
-func init() {
- retention.Default.Register(retention.Spec{Key: "npm", CanonicalPackage: retention.DefaultCanonical("npm")})
-}
-
// NPMHandler handles npm registry protocol requests.
type NPMHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string // URL where this proxy is hosted
- routes packageRoutes
}
// NewNPMHandler creates a new npm protocol handler.
-func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler {
- if strings.TrimSpace(upstreamURL) == "" {
- upstreamURL = npmUpstream
- }
-
+func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler {
return &NPMHandler{
proxy: proxy,
- upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
+ upstreamURL: npmUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
-// WithPackageRoutes sends packages matching a pattern to a dedicated npm
-// registry instead of the default one. See packageRoutes.
-func (h *NPMHandler) WithPackageRoutes(routes map[string]string) *NPMHandler {
- h.routes = newPackageRoutes(routes)
- return h
-}
-
// Routes returns the HTTP handler for npm requests.
// Mount this at /npm on your router.
func (h *NPMHandler) Routes() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // The /-/npm/v1 endpoints share the /-/ prefix with tarball paths, so
- // they are routed before the tarball dispatch below. Audits are POSTs,
- // so they also precede the GET-only gate.
- if strings.HasPrefix(r.URL.Path, npmSecurityPrefix) {
- h.handleSecurity(w, r)
- return
- }
-
- // pnpm 12 checks packuments with HEAD before fetching them, so
- // packuments also answer HEAD; net/http leaves out the body.
- if r.Method == http.MethodHead && isPackumentPath(r.URL.Path) {
- h.handlePackageMetadata(w, r)
- return
- }
-
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if strings.HasPrefix(r.URL.Path, npmAttestationsPrefix) {
- h.handleAttestations(w, r)
- return
- }
-
- switch r.URL.Path {
- case npmKeysPath:
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+npmKeysPath,
- []string{headerAccept, headerAcceptEncoding})
- return
- case npmSearchPath, npmPingPath:
- // Every query has its own answer, so these are relayed uncached
- // and keep their query string.
- upstreamURL := h.upstreamURL + r.URL.Path
- if r.URL.RawQuery != "" {
- upstreamURL += "?" + r.URL.RawQuery
- }
- h.proxy.ProxyUpstream(w, r, upstreamURL,
- []string{headerAccept, headerAcceptEncoding})
- return
- }
-
- if strings.HasPrefix(r.URL.Path, npmPackagePrefix) && strings.HasSuffix(r.URL.Path, npmDistTagsSuffix) {
- h.handleDistTags(w, r)
- return
- }
-
path := strings.TrimPrefix(r.URL.Path, "/")
// Check if this is a tarball download (contains /-/)
@@ -144,613 +51,108 @@ func (h *NPMHandler) Routes() http.Handler {
})
}
-// isPackumentPath reports whether a request path names package metadata
-// rather than a tarball or a registry endpoint under /-/.
-func isPackumentPath(urlPath string) bool {
- return !strings.HasPrefix(urlPath, "/-/") && !strings.Contains(strings.TrimPrefix(urlPath, "/"), "/-/")
-}
-
-// handleSecurity relays the npm audit endpoints to upstream. The request body
-// is the dependency tree being audited, so no two requests share a cache key,
-// and the response carries no tarball URLs to rewrite.
-//
-// Advisories come from upstream's database, not the proxy's own vulnerability
-// data, and versions withheld by cooldown are not excluded from the report.
-func (h *NPMHandler) handleSecurity(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- JSONError(w, http.StatusMethodNotAllowed, "method not allowed")
- return
- }
-
- if containsPathTraversal(r.URL.Path) {
- JSONError(w, http.StatusBadRequest, "invalid path")
- return
- }
-
- body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, npmSecurityMaxBody))
- if err != nil {
- var maxBytesErr *http.MaxBytesError
- if errors.As(err, &maxBytesErr) {
- JSONError(w, http.StatusRequestEntityTooLarge, "audit request too large")
- return
- }
- // A client aborting mid-upload must not be told its payload was too big.
- h.proxy.Logger.Warn("npm audit request body unreadable", "path", r.URL.Path, "error", err)
- JSONError(w, http.StatusBadRequest, "could not read audit request")
- return
- }
-
- // EscapedPath keeps an encoded "?" or "#" from turning the rest of the path
- // into a query or fragment upstream.
- upstreamURL := h.upstreamURL + r.URL.EscapedPath()
- if r.URL.RawQuery != "" {
- upstreamURL += "?" + r.URL.RawQuery
- }
-
- h.proxy.Logger.Info("npm audit request", "path", r.URL.Path, "bytes", len(body))
-
- req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(body))
- if err != nil {
- JSONError(w, http.StatusInternalServerError, "failed to create request")
- return
- }
-
- // Clients may gzip the body, so the headers describing it travel with it.
- for _, header := range []string{headerContentType, headerContentEncoding, headerAccept, headerAcceptEncoding} {
- if v := r.Header.Get(header); v != "" {
- req.Header.Set(header, v)
- }
- }
- if req.Header.Get(headerContentType) == "" {
- req.Header.Set(headerContentType, contentTypeJSON)
- }
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("npm audit request failed", "path", r.URL.Path, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to reach upstream registry")
- return
- }
- defer func() { _ = resp.Body.Close() }()
-
- h.proxy.relayResponse(w, r, resp, nil)
-}
-
-// sourceFor returns the registry serving a package and the key its metadata is
-// cached under.
-func (h *NPMHandler) sourceFor(packageName string) (registryURL, cacheKey string) {
- if route, routed := h.routes.match(packageName); routed {
- return route.url, route.cacheKey(packageName)
- }
- return h.upstreamURL, packageName
-}
-
// handlePackageMetadata proxies package metadata from upstream and rewrites tarball URLs.
func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) {
- packagePath := h.extractPackageName(r)
- if packagePath == "" || containsPathTraversal(packagePath) {
- JSONError(w, http.StatusBadRequest, "invalid package name")
- return
- }
- packageName, registryPath := npmMetadataPath(packagePath)
+ packageName := h.extractPackageName(r)
if packageName == "" {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}
- h.proxy.Logger.Debug("npm metadata request", "package", packageName, "path", packagePath)
+ h.proxy.Logger.Info("npm metadata request", "package", packageName)
- registryURL, cacheKey := h.sourceFor(packageName)
- // A version or dist-tag is its own path segment. Escaping the whole path
- // turns "lodash/4.17.21" into "lodash%2F4.17.21", which the registry
- // rejects with 405.
- upstreamURL := registryURL + "/" + registryPath
- metadataCacheKey := npmMetadataCacheKey(cacheKey, packagePath, packageName)
+ // Fetch metadata from upstream
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, url.PathEscape(packageName))
- accept := h.packumentAccept()
-
- specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
- if h.serveLocalMetadata(w, r, npmMetadataRequest{packageName, specifier, versionLookup, upstreamURL, accept, metadataCacheKey}) {
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "failed to create request")
return
}
+ req.Header.Set("Accept", "application/json")
- if stored, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok {
- serveRewrittenMetadata(w, r, stored)
- return
- }
-
- if versionLookup && h.npmVersionMetadataUsesFilteredPackument(packageName) {
- h.handleFilteredNPMVersionMetadata(w, r, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey)
- return
- }
-
- if !versionLookup && h.serveStreamedPackument(w, r, packageName, upstreamURL, accept, metadataCacheKey) {
- return
- }
-
- var validate func([]byte) error
- if !versionLookup {
- validate = h.packumentValidator()
- }
- upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept, validate)
- if err := upstream.err; err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- if versionLookup {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- } else {
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- }
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("failed to fetch upstream metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}
+ defer func() { _ = resp.Body.Close() }()
- rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) {
- return h.rewriteMetadataKeeping(packageName, b)
- })
+ if resp.StatusCode == http.StatusNotFound {
+ JSONError(w, http.StatusNotFound, "package not found")
+ return
+ }
+ if resp.StatusCode != http.StatusOK {
+ JSONError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode))
+ return
+ }
+
+ // Parse and rewrite tarball URLs
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "failed to read response")
+ return
+ }
+
+ rewritten, err := h.rewriteMetadata(packageName, body)
if err != nil {
- if r.Context().Err() != nil {
- return // the client left while waiting on a shared rewrite
- }
- if errors.Is(err, ErrVersionDenied) {
- JSONError(w, http.StatusForbidden, err.Error())
- return
- }
- if errors.Is(err, ErrVersionNotFound) {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- return
- }
- if h.proxy.mustFilterMetadata("npm", packageName) {
- h.proxy.Logger.Error("failed to filter npm metadata", "package", packageName, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
- return
- }
// If rewriting fails, just proxy the original
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
- w.Header().Set(headerContentType, contentTypeJSON)
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- _, _ = w.Write(upstream.body)
+ _, _ = w.Write(body)
return
}
- serveRewrittenMetadata(w, r, rewritten)
-}
-
-// serveStreamedPackument serves a packument rewritten from storage: the
-// document is streamed from upstream into storage, then read twice, once to
-// collect the time map and version names cooldown and the denylist decide
-// on, and once to copy each kept version. registry.npmjs.org writes
-// dist-tags before versions and time after them, so one pass can't do it.
-// Neither the packument nor its rewrite is held in memory uncompressed, and
-// packuments larger than metadata_max_size can be served.
-//
-// It reports false when it leaves the request to the in-memory rewrite:
-// without metadata or rewrite caching, for a document it can't stream, such
-// as an abbreviated packument where the full one is required, and when the
-// rewrite fails. The document is stored by then, so the in-memory path reads
-// it from storage.
-func (h *NPMHandler) serveStreamedPackument(w http.ResponseWriter, r *http.Request, packageName, upstreamURL, accept, metadataCacheKey string) bool {
- if h.proxy.rewrites == nil {
- return false
- }
- fullRequired := h.packumentValidator() != nil
- options := h.npmRewriteOptions(packageName, nil)
- var rewritten rewrittenMetadata
- err, fetchErr := h.readStoredPackument(r.Context(), upstreamURL, accept, metadataCacheKey, func(entry *database.MetadataCacheEntry) (err error) {
- rewritten, err = h.proxy.streamedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, entry, func(open func() (io.ReadCloser, error), out io.Writer) error {
- var scan *npm.Scan
- err := readStored(open, func(in io.Reader) (err error) {
- scan, err = npm.ScanPackument(in)
- return err
- })
- if err != nil {
- return err
- }
- if fullRequired && scan.Abbreviated() {
- return npm.ErrNotStreamable
- }
- return readStored(open, func(in io.Reader) error {
- _, err := npm.RewriteStream(packageName, scan, in, out, options)
- return err
- })
- })
- return err
- })
- switch {
- case errors.Is(fetchErr, errInMemory):
- return false
- case fetchErr != nil:
- h.writeFetchError(w, fetchErr)
- return true
- }
- if err != nil {
- if r.Context().Err() != nil {
- return true // the client left while waiting on a shared rewrite
- }
- if !errors.Is(err, npm.ErrNotStreamable) {
- h.proxy.Logger.Warn("failed to rewrite stored npm metadata, rewriting in memory",
- "package", packageName, "error", err)
- }
- return false
- }
- serveRewrittenMetadata(w, r, rewritten)
- return true
-}
-
-// npmFetchError is a failure to fetch a packument, answered as such rather
-// than as a failure to filter it.
-type npmFetchError struct{ err error }
-
-func (e *npmFetchError) Error() string { return e.err.Error() }
-func (e *npmFetchError) Unwrap() error { return e.err }
-
-// writeFetchError answers a request whose packument could not be fetched.
-func (h *NPMHandler) writeFetchError(w http.ResponseWriter, err error) {
- switch {
- case errors.Is(err, ErrUpstreamNotFound):
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- case writeUpstreamHeld(w, err):
- default:
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
- }
-}
-
-// errInMemory is returned when a packument has to be read into memory to
-// answer the request: metadata is not cached in storage, or the stored
-// document can't be read as a stream.
-var errInMemory = errors.New("npm packument needs the in-memory path")
-
-// storedPackument returns the lookup of the packument stored for cacheKey,
-// fetching it into storage first when needed. Its err is errInMemory when
-// metadata isn't cached in storage, and ErrUpstreamNotFound and upstream
-// errors as they come.
-// With refresh, a stored copy within its TTL is fetched again rather than
-// served.
-func (h *NPMHandler) storedPackument(ctx context.Context, packumentURL, accept, cacheKey string, refresh bool) metadataResult {
- var gone string
- if refresh && h.proxy.DB != nil {
- if row, _ := h.proxy.DB.GetMetadataCache("npm", cacheKey); usableStoredMetadata(row) {
- gone = row.ContentDigest.String
- }
- }
- res := h.proxy.storedMetadata(ctx, "npm", cacheKey, packumentURL, accept, h.packumentStreamValidator(), gone)
- if errors.Is(res.err, errMetadataNotStored) {
- res.err = errInMemory
- }
- return res
-}
-
-// readStoredPackument reads the packument stored for cacheKey with read,
-// fetching it into storage first when needed. When the stored copy is gone
-// or no longer has the digest its row records, the packument is fetched
-// again and read once more. fetchErr is what storedPackument returned when the packument
-// couldn't be fetched, and err is what read returned.
-func (h *NPMHandler) readStoredPackument(ctx context.Context, packumentURL, accept, cacheKey string, read func(*database.MetadataCacheEntry) error) (err, fetchErr error) {
- fetched, err := h.lookupStoredPackument(ctx, packumentURL, accept, cacheKey, false, read)
- return err, fetched.err
-}
-
-// lookupStoredPackument implements readStoredPackument. fetched is the
-// lookup of the packument read, whose err is set when it couldn't be
-// fetched.
-func (h *NPMHandler) lookupStoredPackument(ctx context.Context, packumentURL, accept, cacheKey string, refresh bool, read func(*database.MetadataCacheEntry) error) (fetched metadataResult, err error) {
- fetched = h.storedPackument(ctx, packumentURL, accept, cacheKey, refresh)
- if fetched.err != nil {
- return fetched, nil
- }
- if err = read(fetched.entry); !errors.Is(err, storage.ErrNotFound) && !errors.Is(err, errStoredMetadataChanged) {
- return fetched, err
- }
- h.proxy.Logger.Warn("stored npm metadata is missing or changed, fetching it again", "url", packumentURL, "error", err)
- fetched = h.proxy.storedMetadata(ctx, "npm", cacheKey, packumentURL, accept, h.packumentStreamValidator(), fetched.entry.ContentDigest.String)
- if errors.Is(fetched.err, errMetadataNotStored) {
- fetched.err = errInMemory
- }
- if fetched.err != nil {
- return fetched, nil
- }
- return fetched, read(fetched.entry)
-}
-
-// packumentStreamValidator is packumentValidator for a packument read from
-// storage.
-func (h *NPMHandler) packumentStreamValidator() func(io.Reader) error {
- if h.packumentValidator() == nil {
- return nil
- }
- return func(r io.Reader) error {
- entry, err := npm.LookupVersion(r, "")
- if err != nil {
- return err
- }
- if entry.Abbreviated {
- return errAbbreviatedPackument
- }
- return nil
- }
-}
-
-// streamedVersionMetadata resolves a version or dist-tag through the
-// packument stored in storage, as rewriteNPMVersionFromPackument does in
-// memory: one pass collects what cooldown and the denylist decide on, and a
-// second finds the version entry. It returns errInMemory when the request
-// has to take the in-memory path.
-func (h *NPMHandler) streamedVersionMetadata(ctx context.Context, packageName, specifier, packumentURL, accept, cacheKey, metadataCacheKey string) (rewrittenMetadata, error) {
- if h.proxy.rewrites == nil {
- return rewrittenMetadata{}, errInMemory
- }
- fullRequired := h.packumentValidator() != nil
- options := h.npmRewriteOptions(packageName, nil)
- var m rewrittenMetadata
- err, fetchErr := h.readStoredPackument(ctx, packumentURL, accept, cacheKey, func(entry *database.MetadataCacheEntry) (err error) {
- m, err = h.proxy.streamedRewrite(ctx, "npm", h.proxyURL, metadataCacheKey, entry, func(open func() (io.ReadCloser, error), out io.Writer) error {
- var scan *npm.Scan
- err := readStored(open, func(in io.Reader) (err error) {
- scan, err = npm.ScanPackument(in)
- return err
- })
- if err != nil {
- return err
- }
- if fullRequired && scan.Abbreviated() {
- return npm.ErrNotStreamable
- }
- return readStored(open, func(in io.Reader) error {
- version, err := npm.VersionStream(packageName, specifier, scan, in, options)
- if err != nil {
- return npmRewriteError(err)
- }
- _, err = out.Write(version)
- return err
- })
- })
- return err
- })
- switch {
- case errors.Is(fetchErr, errInMemory):
- return rewrittenMetadata{}, fetchErr
- case fetchErr != nil:
- return rewrittenMetadata{}, &npmFetchError{fetchErr}
- }
- switch {
- case err == nil, ctx.Err() != nil, errors.Is(err, ErrVersionDenied), errors.Is(err, ErrVersionNotFound):
- return m, err
- case !errors.Is(err, npm.ErrNotStreamable):
- h.proxy.Logger.Warn("failed to read stored npm metadata, reading it in memory",
- "package", packageName, "error", err)
- }
- return rewrittenMetadata{}, errInMemory
-}
-
-// readStored opens a stored document and reads it with read.
-func readStored(open func() (io.ReadCloser, error), read func(io.Reader) error) error {
- in, err := open()
- if err != nil {
- return err
- }
- defer func() { _ = in.Close() }()
- return read(in)
-}
-
-// packumentAccept returns the Accept header packuments are requested with.
-// It prefers the smaller abbreviated packument format but includes
-// application/json as a fallback so upstreams that reject the abbreviated type
-// (e.g. JFrog Artifactory, which returns 406) can still respond with full
-// metadata. When cooldown is enabled we must use full metadata exclusively
-// because the abbreviated format omits the "time" map required for version age
-// filtering. Operators can also force full metadata so clients that gate on
-// publish age (for example Yarn's npmMinimalAgeGate) keep working through the
-// proxy.
-//
-// Every request for a packument uses it, since they share one cache row: a
-// request with another Accept would replace the document the others are
-// served with one in the other format. Cooldown profiles share that row too,
-// so cooldown in any of them asks for full metadata in all of them.
-func (h *NPMHandler) packumentAccept() string {
- if h.proxy.NPMFullMetadata || h.proxy.anyCooldownEnabled() {
- return contentTypeJSON
- }
- return npmAcceptDefault
-}
-
-// errAbbreviatedPackument rejects an abbreviated packument where the full one
-// is needed.
-var errAbbreviatedPackument = errors.New("abbreviated packument where full metadata is required")
-
-// packumentValidator returns the check a packument must pass to be served or
-// cached, or nil when any document will do. Packuments share one cache row
-// whatever format they were fetched in, so one cached in abbreviated form
-// before cooldown or full metadata was turned on would otherwise still be
-// served, and used for the cooldown tarball check, while it stayed fresh or
-// as a stale copy when upstream failed. It has no "time" map, so cooldown
-// would let every version through. Rejecting it makes the lookup fetch the
-// full document instead.
-func (h *NPMHandler) packumentValidator() func([]byte) error {
- if h.packumentAccept() != contentTypeJSON {
- return nil
- }
- return requireFullPackument
-}
-
-// requireFullPackument rejects an abbreviated packument (see
-// npm.Abbreviated). A full document without a time map, as some registries
-// serve, is let through as before, and so is anything that is not a JSON
-// object, leaving it to the usual handling.
-func requireFullPackument(body []byte) error {
- if npm.Abbreviated(body) {
- return errAbbreviatedPackument
- }
- return nil
-}
-
-// handleFilteredNPMVersionMetadata serves /pkg/ and /pkg/ from the
-// filtered packument so cooldown and dist-tags match the root metadata response.
-func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r *http.Request, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey string) {
- _, packumentPath := npmMetadataPath(packageName)
- packumentURL := registryURL + "/" + packumentPath
- rewritten, err := h.streamedVersionMetadata(r.Context(), packageName, specifier, packumentURL, accept, cacheKey, metadataCacheKey)
- var fetchErr *npmFetchError
- switch {
- case errors.As(err, &fetchErr):
- h.writeFetchError(w, fetchErr.err)
- return
- case !errors.Is(err, errInMemory):
- h.writeVersionMetadata(w, r, packageName, rewritten, err)
- return
- }
-
- packument, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "npm", cacheKey, packumentURL, "", h.packumentValidator(), accept)
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
- return
- }
-
- rewritten, err = h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) {
- out, err := h.rewriteNPMVersionFromPackument(packageName, specifier, b)
- return out, nil, err
- })
- h.writeVersionMetadata(w, r, packageName, rewritten, err)
-}
-
-// writeVersionMetadata answers a version metadata request with its rewrite
-// or the error that kept it from being made.
-func (h *NPMHandler) writeVersionMetadata(w http.ResponseWriter, r *http.Request, packageName string, rewritten rewrittenMetadata, err error) {
- if err != nil {
- if r.Context().Err() != nil {
- return
- }
- if errors.Is(err, ErrVersionDenied) {
- JSONError(w, http.StatusForbidden, err.Error())
- return
- }
- if errors.Is(err, ErrVersionNotFound) {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- return
- }
- if h.proxy.mustFilterMetadata("npm", packageName) {
- h.proxy.Logger.Error("failed to filter npm metadata", "package", packageName, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
- return
- }
- h.proxy.Logger.Warn("failed to rewrite npm version metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
- return
- }
-
- w.Header().Set(headerContentType, contentTypeJSON)
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- _, _ = w.Write(rewritten.content())
+ _, _ = w.Write(rewritten)
}
-// npmMetadataCacheKey qualifies routed-package metadata cache entries with the
-// requested version or dist-tag when the client asks for a single version.
-func npmMetadataCacheKey(cacheKey, packagePath, packageName string) string {
- specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
- if !versionLookup {
- return cacheKey
+// rewriteMetadata rewrites tarball URLs in npm package metadata to point at this proxy.
+func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, error) {
+ var metadata map[string]any
+ if err := json.Unmarshal(body, &metadata); err != nil {
+ return nil, err
}
- return cacheKey + "/" + specifier
-}
-// npmVersionSpecifier returns the version or dist-tag segment after the package
-// name in a metadata path, for example "4.17.21" or "latest".
-func npmVersionSpecifier(packagePath, packageName string) (string, bool) {
- if packagePath == packageName {
- return "", false
+ // Rewrite tarball URLs in versions
+ versions, ok := metadata["versions"].(map[string]any)
+ if !ok {
+ return body, nil // No versions to rewrite
}
- specifier, ok := strings.CutPrefix(packagePath, packageName+"/")
- return specifier, ok
-}
-func (h *NPMHandler) npmVersionMetadataUsesFilteredPackument(packageName string) bool {
- if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
- return true
+ for version, vdata := range versions {
+ vmap, ok := vdata.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ dist, ok := vmap["dist"].(map[string]any)
+ if !ok {
+ continue
+ }
+
+ if tarball, ok := dist["tarball"].(string); ok {
+ // Extract filename from tarball URL
+ filename := tarball
+ if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
+ filename = tarball[idx+1:]
+ }
+
+ // Rewrite to our proxy URL
+ escapedName := url.PathEscape(packageName)
+ newTarball := fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, escapedName, filename)
+ dist["tarball"] = newTarball
+
+ h.proxy.Logger.Debug("rewrote tarball URL",
+ "package", packageName, "version", version,
+ "old", tarball, "new", newTarball)
+ }
}
- if h.proxy.Denylist != nil {
- return len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0
- }
- return false
-}
-// rewriteNPMVersionFromPackument picks one version entry out of an already
-// filtered packument and rewrites its tarball URL.
-func (h *NPMHandler) rewriteNPMVersionFromPackument(packageName, specifier string, packument []byte) ([]byte, error) {
- out, err := npm.Version(packageName, specifier, packument, h.npmRewriteOptions(packageName, nil))
- return out, npmRewriteError(err)
-}
-
-// rewriteMetadataKeeping rewrites tarball URLs in npm package metadata to
-// point at this proxy, filters the versions cooldown and the denylist
-// withhold, and reports the versions it kept. See npm.Rewrite.
-func (h *NPMHandler) rewriteMetadataKeeping(packageName string, body []byte) ([]byte, []string, error) {
- return h.rewriteMetadataWithLocal(packageName, body, nil)
-}
-
-// rewriteMetadataWithLocal is rewriteMetadataKeeping that also lists the
-// pushed versions in local, in place of any upstream entry with the same
-// version. Cooldown and the denylist only filter upstream versions, and
-// dist-tags stay as upstream has them.
-func (h *NPMHandler) rewriteMetadataWithLocal(packageName string, body []byte, local []npmLocalEntry) ([]byte, []string, error) {
- out, kept, err := npm.Rewrite(packageName, body, h.npmRewriteOptions(packageName, local))
- return out, kept, npmRewriteError(err)
-}
-
-// npmRewriteOptions gathers what a rewrite of packageName's metadata depends
-// on from the proxy: its URL, the cooldown and denylist policy, the time
-// cooldown is evaluated at, and the pushed versions to list. The callbacks
-// log what the rewrite did.
-func (h *NPMHandler) npmRewriteOptions(packageName string, local []npmLocalEntry) npm.Options {
- return npm.Options{
- ProxyURL: h.proxyURL,
- Cooldown: h.proxy.cooldown(),
- DeniedVersions: h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName)),
- EvaluatedAt: time.Now(),
- LocalVersions: local,
- // Logged once per document: a line per version flooded debug logs,
- // since packages such as eslint list hundreds of versions and with
- // cooldown on every request rewrites the document again.
- OnTarballsRewritten: func(count int) {
- h.proxy.Logger.Debug("rewrote tarball URLs", "package", packageName, "count", count)
- },
- OnCooldownFilter: func(version, publishedAt string) {
- h.proxy.Logger.Info("cooldown: filtering npm version", "package", packageName, "version", version,
- "published", publishedAt)
- },
- }
-}
-
-// npmRewriteError maps the rewrite's errors to the handler's, which the
-// routes answer with 403 and 404.
-func npmRewriteError(err error) error {
- switch {
- case errors.Is(err, npm.ErrVersionDenied):
- return ErrVersionDenied
- case errors.Is(err, npm.ErrVersionNotFound):
- return ErrVersionNotFound
- default:
- return err
- }
-}
-
-func (h *NPMHandler) proxyTarballURL(packageName, version, tarball string) string {
- return npm.TarballURL(h.proxyURL, packageName, version, tarball)
+ return json.Marshal(metadata)
}
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
@@ -769,325 +171,17 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
return
}
- h.proxy.Logger.Debug("npm download request",
+ h.proxy.Logger.Info("npm download request",
"package", packageName, "version", version, "filename", filename)
- if h.proxy.versionDenied("npm", packageName, version) {
- JSONError(w, http.StatusForbidden, ErrVersionDenied.Error()+": "+canonicalVersionPURL("npm", packageName, version))
- return
- }
- // Pushed versions are listed without cooldown, so they are served without
- // it too, even when the upstream published the same version recently.
- local, err := h.proxy.GetLocalArtifact(r.Context(), "npm", packageName, version, filename)
+ result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename)
if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to read pushed package")
- return
- }
- if local != nil {
- ServeArtifactRequest(w, r, local)
- return
- }
- if h.proxy.hostedOnly("npm", packageName) {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- return
- }
- registryURL, cacheKey := h.sourceFor(packageName)
- metadata := sync.OnceValues(func() (npmVersionData, error) {
- return h.versionData(r.Context(), packageName, registryURL+"/"+url.PathEscape(packageName), cacheKey, version)
- })
- inCooldown, err := h.versionInCooldown(packageName, version, metadata)
- if err != nil {
- if h.proxy.serveCachedDespiteCooldownError(w, r, "npm", packageName, version, h.routes.cacheFilename(packageName, filename), err) {
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Warn("cooldown: could not read npm publish time",
- "package", packageName, "version", version, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to check cooldown")
- return
- }
- if inCooldown {
- h.proxy.Logger.Info("cooldown: withholding npm tarball",
- "package", packageName, "version", version)
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ JSONError(w, http.StatusBadGateway, "failed to fetch package")
return
}
- result, err := h.getTarball(r, registryURL, packageName, version, filename, metadata)
- if err != nil {
- switch {
- case errors.Is(err, ErrUpstreamNotFound):
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- case errors.Is(err, ErrArtifactBlocked), errors.Is(err, ErrVersionDenied):
- JSONError(w, http.StatusForbidden, err.Error())
- default:
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch package")
- }
- return
- }
-
- ServeArtifactRequest(w, r, result)
-}
-
-func (h *NPMHandler) getTarball(r *http.Request, registryURL, packageName, version, filename string, metadata func() (npmVersionData, error)) (*CacheResult, error) {
- cacheFilename := h.routes.cacheFilename(packageName, filename)
- if cached, err := h.proxy.GetCachedArtifact(r.Context(), "npm", packageName, version, cacheFilename); err != nil || cached != nil {
- return cached, err
- }
- // The packument says where the tarball is and what it must hash to.
- // Without it the tarball could not be verified, and an unverified copy
- // would be cached and served to everyone, so the download fails instead.
- data, err := metadata()
- if err != nil {
- return nil, fmt.Errorf("fetching npm metadata to verify tarball: %w", err)
- }
- downloadURL := fmt.Sprintf("%s/%s/-/%s", registryURL, escapeNPMDownloadPackage(packageName), url.PathEscape(filename))
- if tarball := npmEntryTarball(data.entry); tarball != "" {
- downloadURL, err = validateTarballURL(tarball, registryURL)
- if err != nil {
- return nil, err
- }
- }
- sri := npmEntryIntegrity(data.entry)
- return h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "npm", packageName, version, cacheFilename, downloadURL, sri)
-}
-
-// npmEntryIntegrity returns the integrity a version's packument entry
-// declares for its tarball: dist.integrity when it is a Subresource Integrity string
-// the proxy can verify, or else the SHA-1 in dist.shasum, which is all
-// versions published before integrity existed carry, as "sha1-". It
-// returns "" when neither is usable, and the tarball is fetched unverified as
-// before.
-func npmEntryIntegrity(entry []byte) string {
- sri, _, _ := jsonscan.LookupString(entry, "dist", "integrity")
- if sri != "" {
- if _, err := integrity.ParseSRI(sri); err == nil {
- return sri
- }
- }
- shasum, _, _ := jsonscan.LookupString(entry, "dist", "shasum")
- return sha1Checksum(shasum)
-}
-
-// npmVersionTarball returns versions[version].dist.tarball from a packument,
-// or "" if it has none. It reads that one value in place rather than decoding
-// every version, since it runs on each tarball the proxy has not cached yet.
-func npmVersionTarball(body []byte, version string) string {
- entry, _ := jsonscan.Lookup(body, "versions", version)
- return npmEntryTarball(entry)
-}
-
-// npmEntryTarball is npmVersionTarball for the version's entry.
-func npmEntryTarball(entry []byte) string {
- tarball, _, _ := jsonscan.LookupString(entry, "dist", "tarball")
- return tarball
-}
-
-// npmVersionData is what a tarball download reads from the packument: the
-// version's entry, nil when it has none, and its publish time from the time
-// map, "" when it has none.
-type npmVersionData struct {
- entry []byte
- published string
-}
-
-// versionData reads what a tarball download needs about version from the
-// packument: from storage in one pass when metadata is cached there, and
-// otherwise from the packument read into memory.
-//
-// A packument cached within its TTL can predate the version. Under cooldown a
-// missing entry would let the version through with no publish time, so the
-// packument is fetched again and the version decided on what upstream lists
-// now, as the Composer handler does.
-func (h *NPMHandler) versionData(ctx context.Context, packageName, packumentURL, cacheKey, version string) (npmVersionData, error) {
- data, fromCache, err := h.readVersionData(ctx, packumentURL, cacheKey, version, false)
- if err == nil && data.entry == nil && fromCache && h.proxy.cooldownApplies("npm", packageName) {
- h.proxy.Logger.Info("cooldown: cached npm metadata lacks version, fetching it again",
- "package", packageName, "version", version)
- data, _, err = h.readVersionData(ctx, packumentURL, cacheKey, version, true)
- }
- return data, err
-}
-
-// readVersionData implements versionData. fromCache reports whether the
-// packument was a cached copy, within its TTL or served stale; refresh
-// fetches it from upstream.
-func (h *NPMHandler) readVersionData(ctx context.Context, packumentURL, cacheKey, version string, refresh bool) (data npmVersionData, fromCache bool, err error) {
- accept := h.packumentAccept()
- var found npm.VersionEntry
- fetched, err := h.lookupStoredPackument(ctx, packumentURL, accept, cacheKey, refresh, func(entry *database.MetadataCacheEntry) error {
- return readStored(func() (io.ReadCloser, error) { return h.proxy.openStoredMetadata(ctx, entry) }, func(in io.Reader) (err error) {
- found, err = npm.LookupVersion(in, version)
- return err
- })
- })
- fetchErr := fetched.err
- switch {
- case fetchErr == nil && err == nil && (!found.Abbreviated || h.packumentValidator() == nil):
- if refresh && fetched.stale {
- return npmVersionData{}, false, errRefetchFailed(fetched)
- }
- return npmVersionData{entry: found.Entry, published: found.Published}, fetched.fromCache || fetched.stale, nil
- case fetchErr == nil && err != nil:
- h.proxy.Logger.Warn("failed to read stored npm metadata, reading it in memory", "url", packumentURL, "error", err)
- case fetchErr != nil && !errors.Is(fetchErr, errInMemory):
- return npmVersionData{}, false, fetchErr
- }
-
- var res metadataResult
- if refresh {
- res = h.proxy.refreshMetadata(ctx, "npm", cacheKey, packumentURL, accept, "", h.packumentValidator())
- } else {
- res = h.proxy.fetchMetadataDocument(ctx, "npm", cacheKey, packumentURL, accept, h.packumentValidator())
- }
- if res.err != nil {
- return npmVersionData{}, false, res.err
- }
- if refresh && res.stale {
- return npmVersionData{}, false, errRefetchFailed(res)
- }
- entry, _ := jsonscan.Lookup(res.body, "versions", version)
- published, _, err := jsonscan.LookupString(res.body, "time", version)
- if err != nil {
- return npmVersionData{}, false, fmt.Errorf("parsing npm metadata: %w", err)
- }
- return npmVersionData{entry: entry, published: published}, res.fromCache || res.stale, nil
-}
-
-// errRefetchFailed is the error for a refetch that got only the stale copy
-// back. That copy is what lacked the version, so it can't decide it.
-func errRefetchFailed(res metadataResult) error {
- return fmt.Errorf("npm metadata could not be fetched again: %w", res.upstreamErr)
-}
-
-func validateTarballURL(raw, registryURL string) (string, error) {
- return validateRegistryURL("tarball", raw, registryURL)
-}
-
-// validateRegistryURL checks that a URL a packument lists for a version's
-// file is on the package's registry, under its base path, and returns it.
-// what names the file in errors.
-func validateRegistryURL(what, raw, registryURL string) (string, error) {
- parsed, err := url.Parse(raw)
- if err != nil {
- return "", fmt.Errorf("parsing npm %s URL: %w", what, err)
- }
- upstream, err := url.Parse(registryURL)
- if err != nil {
- return "", fmt.Errorf("parsing npm upstream URL: %w", err)
- }
- if parsed.User != nil || parsed.Fragment != "" ||
- parsed.Scheme != upstream.Scheme || !strings.EqualFold(parsed.Host, upstream.Host) {
- return "", fmt.Errorf("npm %s URL does not match upstream registry", what)
- }
- if containsPathTraversal(parsed.Path) || strings.Contains(parsed.Path, "\\") {
- return "", fmt.Errorf("npm %s URL contains path traversal", what)
- }
- basePath := strings.TrimRight(upstream.Path, "/")
- if basePath != "" && !strings.HasPrefix(parsed.Path, basePath+"/") {
- return "", fmt.Errorf("npm %s URL is outside upstream base path", what)
- }
- return parsed.String(), nil
-}
-
-// versionInCooldown reports whether a version is still inside the cooldown
-// window. Filtering the packument is not enough on its own: tarball URLs are
-// predictable and lockfiles record them directly, so `npm ci` reaches the
-// download path without ever requesting metadata.
-//
-// A version's publish time is immutable, so the check reads the stored
-// versions row first and only falls back to the packument for a version the
-// proxy has never seen, persisting the parsed time so the packument is
-// fetched and parsed at most once per version. A version with no usable
-// publish time is allowed through, matching how applyCooldownFiltering
-// treats it. A packument that can't be fetched or parsed is an error, so a
-// failing upstream can't let a version through.
-//
-// The versions row is keyed by the plain version PURL, which does not say
-// which registry the version came from, so routed packages leave it alone:
-// their publish time always comes from the route's own metadata, cached under
-// the route. Otherwise a time read from the default registry would be trusted
-// for a private version of the same name, a private time would hold back the
-// public version, and moving a route to another upstream would keep the old
-// one's times.
-func (h *NPMHandler) versionInCooldown(packageName, version string, metadata func() (npmVersionData, error)) (bool, error) {
- if !h.proxy.cooldownApplies("npm", packageName) {
- return false, nil
- }
-
- _, routed := h.routes.match(packageName)
- versionPURL := canonicalVersionPURL("npm", packageName, version)
- if !routed {
- if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
- return !h.proxy.cooldown().IsAllowed("npm", canonicalPackagePURL("npm", packageName), ver.PublishedAt.Time), nil
- }
- }
-
- data, err := metadata()
- if errors.Is(err, ErrUpstreamNotFound) {
- return false, nil
- }
- if err != nil {
- return false, err
- }
-
- published := data.published
- if published == "" {
- return false, nil
- }
-
- publishedAt, err := time.Parse(time.RFC3339, published)
- if err != nil {
- return false, nil
- }
-
- if !routed {
- if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, canonicalPackagePURL("npm", packageName), publishedAt); err != nil {
- h.proxy.Logger.Warn("cooldown: could not store npm publish time",
- "package", packageName, "version", version, "error", err)
- }
- }
-
- return !h.proxy.cooldown().IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt), nil
-}
-
-// npmMetadataPath returns the package name and the encoded registry path.
-// Examples: lodash → lodash; lodash/4.17.21 → lodash/4.17.21;
-// @babel/core → @babel%2Fcore; @babel/core/7.23.0 → @babel%2Fcore/7.23.0.
-func npmMetadataPath(packagePath string) (packageName, registryPath string) {
- // GET /npm/ has no package segment; handlePackageMetadata rejects that earlier.
- if packagePath == "" {
- return "", ""
- }
- if !strings.Contains(packagePath, "/") {
- return packagePath, url.PathEscape(packagePath)
- }
- if strings.HasPrefix(packagePath, "@") {
- scope, rest, _ := strings.Cut(packagePath, "/")
- name, version, ok := strings.Cut(rest, "/")
- packageName = scope + "/" + name
- registryPath = url.PathEscape(scope) + "%2F" + url.PathEscape(name)
- if ok {
- registryPath += "/" + url.PathEscape(version)
- }
- return packageName, registryPath
- }
- name, version, _ := strings.Cut(packagePath, "/")
- return name, url.PathEscape(name) + "/" + url.PathEscape(version)
-}
-
-func escapeNPMDownloadPackage(packageName string) string {
- scope, name, scoped := strings.Cut(packageName, "/")
- if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
- return url.PathEscape(scope) + "/" + url.PathEscape(name)
- }
- return url.PathEscape(packageName)
+ ServeArtifact(w, result)
}
// extractPackageName extracts the package name from the request path.
@@ -1130,6 +224,28 @@ func (h *NPMHandler) parseDownloadPath(path string) (packageName, filename strin
return packageName, filename
}
+// extractVersionFromFilename extracts version from npm tarball filename.
+// e.g., "lodash-4.17.21.tgz" -> "4.17.21"
+// e.g., "core-7.23.0.tgz" for @babel/core -> "7.23.0"
func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) string {
- return npm.VersionFromFilename(packageName, filename)
+ // Remove .tgz extension
+ if !strings.HasSuffix(filename, ".tgz") {
+ return ""
+ }
+ base := strings.TrimSuffix(filename, ".tgz")
+
+ // For scoped packages, the filename uses the short name
+ shortName := packageName
+ if strings.Contains(packageName, "/") {
+ parts := strings.SplitN(packageName, "/", 2)
+ shortName = parts[1]
+ }
+
+ // Expected format: {shortName}-{version}
+ prefix := shortName + "-"
+ if !strings.HasPrefix(base, prefix) {
+ return ""
+ }
+
+ return strings.TrimPrefix(base, prefix)
}
diff --git a/internal/handler/npm_attestations.go b/internal/handler/npm_attestations.go
deleted file mode 100644
index cc84003..0000000
--- a/internal/handler/npm_attestations.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package handler
-
-import (
- "errors"
- "net/http"
- "net/url"
- "strings"
- "sync"
-
- "github.com/git-pkgs/proxy/internal/jsonscan"
-)
-
-// errNoAttestations answers a request for a version whose packument entry
-// lists no attestations.
-var errNoAttestations = errors.New("attestations not found")
-
-// handleAttestations serves /-/npm/v1/attestations/{name}@{version}, which
-// `npm audit signatures` reads to verify provenance. Packuments point
-// dist.attestations.url here (see npm.AttestationsURL).
-func (h *NPMHandler) handleAttestations(w http.ResponseWriter, r *http.Request) {
- packageName, version, ok := parseNPMAttestationsPath(strings.TrimPrefix(r.URL.Path, npmAttestationsPrefix))
- if !ok {
- JSONError(w, http.StatusBadRequest, "invalid package name")
- return
- }
-
- if h.proxy.versionDenied("npm", packageName, version) {
- JSONError(w, http.StatusForbidden, ErrVersionDenied.Error()+": "+canonicalVersionPURL("npm", packageName, version))
- return
- }
- local, err := h.npmLocalEntries(packageName)
- if err != nil {
- h.proxy.Logger.Error("failed to read pushed npm versions", "package", packageName, "error", err)
- JSONError(w, http.StatusInternalServerError, "failed to read pushed versions")
- return
- }
- if _, pushed := findNPMLocalEntry(local, version); pushed || h.proxy.hostedOnly("npm", packageName) {
- JSONError(w, http.StatusNotFound, errNoAttestations.Error())
- return
- }
-
- registryURL, cacheKey := h.sourceFor(packageName)
- metadata := sync.OnceValues(func() (npmVersionData, error) {
- return h.versionData(r.Context(), packageName, registryURL+"/"+url.PathEscape(packageName), cacheKey, version)
- })
-
- inCooldown, err := h.versionInCooldown(packageName, version, metadata)
- if err != nil {
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Warn("cooldown: could not read npm publish time",
- "package", packageName, "version", version, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to check cooldown")
- return
- }
- if inCooldown {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- return
- }
-
- data, err := metadata()
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
- return
- }
- listed, _, _ := jsonscan.LookupString(data.entry, "dist", "attestations", "url")
- if listed == "" {
- JSONError(w, http.StatusNotFound, errNoAttestations.Error())
- return
- }
- upstreamURL, err := validateRegistryURL("attestations", listed, registryURL)
- if err != nil {
- // Mirrors of npmjs can keep its URLs in packuments. The proxy only
- // ever asks the package's own registry.
- upstreamURL = registryURL + "/-/npm/v1/attestations/" + url.PathEscape(packageName) + "@" + url.PathEscape(version)
- }
-
- h.proxy.proxyImmutable(w, r, upstreamURL, "npm", cacheKey+"/-/attestations/"+version, contentTypeJSON)
-}
-
-// parseNPMAttestationsPath splits "{name}@{version}" as the attestations
-// endpoint takes it, where name may be "@scope/name" with the slash encoded
-// or not. It rejects anything that is not one package and one version.
-func parseNPMAttestationsPath(spec string) (packageName, version string, ok bool) {
- at := strings.LastIndex(spec, "@")
- if at <= 0 {
- return "", "", false
- }
- packageName, version = spec[:at], spec[at+1:]
- if version == "" || strings.ContainsAny(version, "/\\") || containsPathTraversal(packageName) || containsPathTraversal(version) {
- return "", "", false
- }
- scope, name, scoped := strings.Cut(packageName, "/")
- switch {
- case scoped:
- ok = len(scope) > 1 && scope[0] == '@' && name != "" && !strings.ContainsAny(name, "/@")
- default:
- ok = !strings.ContainsAny(packageName, "@\\")
- }
- return packageName, version, ok
-}
diff --git a/internal/handler/npm_attestations_test.go b/internal/handler/npm_attestations_test.go
deleted file mode 100644
index 3717eaf..0000000
--- a/internal/handler/npm_attestations_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package handler
-
-import (
- "net/http"
- "slices"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-const testAttestations = `{"attestations":[{"predicateType":"https://slsa.dev/provenance/v1","bundle":{}}]}`
-
-// attestedPackument returns a packument whose version lists attestations at
-// attestationsURL, resolved against the upstream's URL when it starts with
-// "/", and was published age ago.
-func attestedPackument(name, version, attestationsURL string, age time.Duration) func(string) string {
- published := time.Now().Add(-age).UTC().Format(time.RFC3339)
- return func(baseURL string) string {
- if strings.HasPrefix(attestationsURL, "/") {
- attestationsURL = baseURL + attestationsURL
- }
- dist := `{"tarball":"` + baseURL + "/" + name + `/-/x-` + version + `.tgz"`
- if attestationsURL != "" {
- dist += `,"attestations":{"url":"` + attestationsURL + `","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}}`
- }
- dist += "}"
- return `{"name":"` + name + `","dist-tags":{"latest":"` + version + `"},` +
- `"versions":{"` + version + `":{"version":"` + version + `","dist":` + dist + `}},` +
- `"time":{"` + version + `":"` + published + `"}}`
- }
-}
-
-func attestationsFile(string) string { return testAttestations }
-
-func newAttestationsProxy(t *testing.T) *Proxy {
- t.Helper()
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- return proxy
-}
-
-func TestNPMAttestationsServedAndCached(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{
- "/sigstore": attestedPackument("sigstore", "3.0.0", "/-/npm/v1/attestations/sigstore@3.0.0", 30*24*time.Hour),
- "/-/npm/v1/attestations/sigstore@3.0.0": attestationsFile,
- }, http.StatusOK)
- h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
-
- for range 2 {
- w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/sigstore@3.0.0", nil, nil)
- if w.Code != http.StatusOK || w.Body.String() != testAttestations {
- t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
- }
- }
- fetched := 0
- for _, p := range upstream.requested() {
- if p == "/-/npm/v1/attestations/sigstore@3.0.0" {
- fetched++
- }
- }
- if fetched != 1 {
- t.Errorf("attestations fetched %d times, want once: %v", fetched, upstream.requested())
- }
-}
-
-func TestNPMAttestationsRoutedPackageStaysOnRoute(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{
- routedNPMPath: attestedPackument("@example/widgets", "1.0.0", "/registry/-/npm/v1/attestations/@example%2fwidgets@1.0.0", 0),
- "/registry/-/npm/v1/attestations/@example/widgets@1.0.0": attestationsFile,
- }, http.StatusOK)
- h := newRoutedNPMHandler(newAttestationsProxy(t), public, private)
-
- for _, target := range []string{
- "/-/npm/v1/attestations/@example%2fwidgets@1.0.0",
- "/-/npm/v1/attestations/@example/widgets@1.0.0",
- } {
- w := serveNPM(t, h, http.MethodGet, target, nil, nil)
- if w.Code != http.StatusOK || w.Body.String() != testAttestations {
- t.Errorf("%s: status = %d, body = %s", target, w.Code, w.Body.String())
- }
- }
- if got := public.requested(); len(got) != 0 {
- t.Errorf("default registry was queried for a routed package: %v", got)
- }
-}
-
-// TestNPMAttestationsOnlyAskThePackageRegistry checks that an attestations
-// URL on another host is not followed: the proxy asks the package's registry
-// at its standard path instead.
-func TestNPMAttestationsOnlyAskThePackageRegistry(t *testing.T) {
- other := newNPMUpstream(t, map[string]func(string) string{"/-/npm/v1/attestations/demo@1.0.0": attestationsFile}, http.StatusOK)
- upstream := newNPMUpstream(t, map[string]func(string) string{
- "/demo": attestedPackument("demo", "1.0.0", other.URL+"/-/npm/v1/attestations/demo@1.0.0", 0),
- "/-/npm/v1/attestations/demo@1.0.0": attestationsFile,
- }, http.StatusOK)
- h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/demo@1.0.0", nil, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
- }
- if got := other.requested(); len(got) != 0 {
- t.Errorf("host named in the packument was queried: %v", got)
- }
- if !slices.Contains(upstream.requested(), "/-/npm/v1/attestations/demo@1.0.0") {
- t.Errorf("registry not asked for attestations: %v", upstream.requested())
- }
-}
-
-func TestNPMAttestationsWithheld(t *testing.T) {
- tests := []struct {
- name string
- packument func(string) string
- setup func(t *testing.T, p *Proxy)
- wantStatus int
- }{
- {"version has none", attestedPackument("demo", "1.0.0", "", 0), nil, http.StatusNotFound},
- {"version in cooldown", attestedPackument("demo", "1.0.0", "/-/npm/v1/attestations/demo@1.0.0", time.Hour),
- func(_ *testing.T, p *Proxy) { p.Cooldown = &cooldown.Config{Default: "7d"} }, http.StatusNotFound},
- {"version denied", attestedPackument("demo", "1.0.0", "/-/npm/v1/attestations/demo@1.0.0", 0),
- func(t *testing.T, p *Proxy) { setTestDenylist(t, p, "pkg:npm/demo@1.0.0") }, http.StatusForbidden},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{
- "/demo": tt.packument,
- "/-/npm/v1/attestations/demo@1.0.0": attestationsFile,
- }, http.StatusOK)
- proxy := newAttestationsProxy(t)
- if tt.setup != nil {
- tt.setup(t, proxy)
- }
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/demo@1.0.0", nil, nil)
- if w.Code != tt.wantStatus {
- t.Errorf("status = %d, want %d; body: %s", w.Code, tt.wantStatus, w.Body.String())
- }
- if slices.Contains(upstream.requested(), "/-/npm/v1/attestations/demo@1.0.0") {
- t.Error("attestations were fetched for a withheld version")
- }
- })
- }
-}
-
-func TestNPMAttestationsRejectsInvalidPaths(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
- h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
-
- for _, target := range []string{
- "/-/npm/v1/attestations/demo",
- "/-/npm/v1/attestations/demo@",
- "/-/npm/v1/attestations/@demo@1.0.0",
- "/-/npm/v1/attestations/@scope/a/b@1.0.0",
- "/-/npm/v1/attestations/demo@1.0.0/x",
- "/-/npm/v1/attestations/..%2f..%2fetc@1.0.0",
- } {
- w := serveNPM(t, h, http.MethodGet, target, nil, nil)
- if w.Code != http.StatusBadRequest {
- t.Errorf("%s: status = %d, want 400", target, w.Code)
- }
- }
- if got := upstream.requested(); len(got) != 0 {
- t.Errorf("upstream queried for invalid paths: %v", got)
- }
-}
-
-func TestNPMMetadataRewritesAttestationsURL(t *testing.T) {
- const attestations = "/registry/-/npm/v1/attestations/@example%2fwidgets@1.0.0"
- upstream := newNPMUpstream(t, map[string]func(string) string{
- routedNPMPath: attestedPackument("@example/widgets", "1.0.0", attestations, 0),
- routedNPMPath + "/1.0.0": func(baseURL string) string {
- return `{"name":"@example/widgets","version":"1.0.0","dist":{"attestations":{"url":"` + baseURL + attestations +
- `"},"tarball":"` + baseURL + `/registry/@example/widgets/-/widgets-1.0.0.tgz"}}`
- },
- }, http.StatusOK)
- h := newRoutedNPMHandler(newAttestationsProxy(t), upstream, upstream)
-
- for _, target := range []string{"/@example%2fwidgets", "/@example%2fwidgets/1.0.0"} {
- w := serveNPM(t, h, http.MethodGet, target, nil, nil)
- want := `"url":"http://proxy.test/npm/-/npm/v1/attestations/@example%2Fwidgets@1.0.0"`
- if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) {
- t.Errorf("%s: status %d, attestations URL not rewritten: %s", target, w.Code, w.Body.String())
- }
- }
-}
diff --git a/internal/handler/npm_dist_tags.go b/internal/handler/npm_dist_tags.go
deleted file mode 100644
index 210b45f..0000000
--- a/internal/handler/npm_dist_tags.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "net/http"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/jsonscan"
-)
-
-// npmPackagePrefix and npmDistTagsSuffix frame the path `npm dist-tag ls`
-// reads: /-/package/{name}/dist-tags, with a scoped name's slash encoded.
-const (
- npmPackagePrefix = "/-/package/"
- npmDistTagsSuffix = "/dist-tags"
-)
-
-// handleDistTags answers `npm dist-tag ls` from the dist-tags of the
-// packument the proxy serves, not from upstream's, so a tag never names a
-// version cooldown or the denylist holds back, and pushed versions and
-// package routes count as they do for the packument. Changing tags is not
-// supported; Routes answers anything but GET with 405.
-func (h *NPMHandler) handleDistTags(w http.ResponseWriter, r *http.Request) {
- packageName := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, npmPackagePrefix), npmDistTagsSuffix)
- if !validNPMPackageName(packageName) {
- JSONError(w, http.StatusBadRequest, "invalid package name")
- return
- }
-
- packument, stale, err := h.servedPackument(r.Context(), packageName)
- if err != nil {
- if r.Context().Err() != nil {
- return
- }
- if errors.Is(err, ErrUpstreamNotFound) || errors.Is(err, ErrPackageNotFound) {
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("failed to read npm dist-tags", "package", packageName, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
- return
- }
-
- tags, err := jsonscan.Lookup(packument, "dist-tags")
- if err != nil || len(tags) == 0 || tags[0] != '{' {
- tags = []byte("{}")
- }
- markStale(w, stale)
- w.Header().Set(headerContentType, contentTypeJSON)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(tags)
-}
-
-// servedPackument returns the packument of packageName as the proxy serves
-// it: from the package's registry or route, filtered by cooldown and the
-// denylist, with pushed versions added, or built from the pushed versions
-// alone in a hosted-only scope. stale reports a cached copy served because
-// upstream failed.
-func (h *NPMHandler) servedPackument(ctx context.Context, packageName string) (body []byte, stale bool, err error) {
- local, err := h.npmLocalEntries(packageName)
- if err != nil {
- return nil, false, err
- }
- if h.proxy.hostedOnly("npm", packageName) {
- if len(local) == 0 {
- return nil, false, ErrPackageNotFound
- }
- body, err := localNPMPackument(packageName, local)
- return body, false, err
- }
-
- registryURL, cacheKey := h.sourceFor(packageName)
- _, registryPath := npmMetadataPath(packageName)
- upstream := h.proxy.fetchMetadataDocument(ctx, "npm", cacheKey, registryURL+"/"+registryPath, h.packumentAccept(), h.packumentValidator())
- switch {
- case errors.Is(upstream.err, ErrUpstreamNotFound) && len(local) > 0:
- body, err := localNPMPackument(packageName, local)
- return body, false, err
- case upstream.err != nil:
- return nil, false, upstream.err
- case len(local) > 0:
- body, _, err := h.rewriteMetadataWithLocal(packageName, upstream.body, local)
- return body, upstream.stale, err
- }
-
- rewritten, err := h.proxy.cachedRewrite(ctx, "npm", h.proxyURL, cacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) {
- return h.rewriteMetadataKeeping(packageName, b)
- })
- if err != nil {
- if h.proxy.mustFilterMetadata("npm", packageName) {
- return nil, false, err
- }
- // The packument route serves upstream's document when the rewrite
- // fails and nothing has to be filtered out.
- return upstream.body, upstream.stale, nil
- }
- return rewritten.content(), upstream.stale, nil
-}
-
-// validNPMPackageName reports whether name is a plain or scoped package
-// name, without path traversal.
-func validNPMPackageName(name string) bool {
- if name == "" || containsPathTraversal(name) || strings.ContainsAny(name, "\\") {
- return false
- }
- scope, rest, scoped := strings.Cut(name, "/")
- if !scoped {
- return !strings.HasPrefix(name, "@")
- }
- return len(scope) > 1 && scope[0] == '@' && rest != "" && !strings.Contains(rest, "/")
-}
diff --git a/internal/handler/npm_dist_tags_test.go b/internal/handler/npm_dist_tags_test.go
deleted file mode 100644
index a23a1c9..0000000
--- a/internal/handler/npm_dist_tags_test.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "net/http"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// taggedPackument has 1.0.0 published a month ago and 2.0.0 an hour ago,
-// tagged latest.
-func taggedPackument(name string) func(string) string {
- month := time.Now().Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
- hour := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
- return func(baseURL string) string {
- return `{"name":"` + name + `","dist-tags":{"latest":"2.0.0","stable":"1.0.0"},"versions":{` +
- `"1.0.0":{"version":"1.0.0","dist":{"tarball":"` + baseURL + `/x/-/x-1.0.0.tgz"}},` +
- `"2.0.0":{"version":"2.0.0","dist":{"tarball":"` + baseURL + `/x/-/x-2.0.0.tgz"}}},` +
- `"time":{"1.0.0":"` + month + `","2.0.0":"` + hour + `"}}`
- }
-}
-
-func decodeDistTags(t *testing.T, code int, body []byte) map[string]string {
- t.Helper()
- if code != http.StatusOK {
- t.Fatalf("status = %d, body: %s", code, body)
- }
- var tags map[string]string
- if err := json.Unmarshal(body, &tags); err != nil {
- t.Fatalf("dist-tags are not a JSON object: %v: %s", err, body)
- }
- return tags
-}
-
-func TestNPMDistTags(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{"/demo": taggedPackument("demo")}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, "/-/package/demo/dist-tags", nil, nil)
- tags := decodeDistTags(t, w.Code, w.Body.Bytes())
- if tags["latest"] != "2.0.0" || tags["stable"] != "1.0.0" || len(tags) != 2 {
- t.Errorf("dist-tags = %v", tags)
- }
-}
-
-// TestNPMDistTagsFollowCooldown checks that latest never names a version
-// cooldown holds back, as in the packument.
-func TestNPMDistTagsFollowCooldown(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{"/demo": taggedPackument("demo")}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, "/-/package/demo/dist-tags", nil, nil)
- if tags := decodeDistTags(t, w.Code, w.Body.Bytes()); tags["latest"] != "1.0.0" {
- t.Errorf("dist-tags = %v, want latest 1.0.0", tags)
- }
-}
-
-func TestNPMDistTagsRoutedPackage(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/-/package/@example%2fwidgets/dist-tags", nil, nil)
- if tags := decodeDistTags(t, w.Code, w.Body.Bytes()); tags["latest"] != "1.0.0" {
- t.Errorf("dist-tags = %v, want the route's latest 1.0.0", tags)
- }
- if got := public.requested(); len(got) != 0 {
- t.Errorf("default registry was queried for a routed package: %v", got)
- }
-}
-
-func TestNPMDistTagsHostedOnlyScope(t *testing.T) {
- f := newNPMHostedFixture(t, nil)
- f.proxy.HostedNPMScopes = []string{"@acme"}
- f.push(t, "@acme/private", "1.10.0", `{"name":"@acme/private","version":"1.10.0"}`)
- f.push(t, "@acme/private", "1.9.0", `{"name":"@acme/private","version":"1.9.0"}`)
-
- rec := f.get(t, "/-/package/@acme%2fprivate/dist-tags")
- if tags := decodeDistTags(t, rec.Code, rec.Body.Bytes()); tags["latest"] != "1.10.0" {
- t.Errorf("dist-tags = %v, want latest 1.10.0", tags)
- }
- if rec := f.get(t, "/-/package/@acme%2fother/dist-tags"); rec.Code != http.StatusNotFound {
- t.Errorf("unpushed package status = %d, want 404", rec.Code)
- }
- if *f.upstreamRequests != 0 {
- t.Errorf("hosted-only scope sent %d requests upstream", *f.upstreamRequests)
- }
-}
-
-func TestNPMDistTagsErrors(t *testing.T) {
- upstream := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- tests := []struct {
- method, target string
- want int
- }{
- {http.MethodGet, "/-/package/missing/dist-tags", http.StatusNotFound},
- {http.MethodGet, "/-/package/@scope/dist-tags", http.StatusBadRequest},
- {http.MethodGet, "/-/package/a/b/c/dist-tags", http.StatusBadRequest},
- {http.MethodGet, "/-/package/..%2f..%2fetc/dist-tags", http.StatusBadRequest},
- {http.MethodPut, "/-/package/demo/dist-tags/beta", http.StatusMethodNotAllowed},
- {http.MethodDelete, "/-/package/demo/dist-tags/beta", http.StatusMethodNotAllowed},
- }
- for _, tt := range tests {
- if w := serveNPM(t, h, tt.method, tt.target, nil, nil); w.Code != tt.want {
- t.Errorf("%s %s: status = %d, want %d", tt.method, tt.target, w.Code, tt.want)
- }
- }
- if got := upstream.requested(); len(got) != 1 {
- t.Errorf("upstream requests = %v, want only the missing package", got)
- }
-}
-
-// TestNPMDistTagsWithPushedVersion checks that a push next to upstream
-// versions leaves the tags as upstream has them, as in the packument.
-func TestNPMDistTagsWithPushedVersion(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.4", `{"name":"request","version":"2.88.4"}`)
-
- rec := f.get(t, "/-/package/request/dist-tags")
- if tags := decodeDistTags(t, rec.Code, rec.Body.Bytes()); tags["latest"] != "2.88.2" {
- t.Errorf("dist-tags = %v, want upstream's latest 2.88.2", tags)
- }
-}
diff --git a/internal/handler/npm_hosted.go b/internal/handler/npm_hosted.go
deleted file mode 100644
index 77f9227..0000000
--- a/internal/handler/npm_hosted.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "time"
-
- "github.com/Masterminds/semver/v3"
- "github.com/git-pkgs/proxy/internal/metadata/npm"
-)
-
-type npmLocalEntry = npm.LocalVersion
-
-// npmLocalEntries returns the pushed versions of a package as packument
-// entries, or nil when there are none or hosted packages are disabled.
-func (h *NPMHandler) npmLocalEntries(packageName string) ([]npmLocalEntry, error) {
- local, err := h.proxy.localVersions("npm", packageName)
- if err != nil || len(local) == 0 {
- return nil, err
- }
- entries := make([]npmLocalEntry, 0, len(local))
- for _, lv := range local {
- var manifest map[string]any
- if err := json.Unmarshal(lv.Manifest, &manifest); err != nil {
- return nil, fmt.Errorf("decoding manifest of %s@%s: %w", packageName, lv.Version, err)
- }
- manifest["name"] = packageName
- manifest["version"] = lv.Version
- manifest["_id"] = packageName + "@" + lv.Version
- manifest["dist"] = map[string]string{
- "tarball": h.proxyTarballURL(packageName, lv.Version, lv.Filename),
- "integrity": lv.Integrity,
- }
- entry, err := json.Marshal(manifest)
- if err != nil {
- return nil, err
- }
- entries = append(entries, npmLocalEntry{Version: lv.Version, Entry: entry, PublishedAt: lv.PushedAt.Format(time.RFC3339)})
- }
- return entries, nil
-}
-
-func findNPMLocalEntry(local []npmLocalEntry, version string) (npmLocalEntry, bool) {
- for _, e := range local {
- if e.Version == version {
- return e, true
- }
- }
- return npmLocalEntry{}, false
-}
-
-// npmMetadataRequest is a packument or version metadata request.
-type npmMetadataRequest struct {
- packageName string
- specifier string
- versionLookup bool
- upstreamURL string
- accept string
- metadataCacheKey string
-}
-
-// serveLocalMetadata answers the metadata requests that pushed versions
-// decide, and reports whether it did: a request for a pushed version, any
-// request for a package in a hosted-only scope, and a packument of a package
-// with pushed versions. Everything else takes the usual upstream path.
-func (h *NPMHandler) serveLocalMetadata(w http.ResponseWriter, r *http.Request, req npmMetadataRequest) bool {
- local, err := h.npmLocalEntries(req.packageName)
- if err != nil {
- h.proxy.Logger.Error("failed to read pushed npm versions", "package", req.packageName, "error", err)
- JSONError(w, http.StatusInternalServerError, "failed to read pushed versions")
- return true
- }
- switch e, pushed := findNPMLocalEntry(local, req.specifier); {
- case req.versionLookup && pushed:
- w.Header().Set(headerContentType, contentTypeJSON)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(e.Entry)
- case h.proxy.hostedOnly("npm", req.packageName):
- h.serveHostedOnlyMetadata(w, r, req.packageName, req.specifier, req.versionLookup, req.metadataCacheKey, local)
- case len(local) > 0 && !req.versionLookup:
- h.serveMetadataWithLocal(w, r, req.packageName, req.upstreamURL, req.accept, req.metadataCacheKey, local)
- default:
- return false
- }
- return true
-}
-
-// serveMetadataWithLocal serves a packument with the pushed versions of the
-// package added. It bypasses the rewrite cache, whose entries are keyed by the
-// upstream document alone, so a push or delete shows up on the next request.
-// When the upstream does not know the package, the packument holds only the
-// pushed versions.
-func (h *NPMHandler) serveMetadataWithLocal(w http.ResponseWriter, r *http.Request, packageName, upstreamURL, accept, metadataCacheKey string, local []npmLocalEntry) {
- upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept, h.packumentValidator())
- var out []byte
- var err error
- switch {
- case errors.Is(upstream.err, ErrUpstreamNotFound):
- out, err = localNPMPackument(packageName, local)
- case upstream.err != nil:
- if writeUpstreamHeld(w, upstream.err) {
- return
- }
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", upstream.err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
- return
- default:
- out, _, err = h.rewriteMetadataWithLocal(packageName, upstream.body, local)
- }
- if err != nil {
- if errors.Is(err, ErrVersionDenied) {
- JSONError(w, http.StatusForbidden, err.Error())
- return
- }
- h.proxy.Logger.Error("failed to add pushed versions to npm metadata", "package", packageName, "error", err)
- JSONError(w, http.StatusBadGateway, "failed to rewrite package metadata")
- return
- }
- markStale(w, upstream.stale)
- serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("npm", h.proxyURL, metadataCacheKey, out))
-}
-
-// serveHostedOnlyMetadata answers a metadata request for a package in a
-// hosted-only scope from its pushed versions alone.
-func (h *NPMHandler) serveHostedOnlyMetadata(w http.ResponseWriter, r *http.Request, packageName, specifier string, versionLookup bool, metadataCacheKey string, local []npmLocalEntry) {
- if len(local) == 0 {
- JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
- return
- }
- if versionLookup {
- if specifier == "latest" {
- specifier = latestNPMVersion(local)
- }
- e, ok := findNPMLocalEntry(local, specifier)
- if !ok {
- JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(e.Entry)
- return
- }
- out, err := localNPMPackument(packageName, local)
- if err != nil {
- h.proxy.Logger.Error("failed to build npm metadata from pushed versions", "package", packageName, "error", err)
- JSONError(w, http.StatusInternalServerError, "failed to build package metadata")
- return
- }
- serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("npm", h.proxyURL, metadataCacheKey, out))
-}
-
-// localNPMPackument builds a packument from pushed versions alone.
-func localNPMPackument(packageName string, local []npmLocalEntry) ([]byte, error) {
- versions := make(map[string]json.RawMessage, len(local))
- times := make(map[string]string, len(local))
- for _, e := range local {
- versions[e.Version] = e.Entry
- times[e.Version] = e.PublishedAt
- }
- return json.Marshal(map[string]any{
- "name": packageName,
- "dist-tags": map[string]string{"latest": latestNPMVersion(local)},
- "versions": versions,
- "time": times,
- })
-}
-
-// latestNPMVersion returns the highest pushed version, or the last pushed
-// when a version is not semver.
-func latestNPMVersion(local []npmLocalEntry) string {
- latest, latestTime := "", ""
- var latestSemver *semver.Version
- semverOnly := true
- for _, e := range local {
- if e.PublishedAt > latestTime {
- latest, latestTime = e.Version, e.PublishedAt
- }
- v, err := semver.NewVersion(e.Version)
- if err != nil {
- semverOnly = false
- continue
- }
- if latestSemver == nil || v.GreaterThan(latestSemver) {
- latestSemver = v
- }
- }
- if semverOnly && latestSemver != nil {
- return latestSemver.Original()
- }
- return latest
-}
diff --git a/internal/handler/npm_hosted_test.go b/internal/handler/npm_hosted_test.go
deleted file mode 100644
index 083319a..0000000
--- a/internal/handler/npm_hosted_test.go
+++ /dev/null
@@ -1,369 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-const requestPackument = `{
- "name": "request",
- "dist-tags": {"latest": "2.88.2"},
- "versions": {
- "2.88.2": {"name": "request", "version": "2.88.2", "dependencies": {"qs": "~6.5.1"},
- "dist": {"tarball": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-upstream2"}},
- "2.88.3": {"name": "request", "version": "2.88.3", "dependencies": {"old": "1.0.0"},
- "dist": {"tarball": "https://registry.npmjs.org/request/-/request-2.88.3.tgz", "integrity": "sha512-upstream3"}}
- },
- "time": {"modified": "2020-02-11T00:00:00Z", "2.88.2": "2020-02-11T00:00:00Z", "2.88.3": "2020-02-12T00:00:00Z"}
-}`
-
-type npmHostedFixture struct {
- proxy *Proxy
- handler http.Handler
- upstreamRequests *int
- fetcher *mockFetcher
-}
-
-// newNPMHostedFixture serves packuments from upstream, keyed by request path,
-// and answers 404 for anything else.
-func newNPMHostedFixture(t *testing.T, upstream map[string]string) npmHostedFixture {
- t.Helper()
- upstreamRequests := 0
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- upstreamRequests++
- body, ok := upstream[r.URL.EscapedPath()]
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(body))
- }))
- t.Cleanup(srv.Close)
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HostedEnabled = true
- proxy.HTTPClient = srv.Client()
- return npmHostedFixture{proxy: proxy, handler: NewNPMHandler(proxy, "http://proxy.local", srv.URL).Routes(),
- upstreamRequests: &upstreamRequests, fetcher: fetcher}
-}
-
-func (f npmHostedFixture) push(t *testing.T, name, version, manifest string) HostedArtifact {
- t.Helper()
- target, err := HostedTargetFor("npm", name, version, "")
- if err != nil {
- t.Fatalf("HostedTargetFor: %v", err)
- }
- pushed, err := f.proxy.PushLocalArtifact(context.Background(), "npm", target, bytes.NewReader(npmTarball(manifest)), "", "")
- if err != nil {
- t.Fatalf("PushLocalArtifact: %v", err)
- }
- return pushed
-}
-
-func (f npmHostedFixture) get(t *testing.T, path string) *httptest.ResponseRecorder {
- t.Helper()
- rec := httptest.NewRecorder()
- f.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
- return rec
-}
-
-type npmTestPackument struct {
- Name string `json:"name"`
- DistTags map[string]string `json:"dist-tags"`
- Versions map[string]npmTestVersion `json:"versions"`
- Time map[string]string `json:"time"`
- Extra map[string]json.RawMessage `json:"-"`
-}
-
-type npmTestVersion struct {
- Name string `json:"name"`
- Version string `json:"version"`
- ID string `json:"_id"`
- Dependencies map[string]string `json:"dependencies"`
- Dist struct {
- Tarball string `json:"tarball"`
- Integrity string `json:"integrity"`
- } `json:"dist"`
-}
-
-func decodePackument(t *testing.T, rec *httptest.ResponseRecorder) npmTestPackument {
- t.Helper()
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, body %s", rec.Code, rec.Body)
- }
- var doc npmTestPackument
- if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
- t.Fatalf("decoding packument: %v\n%s", err, rec.Body)
- }
- return doc
-}
-
-func TestNPMMetadataListsPushedVersion(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- pushed := f.push(t, "request", "2.88.4", `{"name":"request","version":"2.88.4","dependencies":{"qs":"~6.5.2"}}`)
-
- doc := decodePackument(t, f.get(t, "/request"))
-
- local, ok := doc.Versions["2.88.4"]
- if !ok {
- t.Fatalf("pushed version missing from versions: %v", doc.Versions)
- }
- if local.Name != "request" || local.Version != "2.88.4" || local.ID != "request@2.88.4" || local.Dependencies["qs"] != "~6.5.2" {
- t.Errorf("pushed version entry = %+v", local)
- }
- if local.Dist.Tarball != "http://proxy.local/npm/request/-/request-2.88.4.tgz" {
- t.Errorf("tarball = %q", local.Dist.Tarball)
- }
- if !strings.HasPrefix(local.Dist.Integrity, "sha512-") {
- t.Errorf("integrity = %q, want the pushed archive's sha512", local.Dist.Integrity)
- }
- if doc.Time["2.88.4"] != pushed.PushedAt.Format(time.RFC3339) {
- t.Errorf("time[2.88.4] = %q, want %q", doc.Time["2.88.4"], pushed.PushedAt.Format(time.RFC3339))
- }
- if doc.Versions["2.88.2"].Dist.Tarball != "http://proxy.local/npm/request/-/request-2.88.2.tgz" {
- t.Errorf("upstream version not rewritten: %+v", doc.Versions["2.88.2"])
- }
- if doc.DistTags["latest"] != "2.88.2" {
- t.Errorf("dist-tags.latest = %q, want upstream's 2.88.2", doc.DistTags["latest"])
- }
-}
-
-func TestNPMMetadataPushedVersionReplacesUpstream(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.3", `{"name":"request","version":"2.88.3","dependencies":{"qs":"~6.5.2"}}`)
-
- rec := f.get(t, "/request")
- if body := rec.Body.String(); strings.Contains(body, "sha512-upstream3") || strings.Count(body, `"2.88.3":{`) != 1 {
- t.Fatalf("upstream entry for the pushed version still listed:\n%s", body)
- }
- local := decodePackument(t, rec).Versions["2.88.3"]
- if local.Dependencies["qs"] != "~6.5.2" || local.Dependencies["old"] != "" {
- t.Errorf("dependencies = %v, want the pushed package.json's", local.Dependencies)
- }
-}
-
-func TestNPMMetadataForPackageOnlyPushed(t *testing.T) {
- f := newNPMHostedFixture(t, nil)
- f.push(t, "@acme/private", "1.10.0", `{"name":"@acme/private","version":"1.10.0"}`)
- f.push(t, "@acme/private", "1.9.0", `{"name":"@acme/private","version":"1.9.0"}`)
-
- doc := decodePackument(t, f.get(t, "/@acme/private"))
- if doc.Name != "@acme/private" || len(doc.Versions) != 2 {
- t.Errorf("packument = %+v", doc)
- }
- if doc.DistTags["latest"] != "1.10.0" {
- t.Errorf("dist-tags.latest = %q, want the highest pushed version 1.10.0", doc.DistTags["latest"])
- }
- if doc.Versions["1.9.0"].Dist.Tarball != "http://proxy.local/npm/@acme%2Fprivate/-/private-1.9.0.tgz" {
- t.Errorf("tarball = %q", doc.Versions["1.9.0"].Dist.Tarball)
- }
-
- if rec := f.get(t, "/@acme/other"); rec.Code != http.StatusNotFound {
- t.Errorf("unknown package status = %d, want 404", rec.Code)
- }
-}
-
-func TestNPMVersionMetadataForPushedVersion(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.4", `{"name":"request","version":"2.88.4","dependencies":{"qs":"~6.5.2"}}`)
-
- rec := f.get(t, "/request/2.88.4")
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, body %s", rec.Code, rec.Body)
- }
- var version npmTestVersion
- if err := json.Unmarshal(rec.Body.Bytes(), &version); err != nil {
- t.Fatalf("decoding version: %v", err)
- }
- if version.Version != "2.88.4" || version.Dist.Tarball != "http://proxy.local/npm/request/-/request-2.88.4.tgz" {
- t.Errorf("version document = %+v", version)
- }
-}
-
-func TestNPMMetadataFollowsPushAndDelete(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.proxy.CacheMetadata = true
- f.proxy.MetadataTTL = time.Hour
- f.proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- before := f.get(t, "/request")
- if before.Code != http.StatusOK {
- t.Fatalf("status = %d", before.Code)
- }
-
- f.push(t, "request", "2.88.4", `{"name":"request","version":"2.88.4"}`)
- pushed := f.get(t, "/request")
- if _, ok := decodePackument(t, pushed).Versions["2.88.4"]; !ok {
- t.Fatal("pushed version not listed while the upstream document is cached")
- }
- if pushed.Header().Get("ETag") == before.Header().Get("ETag") {
- t.Error("ETag unchanged after a push")
- }
-
- if err := f.proxy.DeleteLocalVersion("npm", "request", "2.88.4"); err != nil {
- t.Fatalf("DeleteLocalVersion: %v", err)
- }
- if _, ok := decodePackument(t, f.get(t, "/request")).Versions["2.88.4"]; ok {
- t.Error("deleted version still listed")
- }
-}
-
-func TestNPMMetadataIgnoresPushesWhenHostedDisabled(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.4", `{"name":"request","version":"2.88.4"}`)
- f.proxy.HostedEnabled = false
-
- if _, ok := decodePackument(t, f.get(t, "/request")).Versions["2.88.4"]; ok {
- t.Error("pushed version listed with hosted packages disabled")
- }
-}
-
-// With hosted packages disabled, a pushed archive is not served ahead of the
-// upstream, but it is still never replaced by the upstream copy: the
-// download is answered from its stored record like any cached archive.
-func TestNPMDownloadWithHostedDisabledKeepsPush(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- manifest := `{"name":"request","version":"2.88.3"}`
- f.push(t, "request", "2.88.3", manifest)
- f.proxy.HostedEnabled = false
-
- rec := f.get(t, "/request/-/request-2.88.3.tgz")
- if rec.Code != http.StatusOK || rec.Body.String() != string(npmTarball(manifest)) {
- t.Errorf("download status = %d, want 200 and the pushed tarball", rec.Code)
- }
- if f.fetcher.fetchCalled {
- t.Error("the upstream tarball was fetched for a pushed version")
- }
-}
-
-func TestNPMDownloadOfPushedPackageWithCooldown(t *testing.T) {
- f := newNPMHostedFixture(t, nil)
- f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
- f.push(t, "@acme/private", "1.0.0", `{"name":"@acme/private","version":"1.0.0"}`)
-
- rec := f.get(t, "/@acme/private/-/private-1.0.0.tgz")
- if rec.Code != http.StatusOK {
- t.Fatalf("download status = %d, body %s, want the pushed tarball", rec.Code, rec.Body)
- }
- if _, ok := decodePackument(t, f.get(t, "/@acme/private")).Versions["1.0.0"]; !ok {
- t.Error("pushed version missing from the packument with cooldown enabled")
- }
-}
-
-func TestNPMDownloadOfPushedOverrideOfVersionInCooldown(t *testing.T) {
- recent := time.Now().Add(-time.Hour).Format(time.RFC3339)
- packument := `{"name":"request","dist-tags":{"latest":"2.88.2"},
- "versions":{
- "2.88.2":{"name":"request","version":"2.88.2","dist":{"tarball":"https://registry.npmjs.org/request/-/request-2.88.2.tgz"}},
- "2.88.3":{"name":"request","version":"2.88.3","dist":{"tarball":"https://registry.npmjs.org/request/-/request-2.88.3.tgz"}}},
- "time":{"2.88.2":"2020-02-11T00:00:00Z","2.88.3":"` + recent + `"}}`
- f := newNPMHostedFixture(t, map[string]string{"/request": packument})
- f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
- f.push(t, "request", "2.88.3", `{"name":"request","version":"2.88.3"}`)
-
- if _, ok := decodePackument(t, f.get(t, "/request")).Versions["2.88.3"]; !ok {
- t.Fatal("pushed version missing from the packument")
- }
- rec := f.get(t, "/request/-/request-2.88.3.tgz")
- if rec.Code != http.StatusOK || rec.Body.String() != string(npmTarball(`{"name":"request","version":"2.88.3"}`)) {
- t.Errorf("download status = %d, want 200 and the pushed tarball: the packument lists it, so the download must not be withheld", rec.Code)
- }
-}
-
-func TestNPMHostedOnlyScopeNeverAsksUpstream(t *testing.T) {
- squatter := `{"name":"@acme/private","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.1":{"name":"@acme/private","version":"1.0.1",
- "dist":{"tarball":"https://registry.npmjs.org/@acme/private/-/private-1.0.1.tgz"}}}}`
- f := newNPMHostedFixture(t, map[string]string{"/@acme%2fprivate": squatter, "/@acme%2Fprivate": squatter, "/request": requestPackument})
- f.proxy.HostedNPMScopes = []string{"@acme"}
- f.push(t, "@acme/private", "1.0.0", `{"name":"@acme/private","version":"1.0.0"}`)
-
- doc := decodePackument(t, f.get(t, "/@acme/private"))
- if len(doc.Versions) != 1 || doc.DistTags["latest"] != "1.0.0" {
- t.Errorf("packument = %+v, want only the pushed 1.0.0", doc)
- }
- if rec := f.get(t, "/@acme/private/latest"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":"1.0.0"`) {
- t.Errorf("latest status = %d, body %s", rec.Code, rec.Body)
- }
- if rec := f.get(t, "/@acme/private/1.0.1"); rec.Code != http.StatusNotFound {
- t.Errorf("unpushed version status = %d, want 404", rec.Code)
- }
- if rec := f.get(t, "/@acme/private/-/private-1.0.1.tgz"); rec.Code != http.StatusNotFound {
- t.Errorf("unpushed tarball status = %d, want 404", rec.Code)
- }
- if rec := f.get(t, "/@acme/other"); rec.Code != http.StatusNotFound {
- t.Errorf("unpushed package status = %d, want 404", rec.Code)
- }
- if rec := f.get(t, "/@acme/private/-/private-1.0.0.tgz"); rec.Code != http.StatusOK {
- t.Errorf("pushed tarball status = %d, want 200", rec.Code)
- }
- if *f.upstreamRequests != 0 {
- t.Errorf("hosted-only scope sent %d requests upstream", *f.upstreamRequests)
- }
-
- if rec := f.get(t, "/request"); rec.Code != http.StatusOK || *f.upstreamRequests == 0 {
- t.Errorf("package outside the scope status = %d after %d upstream requests, want it fetched upstream", rec.Code, *f.upstreamRequests)
- }
-}
-
-func TestNPMDownloadOfUnreadablePushedTarball(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.3", `{"name":"request","version":"2.88.3"}`)
- removeStored(t, f.proxy, "pkg:npm/request@2.88.3", "request-2.88.3.tgz")
-
- if rec := f.get(t, "/request/-/request-2.88.3.tgz"); rec.Code != http.StatusServiceUnavailable {
- t.Errorf("download status = %d, body %s, want 503 rather than the upstream tarball", rec.Code, rec.Body)
- }
-}
-
-func TestNPMMetadataLeavesOutDeniedPushedVersions(t *testing.T) {
- f := newNPMHostedFixture(t, nil)
- f.push(t, "@acme/private", "1.0.0", `{"name":"@acme/private","version":"1.0.0"}`)
- f.push(t, "@acme/private", "1.1.0", `{"name":"@acme/private","version":"1.1.0"}`)
- setTestDenylist(t, f.proxy, "pkg:npm/@acme/private@1.1.0")
-
- doc := decodePackument(t, f.get(t, "/@acme/private"))
- if _, ok := doc.Versions["1.1.0"]; ok || len(doc.Versions) != 1 {
- t.Errorf("versions = %v, want the denied 1.1.0 left out", doc.Versions)
- }
- if doc.DistTags["latest"] != "1.0.0" {
- t.Errorf("dist-tags.latest = %q, want 1.0.0", doc.DistTags["latest"])
- }
-}
-
-func TestNPMDownloadOfPushedTarballWhileStreamingArtifacts(t *testing.T) {
- const manifest = `{"name":"request","version":"2.88.3"}`
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("upstream tarball")), ContentType: "application/gzip"}
- f.push(t, "request", "2.88.3", manifest)
- f.proxy.StreamArtifacts = true
-
- rec := f.get(t, "/request/-/request-2.88.3.tgz")
- if rec.Code != http.StatusOK || rec.Body.String() != string(npmTarball(manifest)) {
- t.Errorf("download status = %d, body %q, want 200 and the pushed tarball", rec.Code, rec.Body)
- }
- if f.fetcher.fetchCalled {
- t.Error("a pushed tarball was fetched upstream while streaming artifacts")
- }
-}
-
-func TestNPMDownloadOfUnreadablePushedTarballWhileStreamingArtifacts(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/request": requestPackument})
- f.push(t, "request", "2.88.3", `{"name":"request","version":"2.88.3"}`)
- removeStored(t, f.proxy, "pkg:npm/request@2.88.3", "request-2.88.3.tgz")
- f.proxy.StreamArtifacts = true
-
- if rec := f.get(t, "/request/-/request-2.88.3.tgz"); rec.Code != http.StatusServiceUnavailable {
- t.Errorf("download status = %d, body %s, want 503 rather than the upstream tarball", rec.Code, rec.Body)
- }
-}
diff --git a/internal/handler/npm_integrity_test.go b/internal/handler/npm_integrity_test.go
deleted file mode 100644
index 2c14614..0000000
--- a/internal/handler/npm_integrity_test.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha1" //nolint:gosec // npm's dist.shasum is SHA-1
- "encoding/hex"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/registries/fetch"
-)
-
-const npmIntegrityTarball = "tarball data"
-
-var npmIntegrityCases = []struct {
- name string
- dist map[string]string
- wantOK bool
-}{
- {"matching sha512", map[string]string{"integrity": sha512SRI(npmIntegrityTarball)}, true},
- {"mismatched sha512", map[string]string{"integrity": sha512SRI("other bytes")}, false},
- {"matching sha256", map[string]string{"integrity": sha256SRI(npmIntegrityTarball)}, true},
- {"strongest algorithm decides", map[string]string{"integrity": sha256SRI("other bytes") + " " + sha512SRI(npmIntegrityTarball)}, true},
- {"any digest of the strongest algorithm", map[string]string{"integrity": sha512SRI("other bytes") + " " + sha512SRI(npmIntegrityTarball)}, true},
- {"unparseable integrity", map[string]string{"integrity": "sha512-nope"}, true},
- {"matching shasum", map[string]string{"shasum": sha1Hex(npmIntegrityTarball)}, true},
- {"mismatched shasum", map[string]string{"shasum": sha1Hex("other bytes")}, false},
- {"integrity wins over shasum", map[string]string{"integrity": sha512SRI("other bytes"), "shasum": sha1Hex(npmIntegrityTarball)}, false},
- {"unparseable integrity falls back to shasum", map[string]string{"integrity": "sha512-nope", "shasum": sha1Hex("other bytes")}, false},
- {"unparseable shasum", map[string]string{"shasum": "not-a-sha1"}, true},
- {"no checksum", map[string]string{}, true},
-}
-
-// TestNPMDownloadVerifiesDeclaredIntegrity checks a freshly fetched tarball
-// against dist.integrity: a mismatch is a 502 and nothing is cached.
-func TestNPMDownloadVerifiesDeclaredIntegrity(t *testing.T) {
- for _, tt := range npmIntegrityCases {
- t.Run(tt.name, func(t *testing.T) {
- proxy, srv := npmIntegrityProxy(t, tt.dist)
-
- resp, body := npmIntegrityGet(t, srv)
- if wantStatus := map[bool]int{true: http.StatusOK, false: http.StatusBadGateway}[tt.wantOK]; resp.StatusCode != wantStatus {
- t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, wantStatus, body)
- }
- if tt.wantOK && body != npmIntegrityTarball {
- t.Errorf("body = %q, want the tarball", body)
- }
- cached, err := proxy.GetCachedArtifact(context.Background(), "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("reading cache: %v", err)
- }
- if cached != nil {
- _ = cached.Reader.Close()
- }
- if (cached != nil) != tt.wantOK {
- t.Errorf("cached = %v, want %v", cached != nil, tt.wantOK)
- }
- })
- }
-}
-
-// TestNPMDownloadRecordsDeclaredIntegrity checks that the integrity a tarball
-// was verified against is kept with the cached version, so cache hits are
-// verified against it too.
-func TestNPMDownloadRecordsDeclaredIntegrity(t *testing.T) {
- sri := sha512SRI(npmIntegrityTarball)
- proxy, srv := npmIntegrityProxy(t, map[string]string{"integrity": sri})
- if resp, body := npmIntegrityGet(t, srv); resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d; body: %s", resp.StatusCode, body)
- }
-
- pkgPURL, versionPURL, err := packagePURLStrings("npm", "leftpad", "1.0.0")
- if err != nil {
- t.Fatal(err)
- }
- record, err := proxy.DB.GetCachedArtifact(pkgPURL, versionPURL, "leftpad-1.0.0.tgz")
- if err != nil || record == nil {
- t.Fatalf("cache record = %v, %v", record, err)
- }
- if record.Integrity.String != sri {
- t.Errorf("recorded integrity = %q, want %q", record.Integrity.String, sri)
- }
-}
-
-// TestNPMStreamedDownloadVerifiesDeclaredIntegrity checks dist.integrity
-// when tarballs are streamed without being stored: a mismatch aborts the
-// response, so the client never receives a complete body.
-func TestNPMStreamedDownloadVerifiesDeclaredIntegrity(t *testing.T) {
- for _, tt := range npmIntegrityCases {
- t.Run(tt.name, func(t *testing.T) {
- proxy, srv := npmIntegrityProxy(t, tt.dist)
- proxy.StreamArtifacts = true
-
- // The response is aborted on a mismatch: before its headers when
- // the body fits in the server's buffer, during the body otherwise.
- body, err := func() ([]byte, error) {
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- return io.ReadAll(resp.Body)
- }()
-
- if tt.wantOK {
- if err != nil || string(body) != npmIntegrityTarball {
- t.Errorf("body = %q, %v; want the complete tarball", body, err)
- }
- return
- }
- if err == nil {
- t.Errorf("received the whole body (%q), want the response aborted", body)
- }
- })
- }
-}
-
-func npmIntegrityProxy(t *testing.T, dist map[string]string) (*Proxy, *httptest.Server) {
- t.Helper()
- upstream := npmIntegrityUpstream(t, dist)
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader(npmIntegrityTarball)), Size: int64(len(npmIntegrityTarball))}
-
- srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
- t.Cleanup(srv.Close)
- return proxy, srv
-}
-
-func npmIntegrityGet(t *testing.T, srv *httptest.Server) (*http.Response, string) {
- t.Helper()
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, _ := io.ReadAll(resp.Body)
- return resp, string(body)
-}
-
-// npmIntegrityUpstream serves a leftpad packument whose 1.0.0 carries dist.
-func npmIntegrityUpstream(t *testing.T, dist map[string]string) *httptest.Server {
- t.Helper()
- packument, err := json.Marshal(map[string]any{
- "name": "leftpad",
- "dist-tags": map[string]string{"latest": "1.0.0"},
- "versions": map[string]any{
- "1.0.0": map[string]any{"dist": dist},
- },
- })
- if err != nil {
- t.Fatalf("encoding packument: %v", err)
- }
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = w.Write(packument)
- }))
- t.Cleanup(upstream.Close)
- return upstream
-}
-
-func sha1Hex(data string) string {
- sum := sha1.Sum([]byte(data)) //nolint:gosec // npm's dist.shasum is SHA-1
- return hex.EncodeToString(sum[:])
-}
diff --git a/internal/handler/npm_packument_accept_test.go b/internal/handler/npm_packument_accept_test.go
deleted file mode 100644
index 07f4e63..0000000
--- a/internal/handler/npm_packument_accept_test.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// TestNPMTarballKeepsCachedPackumentFormat checks that a tarball download
-// revalidates the packument in the format the metadata route cached it in,
-// rather than replacing the abbreviated document every client is served with
-// the full one.
-func TestNPMTarballKeepsCachedPackumentFormat(t *testing.T) {
- const abbreviated = "application/vnd.npm.install-v1+json"
- var (
- mu sync.Mutex
- accepts []string
- )
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- accept := r.Header.Get("Accept")
- mu.Lock()
- accepts = append(accepts, accept)
- mu.Unlock()
- contentType, etag := contentTypeJSON, `"full"`
- if strings.HasPrefix(accept, abbreviated) {
- contentType, etag = abbreviated, `"abbreviated"`
- }
- w.Header().Set(headerETag, etag)
- if r.Header.Get("If-None-Match") == etag {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentType, contentType)
- _, _ = fmt.Fprintf(w, `{"name":"demo","versions":{"1.0.0":{"name":"demo","version":"1.0.0","dist":{"tarball":%q}}}}`,
- upstream.URL+"/demo/-/demo-1.0.0.tgz")
- }))
- t.Cleanup(upstream.Close)
-
- proxy, db, _, artifactFetcher := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Nanosecond // the tarball request revalidates
- proxy.HTTPClient = upstream.Client()
- artifactFetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/gzip"}
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- if w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("metadata: status %d: %s", w.Code, w.Body.String())
- }
- if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-1.0.0.tgz", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("tarball: status %d: %s", w.Code, w.Body.String())
- }
-
- mu.Lock()
- defer mu.Unlock()
- if len(accepts) != 2 {
- t.Fatalf("upstream packument requests = %d (%q), want 2", len(accepts), accepts)
- }
- for _, accept := range accepts {
- if accept != npmAcceptDefault {
- t.Errorf("packument requested with Accept %q, want %q", accept, npmAcceptDefault)
- }
- }
- entry, err := db.GetMetadataCache("npm", "demo")
- if err != nil || entry == nil {
- t.Fatalf("cache row: %v, %v", entry, err)
- }
- if entry.ContentType.String != abbreviated {
- t.Errorf("cached packument Content-Type = %q, want %q", entry.ContentType.String, abbreviated)
- }
-}
-
-// abbreviatedThenFullRegistry serves a packument with a 2.0.0 published an
-// hour ago and a 1.0.0 published a month ago. The abbreviated form, like
-// registry.npmjs.org's, has no time map. fail makes requests for the full
-// document fail.
-func abbreviatedThenFullRegistry(t *testing.T, fail *atomic.Bool, fullRequests *atomic.Int64) *httptest.Server {
- t.Helper()
- const abbreviated = "application/vnd.npm.install-v1+json"
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/demo" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- versions := fmt.Sprintf(`{"1.0.0":{"name":"demo","version":"1.0.0","dist":{"tarball":%q}},"2.0.0":{"name":"demo","version":"2.0.0","dist":{"tarball":%q}}}`,
- upstream.URL+"/demo/-/demo-1.0.0.tgz", upstream.URL+"/demo/-/demo-2.0.0.tgz")
- if strings.HasPrefix(r.Header.Get("Accept"), abbreviated) {
- w.Header().Set(headerContentType, abbreviated)
- _, _ = fmt.Fprintf(w, `{"name":"demo","modified":%q,"dist-tags":{"latest":"2.0.0"},"versions":%s}`,
- time.Now().Format(time.RFC3339), versions)
- return
- }
- fullRequests.Add(1)
- if fail.Load() {
- w.WriteHeader(http.StatusServiceUnavailable)
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- _, _ = fmt.Fprintf(w, `{"name":"demo","dist-tags":{"latest":"2.0.0"},"time":{"1.0.0":%q,"2.0.0":%q},"versions":%s}`,
- time.Now().Add(-30*24*time.Hour).Format(time.RFC3339), time.Now().Add(-time.Hour).Format(time.RFC3339), versions)
- }))
- t.Cleanup(upstream.Close)
- return upstream
-}
-
-// seedAbbreviatedPackument caches the abbreviated packument for demo through
-// the metadata route while cooldown is off, then turns cooldown on.
-func seedAbbreviatedPackument(t *testing.T, upstream *httptest.Server) *NPMHandler {
- t.Helper()
- proxy, db, _, artifactFetcher := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- artifactFetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/gzip"}
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- if w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("seeding metadata: status %d: %s", w.Code, w.Body.String())
- }
- entry, err := db.GetMetadataCache("npm", "demo")
- if err != nil || entry == nil || entry.ContentType.String != "application/vnd.npm.install-v1+json" {
- t.Fatalf("expected an abbreviated packument in the cache, got %+v (%v)", entry, err)
- }
-
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- return h
-}
-
-// A packument cached in abbreviated form before cooldown was turned on has
-// no publish times, so cooldown could not filter anything while it stayed
-// fresh. With cooldown on it must be refetched in full.
-func TestNPMCooldownRefetchesCachedAbbreviatedPackument(t *testing.T) {
- var fail atomic.Bool
- var fullRequests atomic.Int64
- upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
- h := seedAbbreviatedPackument(t, upstream)
-
- w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("metadata: status %d: %s", w.Code, w.Body.String())
- }
- var doc struct {
- DistTags map[string]string `json:"dist-tags"`
- Versions map[string]json.RawMessage `json:"versions"`
- }
- if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
- t.Fatalf("decoding metadata: %v", err)
- }
- if _, ok := doc.Versions["2.0.0"]; ok {
- t.Error("version 2.0.0, published an hour ago, was not filtered by cooldown")
- }
- if _, ok := doc.Versions["1.0.0"]; !ok {
- t.Error("version 1.0.0 missing")
- }
- if doc.DistTags["latest"] != "1.0.0" {
- t.Errorf("dist-tags.latest = %q, want 1.0.0", doc.DistTags["latest"])
- }
- if fullRequests.Load() == 0 {
- t.Error("full packument was never fetched from upstream")
- }
-
- if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-2.0.0.tgz", nil, nil); w.Code != http.StatusNotFound {
- t.Errorf("tarball 2.0.0: status %d, want %d (withheld by cooldown)", w.Code, http.StatusNotFound)
- }
-}
-
-// The tarball check alone must not trust the abbreviated packument either.
-func TestNPMCooldownTarballRefetchesCachedAbbreviatedPackument(t *testing.T) {
- var fail atomic.Bool
- var fullRequests atomic.Int64
- upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
- h := seedAbbreviatedPackument(t, upstream)
-
- if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-2.0.0.tgz", nil, nil); w.Code != http.StatusNotFound {
- t.Errorf("tarball 2.0.0: status %d, want %d (withheld by cooldown)", w.Code, http.StatusNotFound)
- }
- if fullRequests.Load() == 0 {
- t.Error("full packument was never fetched from upstream")
- }
-}
-
-// When upstream cannot supply the full packument, the abbreviated one must not
-// stand in for it as a stale copy: it would let every version through.
-func TestNPMCooldownSkipsAbbreviatedStaleFallback(t *testing.T) {
- var fail atomic.Bool
- var fullRequests atomic.Int64
- upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
- h := seedAbbreviatedPackument(t, upstream)
- h.proxy.MetadataTTL = time.Nanosecond // force a refetch
- fail.Store(true)
-
- w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil)
- if w.Code != http.StatusBadGateway {
- t.Errorf("metadata: status %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
- }
- if strings.Contains(w.Body.String(), "2.0.0") {
- t.Errorf("served the abbreviated packument as a stale copy: %s", w.Body.String())
- }
-}
-
-// Cooldown profiles share a packument's cache row with the unprefixed URLs.
-// With cooldown on for only some of them, the others must not replace the
-// full packument with the abbreviated one: the next request under cooldown
-// would fetch it again, and have no stale copy when upstream fails.
-func TestNPMCooldownProfilesShareFullPackument(t *testing.T) {
- for _, tc := range []struct{ name, global, profile string }{
- {"cooldown in the profile", "0", "7d"},
- {"cooldown in the global policy", "7d", "0"},
- } {
- t.Run(tc.name, func(t *testing.T) {
- var fail atomic.Bool
- var fullRequests atomic.Int64
- upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Nanosecond // every request revalidates
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = profilePolicy(t, tc.global)
- global := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
- profile := NewNPMHandler(proxy.WithCooldown(profilePolicy(t, tc.profile), 0), "http://proxy.test/cooldown/p", upstream.URL)
- filtered, unfiltered := profile, global
- if tc.global != "0" {
- filtered, unfiltered = global, profile
- }
-
- for _, h := range []*NPMHandler{filtered, unfiltered} {
- if w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("metadata: status %d: %s", w.Code, w.Body.String())
- }
- }
- entry, err := db.GetMetadataCache("npm", "demo")
- if err != nil || entry == nil {
- t.Fatalf("cache row: %v, %v", entry, err)
- }
- if entry.ContentType.String != contentTypeJSON {
- t.Errorf("cached packument Content-Type = %q, want %q", entry.ContentType.String, contentTypeJSON)
- }
-
- fail.Store(true)
- w := serveNPM(t, filtered, http.MethodGet, "/demo", nil, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("metadata while upstream fails: status %d, want the stale full packument", w.Code)
- }
- if strings.Contains(w.Body.String(), "demo-2.0.0.tgz") {
- t.Errorf("stale packument lists a version in cooldown: %s", w.Body.String())
- }
- })
- }
-}
diff --git a/internal/handler/npm_portable_test.go b/internal/handler/npm_portable_test.go
deleted file mode 100644
index d0bb193..0000000
--- a/internal/handler/npm_portable_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package handler
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "os"
- "strings"
- "testing"
-
- "github.com/git-pkgs/cooldown"
-)
-
-func TestNPMMetadataLogsPolicyAndURLChanges(t *testing.T) {
- body, err := os.ReadFile("../metadata/npm/testdata/packument.json")
- if err != nil {
- t.Fatal(err)
- }
- f := newNPMHostedFixture(t, map[string]string{"/@example%2Fwidgets": string(body)})
- f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
- setTestDenylist(t, f.proxy, "pkg:npm/@example/widgets@2.0.0")
- var logs bytes.Buffer
- f.proxy.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
- if rec := f.get(t, "/@example/widgets"); rec.Code != http.StatusOK {
- t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
- }
- want := map[string]map[string]string{
- "cooldown: filtering npm version": {"level": "INFO", "package": "@example/widgets", "version": "3.0.0", "published": "2099-01-01T00:00:00Z"},
- "rewrote tarball URLs": {"level": "DEBUG", "package": "@example/widgets", "count": "1"},
- }
- decoder := json.NewDecoder(&logs)
- for {
- var record map[string]any
- if err := decoder.Decode(&record); err == io.EOF {
- break
- } else if err != nil {
- t.Fatal(err)
- }
- message, _ := record["msg"].(string)
- fields, ok := want[message]
- if !ok {
- continue
- }
- for key, value := range fields {
- if fmt.Sprint(record[key]) != value {
- t.Errorf("%s: %s = %v, want %q", message, key, record[key], value)
- }
- }
- delete(want, message)
- }
- if len(want) != 0 {
- t.Errorf("missing log records: %v", want)
- }
-}
-
-func TestNPMRoutesWithPortableMetadata(t *testing.T) {
- body, err := os.ReadFile("../metadata/npm/testdata/packument.json")
- if err != nil {
- t.Fatal(err)
- }
- for _, hosted := range []bool{false, true} {
- name := "upstream"
- if hosted {
- name = "hosted override"
- }
- t.Run(name, func(t *testing.T) {
- f := newNPMHostedFixture(t, map[string]string{"/@example%2Fwidgets": string(body)})
- f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
- setTestDenylist(t, f.proxy, "pkg:npm/@example/widgets@2.0.0")
- if hosted {
- f.push(t, "@example/widgets", "3.0.0", `{"name":"@example/widgets","version":"3.0.0","dependencies":{"local":"1.0.0"}}`)
- }
- rec := f.get(t, "/@example/widgets")
- doc := decodePackument(t, rec)
- count := 1
- if hosted {
- count = 2
- }
- if len(doc.Versions) != count || doc.DistTags["latest"] != "1.0.0" || len(doc.DistTags) != 1 {
- t.Fatalf("filtered metadata: %s", rec.Body.String())
- }
- if doc.Versions["1.0.0"].Dist.Tarball != "http://proxy.local/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz" || !strings.Contains(rec.Body.String(), "12345678901234567890") {
- t.Fatalf("rewritten metadata: %s", rec.Body.String())
- }
- if hosted && doc.Versions["3.0.0"].Dependencies["local"] != "1.0.0" {
- t.Fatalf("hosted override: %s", rec.Body.String())
- }
- checkNPMVersionResponses(t, f, hosted)
- })
- }
-}
-
-func checkNPMVersionResponses(t *testing.T, f npmHostedFixture, hosted bool) {
- t.Helper()
- for specifier, want := range map[string]int{"latest": http.StatusOK, "1.0.0": http.StatusOK, "2.0.0": http.StatusForbidden, "3.0.0": http.StatusNotFound} {
- if hosted && specifier == "3.0.0" {
- want = http.StatusOK
- }
- got := f.get(t, "/@example/widgets/"+specifier)
- if got.Code != want {
- t.Errorf("%s: HTTP %d, want %d: %s", specifier, got.Code, want, got.Body.String())
- }
- }
-}
diff --git a/internal/handler/npm_rewrite_bench_test.go b/internal/handler/npm_rewrite_bench_test.go
deleted file mode 100644
index cf8b895..0000000
--- a/internal/handler/npm_rewrite_bench_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package handler
-
-import (
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metadata/npm/npmbench"
-)
-
-func BenchmarkNPMRewriteMetadata(b *testing.B) {
- body := npmbench.Packument(5000)
- h := &NPMHandler{proxy: testProxy(), proxyURL: "http://proxy.example"}
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- if _, _, err := h.rewriteMetadataKeeping("big", body); err != nil {
- b.Fatal(err)
- }
- }
-}
-
-func BenchmarkNPMVersionTarball(b *testing.B) {
- body := npmbench.Packument(5000)
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- if npmVersionTarball(body, "1.0.2500") == "" {
- b.Fatal("tarball not found")
- }
- }
-}
diff --git a/internal/handler/npm_rewrite_test.go b/internal/handler/npm_rewrite_test.go
deleted file mode 100644
index 9b8e35b..0000000
--- a/internal/handler/npm_rewrite_test.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package handler
-
-import (
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// TestMetadataServesRewriteWithoutReadingStorage checks that a repeat
-// request inside the metadata TTL is answered from the rewrite cache by the
-// stored digest alone, without reading the cached document back.
-func TestMetadataServesRewriteWithoutReadingStorage(t *testing.T) {
- var upstreamCalls atomic.Int64
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- upstreamCalls.Add(1)
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/left-pad":
- _, _ = w.Write([]byte(`{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`))
- case "/p2/vendor/pkg.json":
- _, _ = w.Write([]byte(`{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- cases := []struct {
- ecosystem, name, path string
- handler func(*Proxy) http.Handler
- }{
- {"npm", "left-pad", "/left-pad", func(p *Proxy) http.Handler {
- return NewNPMHandler(p, "http://proxy.example", upstream.URL).Routes()
- }},
- {"composer", "vendor/pkg", "/p2/vendor/pkg.json", func(p *Proxy) http.Handler {
- return NewComposerHandlerWithUpstreams(p, "http://proxy.example", upstream.URL, upstream.URL).Routes()
- }},
- }
- for _, c := range cases {
- t.Run(c.ecosystem, func(t *testing.T) {
- upstreamCalls.Store(0)
- proxy, db, store, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- handler := c.handler(proxy)
-
- get := func() string {
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
- if rec.Code != http.StatusOK {
- t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
- }
- return rec.Body.String()
- }
-
- first := get()
- entry, err := db.GetMetadataCache(c.ecosystem, c.name)
- if err != nil || entry == nil || !strings.HasPrefix(entry.ContentDigest.String, "sha256:") {
- t.Fatalf("metadata row = %+v, %v; want a sha256 content digest", entry, err)
- }
-
- store.mu.Lock()
- store.openErr = errors.New("storage must not be read")
- store.mu.Unlock()
-
- if second := get(); second != first {
- t.Errorf("second response %q differs from first %q", second, first)
- }
- if got := upstreamCalls.Load(); got != 1 {
- t.Errorf("upstream calls = %d, want 1", got)
- }
- })
- }
-}
-
-// TestNPMStoredRewriteSkippedWhenStale checks that the digest fast path is
-// only taken inside the metadata TTL.
-func TestNPMStoredRewriteSkippedWhenStale(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- body := []byte(`{}`)
- proxy.cacheMetadataBlob(t.Context(), "npm", "demo", metadataStoragePath("npm", "demo"), &upstreamMetadata{body: body})
- key := rewriteCacheKey("npm", "http://proxy", "demo", body)
- if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil {
- t.Fatal(err)
- }
- if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); !ok {
- t.Fatal("fresh row: expected a stored rewrite")
- }
-
- entry, _ := db.GetMetadataCache("npm", "demo")
- entry.FetchedAt.Time = time.Now().Add(-2 * time.Hour)
- if err := db.UpsertMetadataCache(entry); err != nil {
- t.Fatal(err)
- }
- if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); ok {
- t.Error("stale row: expected no stored rewrite")
- }
-
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- entry.FetchedAt.Time = time.Now()
- _ = db.UpsertMetadataCache(entry)
- if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); !ok {
- t.Error("cooldown on, fresh row: expected the stored rewrite")
- }
-}
-
-// TestNPMStoredRewriteRecordsMetadataHit checks the digest fast path counts as
-// a metadata cache hit with the bytes it serves, never as an artifact hit.
-func TestNPMStoredRewriteRecordsMetadataHit(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- body := []byte(`{"name":"demo"}`)
- proxy.cacheMetadataBlob(t.Context(), "npm", "hit-demo", metadataStoragePath("npm", "hit-demo"), &upstreamMetadata{body: body})
- key := rewriteCacheKey("npm", "http://proxy", "hit-demo", body)
- if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil {
- t.Fatal(err)
- }
-
- metadataHits := func() float64 {
- return testutil.ToFloat64(metrics.CacheHits.WithLabelValues("npm", metrics.CacheKindMetadata))
- }
- artifactHits := func() float64 {
- return testutil.ToFloat64(metrics.CacheHits.WithLabelValues("npm", metrics.CacheKindArtifact))
- }
- served := func() float64 { return testutil.ToFloat64(metrics.MetadataServedBytes.WithLabelValues("npm")) }
- metadataBefore, artifactBefore, servedBefore := metadataHits(), artifactHits(), served()
-
- m, ok := proxy.storedRewrite("npm", "hit-demo", "http://proxy", "hit-demo")
- if !ok {
- t.Fatal("expected a stored rewrite")
- }
-
- if diff := metadataHits() - metadataBefore; diff != 1 {
- t.Errorf("metadata hits delta = %.0f, want 1", diff)
- }
- if diff := artifactHits() - artifactBefore; diff != 0 {
- t.Errorf("artifact hits delta = %.0f, want 0", diff)
- }
- if diff := served() - servedBefore; diff != float64(len(m.content())) {
- t.Errorf("metadata served bytes delta = %.0f, want %d", diff, len(m.content()))
- }
-}
diff --git a/internal/handler/npm_routes_test.go b/internal/handler/npm_routes_test.go
deleted file mode 100644
index 56fc952..0000000
--- a/internal/handler/npm_routes_test.go
+++ /dev/null
@@ -1,301 +0,0 @@
-package handler
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// npmUpstreamRecorder serves canned packuments and records which paths were
-// requested.
-type npmUpstreamRecorder struct {
- *httptest.Server
- mu sync.Mutex
- requests []string
-}
-
-func newNPMUpstream(t *testing.T, packuments map[string]func(baseURL string) string, status int) *npmUpstreamRecorder {
- t.Helper()
- u := &npmUpstreamRecorder{}
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.mu.Lock()
- u.requests = append(u.requests, r.URL.Path)
- u.mu.Unlock()
- if status != http.StatusOK {
- w.WriteHeader(status)
- return
- }
- packument, ok := packuments[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, packument(u.URL))
- }))
- t.Cleanup(u.Close)
- return u
-}
-
-func (u *npmUpstreamRecorder) requested() []string {
- u.mu.Lock()
- defer u.mu.Unlock()
- return append([]string(nil), u.requests...)
-}
-
-func routedTestPackument(version, tarballBase string) func(string) string {
- return func(baseURL string) string {
- return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
- `":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}}}`
- }
-}
-
-const routedNPMPath = "/registry/@example/widgets"
-
-func newRoutedNPMHandler(proxy *Proxy, public, private *npmUpstreamRecorder) *NPMHandler {
- return NewNPMHandler(proxy, "http://proxy.test", public.URL).
- WithPackageRoutes(map[string]string{"@example/*": private.URL + "/registry"})
-}
-
-func TestNPMRoutedMetadataComesFromRouteOnly(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if got := public.requested(); len(got) != 0 {
- t.Errorf("default registry was queried for a routed package: %v", got)
- }
- if strings.Contains(w.Body.String(), "9.9.9") {
- t.Errorf("routed package served the default registry's metadata: %s", w.Body.String())
- }
- wantTarball := "http://proxy.test/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"
- if !strings.Contains(w.Body.String(), wantTarball) {
- t.Errorf("tarball not rewritten to %q: %s", wantTarball, w.Body.String())
- }
-}
-
-func TestNPMRoutedMetadataDoesNotFallBack(t *testing.T) {
- tests := []struct {
- name string
- privateStatus int
- wantStatus int
- }{
- {"package missing on route", http.StatusOK, http.StatusNotFound},
- {"route rejects credentials", http.StatusUnauthorized, http.StatusBadGateway},
- {"route unavailable", http.StatusInternalServerError, http.StatusBadGateway},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{}, tt.privateStatus)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
-
- if w.Code != tt.wantStatus {
- t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
- }
- if got := public.requested(); len(got) != 0 {
- t.Errorf("default registry was queried for a routed package: %v", got)
- }
- })
- }
-}
-
-func TestNPMRoutedMetadataIgnoresDefaultRegistryCache(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
-
- if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example%2fwidgets", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("priming the default registry cache: status %d", w.Code)
- }
-
- w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example%2fwidgets", nil, nil)
-
- if strings.Contains(w.Body.String(), "9.9.9") {
- t.Errorf("routed package served metadata cached from the default registry: %s", w.Body.String())
- }
-}
-
-func TestNPMUnroutedMetadataUsesDefaultRegistry(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/lodash": routedTestPackument("4.17.21", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/lodash", nil, nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
- if got := private.requested(); len(got) != 0 {
- t.Errorf("route was queried for an unrouted package: %v", got)
- }
-}
-
-func TestNPMRoutedDownloadResolvesFromRoute(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if want := private.URL + "/registry/@example/widgets/-/widgets-1.0.0.tgz"; artifactFetcher.fetchedURL != want {
- t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
- }
- if got := public.requested(); len(got) != 0 {
- t.Errorf("default registry was queried for a routed download: %v", got)
- }
-}
-
-func TestNPMRoutedDownloadWithoutMetadataStaysOnRoute(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusInternalServerError)
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- h := newRoutedNPMHandler(proxy, public, private)
-
- w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
-
- // Without the route's metadata the tarball can't be verified, so it is
- // not fetched at all, and in particular not from the default registry.
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want 502", w.Code)
- }
- if artifactFetcher.fetchedURL != "" {
- t.Errorf("fetched URL = %q, want none", artifactFetcher.fetchedURL)
- }
-}
-
-func TestNPMRoutedDownloadIgnoresDefaultRegistryArtifact(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("public package")),
- ContentType: "application/gzip",
- }
-
- if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil); w.Code != http.StatusOK {
- t.Fatalf("priming the artifact cache: status %d; body: %s", w.Code, w.Body.String())
- }
-
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("private package")),
- ContentType: "application/gzip",
- }
- w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
-
- if w.Body.String() != "private package" {
- t.Errorf("routed download served %q, want the routed registry's package", w.Body.String())
- }
-}
-
-// timedTestPackument is routedTestPackument with a publish time for the
-// version, published age ago.
-func timedTestPackument(version, tarballBase string, age time.Duration) func(string) string {
- published := time.Now().Add(-age).UTC().Format(time.RFC3339)
- return func(baseURL string) string {
- return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
- `":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}},` +
- `"time":{"` + version + `":"` + published + `"}}`
- }
-}
-
-// TestNPMRoutedCooldownUsesRoutePublishTimes is a regression test: the
-// download cooldown check stored publish times under the package's plain
-// version PURL, so a time read from one registry was trusted for the same
-// version from another. A route's version published an hour ago must stay in
-// a seven-day cooldown even when the default registry's version of the same
-// name is a month old, whichever was downloaded first, and changing a route's
-// upstream must not carry over the old one's times.
-func TestNPMRoutedCooldownUsesRoutePublishTimes(t *testing.T) {
- const tarball = "/@example/widgets/-/widgets-1.0.0.tgz"
- month, hour := 30*24*time.Hour, time.Hour
-
- type step struct {
- handler string // "public", "route" or "other route"
- wantStatus int
- }
- tests := []struct {
- name string
- steps []step
- }{
- {"route added after a public download", []step{
- {"public", http.StatusOK},
- {"route", http.StatusNotFound},
- }},
- {"public download after a routed one", []step{
- {"route", http.StatusNotFound},
- {"public", http.StatusOK},
- }},
- {"route moved to another upstream", []step{
- {"other route", http.StatusOK},
- {"route", http.StatusNotFound},
- }},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": timedTestPackument("1.0.0", "", month)}, http.StatusOK)
- private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", hour)}, http.StatusOK)
- old := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", month)}, http.StatusOK)
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- proxy.HTTPClient = http.DefaultClient
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- handlers := map[string]*NPMHandler{
- "public": NewNPMHandler(proxy, "http://proxy.test", public.URL),
- "route": newRoutedNPMHandler(proxy, public, private),
- "other route": newRoutedNPMHandler(proxy, public, old),
- }
-
- for _, s := range tt.steps {
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- w := serveNPM(t, handlers[s.handler], http.MethodGet, tarball, nil, nil)
- if w.Code != s.wantStatus {
- t.Errorf("%s download: status %d, want %d; body: %s", s.handler, w.Code, s.wantStatus, w.Body.String())
- }
- }
- })
- }
-}
diff --git a/internal/handler/npm_stream_harden_test.go b/internal/handler/npm_stream_harden_test.go
deleted file mode 100644
index 0cebf0a..0000000
--- a/internal/handler/npm_stream_harden_test.go
+++ /dev/null
@@ -1,238 +0,0 @@
-package handler
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-// bigMetadataFiles returns a copy of what is in storage under the metadata path
-// of big, staged copies included.
-func bigMetadataFiles(store *mockStorage) map[string]string {
- store.mu.Lock()
- defer store.mu.Unlock()
- files := map[string]string{}
- for path, b := range store.files {
- if strings.HasPrefix(path, metadataStoragePath("npm", "big")) {
- files[path] = string(b)
- }
- }
- return files
-}
-
-// TestNPMStreamedPackumentBlobCorrupt checks that a stored copy whose bytes
-// no longer match the digest of its row, within its TTL, is fetched again
-// rather than failing the request, also above metadata_max_size.
-func TestNPMStreamedPackumentBlobCorrupt(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
- decodeStreamed(t, serve(handler, "/big", nil))
-
- path := metadataStoragePath("npm", "big")
- store.mu.Lock()
- store.files[path] = []byte(strings.Replace(body, "1.0.5", "1.0.X", 1))
- store.mu.Unlock()
- proxy.SetMetadataRewriteCacheSize(64 << 20)
-
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != 400 {
- t.Errorf("served %d versions, want 400", len(doc.Versions))
- }
- if got := bigMetadataFiles(store)[path]; got != body {
- t.Error("the corrupt copy was not replaced")
- }
-}
-
-// TestNPMStreamedPackumentTruncated checks that a document upstream cuts
-// off halfway neither replaces the stored copy, which is served instead,
-// nor leaves anything behind, with and without cooldown.
-func TestNPMStreamedPackumentTruncated(t *testing.T) {
- for _, withCooldown := range []bool{true, false} {
- t.Run(map[bool]string{true: "cooldown", false: "no cooldown"}[withCooldown], func(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- if !withCooldown {
- proxy.Cooldown = nil
- }
- proxy.MetadataMaxSize = int64(len(body) / 4)
- want := len(decodeStreamed(t, serve(handler, "/big", nil)).Versions)
- before := bigMetadataFiles(store)
-
- u.replace(bigPackument(401), `"v2"`)
- u.truncate.Store(true)
- proxy.MetadataTTL = time.Nanosecond
- proxy.SetMetadataRewriteCacheSize(64 << 20)
-
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != want {
- t.Errorf("served %d versions, want the stored %d", len(doc.Versions), want)
- }
- after := bigMetadataFiles(store)
- if len(after) != len(before) {
- t.Errorf("storage holds %d files, %d before", len(after), len(before))
- }
- for path, b := range before {
- if after[path] != b {
- t.Errorf("%s was changed", path)
- }
- }
- })
- }
-}
-
-// TestNPMStreamedPackumentTruncatedUncached checks that a document upstream
-// cuts off halfway fails the request and is not kept.
-func TestNPMStreamedPackumentTruncatedUncached(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- proxy.Cooldown = nil
- proxy.MetadataMaxSize = int64(len(body) / 4)
- u.truncate.Store(true)
-
- if rec := serve(handler, "/big", nil); rec.Code != http.StatusBadGateway {
- t.Errorf("status %d, want 502", rec.Code)
- }
- if files := bigMetadataFiles(store); len(files) != 0 {
- t.Errorf("left %d files in storage", len(files))
- }
- if entry, _ := proxy.DB.GetMetadataCache("npm", "big"); entry != nil {
- t.Error("left a metadata cache row")
- }
-}
-
-// TestNPMStreamedPackumentUncachedErrors checks the answers to upstream
-// failures with nothing cached.
-func TestNPMStreamedPackumentUncachedErrors(t *testing.T) {
- for _, tt := range []struct {
- name string
- set func(*streamUpstream)
- want int
- }{
- {"not found", func(u *streamUpstream) { u.gone.Store(true) }, http.StatusNotFound},
- {"upstream down", func(u *streamUpstream) { u.fail.Store(true) }, http.StatusBadGateway},
- } {
- t.Run(tt.name, func(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- _, store, handler := streamProxy(t, u)
- tt.set(u)
- if rec := serve(handler, "/big", nil); rec.Code != tt.want {
- t.Errorf("status %d, want %d", rec.Code, tt.want)
- }
- if files := bigMetadataFiles(store); len(files) != 0 {
- t.Errorf("left %d files in storage", len(files))
- }
- })
- }
-}
-
-// TestNPMStreamedPackumentConcurrent checks that concurrent requests for an
-// uncached packument share one upstream fetch and all get it.
-func TestNPMStreamedPackumentConcurrent(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, _, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
- const n = 16
- var wg sync.WaitGroup
- recs := make([]*httptest.ResponseRecorder, n)
- for i := range n {
- wg.Go(func() { recs[i] = serve(handler, "/big", nil) })
- }
- wg.Wait()
- for _, rec := range recs {
- if doc := decodeStreamed(t, rec); len(doc.Versions) != 400 {
- t.Errorf("served %d versions, want 400", len(doc.Versions))
- }
- }
- if got := u.sent.Load(); got != 1 {
- t.Errorf("upstream sent the document %d times, want once", got)
- }
-}
-
-// TestNPMStreamedPackumentClientRevalidates checks that a client sending
-// the ETag it got gets a 304, and a new ETag once the document changes.
-func TestNPMStreamedPackumentClientRevalidates(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, _, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
-
- first := serve(handler, "/big", nil)
- etag := first.Header().Get("ETag")
- if first.Code != http.StatusOK || etag == "" {
- t.Fatalf("status %d, ETag %q", first.Code, etag)
- }
- if rec := serve(handler, "/big", http.Header{"If-None-Match": {etag}}); rec.Code != http.StatusNotModified {
- t.Errorf("same document: status %d, want 304", rec.Code)
- }
-
- u.replace(bigPackument(401), `"v2"`)
- proxy.MetadataTTL = time.Nanosecond
- rec := serve(handler, "/big", http.Header{"If-None-Match": {etag}})
- if rec.Code != http.StatusOK || rec.Header().Get("ETag") == etag {
- t.Errorf("changed document: status %d, ETag %q (was %q)", rec.Code, rec.Header().Get("ETag"), etag)
- }
- if doc := decodeStreamed(t, rec); len(doc.Versions) != 401 {
- t.Errorf("served %d versions, want 401", len(doc.Versions))
- }
-}
-
-// TestNPMStreamedPackumentOverStreamLimit checks that a document over
-// MetadataStreamMaxSize is neither stored nor served: the stored copy is
-// served instead, with and without cooldown, and with nothing stored the
-// request fails.
-func TestNPMStreamedPackumentOverStreamLimit(t *testing.T) {
- for _, withCooldown := range []bool{true, false} {
- t.Run(map[bool]string{true: "cooldown", false: "no cooldown"}[withCooldown], func(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- if !withCooldown {
- proxy.Cooldown = nil
- }
- proxy.MetadataMaxSize = int64(len(body) / 4)
- proxy.MetadataStreamMaxSize = int64(len(body) + 1000)
- want := len(decodeStreamed(t, serve(handler, "/big", nil)).Versions)
- before := bigMetadataFiles(store)
-
- u.replace(bigPackument(500), `"v2"`)
- proxy.MetadataTTL = time.Nanosecond
- proxy.SetMetadataRewriteCacheSize(64 << 20)
-
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != want {
- t.Errorf("served %d versions, want the stored %d", len(doc.Versions), want)
- }
- after := bigMetadataFiles(store)
- if len(after) != len(before) {
- t.Errorf("storage holds %d files, %d before", len(after), len(before))
- }
- for path, b := range before {
- if after[path] != b {
- t.Errorf("%s was changed", path)
- }
- }
- })
- }
- t.Run("uncached", func(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
- proxy.MetadataStreamMaxSize = int64(len(body) / 2)
-
- if rec := serve(handler, "/big", nil); rec.Code != http.StatusBadGateway {
- t.Errorf("status %d, want 502", rec.Code)
- }
- if files := bigMetadataFiles(store); len(files) != 0 {
- t.Errorf("left %d files in storage", len(files))
- }
- if entry, _ := proxy.DB.GetMetadataCache("npm", "big"); entry != nil {
- t.Error("left a metadata cache row")
- }
- })
-}
diff --git a/internal/handler/npm_stream_test.go b/internal/handler/npm_stream_test.go
deleted file mode 100644
index 5d2ab73..0000000
--- a/internal/handler/npm_stream_test.go
+++ /dev/null
@@ -1,464 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// bigPackument returns a packument laid out as registry.npmjs.org writes
-// them, dist-tags before versions and time after, with n old versions padded
-// so the document is large, and a latest published an hour ago.
-func bigPackument(n int) string {
- return bigPackumentAt("https://registry.npmjs.org", n)
-}
-
-// bigPackumentAt is bigPackument with tarballs under base.
-func bigPackumentAt(base string, n int) string {
- var b strings.Builder
- b.WriteString(`{"_id":"big","name":"big","dist-tags":{"latest":"9.0.0","old":"1.0.0"},"versions":{`)
- times := []string{`"created":"2015-01-01T00:00:00Z"`}
- for i := range n {
- v := fmt.Sprintf("1.0.%d", i)
- if i == 0 {
- v = "1.0.0"
- }
- fmt.Fprintf(&b, `%q:{"name":"big","version":%q,"readme":%q,"dist":{"tarball":"%s/big/-/big-%s.tgz"}},`,
- v, v, strings.Repeat("x", 512), base, v)
- times = append(times, fmt.Sprintf(`%q:"2016-01-01T00:%02d:%02dZ"`, v, i/60%60, i%60))
- }
- recent := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
- b.WriteString(`"9.0.0":{"name":"big","version":"9.0.0","dist":{"tarball":"` + base + `/big/-/big-9.0.0.tgz"}}},`)
- times = append(times, `"9.0.0":"`+recent+`"`)
- b.WriteString(`"time":{` + strings.Join(times, ",") + `},"readme":"big"}`)
- return b.String()
-}
-
-type streamUpstream struct {
- *httptest.Server
- body atomic.Value // string
- etag atomic.Value // string
- fail atomic.Bool
- gone atomic.Bool
- // truncate makes upstream close the connection halfway through the
- // document it announced.
- truncate atomic.Bool
- requests atomic.Int32
- // sent counts the responses that carried the document.
- sent atomic.Int32
-}
-
-// replace makes upstream send body under a new ETag, so a revalidation
-// gets it in a 200.
-func (u *streamUpstream) replace(body, etag string) {
- u.body.Store(body)
- u.etag.Store(etag)
-}
-
-func newStreamUpstream(t *testing.T, body string) *streamUpstream {
- t.Helper()
- u := &streamUpstream{}
- u.replace(body, `"v1"`)
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.requests.Add(1)
- switch {
- case u.fail.Load():
- http.Error(w, "down", http.StatusBadGateway)
- case u.gone.Load():
- http.NotFound(w, r)
- case r.URL.Path == "/big" && r.Header.Get("If-None-Match") == u.etag.Load().(string):
- w.WriteHeader(http.StatusNotModified)
- case r.URL.Path == "/big":
- u.sent.Add(1)
- body := u.body.Load().(string)
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("ETag", u.etag.Load().(string))
- if u.truncate.Load() {
- w.Header().Set("Content-Length", fmt.Sprint(len(body)))
- _, _ = io.WriteString(w, body[:len(body)/2])
- return
- }
- _, _ = io.WriteString(w, body)
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(u.Close)
- return u
-}
-
-func streamProxy(t *testing.T, u *streamUpstream) (*Proxy, *mockStorage, http.Handler) {
- t.Helper()
- proxy, _, store, _ := setupTestProxy(t)
- proxy.HTTPClient = u.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.SetMetadataRewriteCacheSize(64 << 20)
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- return proxy, store, NewNPMHandler(proxy, "http://proxy.example", u.URL).Routes()
-}
-
-type packumentDoc struct {
- DistTags map[string]string `json:"dist-tags"`
- Versions map[string]json.RawMessage `json:"versions"`
- Time map[string]string `json:"time"`
-}
-
-func decodeStreamed(t *testing.T, rec *httptest.ResponseRecorder) packumentDoc {
- t.Helper()
- if rec.Code != http.StatusOK {
- t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
- }
- body := rec.Body.Bytes()
- if rec.Header().Get("Content-Encoding") == "gzip" {
- zr, err := gzip.NewReader(bytes.NewReader(body))
- if err != nil {
- t.Fatal(err)
- }
- if body, err = io.ReadAll(zr); err != nil {
- t.Fatal(err)
- }
- }
- var doc packumentDoc
- if err := json.Unmarshal(body, &doc); err != nil {
- t.Fatalf("response is not JSON: %v", err)
- }
- return doc
-}
-
-// TestNPMStreamedPackument requests a packument larger than
-// metadata_max_size with cooldown on, its time map after its versions and
-// its latest inside cooldown. The in-memory rewrite can't read it at all.
-func TestNPMStreamedPackument(t *testing.T) {
- const versions = 400
- body := bigPackument(versions)
- u := newStreamUpstream(t, body)
- proxy, _, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
-
- for _, header := range []http.Header{nil, {"Accept-Encoding": {"gzip"}}} {
- doc := decodeStreamed(t, serve(handler, "/big", header))
- if len(doc.Versions) != versions {
- t.Errorf("%d versions, want %d without 9.0.0", len(doc.Versions), versions)
- }
- if _, ok := doc.Versions["9.0.0"]; ok {
- t.Error("9.0.0 is listed although it is inside cooldown")
- }
- if _, ok := doc.Time["9.0.0"]; ok {
- t.Error("time still lists 9.0.0")
- }
- if doc.DistTags["latest"] != fmt.Sprintf("1.0.%d", versions-1) || doc.DistTags["old"] != "1.0.0" {
- t.Errorf("dist-tags = %v, want latest moved to the newest version left", doc.DistTags)
- }
- if !strings.Contains(string(doc.Versions["1.0.0"]), `"tarball":"http://proxy.example/npm/big/-/big-1.0.0.tgz"`) {
- t.Errorf("tarball not rewritten: %s", doc.Versions["1.0.0"])
- }
- }
- if n := u.requests.Load(); n != 1 {
- t.Errorf("upstream asked %d times, want once", n)
- }
-
- // Without the rewrite cache the in-memory path runs, which the size
- // limit stops.
- proxy.SetMetadataRewriteCacheSize(0)
- if rec := serve(handler, "/big", nil); rec.Code == http.StatusOK {
- t.Error("in-memory rewrite served a packument over metadata_max_size; the test no longer covers the stream")
- }
-}
-
-// TestNPMStreamedPackumentMatchesInMemory checks that both paths serve the
-// same document and ETag.
-func TestNPMStreamedPackumentMatchesInMemory(t *testing.T) {
- body := bigPackument(50)
- u := newStreamUpstream(t, body)
- _, _, streamed := streamProxy(t, u)
- inMemory, _, handler := streamProxy(t, u)
- inMemory.CacheMetadata = false
-
- a, b := serve(streamed, "/big", nil), serve(handler, "/big", nil)
- if a.Code != http.StatusOK || b.Code != http.StatusOK {
- t.Fatalf("status %d and %d", a.Code, b.Code)
- }
- var ca, cb bytes.Buffer
- if err := json.Compact(&ca, a.Body.Bytes()); err != nil {
- t.Fatal(err)
- }
- if err := json.Compact(&cb, b.Body.Bytes()); err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(ca.Bytes(), cb.Bytes()) {
- t.Error("streamed and in-memory rewrites differ")
- }
-}
-
-// TestNPMStreamedPackumentStale serves the stored copy when upstream fails
-// after the TTL, and 404 for a package upstream doesn't have.
-func TestNPMStreamedPackumentStale(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- proxy, _, handler := streamProxy(t, u)
- decodeStreamed(t, serve(handler, "/big", nil))
-
- proxy.MetadataTTL = time.Nanosecond
- proxy.SetMetadataRewriteCacheSize(64 << 20) // drop the cached rewrite
- u.fail.Store(true)
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != 10 {
- t.Errorf("stale copy has %d versions, want 10", len(doc.Versions))
- }
-
- u.fail.Store(false)
- if rec := serve(handler, "/missing", nil); rec.Code != http.StatusNotFound {
- t.Errorf("missing package: status %d, want 404", rec.Code)
- }
-}
-
-// TestNPMStreamedPackumentStaleOn404 checks that a stored copy past its TTL
-// is served when upstream answers 404, as the in-memory path does.
-func TestNPMStreamedPackumentStaleOn404(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- proxy, _, handler := streamProxy(t, u)
- decodeStreamed(t, serve(handler, "/big", nil))
-
- proxy.MetadataTTL = time.Nanosecond
- u.gone.Store(true)
- for _, size := range []int64{64 << 20, 0} { // streamed, then in memory
- proxy.SetMetadataRewriteCacheSize(size)
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != 10 {
- t.Errorf("rewrite cache %d: stale copy has %d versions, want 10", size, len(doc.Versions))
- }
- }
-}
-
-// TestNPMStreamedPackumentBlobMissing checks that a row whose document is
-// gone from storage, within its TTL or revalidated by a 304 after it, fetches
-// the document again, also when it is larger than metadata_max_size.
-func TestNPMStreamedPackumentBlobMissing(t *testing.T) {
- for _, tt := range []struct {
- name string
- ttl time.Duration
- }{
- {"fresh row", time.Hour},
- {"expired row", time.Nanosecond},
- } {
- t.Run(tt.name, func(t *testing.T) {
- body := bigPackument(400)
- u := newStreamUpstream(t, body)
- proxy, store, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
- decodeStreamed(t, serve(handler, "/big", nil))
-
- path := metadataStoragePath("npm", "big")
- store.mu.Lock()
- delete(store.files, path)
- store.mu.Unlock()
- proxy.MetadataTTL = tt.ttl
- proxy.SetMetadataRewriteCacheSize(64 << 20)
-
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != 400 {
- t.Errorf("served %d versions, want 400", len(doc.Versions))
- }
- if n := u.sent.Load(); n != 2 {
- t.Errorf("upstream sent the document %d times, want 2", n)
- }
- store.mu.Lock()
- defer store.mu.Unlock()
- if string(store.files[path]) != body {
- t.Error("the document was not stored again")
- }
- })
- }
-}
-
-// TestNPMStreamedVersionBlobMissing checks that a version request and a
-// tarball download fetch a packument again whose stored copy is gone while
-// its row is within the TTL.
-func TestNPMStreamedVersionBlobMissing(t *testing.T) {
- u := newStreamUpstream(t, "")
- u.body.Store(bigPackumentAt(u.URL, 100))
- proxy, store, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = 1024
- fetcher := proxy.Fetcher.(*mockFetcher)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package"))}
- path := metadataStoragePath("npm", "big")
-
- for _, req := range []string{"/big/1.0.5", "/big/-/big-1.0.5.tgz"} {
- decodeStreamed(t, serve(handler, "/big", nil))
- store.mu.Lock()
- delete(store.files, path)
- store.mu.Unlock()
- proxy.SetMetadataRewriteCacheSize(64 << 20)
-
- if rec := serve(handler, req, nil); rec.Code != http.StatusOK {
- t.Errorf("%s: status %d: %.200s", req, rec.Code, rec.Body.String())
- }
- store.mu.Lock()
- _, ok := store.files[path]
- store.mu.Unlock()
- if !ok {
- t.Errorf("%s: the packument was not stored again", req)
- }
- }
-}
-
-// TestNPMStreamedPackumentChangedInStorage checks that a document replaced
-// in storage between the two passes isn't rewritten from mixed bytes.
-func TestNPMStreamedPackumentChangedInStorage(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- proxy, store, _ := streamProxy(t, u)
- entry, err := proxy.storedMetadataDocument(context.Background(), "npm", "big", u.URL+"/big", contentTypeJSON, nil)
- if err != nil {
- t.Fatal(err)
- }
- store.mu.Lock()
- store.files[entry.StoragePath] = []byte(bigPackument(11))
- store.mu.Unlock()
-
- r, err := proxy.openStoredMetadata(context.Background(), entry)
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = r.Close() }()
- if _, err := io.ReadAll(r); !errors.Is(err, errStoredMetadataChanged) {
- t.Errorf("read error %v, want errStoredMetadataChanged", err)
- }
-}
-
-// TestNPMStreamedPackumentRevalidates checks that a 304 after the TTL keeps
-// the stored document and its cached rewrite, without storing it again.
-func TestNPMStreamedPackumentRevalidates(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- proxy, store, handler := streamProxy(t, u)
- first := serve(handler, "/big", nil)
- decodeStreamed(t, first)
- store.mu.Lock()
- stores := store.stores
- store.mu.Unlock()
-
- proxy.MetadataTTL = time.Nanosecond
- second := serve(handler, "/big", nil)
- decodeStreamed(t, second)
- if second.Header().Get("ETag") != first.Header().Get("ETag") {
- t.Errorf("ETag changed from %s to %s", first.Header().Get("ETag"), second.Header().Get("ETag"))
- }
- if n := u.requests.Load(); n != 2 {
- t.Errorf("upstream asked %d times, want 2", n)
- }
- store.mu.Lock()
- defer store.mu.Unlock()
- if store.stores != stores {
- t.Errorf("the 304 stored the document again (%d stores, %d before)", store.stores, stores)
- }
-}
-
-// TestNPMStreamedPackumentKeepsFullCopy checks that an abbreviated packument
-// from upstream, where cooldown needs the full one, doesn't replace the full
-// copy in storage, which is served instead as when upstream fails.
-func TestNPMStreamedPackumentKeepsFullCopy(t *testing.T) {
- full := bigPackument(10)
- u := newStreamUpstream(t, full)
- proxy, store, handler := streamProxy(t, u)
- decodeStreamed(t, serve(handler, "/big", nil))
-
- proxy.MetadataTTL = time.Nanosecond
- proxy.SetMetadataRewriteCacheSize(64 << 20)
- u.replace(`{"name":"big","modified":"2026-01-01T00:00:00Z","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{}}}`, `"v2"`)
- if doc := decodeStreamed(t, serve(handler, "/big", nil)); len(doc.Versions) != 10 {
- t.Errorf("served %d versions, want the 10 of the full copy", len(doc.Versions))
- }
- if n := u.sent.Load(); n != 2 {
- t.Fatalf("upstream sent a document %d times, want 2: the abbreviated one was not fetched", n)
- }
-
- store.mu.Lock()
- defer store.mu.Unlock()
- if got := string(store.files[metadataStoragePath("npm", "big")]); got != full {
- t.Errorf("stored packument was replaced: %.80s", got)
- }
- for path := range store.files {
- if strings.Contains(path, ".staged-") {
- t.Errorf("staged copy %s left in storage", path)
- }
- }
-}
-
-// TestNPMStreamedPackumentStorageFails checks that a packument is still
-// served, read into memory, when storing it fails.
-func TestNPMStreamedPackumentStorageFails(t *testing.T) {
- u := newStreamUpstream(t, bigPackument(10))
- _, store, handler := streamProxy(t, u)
- store.storeErr = errors.New("no space left on device")
- doc := decodeStreamed(t, serve(handler, "/big", nil))
- if len(doc.Versions) != 10 || doc.DistTags["latest"] != "1.0.9" {
- t.Errorf("served %d versions, latest %q; want 10 and 1.0.9", len(doc.Versions), doc.DistTags["latest"])
- }
-}
-
-// TestNPMStreamedVersionMetadata resolves versions and dist-tags of a
-// packument larger than metadata_max_size.
-func TestNPMStreamedVersionMetadata(t *testing.T) {
- body := bigPackument(100)
- u := newStreamUpstream(t, body)
- proxy, _, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = int64(len(body) / 4)
-
- for path, want := range map[string]string{
- "/big/1.0.5": "1.0.5",
- "/big/latest": "1.0.99", // 9.0.0 is inside cooldown
- "/big/old": "1.0.0",
- } {
- rec := serve(handler, path, nil)
- var v struct {
- Version string `json:"version"`
- Dist struct {
- Tarball string `json:"tarball"`
- } `json:"dist"`
- }
- if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &v) != nil {
- t.Errorf("%s: status %d: %.200s", path, rec.Code, rec.Body.String())
- continue
- }
- if v.Version != want || v.Dist.Tarball != "http://proxy.example/npm/big/-/big-"+want+".tgz" {
- t.Errorf("%s = %s with tarball %s, want %s", path, v.Version, v.Dist.Tarball, want)
- }
- }
- if rec := serve(handler, "/big/9.0.0", nil); rec.Code != http.StatusNotFound {
- t.Errorf("version inside cooldown: status %d, want 404", rec.Code)
- }
- if n := u.requests.Load(); n != 1 {
- t.Errorf("upstream asked %d times, want once", n)
- }
-}
-
-// TestNPMStreamedTarballCooldown checks the tarball download against a
-// packument larger than metadata_max_size: the version's publish time and
-// tarball URL are read from storage.
-func TestNPMStreamedTarballCooldown(t *testing.T) {
- u := newStreamUpstream(t, "")
- u.body.Store(bigPackumentAt(u.URL, 100))
- proxy, _, handler := streamProxy(t, u)
- proxy.MetadataMaxSize = 1024
- fetcher := proxy.Fetcher.(*mockFetcher)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package"))}
-
- if rec := serve(handler, "/big/-/big-9.0.0.tgz", nil); rec.Code != http.StatusNotFound {
- t.Errorf("tarball inside cooldown: status %d, want 404", rec.Code)
- }
- if rec := serve(handler, "/big/-/big-1.0.5.tgz", nil); rec.Code != http.StatusOK || rec.Body.String() != "package" {
- t.Errorf("tarball: status %d, body %q", rec.Code, rec.Body.String())
- }
- if want := u.URL + "/big/-/big-1.0.5.tgz"; fetcher.fetchedURL != want {
- t.Errorf("fetched %s, want %s from the packument", fetcher.fetchedURL, want)
- }
-}
diff --git a/internal/handler/npm_tarball_test.go b/internal/handler/npm_tarball_test.go
deleted file mode 100644
index 6bca0f6..0000000
--- a/internal/handler/npm_tarball_test.go
+++ /dev/null
@@ -1,426 +0,0 @@
-package handler
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- registryclient "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestNPMContentAddressedTarball(t *testing.T) {
- for _, tc := range []struct {
- cacheMetadata, stream bool
- metadata, tarballs int64
- }{
- {false, false, 2, 1},
- {false, true, 3, 2},
- {true, false, 1, 1},
- {true, true, 1, 2},
- } {
- t.Run(fmt.Sprintf("metadata=%t/stream=%t", tc.cacheMetadata, tc.stream), func(t *testing.T) {
- var metadataCalls, tarballCalls atomic.Int64
- upstream := npmContentAddressedRegistry(t, &metadataCalls, &tarballCalls)
- p, _, _, _ := setupTestProxy(t)
- p.CacheMetadata = tc.cacheMetadata
- p.MetadataTTL = time.Hour
- p.StreamArtifacts = tc.stream
- p.Cooldown = &cooldown.Config{Default: "7d"}
- p.HTTPClient = upstream.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- p.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- h := NewNPMHandler(p, "http://proxy.test", upstream.URL+"/registry")
- routes := http.StripPrefix("/npm", h.Routes())
- metadata := httptest.NewRecorder()
- routes.ServeHTTP(metadata, httptest.NewRequest(http.MethodGet, "/npm/@example/widget", nil))
- if metadata.Code != http.StatusOK {
- t.Fatalf("metadata status = %d: %s", metadata.Code, metadata.Body.String())
- }
- tarball := npmVersionTarball(metadata.Body.Bytes(), testVersion100)
- if want := "http://proxy.test/npm/@example%2Fwidget/-/widget-1.0.0.tgz"; tarball != want {
- t.Fatalf("rewritten tarball = %q, want %q", tarball, want)
- }
- for i := range 2 {
- response := httptest.NewRecorder()
- routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tarball, nil))
- if response.Code != http.StatusOK || response.Body.String() != "tarball bytes" {
- t.Fatalf("download %d = %d %q", i, response.Code, response.Body.String())
- }
- if !tc.stream {
- upstream.Close()
- }
- }
- if metadataCalls.Load() != tc.metadata || tarballCalls.Load() != tc.tarballs {
- t.Errorf("upstream requests: metadata=%d tarballs=%d, want %d %d", metadataCalls.Load(), tarballCalls.Load(), tc.metadata, tc.tarballs)
- }
- })
- }
-}
-
-func npmContentAddressedRegistry(t *testing.T, metadataCalls, tarballCalls *atomic.Int64) *httptest.Server {
- t.Helper()
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/registry/@example/widget":
- metadataCalls.Add(1)
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = fmt.Fprintf(w, `{"name":"@example/widget","time":{"1.0.0":%q},"versions":{"1.0.0":{"dist":{"tarball":%q}}}}`,
- time.Now().Add(-30*24*time.Hour).Format(time.RFC3339), upstream.URL+"/registry/download/@example/widget/1.0.0/abc123?token=example")
- case "/registry/download/@example/widget/1.0.0/abc123":
- if r.URL.RawQuery != "token=example" {
- t.Errorf("tarball query = %q", r.URL.RawQuery)
- }
- tarballCalls.Add(1)
- _, _ = io.WriteString(w, "tarball bytes")
- default:
- t.Errorf("unexpected upstream request: %s", r.URL)
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(upstream.Close)
- return upstream
-}
-
-func TestNPMTarballMetadataFallback(t *testing.T) {
- for _, body := range []string{`{"versions":{}}`, `{"versions":{"1.0.0":{"dist":{}}}}`} {
- t.Run(body, func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = io.WriteString(w, body)
- }))
- t.Cleanup(upstream.Close)
- p, _, _, fetcher := setupTestProxy(t)
- p.CacheMetadata = true
- p.MetadataTTL = time.Hour
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package"))}
- h := NewNPMHandler(p, "http://proxy.test", upstream.URL)
- if _, _, err := p.FetchOrCacheMetadata(t.Context(), "npm", "@example/widget", upstream.URL); err != nil {
- t.Fatal(err)
- }
- upstream.Close()
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/@example/widget/-/widget-1.0.0.tgz", nil))
- if response.Code != http.StatusOK {
- t.Fatalf("status = %d: %s", response.Code, response.Body.String())
- }
- if want := upstream.URL + "/@example/widget/-/widget-1.0.0.tgz"; fetcher.fetchedURL != want {
- t.Errorf("fetched URL = %q, want %q", fetcher.fetchedURL, want)
- }
- })
- }
-}
-
-// TestNPMTarballWithoutMetadataIsNotFetched checks that a tarball whose
-// packument can't be read, so its integrity is unknown, is neither fetched
-// nor cached.
-func TestNPMTarballWithoutMetadataIsNotFetched(t *testing.T) {
- for _, tc := range []struct {
- name string
- status int
- body string
- limit int64
- }{
- {name: "unavailable", status: http.StatusServiceUnavailable, body: "unavailable"},
- {name: "over metadata_max_size", status: http.StatusOK, body: `{"versions":{"1.0.0":{"dist":{"integrity":"` + sha512SRI("package") + `"}}}}`, limit: 16},
- } {
- t.Run(tc.name, func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(tc.status)
- _, _ = io.WriteString(w, tc.body)
- }))
- t.Cleanup(upstream.Close)
- p, _, _, fetcher := setupTestProxy(t)
- if tc.limit > 0 {
- p.MetadataMaxSize = tc.limit
- }
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("tampered"))}
- h := NewNPMHandler(p, "http://proxy.test", upstream.URL)
-
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/@example/widget/-/widget-1.0.0.tgz", nil))
- if response.Code != http.StatusBadGateway {
- t.Fatalf("status = %d, want 502: %s", response.Code, response.Body.String())
- }
- if fetcher.fetchedURL != "" {
- t.Errorf("fetched %q without the metadata to verify it", fetcher.fetchedURL)
- }
- cached, err := p.GetCachedArtifact(t.Context(), "npm", "@example/widget", "1.0.0", "widget-1.0.0.tgz")
- if err != nil {
- t.Fatal(err)
- }
- if cached != nil {
- _ = cached.Reader.Close()
- t.Error("tarball was cached without verification")
- }
- })
- }
-}
-
-// serveEmptyPackuments answers every metadata request with a packument that
-// lists no versions, for tests about the tarball download itself.
-func serveEmptyPackuments(p *Proxy) {
- p.HTTPClient = &http.Client{Transport: pypiRoundTripFunc(func(r *http.Request) (*http.Response, error) {
- return pypiHTTPResponse(r, "application/json", `{"versions":{}}`), nil
- })}
-}
-
-func TestNPMTarballRejectsUnsafeMetadataURL(t *testing.T) {
- for _, target := range []string{
- "https://outside.invalid/package.tgz", "//outside.invalid/package.tgz", "/registry/package.tgz",
- "UPSTREAM/outside/package.tgz", "UPSTREAM/registry-other/package.tgz",
- "UPSTREAM/registry/../outside/package.tgz", "UPSTREAM/registry/%2e%2e/outside/package.tgz",
- "UPSTREAM/registry/%252e%252e/outside/package.tgz", "UPSTREAM/registry/..%2foutside/package.tgz",
- "UPSTREAM/registry/..%5coutside/package.tgz", "UPSTREAM/registry/package.tgz#fragment",
- "USERINFO/registry/package.tgz", "SCHEME/registry/package.tgz",
- } {
- t.Run(target, func(t *testing.T) {
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- address, _ := url.Parse(upstream.URL)
- address.User = url.UserPassword("user", "secret")
- tarball := strings.NewReplacer("UPSTREAM", upstream.URL, "USERINFO", address.String(), "SCHEME", strings.Replace(upstream.URL, "http:", "https:", 1)).Replace(target)
- _ = json.NewEncoder(w).Encode(map[string]any{"versions": map[string]any{testVersion100: map[string]any{"dist": map[string]string{"tarball": tarball}}}})
- }))
- t.Cleanup(upstream.Close)
- p, _, _, fetcher := setupTestProxy(t)
- h := NewNPMHandler(p, "http://proxy.test", upstream.URL+"/registry")
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/widget/-/widget-1.0.0.tgz", nil))
- if response.Code != http.StatusBadGateway || fetcher.fetchCalled {
- t.Errorf("status = %d, fetched = %t; body: %s", response.Code, fetcher.fetchCalled, response.Body.String())
- }
- })
- }
-}
-
-// TestNPMTarballCooldownRefetchesStalePackument checks that a tarball for a
-// version the cached packument does not list yet is decided on the packument
-// upstream serves now, not let through for want of a publish time.
-func TestNPMTarballCooldownRefetchesStalePackument(t *testing.T) {
- for _, tc := range []struct {
- name string
- published time.Time
- down bool
- want int
- }{
- {"recent", time.Now().Add(-time.Hour), false, http.StatusNotFound},
- // Without upstream the version can't be checked, so it isn't served.
- {"upstream down", time.Now().Add(-time.Hour), true, http.StatusBadGateway},
- // The refetched entry's integrity is checked, so a tarball that
- // doesn't match it is refused rather than served unverified.
- {"old", time.Now().Add(-30 * 24 * time.Hour), false, http.StatusBadGateway},
- } {
- t.Run(tc.name, func(t *testing.T) {
- var released atomic.Bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- old := time.Now().Add(-60 * 24 * time.Hour).Format(time.RFC3339)
- if released.Load() && tc.down {
- w.WriteHeader(http.StatusServiceUnavailable)
- return
- }
- if !released.Load() {
- _, _ = fmt.Fprintf(w, `{"name":"widget","time":{"1.0.0":%q},"versions":{"1.0.0":{"dist":{}}}}`, old)
- return
- }
- _, _ = fmt.Fprintf(w, `{"name":"widget","time":{"1.0.0":%q,"2.0.0":%q},"versions":{"1.0.0":{"dist":{}},"2.0.0":{"dist":{"integrity":%q}}}}`,
- old, tc.published.Format(time.RFC3339), sha512SRI("package"))
- }))
- t.Cleanup(upstream.Close)
- p, _, _, fetcher := setupTestProxy(t)
- p.CacheMetadata = true
- p.MetadataTTL = time.Hour
- p.Cooldown = &cooldown.Config{Default: "7d"}
- p.HTTPClient = upstream.Client()
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("tampered"))}
- h := NewNPMHandler(p, "http://proxy.test", upstream.URL)
-
- metadata := httptest.NewRecorder()
- h.Routes().ServeHTTP(metadata, httptest.NewRequest(http.MethodGet, "/widget", nil))
- if metadata.Code != http.StatusOK {
- t.Fatalf("metadata status = %d: %s", metadata.Code, metadata.Body.String())
- }
- released.Store(true)
-
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/widget/-/widget-2.0.0.tgz", nil))
- if response.Code != tc.want {
- t.Fatalf("status = %d, want %d: %s", response.Code, tc.want, response.Body.String())
- }
- })
- }
-}
-
-// widgetPackument is a packument listing only widget 1.0.0, published long
-// ago.
-func widgetPackument() string {
- old := time.Now().Add(-60 * 24 * time.Hour).Format(time.RFC3339)
- return fmt.Sprintf(`{"name":"widget","time":{"1.0.0":%q},"versions":{"1.0.0":{"dist":{}}}}`, old)
-}
-
-// cooldownNPMHandler returns an npm handler for upstream with cooldown on
-// and a tarball fetcher that would serve anything asked of it.
-func cooldownNPMHandler(t *testing.T, upstream *httptest.Server, ttl time.Duration) *NPMHandler {
- t.Helper()
- p, _, _, fetcher := setupTestProxy(t)
- p.CacheMetadata = true
- p.MetadataTTL = ttl
- p.Cooldown = &cooldown.Config{Default: "7d"}
- p.HTTPClient = upstream.Client()
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package"))}
- return NewNPMHandler(p, "http://proxy.test", upstream.URL)
-}
-
-// TestNPMTarballCooldownStalePackument checks that a stale packument served
-// because upstream failed doesn't let through a version it doesn't list,
-// and that the upstream failure decides the response.
-func TestNPMTarballCooldownStalePackument(t *testing.T) {
- for _, tc := range []struct {
- name string
- status int
- limited bool
- want int
- }{
- {"upstream down", http.StatusServiceUnavailable, false, http.StatusBadGateway},
- {"not found", http.StatusNotFound, false, http.StatusNotFound},
- {"rate limited", http.StatusTooManyRequests, true, http.StatusServiceUnavailable},
- } {
- t.Run(tc.name, func(t *testing.T) {
- var down atomic.Bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- if down.Load() {
- w.Header().Set("Retry-After", "60")
- w.WriteHeader(tc.status)
- return
- }
- _, _ = io.WriteString(w, widgetPackument())
- }))
- t.Cleanup(upstream.Close)
- // With no TTL the packument is read in memory and every read
- // asks upstream.
- h := cooldownNPMHandler(t, upstream, 0)
- if tc.limited {
- gate := registryclient.NewRateLimitGate(http.DefaultTransport)
- h.proxy.HTTPClient = &http.Client{Transport: gate}
- h.proxy.RateLimits = gate
- }
-
- metadata := httptest.NewRecorder()
- h.Routes().ServeHTTP(metadata, httptest.NewRequest(http.MethodGet, "/widget", nil))
- if metadata.Code != http.StatusOK {
- t.Fatalf("metadata status = %d: %s", metadata.Code, metadata.Body.String())
- }
- down.Store(true)
-
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/widget/-/widget-2.0.0.tgz", nil))
- if response.Code != tc.want {
- t.Fatalf("status = %d, want %d: %s", response.Code, tc.want, response.Body.String())
- }
- if tc.limited && response.Header().Get("Retry-After") == "" {
- t.Error("no Retry-After on a rate-limited response")
- }
- })
- }
-}
-
-// TestNPMTarballCooldownFetchesMissingVersionOnce checks that a packument
-// just fetched from upstream isn't fetched again because it doesn't list the
-// requested version.
-func TestNPMTarballCooldownFetchesMissingVersionOnce(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests.Add(1)
- _, _ = io.WriteString(w, widgetPackument())
- }))
- t.Cleanup(upstream.Close)
- h := cooldownNPMHandler(t, upstream, time.Hour)
-
- response := httptest.NewRecorder()
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/widget/-/widget-99.0.0.tgz", nil))
- if n := requests.Load(); n != 1 {
- t.Errorf("upstream packument requests = %d, want 1", n)
- }
-}
-
-// TestNPMTarballCooldownRefreshIgnoresSharedStale checks that a refetch of a
-// stored packument lacking the version, joining another caller's fetch that
-// falls back to the stored copy, doesn't take that copy as upstream's answer.
-func TestNPMTarballCooldownRefreshIgnoresSharedStale(t *testing.T) {
- var blocked atomic.Bool
- arrived := make(chan struct{}, 1)
- release := make(chan struct{})
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !blocked.Load() {
- _, _ = io.WriteString(w, widgetPackument())
- return
- }
- arrived <- struct{}{}
- select {
- case <-release:
- case <-r.Context().Done():
- }
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- t.Cleanup(upstream.Close)
- h := cooldownNPMHandler(t, upstream, time.Hour)
- p := h.proxy
-
- metadata := httptest.NewRecorder()
- h.Routes().ServeHTTP(metadata, httptest.NewRequest(http.MethodGet, "/widget", nil))
- if metadata.Code != http.StatusOK {
- t.Fatalf("metadata status = %d: %s", metadata.Code, metadata.Body.String())
- }
- registryURL, cacheKey := h.sourceFor("widget")
- packumentURL := registryURL + "/widget"
- accept := h.packumentAccept()
- row, err := p.DB.GetMetadataCache("npm", cacheKey)
- if err != nil || row == nil {
- t.Fatalf("no stored packument row: %v", err)
- }
- setFetchedAt := func(at time.Time) {
- t.Helper()
- if ok, err := p.DB.RefreshMetadataCache("npm", cacheKey, row.ContentDigest.String, at); err != nil || !ok {
- t.Fatalf("setting fetched_at: %t, %v", ok, err)
- }
- }
-
- // Another caller revalidates the expired copy, and upstream holds it.
- setFetchedAt(time.Now().Add(-2 * time.Hour))
- blocked.Store(true)
- other := make(chan error, 1)
- go func() {
- _, err := p.storedMetadataDocument(context.Background(), "npm", cacheKey, packumentURL, accept, h.packumentStreamValidator())
- other <- err
- }()
- <-arrived
-
- // The tarball request reads the copy as within its TTL and refetches it,
- // joining that fetch.
- setFetchedAt(time.Now())
- response := httptest.NewRecorder()
- done := make(chan struct{})
- go func() {
- defer close(done)
- h.Routes().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/widget/-/widget-2.0.0.tgz", nil))
- }()
- waitForMetadataWaiters(t, p, metadataCoalesceKey("npm", cacheKey, packumentURL, accept, "", false)+"\x00stored", 1)
- close(release)
- <-done
- if err := <-other; err != nil {
- t.Fatalf("other caller: %v", err)
- }
- if response.Code != http.StatusBadGateway {
- t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusBadGateway, response.Body.String())
- }
-}
diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go
index 9b9a6c3..7d2db00 100644
--- a/internal/handler/npm_test.go
+++ b/internal/handler/npm_test.go
@@ -1,31 +1,16 @@
package handler
import (
- "bytes"
- "compress/gzip"
"encoding/json"
- "errors"
- "fmt"
- "io"
"log/slog"
"net/http"
"net/http/httptest"
- "strings"
- "sync/atomic"
"testing"
- "testing/iotest"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
)
-const testVersion100 = "1.0.0"
-
func testProxy() *Proxy {
return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
+ Logger: slog.Default(),
}
}
@@ -41,9 +26,9 @@ func TestNPMExtractVersionFromFilename(t *testing.T) {
{"@babel/core", "core-7.23.0.tgz", "7.23.0"},
{"@types/node", "node-20.10.0.tgz", "20.10.0"},
{"express", "express-4.18.2.tgz", "4.18.2"},
- {"lodash", "lodash.tgz", ""}, // no version
- {"lodash", "lodash-4.17.21.zip", ""}, // wrong extension
- {"lodash", "other-4.17.21.tgz", ""}, // wrong package name
+ {"lodash", "lodash.tgz", ""}, // no version
+ {"lodash", "lodash-4.17.21.zip", ""}, // wrong extension
+ {"lodash", "other-4.17.21.tgz", ""}, // wrong package name
}
for _, tt := range tests {
@@ -55,86 +40,6 @@ func TestNPMExtractVersionFromFilename(t *testing.T) {
}
}
-func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) {
- t.Run("metadata", func(t *testing.T) {
- var requestPath, authHeader string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestPath = r.URL.Path
- authHeader = r.Header.Get("Authorization")
- if authHeader != "Bearer npm-token" {
- w.WriteHeader(http.StatusUnauthorized)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, `{"versions":{}}`)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- authorizeUpstream(proxy, "Bearer npm-token")
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/")
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if requestPath != "/root/testpkg" {
- t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg")
- }
- if authHeader != "Bearer npm-token" {
- t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token")
- }
- })
-
- t.Run("download", func(t *testing.T) {
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- serveEmptyPackuments(proxy)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz"
- if artifactFetcher.fetchedURL != want {
- t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
- }
- })
-
- t.Run("scoped download", func(t *testing.T) {
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- serveEmptyPackuments(proxy)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
-
- req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz"
- if artifactFetcher.fetchedURL != want {
- t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
- }
- })
-}
-
func TestNPMRewriteMetadata(t *testing.T) {
h := &NPMHandler{
proxy: testProxy(),
@@ -155,7 +60,7 @@ func TestNPMRewriteMetadata(t *testing.T) {
}
}`
- output, _, err := h.rewriteMetadataKeeping("lodash", []byte(input))
+ output, err := h.rewriteMetadata("lodash", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@@ -176,26 +81,6 @@ func TestNPMRewriteMetadata(t *testing.T) {
}
}
-func TestNPMRewriteMetadataLogsOncePerDocument(t *testing.T) {
- var logs bytes.Buffer
- proxy := testProxy()
- proxy.Logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
- h := &NPMHandler{proxy: proxy, proxyURL: "http://localhost:8080"}
-
- input := `{"name":"lodash","versions":{
- "4.17.19":{"dist":{"tarball":"https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz"}},
- "4.17.20":{"dist":{"tarball":"https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz"}},
- "4.17.21":{"dist":{"tarball":"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}}}}`
- if _, _, err := h.rewriteMetadataKeeping("lodash", []byte(input)); err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- lines := strings.Split(strings.TrimSpace(logs.String()), "\n")
- if len(lines) != 1 || !strings.Contains(lines[0], `msg="rewrote tarball URLs"`) || !strings.Contains(lines[0], "count=3") {
- t.Errorf("logs = %q, want one line counting 3 rewrites", logs.String())
- }
-}
-
func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
h := &NPMHandler{
proxy: testProxy(),
@@ -215,7 +100,7 @@ func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
}
}`
- output, _, err := h.rewriteMetadataKeeping("@babel/core", []byte(input))
+ output, err := h.rewriteMetadata("@babel/core", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@@ -283,7 +168,7 @@ func TestNPMHandlerMetadataProxy(t *testing.T) {
// Check that tarball URL was rewritten
versions := result["versions"].(map[string]any)
- v := versions[testVersion100].(map[string]any)
+ v := versions["1.0.0"].(map[string]any)
dist := v["dist"].(map[string]any)
tarball := dist["tarball"].(string)
@@ -292,191 +177,6 @@ func TestNPMHandlerMetadataProxy(t *testing.T) {
}
}
-func TestNPMRewriteMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &NPMHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "testpkg",
- "dist-tags": {"latest": "2.0.0"},
- "time": {
- "1.0.0": "` + old + `",
- "2.0.0": "` + recent + `"
- },
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
- }
- },
- "2.0.0": {
- "name": "testpkg",
- "version": "2.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-2.0.0.tgz"
- }
- }
- }
- }`
-
- output, _, err := h.rewriteMetadataKeeping("testpkg", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].(map[string]any)
-
- // Old version should remain
- if _, ok := versions[testVersion100]; !ok {
- t.Error("version 1.0.0 should not be filtered")
- }
-
- // Recent version should be filtered
- if _, ok := versions["2.0.0"]; ok {
- t.Error("version 2.0.0 should be filtered by cooldown")
- }
-
- // dist-tags.latest should be updated to 1.0.0
- distTags := result["dist-tags"].(map[string]any)
- if distTags["latest"] != testVersion100 {
- t.Errorf("dist-tags.latest = %q, want %q", distTags["latest"], testVersion100)
- }
-}
-
-func TestNPMRewriteMetadataCooldownExemptPackage(t *testing.T) {
- now := time.Now()
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:npm/testpkg": "0"},
- }
-
- h := &NPMHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "testpkg",
- "time": {"1.0.0": "` + recent + `"},
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"}
- }
- }
- }`
-
- output, _, err := h.rewriteMetadataKeeping("testpkg", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].(map[string]any)
- if _, ok := versions[testVersion100]; !ok {
- t.Error("exempt package version should not be filtered")
- }
-}
-
-func TestNPMHandlerUsesAbbreviatedMetadata(t *testing.T) {
- var gotAccept string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAccept = r.Header.Get("Accept")
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "name": "testpkg",
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
- }
- }
- }
- }`))
- }))
- defer upstream.Close()
-
- t.Run("no cooldown uses combined accept header", func(t *testing.T) {
- h := &NPMHandler{
- proxy: testProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if gotAccept != npmAcceptDefault {
- t.Errorf("Accept = %q, want %q", gotAccept, npmAcceptDefault)
- }
- })
-
- t.Run("cooldown enabled uses full metadata only", func(t *testing.T) {
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &NPMHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if gotAccept != contentTypeJSON {
- t.Errorf("Accept = %q, want %q (cooldown requires full metadata)", gotAccept, contentTypeJSON)
- }
- })
-
- t.Run("full metadata option uses full metadata without cooldown", func(t *testing.T) {
- proxy := testProxy()
- proxy.NPMFullMetadata = true
-
- h := &NPMHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if gotAccept != contentTypeJSON {
- t.Errorf("Accept = %q, want %q (npm_full_metadata requires full metadata)", gotAccept, contentTypeJSON)
- }
- })
-}
-
func TestNPMHandlerMetadataNotFound(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -499,933 +199,3 @@ func TestNPMHandlerMetadataNotFound(t *testing.T) {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
-
-func TestNPMDownloadCooldown(t *testing.T) {
- now := time.Now()
- packument := `{
- "name": "leftpad",
- "dist-tags": {"latest": "2.0.0"},
- "time": {
- "1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `",
- "2.0.0": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"
- },
- "versions": {"1.0.0": {}, "2.0.0": {}}
- }`
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = io.WriteString(w, packument)
- }))
- defer upstream.Close()
-
- tests := []struct {
- name string
- version string
- wantStatus int
- }{
- {"published before the window serves the tarball", testVersion100, http.StatusOK},
- {"published inside the window is withheld", "2.0.0", http.StatusNotFound},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("tarball data")),
- ContentType: "application/octet-stream",
- }
-
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != tt.wantStatus {
- t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
- }
- if tt.wantStatus == http.StatusNotFound && fetcher.fetchCalled {
- t.Error("fetched a version that is still inside the cooldown window")
- }
- })
- }
-}
-
-func TestNPMDownloadCooldownDisabled(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewNPMHandler(proxy, "http://proxy.test", "")
-
- if inCooldown, _ := h.versionInCooldown("leftpad", testVersion100, func() (npmVersionData, error) {
- t.Fatal("cooldown must not request metadata when disabled")
- return npmVersionData{}, nil
- }); inCooldown {
- t.Error("versionInCooldown = true, want false when cooldown is not configured")
- }
-}
-
-func TestNPMDownloadCooldownUsesStoredPublishTime(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- t.Error("metadata must not be fetched when the publish time is already stored")
- w.WriteHeader(http.StatusInternalServerError)
- }))
- defer upstream.Close()
-
- tests := []struct {
- name string
- version string
- publishedAt time.Time
- wantStatus int
- }{
- {"stored time before the window serves the tarball", testVersion100, time.Now().Add(-30 * 24 * time.Hour), http.StatusOK},
- {"stored time inside the window is withheld", "2.0.0", time.Now().Add(-1 * time.Hour), http.StatusNotFound},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- seedPackage(t, db, store, "npm", "leftpad", tt.version, "leftpad-"+tt.version+".tgz", "tarball data")
-
- if err := db.SetVersionPublishedAt("pkg:npm/leftpad@"+tt.version, "pkg:npm/leftpad", tt.publishedAt); err != nil {
- t.Fatalf("seeding publish time failed: %v", err)
- }
-
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != tt.wantStatus {
- t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
- }
- })
- }
-}
-
-func TestNPMDownloadCooldownFetchesMetadataOnce(t *testing.T) {
- now := time.Now()
- packument := `{
- "name": "leftpad",
- "dist-tags": {"latest": "1.0.0"},
- "time": {
- "1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `"
- },
- "versions": {"1.0.0": {}}
- }`
-
- var metadataRequests atomic.Int64
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- metadataRequests.Add(1)
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = io.WriteString(w, packument)
- }))
- defer upstream.Close()
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
-
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- // The first download parses the packument once and persists the publish
- // time; caching the artifact afterwards upserts the versions row without a
- // publish time, which must not erase the stored value. The second download
- // must answer from the stored time alone.
- for i := 0; i < 2; i++ {
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("tarball data")),
- ContentType: "application/octet-stream",
- }
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + testVersion100 + ".tgz")
- if err != nil {
- t.Fatalf("request %d failed: %v", i+1, err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("request %d status = %d, want %d", i+1, resp.StatusCode, http.StatusOK)
- }
- }
-
- if got := metadataRequests.Load(); got != 1 {
- t.Errorf("metadata requests = %d, want 1", got)
- }
-}
-
-// TestNPMDownloadErrorResponsesAreJSON guards against a regression where
-// routing handleDownload's error path through the shared serveArtifactError
-// helper silently switched npm's 404/502 tarball error bodies from JSON to
-// plain text; npm clients expect a JSON {"error": "..."} body on every
-// download failure, including the newer scan-blocked (403) case.
-func TestNPMDownloadErrorResponsesAreJSON(t *testing.T) {
- tests := []struct {
- name string
- fetchErr error
- blocked bool
- wantStatus int
- }{
- {"upstream not found", fetch.ErrNotFound, false, http.StatusNotFound},
- {"upstream failure", errors.New("connection refused"), false, http.StatusBadGateway},
- {"blocked by scan", nil, true, http.StatusForbidden},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- if tt.blocked {
- proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, false, "malware detected").URL, false)
- }
- fetcher.fetchErr = tt.fetchErr
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("tarball data")),
- ContentType: "application/octet-stream",
- }
-
- serveEmptyPackuments(proxy)
- h := NewNPMHandler(proxy, "http://proxy.test", "http://upstream.invalid")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != tt.wantStatus {
- t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
- }
- if ct := resp.Header.Get("Content-Type"); ct != contentTypeJSON {
- t.Errorf("Content-Type = %q, want %q", ct, contentTypeJSON)
- }
- var body map[string]any
- if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
- t.Fatalf("response body is not valid JSON: %v", err)
- }
- if _, ok := body["error"]; !ok {
- t.Errorf("response body %v missing \"error\" key", body)
- }
- })
- }
-}
-
-// newNPMAuditUpstream returns a handler pointed at a stub registry, plus the
-// last request that registry saw.
-func newNPMAuditUpstream(t *testing.T, respond http.HandlerFunc) (*NPMHandler, *npmAuditCapture) {
- t.Helper()
-
- capture := &npmAuditCapture{}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- capture.method = r.Method
- capture.path = r.URL.Path
- capture.query = r.URL.RawQuery
- capture.contentType = r.Header.Get("Content-Type")
- capture.contentEncoding = r.Header.Get("Content-Encoding")
- capture.authorization = r.Header.Get("Authorization")
- capture.body, _ = io.ReadAll(r.Body)
- respond(w, r)
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- return NewNPMHandler(proxy, "http://proxy.test", upstream.URL), capture
-}
-
-type npmAuditCapture struct {
- method string
- path string
- query string
- contentType string
- contentEncoding string
- authorization string
- body []byte
-}
-
-func npmAuditJSON(body string) http.HandlerFunc {
- return func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, body)
- }
-}
-
-func serveNPM(t *testing.T, h *NPMHandler, method, target string, body io.Reader,
- headers map[string]string,
-) *httptest.ResponseRecorder {
- t.Helper()
-
- req := httptest.NewRequest(method, target, body)
- for name, value := range headers {
- req.Header.Set(name, value)
- }
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
- return w
-}
-
-func TestNPMAuditRelaysRequestAndResponse(t *testing.T) {
- const report = `{"actions":[],"advisories":{},"metadata":{"vulnerabilities":{"total":0}}}`
- const payload = `{"name":"app","requires":{"lodash":"^4.17.21"},"dependencies":{}}`
-
- h, got := newNPMAuditUpstream(t, npmAuditJSON(report))
- w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits?foo=bar",
- strings.NewReader(payload), map[string]string{"Content-Type": "application/json"})
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if w.Body.String() != report {
- t.Errorf("body = %q, want %q", w.Body.String(), report)
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("response Content-Type = %q, want application/json", ct)
- }
- if got.method != http.MethodPost {
- t.Errorf("upstream method = %q, want POST", got.method)
- }
- if got.path != "/-/npm/v1/security/audits" {
- t.Errorf("upstream path = %q, want /-/npm/v1/security/audits", got.path)
- }
- if got.query != "foo=bar" {
- t.Errorf("upstream query = %q, want foo=bar", got.query)
- }
- if string(got.body) != payload {
- t.Errorf("upstream body = %q, want %q", got.body, payload)
- }
- if got.contentType != "application/json" {
- t.Errorf("upstream Content-Type = %q, want application/json", got.contentType)
- }
-}
-
-// npm gzips its audit payload, so the bytes and the header describing them
-// must travel together.
-func TestNPMAuditForwardsGzippedBody(t *testing.T) {
- var gzipped bytes.Buffer
- zw := gzip.NewWriter(&gzipped)
- if _, err := io.WriteString(zw, `{"name":"app"}`); err != nil {
- t.Fatalf("writing gzip body: %v", err)
- }
- if err := zw.Close(); err != nil {
- t.Fatalf("closing gzip writer: %v", err)
- }
- want := gzipped.Bytes()
-
- h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
- w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits",
- bytes.NewReader(want), map[string]string{
- "Content-Type": "application/json",
- "Content-Encoding": "gzip",
- })
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if got.contentEncoding != "gzip" {
- t.Errorf("upstream Content-Encoding = %q, want gzip", got.contentEncoding)
- }
- if !bytes.Equal(got.body, want) {
- t.Errorf("upstream body was altered: got %d bytes, want %d", len(got.body), len(want))
- }
-}
-
-func TestNPMAuditCoversAllSecurityEndpoints(t *testing.T) {
- paths := []string{
- "/-/npm/v1/security/audits", // pnpm audit, npm audit (full)
- "/-/npm/v1/security/audits/quick", // yarn npm audit, npm audit fallback
- "/-/npm/v1/security/advisories/bulk", // npm audit (npm 7+)
- }
-
- for _, path := range paths {
- t.Run(path, func(t *testing.T) {
- h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
- w := serveNPM(t, h, http.MethodPost, path, strings.NewReader(`{}`), nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if got.path != path {
- t.Errorf("upstream path = %q, want %q", got.path, path)
- }
- })
- }
-}
-
-func TestNPMAuditAppliesUpstreamAuth(t *testing.T) {
- h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
- authorizeUpstream(h.proxy, "Bearer npm-token")
-
- serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
-
- if got.authorization != "Bearer npm-token" {
- t.Errorf("Authorization = %q, want %q", got.authorization, "Bearer npm-token")
- }
-}
-
-func TestNPMAuditRelaysUpstreamError(t *testing.T) {
- const upstreamBody = `{"error":"unauthorized"}`
-
- h, _ := newNPMAuditUpstream(t, func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = io.WriteString(w, upstreamBody)
- })
- w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
-
- if w.Code != http.StatusUnauthorized {
- t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
- }
- if w.Body.String() != upstreamBody {
- t.Errorf("body = %q, want %q", w.Body.String(), upstreamBody)
- }
-}
-
-// A proxy-side failure must still be JSON, or the client reports it as a
-// malformed audit response.
-func TestNPMAuditUpstreamUnreachable(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
- upstreamURL := upstream.URL
- upstream.Close() // nothing is listening now
-
- proxy, _, _, _ := setupTestProxy(t)
- h := NewNPMHandler(proxy, "http://proxy.test", upstreamURL)
-
- w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", ct)
- }
- if !json.Valid(w.Body.Bytes()) {
- t.Errorf("body is not valid JSON: %q", w.Body.String())
- }
-}
-
-func TestNPMAuditRejectsBadRequests(t *testing.T) {
- t.Run("non-POST method", func(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
-
- w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/security/audits", nil, nil)
-
- if w.Code != http.StatusMethodNotAllowed {
- t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
- }
- if !json.Valid(w.Body.Bytes()) {
- t.Errorf("body is not valid JSON: %q", w.Body.String())
- }
- })
-
- t.Run("body over the size cap", func(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
-
- body := strings.NewReader(strings.Repeat("a", npmSecurityMaxBody+1))
- w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", body, nil)
-
- if w.Code != http.StatusRequestEntityTooLarge {
- t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
- }
- })
-
- t.Run("unreadable body is not reported as too large", func(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
-
- req := httptest.NewRequest(http.MethodPost, "/-/npm/v1/security/audits",
- iotest.TimeoutReader(strings.NewReader("{}")))
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
- if !json.Valid(w.Body.Bytes()) {
- t.Errorf("body is not valid JSON: %q", w.Body.String())
- }
- })
-}
-
-func TestNPMAuditDoesNotInjectUpstreamQuery(t *testing.T) {
- h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
-
- serveNPM(t, h, http.MethodPost,
- "/-/npm/v1/security/audits%3Fevil=1", strings.NewReader(`{}`), nil)
-
- if got.query != "" {
- t.Errorf("upstream query = %q, want empty: encoded ? leaked into the query", got.query)
- }
- if got.path != "/-/npm/v1/security/audits?evil=1" {
- t.Errorf("upstream path = %q, want the encoded ? kept in the path", got.path)
- }
-}
-
-// The audit POST must go through relayResponse, not copy upstream headers
-// wholesale: a relayed Connection header would be honoured downstream.
-// TestRelayRoutes covers the GET paths; this covers the POST.
-func TestNPMAuditStripsHopByHopHeaders(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- conn, rw, err := w.(http.Hijacker).Hijack()
- if err != nil {
- t.Error(err)
- return
- }
- defer func() { _ = conn.Close() }()
- _, _ = fmt.Fprint(rw, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"+
- "Connection: X-Private\r\nX-Private: secret\r\nContent-Length: 2\r\n\r\n{}")
- _ = rw.Flush()
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- downstream := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
- defer downstream.Close()
-
- resp, err := downstream.Client().Post(
- downstream.URL+"/-/npm/v1/security/audits", contentTypeJSON, strings.NewReader(`{}`))
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.Header.Get("Connection") != "" || resp.Header.Get("X-Private") != "" {
- t.Errorf("connection-scoped headers leaked: %v", resp.Header)
- }
-}
-
-// `npm audit signatures` reads the registry signing keys. The path used to fall
-// through to the package dispatch and be escaped into a package name.
-func TestNPMKeysProxiesUpstream(t *testing.T) {
- const keys = `{"keys":[{"keyid":"SHA256:jl3bwswu","keytype":"ecdsa-sha2-nistp256"}]}`
-
- var gotPath, gotAuth string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // EscapedPath, not Path: the bug escaped the slashes into a package
- // name, and the server decodes %2F back into Path either way.
- gotPath, gotAuth = r.URL.EscapedPath(), r.Header.Get("Authorization")
- if gotPath != npmKeysPath {
- w.WriteHeader(http.StatusMethodNotAllowed) // what registry.npmjs.org answers
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, keys)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- authorizeUpstream(proxy, "Bearer npm-token")
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, npmKeysPath, nil, nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
- if w.Body.String() != keys {
- t.Errorf("body = %q, want %q", w.Body.String(), keys)
- }
- if gotPath != npmKeysPath {
- t.Errorf("upstream path = %q, want %q", gotPath, npmKeysPath)
- }
- if gotAuth != "Bearer npm-token" {
- t.Errorf("Authorization = %q, want it applied", gotAuth)
- }
-}
-
-// Tarball paths share the /-/ prefix with the /-/npm/v1 endpoints.
-func TestNPMTarballStillRoutesToDownload(t *testing.T) {
- proxy, _, _, artifactFetcher := setupTestProxy(t)
- artifactFetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package")),
- ContentType: "application/gzip",
- }
- serveEmptyPackuments(proxy)
- h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
-
- w := serveNPM(t, h, http.MethodGet, "/lodash/-/lodash-4.17.21.tgz", nil, nil)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
-}
-
-func TestNPMMetadataPath(t *testing.T) {
- tests := []struct {
- path, wantName, wantRegistry string
- }{
- {"", "", ""},
- {"lodash", "lodash", "lodash"},
- {"lodash/4.17.21", "lodash", "lodash/4.17.21"},
- {"lodash/latest", "lodash", "lodash/latest"},
- {"@babel/core", "@babel/core", "@babel%2Fcore"},
- {"@babel/core/7.23.0", "@babel/core", "@babel%2Fcore/7.23.0"},
- }
- for _, tt := range tests {
- name, registry := npmMetadataPath(tt.path)
- if name != tt.wantName || registry != tt.wantRegistry {
- t.Errorf("npmMetadataPath(%q) = (%q, %q), want (%q, %q)",
- tt.path, name, registry, tt.wantName, tt.wantRegistry)
- }
- }
-}
-
-func TestNPMVersionMetadataKeepsVersionSegment(t *testing.T) {
- tests := []struct {
- request, upstreamPath, wantTarball string
- body string
- }{
- {
- request: "/lodash/4.17.21",
- upstreamPath: "/lodash/4.17.21",
- wantTarball: "http://proxy.local/npm/lodash/-/lodash-4.17.21.tgz",
- body: `{
- "name": "lodash",
- "version": "4.17.21",
- "dist": {"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}
- }`,
- },
- {
- request: "/@babel/core/7.23.0",
- upstreamPath: "/@babel%2Fcore/7.23.0",
- wantTarball: "http://proxy.local/npm/@babel%2Fcore/-/core-7.23.0.tgz",
- body: `{
- "name": "@babel/core",
- "version": "7.23.0",
- "dist": {"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz"}
- }`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.request, func(t *testing.T) {
- var gotPath string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath = r.URL.EscapedPath()
- if gotPath != tt.upstreamPath {
- w.WriteHeader(http.StatusMethodNotAllowed)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, tt.body)
- }))
- defer upstream.Close()
-
- h := &NPMHandler{proxy: testProxy(), upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, httptest.NewRequest(http.MethodGet, tt.request, nil))
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if gotPath != tt.upstreamPath {
- t.Fatalf("upstream path = %q, want %q", gotPath, tt.upstreamPath)
- }
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatalf("parse response: %v", err)
- }
- if result["dist"].(map[string]any)["tarball"] != tt.wantTarball {
- t.Errorf("tarball = %v", result["dist"].(map[string]any)["tarball"])
- }
- })
- }
-}
-
-func TestNPMMetadataRejectsEmptyPath(t *testing.T) {
- h := NewNPMHandler(testProxy(), "http://proxy.local", "http://unused.test")
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
- if w.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
- }
-}
-
-func TestNPMVersionMetadataRejectsTraversal(t *testing.T) {
- called := false
- upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
- called = true
- }))
- defer upstream.Close()
-
- h := &NPMHandler{proxy: testProxy(), upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
- req := httptest.NewRequest(http.MethodGet, "/lodash/x", nil)
- req.URL.Path = "/lodash/.."
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
- }
- if called {
- t.Fatal("upstream contacted for traversal path")
- }
-}
-
-func TestNPMVersionMetadataCooldown(t *testing.T) {
- proxyURL, upstreamPaths := npmCooldownMetadataHarness(t)
-
- resp, err := http.Get(proxyURL + "/demo")
- if err != nil {
- t.Fatalf("packument request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("packument status = %d, want 200", resp.StatusCode)
- }
- var packument map[string]any
- if err := json.NewDecoder(resp.Body).Decode(&packument); err != nil {
- t.Fatalf("decode packument: %v", err)
- }
- distTags := packument["dist-tags"].(map[string]any)
- if distTags["latest"] != testVersion100 {
- t.Fatalf("dist-tags.latest = %v, want %s", distTags["latest"], testVersion100)
- }
- if _, ok := packument["versions"].(map[string]any)["2.0.0"]; ok {
- t.Fatal("packument should not include version 2.0.0")
- }
-
- for _, tt := range []struct {
- path string
- wantStatus int
- wantVersion string
- wantError string
- }{
- {"/demo/latest", http.StatusOK, testVersion100, ""},
- {"/demo/1.0.0", http.StatusOK, testVersion100, ""},
- {"/demo/2.0.0", http.StatusNotFound, "", "version not found"},
- } {
- t.Run(tt.path, func(t *testing.T) {
- testNPMVersionMetadataCooldownPath(t, proxyURL, upstreamPaths, tt.path, tt.wantStatus, tt.wantVersion, tt.wantError)
- })
- }
-}
-
-// testNPMVersionMetadataCooldownPath is a subtest helper so the parent test stays
-// under gocognit's complexity limit.
-func testNPMVersionMetadataCooldownPath(t *testing.T, proxyURL string, upstreamPaths *[]string, path string, wantStatus int, wantVersion, wantError string) {
- t.Helper()
- before := len(*upstreamPaths)
- resp, err := http.Get(proxyURL + path)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body := readNPMTestBody(t, resp.Body)
- if resp.StatusCode != wantStatus {
- t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, wantStatus, body)
- }
- for _, upstreamPath := range (*upstreamPaths)[before:] {
- if upstreamPath == path {
- t.Fatalf("upstream must not be called for version path %s when answering from the filtered packument", path)
- }
- }
- if wantError != "" {
- if !strings.Contains(body, wantError) {
- t.Fatalf("error body should mention %q, got %s", wantError, body)
- }
- return
- }
- var versionDoc map[string]any
- if err := json.Unmarshal([]byte(body), &versionDoc); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if versionDoc["version"] != wantVersion {
- t.Fatalf("version = %v, want %s", versionDoc["version"], wantVersion)
- }
- wantTarball := "http://proxy.test/npm/demo/-/demo-" + wantVersion + ".tgz"
- if versionDoc["dist"].(map[string]any)["tarball"] != wantTarball {
- t.Fatalf("tarball = %v, want %s", versionDoc["dist"].(map[string]any)["tarball"], wantTarball)
- }
-}
-
-func npmCooldownMetadataHarness(t *testing.T) (proxyURL string, upstreamPaths *[]string) {
- t.Helper()
- now := time.Now()
- old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
- packument := `{
- "name": "demo",
- "dist-tags": {"latest": "2.0.0"},
- "time": {"1.0.0": "` + old + `", "2.0.0": "` + recent + `"},
- "versions": {
- "1.0.0": {
- "name": "demo",
- "version": "1.0.0",
- "dist": {"tarball": "https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}
- },
- "2.0.0": {
- "name": "demo",
- "version": "2.0.0",
- "dist": {"tarball": "https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}
- }
- }
- }`
- version200 := `{"name":"demo","version":"2.0.0","dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}`
- version100 := `{"name":"demo","version":"1.0.0","dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}}`
-
- var paths []string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- paths = append(paths, r.URL.Path)
- w.Header().Set("Content-Type", contentTypeJSON)
- switch r.URL.Path {
- case "/demo":
- _, _ = io.WriteString(w, packument)
- case "/demo/latest", "/demo/2.0.0":
- _, _ = io.WriteString(w, version200)
- case "/demo/1.0.0":
- _, _ = io.WriteString(w, version100)
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
- t.Cleanup(srv.Close)
- return srv.URL, &paths
-}
-
-func readNPMTestBody(t *testing.T, r io.Reader) string {
- b, err := io.ReadAll(r)
- if err != nil {
- t.Fatalf("read body: %v", err)
- }
- return string(b)
-}
-
-func TestNPMVersionMetadataDenied(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = io.WriteString(w, `{
- "name": "lodash",
- "version": "4.17.21",
- "dist": {"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}
- }`)
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- setTestDenylist(t, proxy, "pkg:npm/lodash@4.17.21")
- h := &NPMHandler{proxy: proxy, upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
-
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, httptest.NewRequest(http.MethodGet, "/lodash/4.17.21", nil))
- if w.Code != http.StatusForbidden {
- t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
- }
-}
-
-// `npm search` asks for /-/v1/search?text=.... The path used to fall through to
-// the package dispatch, which dropped the query string, escaped the path into
-// a package name and cached the answer, so every later search got the first
-// one's results.
-func TestNPMSearchRelaysQueryUncached(t *testing.T) {
- var (
- requests int
- gotPaths []string
- )
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests++
- gotPaths = append(gotPaths, r.URL.EscapedPath())
- if r.URL.EscapedPath() != "/-/v1/search" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"objects":[],"total":0,"query":%q}`, r.URL.RawQuery)
- }))
- defer upstream.Close()
-
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- for _, query := range []string{"text=foo&size=20", "text=bar&size=20", "text=foo&size=20"} {
- w := serveNPM(t, h, http.MethodGet, "/-/v1/search?"+query, nil, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("search %q: status = %d; body: %s", query, w.Code, w.Body.String())
- }
- if want := fmt.Sprintf(`{"objects":[],"total":0,"query":%q}`, query); w.Body.String() != want {
- t.Errorf("search %q: body = %s, want %s", query, w.Body.String(), want)
- }
- }
- if requests != 3 {
- t.Errorf("upstream requests = %d (%q), want 3: searches must not be cached", requests, gotPaths)
- }
- for _, key := range []string{"-/v1/search", "-", "-/v1"} {
- if entry, _ := db.GetMetadataCache("npm", key); entry != nil {
- t.Errorf("search response cached under %q", key)
- }
- }
-}
-
-// `npm ping` reads /-/ping, which used to be looked up as a package.
-func TestNPMPingRelaysUpstream(t *testing.T) {
- var gotPath, gotQuery string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath, gotQuery = r.URL.EscapedPath(), r.URL.RawQuery
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, "{}")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
-
- w := serveNPM(t, h, http.MethodGet, "/-/ping?write=true", nil, nil)
- if w.Code != http.StatusOK || w.Body.String() != "{}" {
- t.Fatalf("ping = %d %q, want 200 {}", w.Code, w.Body.String())
- }
- if gotPath != "/-/ping" || gotQuery != "write=true" {
- t.Errorf("upstream request = %q?%q, want /-/ping?write=true", gotPath, gotQuery)
- }
-}
-
-// TestNPMHeadPackument checks that packuments answer HEAD, which pnpm 12
-// sends before fetching each one, while tarballs and registry endpoints
-// still only answer GET.
-func TestNPMHeadPackument(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/testpkg" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"name":"testpkg","versions":{"1.0.0":{"name":"testpkg","version":"1.0.0","dist":{"tarball":"https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"}}}}`))
- }))
- defer upstream.Close()
- h := &NPMHandler{proxy: testProxy(), upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
-
- get := serveNPM(t, h, http.MethodGet, "/testpkg", nil, nil)
- head := serveNPM(t, h, http.MethodHead, "/testpkg", nil, nil)
- if head.Code != http.StatusOK || head.Header().Get("ETag") == "" || head.Header().Get("ETag") != get.Header().Get("ETag") {
- t.Errorf("HEAD /testpkg = %d, ETag %q; want 200 with GET's ETag %q", head.Code, head.Header().Get("ETag"), get.Header().Get("ETag"))
- }
-
- for _, path := range []string{"/testpkg/-/testpkg-1.0.0.tgz", "/-/ping", "/-/v1/search", "/-/package/testpkg/dist-tags"} {
- if w := serveNPM(t, h, http.MethodHead, path, nil, nil); w.Code != http.StatusMethodNotAllowed {
- t.Errorf("HEAD %s = %d, want %d", path, w.Code, http.StatusMethodNotAllowed)
- }
- }
-}
diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go
index 7a5b1b2..1b0360a 100644
--- a/internal/handler/nuget.go
+++ b/internal/handler/nuget.go
@@ -2,43 +2,20 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
+ "io"
"net/http"
"strings"
-
- "github.com/git-pkgs/proxy/internal/retention"
- "github.com/git-pkgs/purl"
)
const (
- nugetUpstream = "https://api.nuget.org"
- nugetSearchUpstream = "https://azuresearch-usnc.nuget.org"
+ nugetUpstream = "https://api.nuget.org"
)
-// Packages are stored under pkg:nuget/{id} with the id lowercased, since
-// NuGet ids are case-insensitive. purl keeps nuget names as written, so
-// package rules go through its comparison form, which lowercases them the
-// same way, before the default mapping.
-func init() {
- retention.Default.Register(retention.Spec{Key: "nuget", CanonicalPackage: nugetRetentionKey})
-}
-
-// nugetRetentionKey maps a package rule to the stored package PURL, so
-// pkg:nuget/Newtonsoft.Json matches the stored pkg:nuget/newtonsoft.json.
-func nugetRetentionKey(p *purl.PURL) (string, bool) {
- if p == nil {
- return "", false
- }
- c := p.ComparisonForm()
- return retention.DefaultCanonical("nuget")(purl.New(c.Type, c.Namespace, c.Name, "", nil))
-}
-
// NuGetHandler handles NuGet V3 API protocol requests.
type NuGetHandler struct {
proxy *Proxy
upstreamURL string
- searchURL string
proxyURL string
}
@@ -47,20 +24,10 @@ func NewNuGetHandler(proxy *Proxy, proxyURL string) *NuGetHandler {
return &NuGetHandler{
proxy: proxy,
upstreamURL: nugetUpstream,
- searchURL: nugetSearchUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
-// NewNuGetHandlerWithUpstreams creates a NuGet handler with custom API and
-// search upstreams.
-func NewNuGetHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, searchURL string) *NuGetHandler {
- h := NewNuGetHandler(proxy, proxyURL)
- h.upstreamURL = configuredUpstreamURL(upstreamURL, nugetUpstream)
- h.searchURL = configuredUpstreamURL(searchURL, nugetSearchUpstream)
- return h
-}
-
// Routes returns the HTTP handler for NuGet requests.
func (h *NuGetHandler) Routes() http.Handler {
mux := http.NewServeMux()
@@ -70,12 +37,10 @@ func (h *NuGetHandler) Routes() http.Handler {
// Package content (downloads)
mux.HandleFunc("GET /v3-flatcontainer/{id}/{version}/{filename}", h.handleDownload)
- mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.handleVersionList)
+ mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.proxyUpstream)
// Registration (package metadata) - use prefix matching since {version}.json isn't allowed
- for _, prefix := range nugetRegistrationPrefixes {
- mux.HandleFunc("GET "+prefix, h.handleRegistration)
- }
+ mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.proxyUpstream)
// Search
mux.HandleFunc("GET /query", h.proxyUpstream)
@@ -88,37 +53,45 @@ func (h *NuGetHandler) Routes() http.Handler {
// handleServiceIndex serves the NuGet V3 service index with rewritten URLs.
func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) {
- h.proxy.Logger.Debug("nuget service index request")
+ h.proxy.Logger.Info("nuget service index request")
upstreamURL := h.upstreamURL + "/v3/index.json"
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "nuget", "_service_index", upstreamURL)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteServiceIndex(body)
if err != nil {
- if h.cooldownEnabled() {
- h.nugetMetadataError(w, err)
- return
- }
h.proxy.Logger.Warn("failed to rewrite service index, proxying original", "error", err)
- w.Header().Set(headerContentType, "application/json")
+ w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
return
}
- w.Header().Set(headerContentType, "application/json")
+ w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(rewritten)
}
@@ -143,35 +116,55 @@ func (h *NuGetHandler) rewriteServiceIndex(body []byte) ([]byte, error) {
id, _ := rmap["@id"].(string)
rtype, _ := rmap["@type"].(string)
- // Rewrite URLs for services we proxy. The service type determines the
- // local route because an upstream index may advertise a different host.
- if id != "" {
- rmap["@id"] = h.rewriteNuGetURL(id, rtype)
+ // Rewrite URLs for services we proxy
+ if id != "" && h.shouldRewriteService(rtype) {
+ newURL := h.rewriteNuGetURL(id)
+ rmap["@id"] = newURL
}
}
return json.Marshal(index)
}
-// rewriteNuGetURL rewrites a NuGet service URL based on its advertised type.
-// Service types the proxy does not handle are returned unchanged.
-func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string {
- switch serviceType {
- case "PackageBaseAddress/3.0.0":
- return h.proxyURL + "/nuget/v3-flatcontainer/"
- case "RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc":
- return h.proxyURL + "/nuget/v3/registration5-semver1/"
- case "RegistrationsBaseUrl/3.4.0":
- return h.proxyURL + "/nuget/v3/registration5-gz-semver1/"
- case "RegistrationsBaseUrl/3.6.0", "RegistrationsBaseUrl/Versioned":
- return h.proxyURL + "/nuget/v3/registration5-gz-semver2/"
- case "SearchQueryService", "SearchQueryService/3.0.0-rc", "SearchQueryService/3.5.0":
- return h.proxyURL + "/nuget/query"
- case "SearchAutocompleteService", "SearchAutocompleteService/3.5.0":
- return h.proxyURL + "/nuget/autocomplete"
- default:
- return origURL
+// shouldRewriteService returns true if the service type should be rewritten.
+func (h *NuGetHandler) shouldRewriteService(serviceType string) bool {
+ // Rewrite package content and registration services
+ rewriteTypes := []string{
+ "PackageBaseAddress/3.0.0",
+ "RegistrationsBaseUrl/3.6.0",
+ "RegistrationsBaseUrl/Versioned",
+ "SearchQueryService",
+ "SearchQueryService/3.0.0-rc",
+ "SearchQueryService/3.5.0",
+ "SearchAutocompleteService",
+ "SearchAutocompleteService/3.5.0",
}
+
+ for _, t := range rewriteTypes {
+ if serviceType == t {
+ return true
+ }
+ }
+ return false
+}
+
+// rewriteNuGetURL rewrites a NuGet API URL to point at this proxy.
+func (h *NuGetHandler) rewriteNuGetURL(origURL string) string {
+ // Map known NuGet API endpoints to our proxy paths
+ replacements := map[string]string{
+ "https://api.nuget.org/v3-flatcontainer/": h.proxyURL + "/nuget/v3-flatcontainer/",
+ "https://api.nuget.org/v3/registration5-gz-semver2/": h.proxyURL + "/nuget/v3/registration5-gz-semver2/",
+ "https://azuresearch-usnc.nuget.org/query": h.proxyURL + "/nuget/query",
+ "https://azuresearch-usnc.nuget.org/autocomplete": h.proxyURL + "/nuget/autocomplete",
+ }
+
+ for old, new := range replacements {
+ if strings.HasPrefix(origURL, old) {
+ return strings.Replace(origURL, old, new, 1)
+ }
+ }
+
+ return origURL
}
// handleDownload serves a package file, fetching and caching from upstream if needed.
@@ -185,25 +178,13 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
return
}
- if h.cooldownEnabled() {
- allowed, err := h.nugetDownloadAllowed(r.Context(), id, version)
- if err != nil {
- h.nugetMetadataError(w, err)
- return
- }
- if !allowed {
- JSONError(w, http.StatusNotFound, "version not found")
- return
- }
- }
-
// Only cache .nupkg files
if !strings.HasSuffix(filename, ".nupkg") {
h.proxyUpstream(w, r)
return
}
- h.proxy.Logger.Debug("nuget download request",
+ h.proxy.Logger.Info("nuget download request",
"id", id, "version", version, "filename", filename)
// NuGet package IDs are case-insensitive, lowercase for storage
@@ -212,11 +193,12 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch package")
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, "failed to fetch package", http.StatusBadGateway)
return
}
- ServeArtifactRequest(w, r, result)
+ ServeArtifact(w, result)
}
// proxyUpstream forwards a request to NuGet without caching.
@@ -233,22 +215,26 @@ func (h *NuGetHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
}
// Copy accept-encoding for compression
- if ae := r.Header.Get(headerAcceptEncoding); ae != "" {
- req.Header.Set(headerAcceptEncoding, ae)
+ if ae := r.Header.Get("Accept-Encoding"); ae != "" {
+ req.Header.Set("Accept-Encoding", ae)
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if writeUpstreamHeld(w, err) {
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
- h.proxy.relayResponse(w, r, resp, nil)
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// buildUpstreamURL constructs the upstream URL for a request.
@@ -257,7 +243,7 @@ func (h *NuGetHandler) buildUpstreamURL(r *http.Request) string {
// Handle query and autocomplete which go to azuresearch
if strings.HasPrefix(path, "/query") || strings.HasPrefix(path, "/autocomplete") {
- return h.searchURL + path + "?" + r.URL.RawQuery
+ return "https://azuresearch-usnc.nuget.org" + path + "?" + r.URL.RawQuery
}
return h.upstreamURL + path
diff --git a/internal/handler/nuget_cooldown.go b/internal/handler/nuget_cooldown.go
deleted file mode 100644
index 7a0a17a..0000000
--- a/internal/handler/nuget_cooldown.go
+++ /dev/null
@@ -1,377 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "context"
- "crypto/sha256"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "slices"
- "strings"
- "time"
-)
-
-var nugetRegistrationPrefixes = []string{
- "/v3/registration5-semver1/",
- "/v3/registration5-gz-semver1/",
- "/v3/registration5-gz-semver2/",
-}
-
-var nugetArtifactPrefixes = append([]string{"/v3-flatcontainer/"}, nugetRegistrationPrefixes...)
-
-const nugetRegistrationPath = "/v3/registration5-gz-semver2/"
-
-func (h *NuGetHandler) cooldownEnabled() bool {
- return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
-}
-
-func (h *NuGetHandler) nugetCooldownApplies(id string) bool {
- return h.cooldownEnabled() && h.proxy.Cooldown.For("nuget", canonicalPackagePURL("nuget", strings.ToLower(id))) > 0
-}
-
-// Cache upstream documents, not filtered results, so policy changes and elapsed
-// time take effect even while metadata is fresh. Include the upstream in the key.
-func (h *NuGetHandler) nugetMetadata(ctx context.Context, path string) (map[string]any, error) {
- target := h.upstreamURL + path
- key := fmt.Sprintf("_cooldown/%x", sha256.Sum256([]byte(target)))
- var document map[string]any
- validate := func(body []byte) error {
- var err error
- document, err = h.decodeNuGetMetadata(body)
- return err
- }
- _, _, err := h.proxy.fetchOrCacheMetadata(ctx, "nuget", key, target, "", validate)
- if err != nil {
- return nil, err
- }
- return document, nil
-}
-
-func (h *NuGetHandler) decodeNuGetMetadata(body []byte) (map[string]any, error) {
- // Normally the HTTP transport decodes gzip. Also support compressed cached
- // bytes and clients with transparent decompression disabled, with the same
- // metadata limit applied to the decompressed document.
- if bytes.HasPrefix(body, []byte{0x1f, 0x8b}) {
- reader, err := gzip.NewReader(bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- defer func() { _ = reader.Close() }()
- body, err = h.proxy.ReadMetadata(reader)
- if err != nil {
- return nil, err
- }
- }
- var document map[string]any
- if err := json.Unmarshal(body, &document); err != nil {
- return nil, fmt.Errorf("parsing NuGet metadata: %w", err)
- }
- if document == nil {
- return nil, fmt.Errorf("empty NuGet metadata")
- }
- return document, nil
-}
-
-// Prefer semver2, but a configured source may advertise only an older hive.
-// Retry only advertised aliases on 404; transport/validation errors must not
-// silently switch to a hive with less complete metadata. Keep requests on the
-// configured upstream, consistent with the service-index route rewriting.
-func (h *NuGetHandler) nugetRegistrationMetadata(ctx context.Context, suffix string) (map[string]any, string, error) {
- path := nugetRegistrationPath + suffix
- document, err := h.nugetMetadata(ctx, path)
- if !errors.Is(err, ErrUpstreamNotFound) {
- return document, path, err
- }
- index, indexErr := h.nugetMetadata(ctx, "/v3/index.json")
- if indexErr != nil {
- return nil, path, indexErr
- }
- resources, _ := index["resources"].([]any)
- seen := map[string]bool{nugetRegistrationPath: true}
- for _, resource := range resources {
- entry, _ := resource.(map[string]any)
- service, _ := entry["@type"].(string)
- id, _ := entry["@id"].(string)
- if id == "" || !strings.HasPrefix(service, "RegistrationsBaseUrl") {
- continue
- }
- prefix := strings.TrimPrefix(h.rewriteNuGetURL(id, service), h.proxyURL+"/nuget")
- if !slices.Contains(nugetRegistrationPrefixes, prefix) || seen[prefix] {
- continue
- }
- seen[prefix] = true
- path = prefix + suffix
- document, err = h.nugetMetadata(ctx, path)
- if !errors.Is(err, ErrUpstreamNotFound) {
- return document, path, err
- }
- }
- return nil, path, err
-}
-
-func (h *NuGetHandler) nugetMetadataError(w http.ResponseWriter, err error) {
- if errors.Is(err, ErrUpstreamNotFound) {
- JSONError(w, http.StatusNotFound, "package metadata not found")
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Warn("failed to process NuGet metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to process package metadata")
-}
-
-func (h *NuGetHandler) handleVersionList(w http.ResponseWriter, r *http.Request) {
- if !h.cooldownEnabled() {
- h.proxyUpstream(w, r)
- return
- }
- id := strings.ToLower(r.PathValue("id"))
- document, err := h.nugetMetadata(r.Context(), "/v3-flatcontainer/"+url.PathEscape(id)+"/index.json")
- if err != nil {
- h.nugetMetadataError(w, err)
- return
- }
- blocked := make(map[string]bool)
- // A globally enabled policy may still exempt this package or ecosystem.
- // Keep metadata caching, but do not require publication data in that case.
- if h.nugetCooldownApplies(id) {
- registration, registrationPath, err := h.nugetRegistrationMetadata(r.Context(), url.PathEscape(id)+"/index.json")
- if err == nil {
- err = h.expandNuGetPages(r.Context(), registration, registrationPath)
- }
- if err != nil {
- h.nugetMetadataError(w, err)
- return
- }
- h.collectNuGetBlockedVersions(registration, id, blocked)
- }
- versions, ok := document["versions"].([]any)
- if !ok {
- h.nugetMetadataError(w, fmt.Errorf("missing NuGet versions"))
- return
- }
- filtered := make([]any, 0, len(versions))
- for _, value := range versions {
- version, ok := value.(string)
- if !ok {
- h.nugetMetadataError(w, fmt.Errorf("invalid NuGet version"))
- return
- }
- if !blocked[nugetVersionKey(version)] {
- filtered = append(filtered, value)
- }
- }
- document["versions"] = filtered
- w.Header().Set(headerContentType, contentTypeJSON)
- _ = json.NewEncoder(w).Encode(document)
-}
-
-func nugetVersionKey(version string) string {
- version, _, _ = strings.Cut(version, "+")
- return strings.ToLower(version)
-}
-
-func (h *NuGetHandler) nugetDownloadAllowed(ctx context.Context, id, version string) (bool, error) {
- if !h.nugetCooldownApplies(id) {
- return true, nil
- }
- suffix := url.PathEscape(strings.ToLower(id)) + "/" + url.PathEscape(nugetVersionKey(version)) + ".json"
- leaf, _, err := h.nugetRegistrationMetadata(ctx, suffix)
- if err != nil {
- return false, err
- }
- return h.nugetLeafAllowed(leaf, id), nil
-}
-
-// A standalone leaf has published at its root; leaves embedded in pages carry
-// it in catalogEntry. Missing/invalid timestamps retain the existing permissive
-// behavior, but fetch and JSON errors must not bypass the policy.
-func (h *NuGetHandler) nugetLeafAllowed(leaf map[string]any, id string) bool {
- if !h.cooldownEnabled() {
- return true
- }
- entry := nugetCatalogEntry(leaf)
- if id == "" {
- id, _ = entry["id"].(string)
- }
- published, _ := entry["published"].(string)
- when, err := time.Parse(time.RFC3339, published)
- if err != nil {
- return true
- }
- return h.proxy.cooldown().IsAllowed("nuget", canonicalPackagePURL("nuget", strings.ToLower(id)), when)
-}
-
-func nugetCatalogEntry(leaf map[string]any) map[string]any {
- if entry, ok := leaf["catalogEntry"].(map[string]any); ok {
- return entry
- }
- return leaf
-}
-
-func (h *NuGetHandler) collectNuGetBlockedVersions(document map[string]any, id string, blocked map[string]bool) {
- entry := nugetCatalogEntry(document)
- if version, ok := entry["version"].(string); ok && !h.nugetLeafAllowed(document, id) {
- blocked[nugetVersionKey(version)] = true
- }
- items, _ := document["items"].([]any)
- for _, item := range items {
- if child, ok := item.(map[string]any); ok {
- h.collectNuGetBlockedVersions(child, id, blocked)
- }
- }
-}
-
-func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) {
- if !h.cooldownEnabled() {
- h.proxyUpstream(w, r)
- return
- }
- id := nugetRegistrationID(r.URL.Path)
- applyCooldown := h.nugetCooldownApplies(id)
- document, err := h.nugetMetadata(r.Context(), r.URL.Path)
- if err == nil && applyCooldown {
- err = h.expandNuGetPages(r.Context(), document, r.URL.Path)
- }
- if err != nil {
- h.nugetMetadataError(w, err)
- return
- }
- _, hasItems := document["items"]
- if applyCooldown && !h.filterNuGetRegistration(document, id) && !hasItems {
- JSONError(w, http.StatusNotFound, "version not found")
- return
- }
- h.rewriteNuGetRegistrationLinks(document)
- w.Header().Set(headerContentType, contentTypeJSON)
- _ = json.NewEncoder(w).Encode(document)
-}
-
-func nugetRegistrationID(path string) string {
- for _, prefix := range nugetRegistrationPrefixes {
- if rest, ok := strings.CutPrefix(path, prefix); ok {
- id, _, _ := strings.Cut(rest, "/")
- return id
- }
- }
- return ""
-}
-
-// Only expand index pages, never recursively follow arbitrary upstream links.
-// Pin requests to this configured upstream and the current package's page path.
-func (h *NuGetHandler) expandNuGetPages(ctx context.Context, document map[string]any, path string) error {
- if !strings.HasSuffix(path, "/index.json") {
- return nil
- }
- items, ok := document["items"].([]any)
- if !ok {
- return fmt.Errorf("missing registration pages")
- }
- base, err := url.Parse(h.upstreamURL + path)
- if err != nil {
- return err
- }
- pagePrefix := strings.TrimSuffix(base.Path, "index.json") + "page/"
- for _, item := range items {
- page, ok := item.(map[string]any)
- if !ok {
- return fmt.Errorf("invalid registration page")
- }
- if _, ok := page["items"].([]any); ok {
- continue
- }
- link, _ := page["@id"].(string)
- target, err := base.Parse(link)
- if err != nil || target.Scheme != base.Scheme || target.Host != base.Host ||
- !strings.HasPrefix(target.Path, pagePrefix) || containsPathTraversal(target.Path) || target.RawQuery != "" || target.Fragment != "" {
- return fmt.Errorf("invalid registration page URL: %q", link)
- }
- upstream, _ := url.Parse(h.upstreamURL)
- pageDocument, err := h.nugetMetadata(ctx, strings.TrimPrefix(target.Path, upstream.Path))
- if err != nil {
- return err
- }
- leaves, ok := pageDocument["items"].([]any)
- if !ok {
- return fmt.Errorf("missing registration leaves")
- }
- page["items"] = leaves
- }
- return nil
-}
-
-func (h *NuGetHandler) filterNuGetRegistration(document map[string]any, id string) bool {
- items, ok := document["items"].([]any)
- if !ok {
- return h.nugetLeafAllowed(document, id)
- }
- filtered := make([]any, 0, len(items))
- for _, item := range items {
- child, ok := item.(map[string]any)
- if ok && h.filterNuGetRegistration(child, id) {
- filtered = append(filtered, child)
- }
- }
- document["items"] = filtered
- document["count"] = len(filtered)
- // Page bounds describe the retained leaves, not versions hidden by cooldown.
- if _, isPage := document["lower"]; isPage && len(filtered) > 0 {
- first, _ := filtered[0].(map[string]any)
- last, _ := filtered[len(filtered)-1].(map[string]any)
- document["lower"] = nugetCatalogEntry(first)["version"]
- document["upper"] = nugetCatalogEntry(last)["version"]
- }
- return len(filtered) > 0
-}
-
-func (h *NuGetHandler) rewriteNuGetRegistrationLinks(value any) {
- switch node := value.(type) {
- case map[string]any:
- for key, child := range node {
- if link, ok := child.(string); ok {
- switch key {
- case "@id", "parent", "registration", "packageContent":
- node[key] = h.nugetProxyLink(link)
- }
- } else {
- h.rewriteNuGetRegistrationLinks(child)
- }
- }
- case []any:
- for _, child := range node {
- h.rewriteNuGetRegistrationLinks(child)
- }
- }
-}
-
-func (h *NuGetHandler) nugetProxyLink(link string) string {
- u, err := url.Parse(link)
- if err != nil {
- return link
- }
- upstream, err := url.Parse(h.upstreamURL)
- if err != nil {
- return link
- }
- path := u.Path
- if u.Host == upstream.Host {
- path = strings.TrimPrefix(path, upstream.Path)
- }
- for _, prefix := range nugetArtifactPrefixes {
- if strings.HasPrefix(path, prefix) {
- proxy, err := url.Parse(h.proxyURL + "/nuget" + path)
- if err != nil {
- return link
- }
- proxy.RawQuery = u.RawQuery
- proxy.Fragment = u.Fragment
- return proxy.String()
- }
- }
- return link
-}
diff --git a/internal/handler/nuget_cooldown_test.go b/internal/handler/nuget_cooldown_test.go
deleted file mode 100644
index 3160bd5..0000000
--- a/internal/handler/nuget_cooldown_test.go
+++ /dev/null
@@ -1,555 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "crypto/sha256"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestNuGetCooldownRoutes(t *testing.T) {
- for _, disableCompression := range []bool{false, true} {
- t.Run(map[bool]string{false: "transport gzip", true: "explicit gzip"}[disableCompression], func(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.Cooldown = &cooldown.Config{Default: "14d"}
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached package")
- seedPackage(t, db, store, "nuget", "testpkg", "1.0.0", "testpkg.1.0.0.nupkg", "old package")
- metadataRequests := 0
- upstream := newNuGetCooldownUpstream(t, &metadataRequests)
- defer upstream.Close()
- transport := http.DefaultTransport.(*http.Transport).Clone()
- transport.DisableCompression = disableCompression
- defer transport.CloseIdleConnections()
- proxy.HTTPClient = &http.Client{Transport: transport}
- h := NewNuGetHandlerWithUpstreams(proxy, "http://proxy.test", upstream.URL, upstream.URL)
- routes := http.StripPrefix("/nuget", h.Routes())
- get := func(path string, status int) *httptest.ResponseRecorder {
- t.Helper()
- return nugetGet(t, routes, path, status)
- }
- list := get("/nuget/v3-flatcontainer/testpkg/index.json", http.StatusOK)
- if got := strings.TrimSpace(list.Body.String()); got != `{"versions":["1.0.0"]}` {
- t.Fatalf("filtered list = %s", got)
- }
- index := get("/nuget"+nugetRegistrationPath+"testpkg/index.json", http.StatusOK)
- if strings.Contains(index.Body.String(), `"version":"2.0.0"`) || strings.Contains(index.Body.String(), upstream.URL) {
- t.Fatalf("registration leaks blocked leaf or upstream link: %s", index.Body.String())
- }
- if index.Header().Get("Content-Encoding") != "" || !json.Valid(index.Body.Bytes()) {
- t.Fatal("registration must be decoded JSON")
- }
- var doc struct {
- Items []struct {
- ID string `json:"@id"`
- Count int
- Lower, Upper string
- Items []struct {
- ID string `json:"@id"`
- PackageContent string
- }
- }
- }
- if err := json.Unmarshal(index.Body.Bytes(), &doc); err != nil {
- t.Fatal(err)
- }
- if len(doc.Items) != 1 || doc.Items[0].Count != 1 || doc.Items[0].Upper != "1.0.0" {
- t.Fatalf("incorrect page: %+v", doc)
- }
- get(doc.Items[0].ID, http.StatusOK)
- get(doc.Items[0].Items[0].ID, http.StatusOK)
- get(doc.Items[0].Items[0].PackageContent, http.StatusOK)
- get("/nuget"+nugetRegistrationPath+"testpkg/2.0.0.json", http.StatusNotFound)
- get("/nuget/v3-flatcontainer/TestPkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusNotFound)
- get("/nuget/v3-flatcontainer/testpkg/2.0.0/testpkg.nuspec", http.StatusNotFound)
- if fetcher.fetchCalled {
- t.Fatal("blocked or cached downloads must not fetch artifacts")
- }
-
- // Reevaluate fresh, unfiltered metadata under a changed package policy.
- requestsBefore := metadataRequests
- proxy.Cooldown = &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "1d"}}
- list = get("/nuget/v3-flatcontainer/testpkg/index.json", http.StatusOK)
- if !strings.Contains(list.Body.String(), "2.0.0") {
- t.Fatal("fresh metadata retained the previous policy")
- }
- get("/nuget/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusOK)
- if metadataRequests != requestsBefore {
- t.Fatal("fresh metadata should be reused")
- }
- })
- }
-}
-
-func nugetGet(t *testing.T, routes http.Handler, path string, status int) *httptest.ResponseRecorder {
- t.Helper()
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- if w.Code != status {
- t.Fatalf("GET %s: status %d, want %d: %s", path, w.Code, status, w.Body.String())
- }
- return w
-}
-
-func TestNuGetMetadataWithoutEffectiveCooldown(t *testing.T) {
- for _, tt := range []struct {
- name string
- policy *cooldown.Config
- }{
- {"package exemption", &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "0"}}},
- {"ecosystem exemption", &cooldown.Config{Default: "14d", Ecosystems: map[string]string{"nuget": "0"}}},
- {"other ecosystem only", &cooldown.Config{Ecosystems: map[string]string{"npm": "14d"}}},
- {"other package only", &cooldown.Config{Packages: map[string]string{"pkg:nuget/other": "14d"}}},
- } {
- t.Run(tt.name, func(t *testing.T) {
- const body = `{"versions":["1.0.0","2.0.0"]}`
- const pagePath = nugetRegistrationPath + "testpkg/page/1.0.0/2.0.0.json"
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/v3-flatcontainer/testpkg/index.json":
- _, _ = io.WriteString(w, body)
- case nugetRegistrationPath + "testpkg/index.json":
- _, _ = io.WriteString(w, `{"count":1,"items":[{"@id":"`+pagePath+`","count":2,"lower":"1.0.0","upper":"2.0.0"}]}`)
- default:
- t.Errorf("unnecessary registration request: %s", r.URL.Path)
- http.Error(w, "registration unavailable", http.StatusServiceUnavailable)
- }
- }))
- defer upstream.Close()
- p := nugetTestProxy()
- p.Cooldown = tt.policy
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- w := nugetGet(t, h.Routes(), "/v3-flatcontainer/TestPkg/index.json", http.StatusOK)
- if got := strings.TrimSpace(w.Body.String()); got != body {
- t.Fatalf("version list = %s, want %s", got, body)
- }
- w = nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusOK)
- if !strings.Contains(w.Body.String(), `"@id":"http://proxy.test/nuget`+pagePath+`"`) {
- t.Fatalf("registration page link was not rewritten: %s", w.Body.String())
- }
- })
- }
-}
-
-func TestNuGetCooldownColdDownload(t *testing.T) {
- for _, tt := range []struct {
- name, published string
- policy *cooldown.Config
- want int
- }{
- {"recent", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Default: "14d"}, http.StatusNotFound},
- {"missing timestamp", "", &cooldown.Config{Default: "14d"}, http.StatusOK},
- {"package exemption", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "0"}}, http.StatusOK},
- {"ecosystem override", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Ecosystems: map[string]string{"nuget": "14d"}}, http.StatusNotFound},
- } {
- t.Run(tt.name, func(t *testing.T) {
- p, _, _, fetcher := setupTestProxy(t)
- p.Cooldown = tt.policy
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/octet-stream"}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewEncoder(w).Encode(map[string]string{"published": tt.published})
- }))
- defer upstream.Close()
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", tt.want)
- if fetcher.fetchCalled != (tt.want == http.StatusOK) {
- t.Errorf("artifact fetch called = %v", fetcher.fetchCalled)
- }
- })
- }
-}
-
-// TestNuGetCooldownConcurrentDownloads asserts that a download which joins
-// another request's metadata fetch still applies the cooldown. Each caller
-// decodes the leaf in its own validate callback, so a waiter on the shared
-// fetch must run its own rather than finding no document and allowing it.
-func TestNuGetCooldownConcurrentDownloads(t *testing.T) {
- p, db, store, fetcher := setupTestProxy(t)
- p.Cooldown = &cooldown.Config{Default: "14d"}
- seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached package")
- entered, release := make(chan struct{}), make(chan struct{})
- var enteredOnce, releaseOnce sync.Once
- releaseUpstream := func() { releaseOnce.Do(func() { close(release) }) }
- var calls atomic.Int64
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- calls.Add(1)
- enteredOnce.Do(func() { close(entered) })
- <-release
- _ = json.NewEncoder(w).Encode(map[string]string{"published": time.Now().Add(-time.Hour).Format(time.RFC3339)})
- }))
- // Cleanups run last first: release the held request before closing the
- // server, so a failed wait can't hang the test.
- t.Cleanup(upstream.Close)
- t.Cleanup(releaseUpstream)
- routes := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL).Routes()
-
- statuses := make(chan int, 2)
- download := func() {
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", nil))
- statuses <- w.Code
- }
- go download()
- <-entered
- go download()
- target := upstream.URL + nugetRegistrationPath + "testpkg/2.0.0.json"
- key := metadataCoalesceKey("nuget", fmt.Sprintf("_cooldown/%x", sha256.Sum256([]byte(target))), target, contentTypeJSON, "", true)
- waitForMetadataWaiters(t, p, key, 1)
- releaseUpstream()
-
- for range 2 {
- if status := <-statuses; status != http.StatusNotFound {
- t.Errorf("download status = %d, want %d", status, http.StatusNotFound)
- }
- }
- if got := calls.Load(); got != 1 {
- t.Errorf("upstream metadata requests = %d, want 1", got)
- }
- if fetcher.fetchCalled {
- t.Error("blocked downloads must not fetch artifacts")
- }
-}
-
-func TestNuGetRegistrationServiceAliases(t *testing.T) {
- h := NewNuGetHandler(nugetTestProxy(), "http://proxy.test")
- for _, tt := range []struct{ service, path string }{
- {"RegistrationsBaseUrl", "/v3/registration5-semver1/"},
- {"RegistrationsBaseUrl/3.0.0-beta", "/v3/registration5-semver1/"},
- {"RegistrationsBaseUrl/3.0.0-rc", "/v3/registration5-semver1/"},
- {"RegistrationsBaseUrl/3.4.0", "/v3/registration5-gz-semver1/"},
- {"RegistrationsBaseUrl/3.6.0", nugetRegistrationPath},
- {"RegistrationsBaseUrl/Versioned", nugetRegistrationPath},
- } {
- t.Run(tt.service, func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != tt.path+"testpkg/index.json" {
- t.Errorf("wrong hive: %s", r.URL.Path)
- }
- _, _ = io.WriteString(w, `{"count":0,"items":[]}`)
- }))
- defer upstream.Close()
- h.upstreamURL = upstream.URL
- h.proxy.Cooldown = &cooldown.Config{Default: "14d"}
- body := []byte(`{"resources":[{"@id":"` + upstream.URL + tt.path + `","@type":"` + tt.service + `"}]}`)
- out, err := h.rewriteServiceIndex(body)
- if err != nil {
- t.Fatal(err)
- }
- var doc struct {
- Resources []struct {
- ID string `json:"@id"`
- }
- }
- if err := json.Unmarshal(out, &doc); err != nil {
- t.Fatal(err)
- }
- w := httptest.NewRecorder()
- http.StripPrefix("/nuget", h.Routes()).ServeHTTP(w, httptest.NewRequest(http.MethodGet, doc.Resources[0].ID+"testpkg/index.json", nil))
- if w.Code != http.StatusOK {
- t.Fatalf("alias route status = %d: %s", w.Code, w.Body.String())
- }
- })
- }
-}
-
-func TestNuGetCooldownMetadataErrors(t *testing.T) {
- for _, tt := range []struct {
- name, body string
- status int
- }{
- {"upstream failure", "unavailable", http.StatusServiceUnavailable},
- {"invalid JSON", "broken JSON", http.StatusOK},
- {"null", "null", http.StatusOK},
- } {
- t.Run(tt.name, func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(tt.status)
- _, _ = io.WriteString(w, tt.body)
- }))
- defer upstream.Close()
- p := nugetTestProxy()
- p.Cooldown = &cooldown.Config{Default: "14d"}
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- for _, path := range []string{"/v3-flatcontainer/testpkg/index.json", "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", nugetRegistrationPath + "testpkg/index.json"} {
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- if w.Code != http.StatusBadGateway {
- t.Errorf("GET %s: %d, want 502", path, w.Code)
- }
- }
- })
- }
-}
-
-func TestNuGetCooldownRejectsUnsafePageLinks(t *testing.T) {
- for _, link := range []string{"https://other.example/page.json", "/v3/registration5-gz-semver2/other/page/1/2.json", "page/../index.json", "index.json"} {
- t.Run(link, func(t *testing.T) {
- requests := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests++
- _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"@id": link}}})
- }))
- defer upstream.Close()
- p := nugetTestProxy()
- p.Cooldown = &cooldown.Config{Default: "14d"}
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusBadGateway)
- if requests != 1 {
- t.Fatalf("unsafe page link was followed (%d requests)", requests)
- }
- })
- }
-}
-
-func TestNuGetCooldownDecompressedMetadataLimit(t *testing.T) {
- var compressed bytes.Buffer
- gz := gzip.NewWriter(&compressed)
- _, _ = io.WriteString(gz, `{"padding":"`+strings.Repeat("x", 2048)+`"}`)
- _ = gz.Close()
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(compressed.Bytes())
- }))
- defer upstream.Close()
- p := nugetTestProxy()
- p.Cooldown = &cooldown.Config{Default: "14d"}
- p.MetadataMaxSize = 1024
- transport := http.DefaultTransport.(*http.Transport).Clone()
- transport.DisableCompression = true
- defer transport.CloseIdleConnections()
- p.HTTPClient = &http.Client{Transport: transport}
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusBadGateway)
-}
-
-func newNuGetCooldownUpstream(t *testing.T, metadataRequests *int) *httptest.Server {
- t.Helper()
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- (*metadataRequests)++
- base := upstream.URL + nugetRegistrationPath + "testpkg/"
- leaf := func(version string, age time.Duration) map[string]any {
- return map[string]any{
- "@id": base + version + ".json",
- "packageContent": upstream.URL + "/v3-flatcontainer/testpkg/" + version + "/testpkg." + version + ".nupkg",
- "catalogEntry": map[string]any{"id": "TestPkg", "version": version, "published": time.Now().Add(-age).Format(time.RFC3339)},
- }
- }
- page := map[string]any{"@id": base + "page/1.0.0/2.0.0.json", "lower": "1.0.0", "upper": "2.0.0", "count": 2,
- "parent": base + "index.json", "items": []any{leaf("1.0.0", 30*24*time.Hour), leaf("2.0.0", 2*24*time.Hour)}}
- var body any
- switch r.URL.Path {
- case "/v3-flatcontainer/testpkg/index.json":
- body = map[string]any{"versions": []string{"1.0.0", "2.0.0"}}
- case nugetRegistrationPath + "testpkg/index.json":
- // This index deliberately does not inline its leaves.
- body = map[string]any{"count": 1, "items": []any{map[string]any{
- "@id": page["@id"], "count": 2, "lower": "1.0.0", "upper": "2.0.0",
- }}}
- case nugetRegistrationPath + "testpkg/page/1.0.0/2.0.0.json":
- body = page
- case nugetRegistrationPath + "testpkg/1.0.0.json":
- body = map[string]any{"published": time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)}
- case nugetRegistrationPath + "testpkg/2.0.0.json":
- body = map[string]any{"published": time.Now().Add(-2 * 24 * time.Hour).Format(time.RFC3339)}
- default:
- t.Errorf("unexpected metadata request: %s", r.URL.Path)
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Encoding", "gzip")
- gz := gzip.NewWriter(w)
- _ = json.NewEncoder(gz).Encode(body)
- _ = gz.Close()
- }))
-
- return upstream
-}
-
-func TestNuGetCooldownLegacyRegistration(t *testing.T) {
- for _, service := range []string{"RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc", "RegistrationsBaseUrl/3.4.0"} {
- t.Run(service, func(t *testing.T) {
- prefix := "/v3/registration5-semver1/"
- if service == "RegistrationsBaseUrl/3.4.0" {
- prefix = "/v3/registration5-gz-semver1/"
- }
- upstream := newNuGetLegacyUpstream(t, service, prefix)
- defer upstream.Close()
- p, db, store, fetcher := setupTestProxy(t)
- p.Cooldown = &cooldown.Config{Default: "14d"}
- seedPackage(t, db, store, "nuget", "testpkg", "1.0.0", "testpkg.1.0.0.nupkg", "cached old package")
- seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached recent package")
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL+"/feed", upstream.URL)
- list := nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/index.json", http.StatusOK)
- if strings.TrimSpace(list.Body.String()) != `{"versions":["1.0.0"]}` {
- t.Fatalf("incorrect version list: %s", list.Body.String())
- }
- nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/1.0.0/testpkg.1.0.0.nupkg", http.StatusOK)
- nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusNotFound)
- if fetcher.fetchCalled {
- t.Fatal("cached or blocked package must not be fetched")
- }
- })
- }
-}
-
-func newNuGetLegacyUpstream(t *testing.T, service, prefix string) *httptest.Server {
- t.Helper()
- var upstream *httptest.Server
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- path := strings.TrimPrefix(r.URL.Path, "/feed")
- base := upstream.URL + "/feed" + prefix + "testpkg/"
- published := func(age time.Duration) string { return time.Now().Add(-age).Format(time.RFC3339) }
- var body any
- switch path {
- case "/v3/index.json":
- body = map[string]any{"resources": []any{map[string]string{"@id": upstream.URL + "/feed" + prefix, "@type": service}}}
- case "/v3-flatcontainer/testpkg/index.json":
- body = map[string]any{"versions": []string{"1.0.0", "2.0.0"}}
- case prefix + "testpkg/index.json":
- body = map[string]any{"items": []any{map[string]any{"@id": base + "page/1.0.0/2.0.0.json"}}}
- case prefix + "testpkg/page/1.0.0/2.0.0.json":
- body = map[string]any{"items": []any{
- map[string]any{"catalogEntry": map[string]string{"id": "testpkg", "version": "1.0.0", "published": published(30 * 24 * time.Hour)}},
- map[string]any{"catalogEntry": map[string]string{"id": "testpkg", "version": "2.0.0", "published": published(time.Hour)}},
- }}
- case prefix + "testpkg/1.0.0.json":
- body = map[string]string{"published": published(30 * 24 * time.Hour)}
- case prefix + "testpkg/2.0.0.json":
- body = map[string]string{"published": published(time.Hour)}
- default:
- if !strings.HasPrefix(path, nugetRegistrationPath) {
- t.Errorf("unexpected request: %s", r.URL.Path)
- }
- http.NotFound(w, r)
- return
- }
- _ = json.NewEncoder(w).Encode(body)
- }))
- return upstream
-}
-
-func TestNuGetRegistrationDoesNotFallbackOnFailure(t *testing.T) {
- for _, status := range []int{http.StatusServiceUnavailable, http.StatusUnauthorized, http.StatusOK} {
- t.Run(http.StatusText(status), func(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != nugetRegistrationPath+"testpkg/index.json" {
- t.Errorf("must not switch registration hive on failure: %s", r.URL.Path)
- }
- w.WriteHeader(status)
- _, _ = io.WriteString(w, "invalid metadata")
- }))
- defer upstream.Close()
- h := NewNuGetHandlerWithUpstreams(nugetTestProxy(), "http://proxy.test", upstream.URL, upstream.URL)
- if _, _, err := h.nugetRegistrationMetadata(t.Context(), "testpkg/index.json"); err == nil {
- t.Fatal("expected metadata error")
- }
- })
- }
-}
-
-func TestNuGetMetadataPreservesValidCache(t *testing.T) {
- for _, invalid := range []string{"broken JSON", "null", "[]", string([]byte{0x1f, 0x8b, 0x00})} {
- t.Run(invalid, func(t *testing.T) {
- const good = `{"published":"2020-01-01T00:00:00Z"}`
- var response atomic.Value
- response.Store(good)
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if requests.Add(1) > 1 && r.Header.Get("If-None-Match") != `"good"` {
- t.Errorf("cached ETag was replaced: %s", r.Header.Get("If-None-Match"))
- }
- body := response.Load().(string)
- etag := `"good"`
- if body == invalid {
- etag = `"bad"`
- }
- w.Header().Set("ETag", etag)
- _, _ = io.WriteString(w, body)
- }))
- defer upstream.Close()
- p, _, _, _ := setupTestProxy(t)
- p.CacheMetadata = true
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- check := func(want string) {
- t.Helper()
- doc, err := h.nugetMetadata(t.Context(), nugetRegistrationPath+"testpkg/1.0.0.json")
- if err != nil || doc["published"] != want {
- t.Fatalf("metadata = %v, err = %v, want publication %s", doc, err, want)
- }
- }
- check("2020-01-01T00:00:00Z")
- response.Store(invalid)
- check("2020-01-01T00:00:00Z") // Bad 200 must fall back without overwriting.
- p.MetadataTTL = time.Hour
- check("2020-01-01T00:00:00Z") // The on-disk cache must still be usable.
- if requests.Load() != 2 {
- t.Fatalf("requests = %d, want 2", requests.Load())
- }
- p.MetadataTTL = 0
- response.Store(`{"published":"2021-01-01T00:00:00Z"}`)
- check("2021-01-01T00:00:00Z") // A later valid response replaces the cache.
- })
- }
-}
-
-func TestNuGetMetadataInvalidResponseNotCached(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if requests.Add(1) == 1 {
- _, _ = io.WriteString(w, "invalid JSON")
- return
- }
- _, _ = io.WriteString(w, `{"published":"2020-01-01T00:00:00Z"}`)
- }))
- defer upstream.Close()
- p, _, _, _ := setupTestProxy(t)
- p.CacheMetadata = true
- p.MetadataTTL = time.Hour
- h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL)
- path := nugetRegistrationPath + "testpkg/1.0.0.json"
- if _, err := h.nugetMetadata(t.Context(), path); err == nil {
- t.Fatal("invalid response without a usable cache must fail")
- }
- if _, err := h.nugetMetadata(t.Context(), path); err != nil {
- t.Fatalf("invalid response was cached: %v", err)
- }
- if requests.Load() != 2 {
- t.Fatalf("requests = %d, want 2", requests.Load())
- }
-}
-
-func TestNuGetRegistrationDoesNotGuessUnadvertisedAliases(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case nugetRegistrationPath + "testpkg/index.json":
- http.NotFound(w, r)
- case "/v3/index.json":
- _, _ = io.WriteString(w, `{"resources":[{"@type":"UnrelatedService","@id":"https://other.example/"}]}`)
- default:
- t.Errorf("unadvertised endpoint requested: %s", r.URL.Path)
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
- h := NewNuGetHandlerWithUpstreams(nugetTestProxy(), "http://proxy.test", upstream.URL, upstream.URL)
- _, _, err := h.nugetRegistrationMetadata(t.Context(), "testpkg/index.json")
- if !errors.Is(err, ErrUpstreamNotFound) {
- t.Fatalf("error = %v, want metadata not found", err)
- }
-}
diff --git a/internal/handler/nuget_test.go b/internal/handler/nuget_test.go
deleted file mode 100644
index 5ae77dc..0000000
--- a/internal/handler/nuget_test.go
+++ /dev/null
@@ -1,1092 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/purl"
-)
-
-func nugetTestProxy() *Proxy {
- return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
- }
-}
-
-func TestNuGetRewriteServiceIndex(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: nugetUpstream,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "version": "3.0.0",
- "resources": [
- {
- "@id": "https://api.nuget.org/v3-flatcontainer/",
- "@type": "PackageBaseAddress/3.0.0"
- },
- {
- "@id": "https://api.nuget.org/v3/registration5-gz-semver2/",
- "@type": "RegistrationsBaseUrl/3.6.0"
- },
- {
- "@id": "https://azuresearch-usnc.nuget.org/query",
- "@type": "SearchQueryService/3.5.0"
- },
- {
- "@id": "https://azuresearch-usnc.nuget.org/autocomplete",
- "@type": "SearchAutocompleteService/3.5.0"
- },
- {
- "@id": "https://example.com/other-service",
- "@type": "SomeOtherService/1.0.0"
- }
- ]
- }`
-
- output, err := h.rewriteServiceIndex([]byte(input))
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- resources := result["resources"].([]any)
- if len(resources) != 5 {
- t.Fatalf("expected 5 resources, got %d", len(resources))
- }
-
- expectations := map[string]string{
- "PackageBaseAddress/3.0.0": "http://localhost:8080/nuget/v3-flatcontainer/",
- "RegistrationsBaseUrl/3.6.0": "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
- "SearchQueryService/3.5.0": "http://localhost:8080/nuget/query",
- "SearchAutocompleteService/3.5.0": "http://localhost:8080/nuget/autocomplete",
- "SomeOtherService/1.0.0": "https://example.com/other-service",
- }
-
- for _, res := range resources {
- rmap := res.(map[string]any)
- rtype := rmap["@type"].(string)
- id := rmap["@id"].(string)
- expected, ok := expectations[rtype]
- if !ok {
- t.Errorf("unexpected resource type: %s", rtype)
- continue
- }
- if id != expected {
- t.Errorf("resource %s: @id = %q, want %q", rtype, id, expected)
- }
- }
-}
-
-func TestNuGetRewriteURL(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- tests := []struct {
- input string
- serviceType string
- want string
- }{
- {
- "https://api.nuget.org/v3-flatcontainer/",
- "PackageBaseAddress/3.0.0",
- "http://localhost:8080/nuget/v3-flatcontainer/",
- },
- {
- "https://api.nuget.org/v3/registration5-gz-semver2/",
- "RegistrationsBaseUrl/3.6.0",
- "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
- },
- {
- "https://api.nuget.org/v3/registration5-gz-semver2/",
- "RegistrationsBaseUrl/Versioned",
- "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
- },
- {
- "https://azuresearch-usnc.nuget.org/query",
- "SearchQueryService",
- "http://localhost:8080/nuget/query",
- },
- {
- "https://azuresearch-usnc.nuget.org/query",
- "SearchQueryService/3.0.0-rc",
- "http://localhost:8080/nuget/query",
- },
- {
- "https://azuresearch-usnc.nuget.org/query",
- "SearchQueryService/3.5.0",
- "http://localhost:8080/nuget/query",
- },
- {
- "https://azuresearch-usnc.nuget.org/autocomplete",
- "SearchAutocompleteService",
- "http://localhost:8080/nuget/autocomplete",
- },
- {
- "https://azuresearch-usnc.nuget.org/autocomplete",
- "SearchAutocompleteService/3.5.0",
- "http://localhost:8080/nuget/autocomplete",
- },
- {
- "https://example.com/unknown",
- "SomeOtherService/1.0.0",
- "https://example.com/unknown",
- },
- {
- "https://api.nuget.org/v2/package",
- "PackagePublish/2.0.0",
- "https://api.nuget.org/v2/package",
- },
- {
- "https://azuresearch-usnc.nuget.org/query",
- "SearchQueryService/99.0.0",
- "https://azuresearch-usnc.nuget.org/query",
- },
- {
- "https://example.com/resource",
- "",
- "https://example.com/resource",
- },
- }
-
- for _, tt := range tests {
- got := h.rewriteNuGetURL(tt.input, tt.serviceType)
- if got != tt.want {
- t.Errorf("rewriteNuGetURL(%q, %q) = %q, want %q", tt.input, tt.serviceType, got, tt.want)
- }
- }
-}
-
-func TestNuGetHandleServiceIndex(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v3/index.json" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "version": "3.0.0",
- "resources": [
- {
- "@id": "https://api.nuget.org/v3-flatcontainer/",
- "@type": "PackageBaseAddress/3.0.0"
- }
- ]
- }`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want %q", ct, "application/json")
- }
-
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatalf("failed to parse response: %v", err)
- }
-
- resources := result["resources"].([]any)
- r0 := resources[0].(map[string]any)
- if r0["@id"] != "http://proxy.local/nuget/v3-flatcontainer/" {
- t.Errorf("resource @id = %q, want rewritten URL", r0["@id"])
- }
-}
-
-func TestNuGetHandleServiceIndexUpstreamError(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte("internal error"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- // With metadata caching, upstream 500 is reported as 502 (bad gateway)
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetHandleServiceIndexUpstreamUnreachable(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://127.0.0.1:1", // unreachable
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetHandleServiceIndexInvalidJSON(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte("not valid json"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- // When rewrite fails, the handler falls back to proxying the original body
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d (should fall back to original)", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "not valid json" {
- t.Errorf("body = %q, want original body passed through", body)
- }
-}
-
-func TestNuGetHandleDownloadEmptyParams(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- // Missing path values
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer///", nil)
- req.SetPathValue("id", "")
- req.SetPathValue("version", "")
- req.SetPathValue("filename", "")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadNonNupkg(t *testing.T) {
- // Non-.nupkg files should be proxied upstream
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/xml")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("test"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.nuspec", nil)
- req.SetPathValue("id", "newtonsoft.json")
- req.SetPathValue("version", "13.0.1")
- req.SetPathValue("filename", "newtonsoft.json.nuspec")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "test" {
- t.Errorf("body = %q, want nuspec content", body)
- }
-}
-
-func TestNuGetProxyUpstream(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v3-flatcontainer/newtonsoft.json/index.json" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"versions":["13.0.1","13.0.2"]}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "13.0.1") {
- t.Errorf("response body does not contain expected version: %s", body)
- }
-}
-
-func TestNuGetProxyUpstreamNotFound(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/nonexistent/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestNuGetProxyUpstreamBadUpstream(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://127.0.0.1:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetProxyUpstreamCopiesHeaders(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Custom", "value")
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Header().Get("X-Custom") != "value" {
- t.Errorf("X-Custom = %q, want %q", w.Header().Get("X-Custom"), "value")
- }
-}
-
-func TestNuGetProxyUpstreamForwardsAcceptEncoding(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ae := r.Header.Get("Accept-Encoding")
- if ae != "gzip" {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("expected Accept-Encoding: gzip"))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- req.Header.Set("Accept-Encoding", "gzip")
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestNuGetBuildUpstreamURL(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- searchURL: "https://azuresearch-usnc.nuget.org",
- }
-
- tests := []struct {
- path string
- query string
- want string
- }{
- {
- "/v3-flatcontainer/newtonsoft.json/index.json",
- "",
- "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.json",
- },
- {
- "/v3/registration5-gz-semver2/newtonsoft.json/index.json",
- "",
- "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json",
- },
- {
- "/query",
- "q=json&take=20",
- "https://azuresearch-usnc.nuget.org/query?q=json&take=20",
- },
- {
- "/autocomplete",
- "q=new&take=10",
- "https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10",
- },
- }
-
- for _, tt := range tests {
- req := httptest.NewRequest(http.MethodGet, tt.path+"?"+tt.query, nil)
- got := h.buildUpstreamURL(req)
- if got != tt.want {
- t.Errorf("buildUpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
- }
- }
-}
-
-func TestNuGetRoutes(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/v3/index.json" {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"version":"3.0.0","resources":[]}`))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- routes := h.Routes()
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("GET /v3/index.json: status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestNewNuGetHandler(t *testing.T) {
- proxy := nugetTestProxy()
- h := NewNuGetHandler(proxy, "http://localhost:8080/")
-
- if h.proxy != proxy {
- t.Error("proxy not set correctly")
- }
- if h.upstreamURL != nugetUpstream {
- t.Errorf("upstreamURL = %q, want %q", h.upstreamURL, nugetUpstream)
- }
- if h.proxyURL != "http://localhost:8080" {
- t.Errorf("proxyURL = %q, want %q (trailing slash should be trimmed)", h.proxyURL, "http://localhost:8080")
- }
-}
-
-func TestNewNuGetHandlerNoTrailingSlash(t *testing.T) {
- proxy := nugetTestProxy()
- h := NewNuGetHandler(proxy, "http://localhost:8080")
-
- if h.proxyURL != "http://localhost:8080" {
- t.Errorf("proxyURL = %q, want %q", h.proxyURL, "http://localhost:8080")
- }
-}
-
-func TestNuGetRewriteServiceIndexNoResources(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- input := `{"version":"3.0.0"}`
- output, err := h.rewriteServiceIndex([]byte(input))
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- // Should return the body unchanged when no resources key
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
- if result["version"] != "3.0.0" {
- t.Errorf("version = %v, want 3.0.0", result["version"])
- }
-}
-
-func TestNuGetRewriteServiceIndexAllTypes(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- // Test every rewritable service type
- resources := []map[string]string{
- {"@id": "https://api.nuget.org/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"},
- {"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/3.6.0"},
- {"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/Versioned"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.0.0-rc"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.5.0"},
- {"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService"},
- {"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService/3.5.0"},
- }
-
- inputResources := make([]any, len(resources))
- for i, r := range resources {
- inputResources[i] = r
- }
-
- input, _ := json.Marshal(map[string]any{
- "version": "3.0.0",
- "resources": inputResources,
- })
-
- output, err := h.rewriteServiceIndex(input)
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- outputResources := result["resources"].([]any)
- for _, res := range outputResources {
- rmap := res.(map[string]any)
- id := rmap["@id"].(string)
- // All should be rewritten to proxy URL
- if strings.HasPrefix(id, "https://api.nuget.org") || strings.HasPrefix(id, "https://azuresearch-usnc.nuget.org") {
- t.Errorf("resource %s was not rewritten: %s", rmap["@type"], id)
- }
- }
-}
-
-func TestNuGetProxyUpstreamPreservesStatusCodes(t *testing.T) {
- codes := []int{
- http.StatusOK,
- http.StatusNotFound,
- http.StatusForbidden,
- http.StatusInternalServerError,
- }
-
- for _, code := range codes {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(code)
- }))
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != code {
- t.Errorf("status = %d, want %d", w.Code, code)
- }
-
- upstream.Close()
- }
-}
-
-func TestNuGetProxyUpstreamCopiesBody(t *testing.T) {
- expected := `{"versions":["1.0.0","2.0.0"]}`
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(expected))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- got, _ := io.ReadAll(w.Body)
- if string(got) != expected {
- t.Errorf("body = %q, want %q", string(got), expected)
- }
-}
-
-func TestNuGetHandleDownloadMissingID(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer//1.0.0/test.nupkg", nil)
- req.SetPathValue("id", "")
- req.SetPathValue("version", "1.0.0")
- req.SetPathValue("filename", "test.nupkg")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadMissingVersion(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test//test.nupkg", nil)
- req.SetPathValue("id", "test")
- req.SetPathValue("version", "")
- req.SetPathValue("filename", "test.nupkg")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadMissingFilename(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/1.0.0/", nil)
- req.SetPathValue("id", "test")
- req.SetPathValue("version", "1.0.0")
- req.SetPathValue("filename", "")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetBuildUpstreamURLQueryPath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- searchURL: "https://azuresearch-usnc.nuget.org",
- }
-
- // Query endpoint should go to azuresearch
- req := httptest.NewRequest(http.MethodGet, "/query?q=json&skip=0&take=20", nil)
- got := h.buildUpstreamURL(req)
- want := "https://azuresearch-usnc.nuget.org/query?q=json&skip=0&take=20"
- if got != want {
- t.Errorf("buildUpstreamURL for /query = %q, want %q", got, want)
- }
-}
-
-func TestNuGetBuildUpstreamURLAutocompletePath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- searchURL: "https://azuresearch-usnc.nuget.org",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/autocomplete?q=new&take=10", nil)
- got := h.buildUpstreamURL(req)
- want := "https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10"
- if got != want {
- t.Errorf("buildUpstreamURL for /autocomplete = %q, want %q", got, want)
- }
-}
-
-func TestNuGetBuildUpstreamURLRegularPath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/newtonsoft.json/index.json", nil)
- got := h.buildUpstreamURL(req)
- want := "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json"
- if got != want {
- t.Errorf("buildUpstreamURL for registration = %q, want %q", got, want)
- }
-}
-
-func TestNuGetCooldownFiltering(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 2,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPackage",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPackage",
- "version": "2.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- if !h.filterNuGetRegistration(registration, "") {
- t.Fatal("expected registration items to be retained")
- }
-
- pages := registration["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item after filtering, got %d", len(items))
- }
-
- entry := items[0].(map[string]any)["catalogEntry"].(map[string]any)
- if entry["version"] != testVersion100 {
- t.Errorf("expected version 1.0.0 to survive, got %s", entry["version"])
- }
-
- count := page["count"]
- if count != 1 {
- t.Errorf("expected page count to be 1, got %v", count)
- }
-}
-
-func TestNuGetCooldownFilteringWithPackageOverride(t *testing.T) {
- now := time.Now()
- recentTime := now.Add(-2 * time.Hour).Format(time.RFC3339)
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "SpecialPackage",
- "version": "1.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:nuget/specialpackage": "1h"},
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- if !h.filterNuGetRegistration(registration, "") {
- t.Fatal("expected registration items to be retained")
- }
-
- pages := registration["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item (package override allows it), got %d", len(items))
- }
-}
-
-func TestNuGetCooldownNoCooldownConfig(t *testing.T) {
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "Test",
- "version": "1.0.0",
- "published": time.Now().Format(time.RFC3339),
- },
- },
- },
- },
- },
- }
-
- // No cooldown: all registration items are retained.
- h := &NuGetHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- if !h.filterNuGetRegistration(registration, "") {
- t.Fatal("expected registration items to be retained")
- }
-
- pages := registration["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item, got %d", len(items))
- }
-}
-
-func TestNuGetCooldownFilteringNuGetTimestamp(t *testing.T) {
- // NuGet uses timestamps like "2024-09-07T01:37:52.233+00:00" which
- // have fractional seconds - verify these parse correctly
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format("2006-01-02T15:04:05.000-07:00")
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "Test",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- },
- },
- },
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- if !h.filterNuGetRegistration(registration, "") {
- t.Fatal("expected registration items to be retained")
- }
-
- pages := registration["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item (old enough to pass cooldown), got %d", len(items))
- }
-}
-
-func TestNuGetHandleRegistrationWithCooldown(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- registrationJSON, _ := json.Marshal(map[string]any{
- "items": []any{
- map[string]any{
- "count": 2,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPkg",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPkg",
- "version": "2.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(registrationJSON)
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
- w := httptest.NewRecorder()
- h.handleRegistration(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item after cooldown filtering, got %d", len(items))
- }
-}
-
-func TestNuGetHandleRegistrationWithoutCooldown(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"items":[]}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(), // no cooldown configured
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
- w := httptest.NewRecorder()
- h.handleRegistration(w, req)
-
- // Without cooldown, should proxy directly
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestNuGetRetentionKey(t *testing.T) {
- tests := []struct {
- key string
- want string
- ok bool
- }{
- {"pkg:nuget/Newtonsoft.Json", "pkg:nuget/newtonsoft.json", true},
- {"pkg:nuget/serilog", "pkg:nuget/serilog", true},
- {"pkg:nuget/serilog/serilog", "", false},
- {"pkg:npm/serilog", "", false},
- }
- for _, tt := range tests {
- p, err := purl.Parse(tt.key)
- if err != nil {
- t.Fatalf("Parse(%s): %v", tt.key, err)
- }
- got, ok := nugetRetentionKey(p)
- if ok != tt.ok || (ok && got != tt.want) {
- t.Errorf("nugetRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
- }
- }
- if _, ok := nugetRetentionKey(nil); ok {
- t.Error("nugetRetentionKey(nil) accepted")
- }
-}
diff --git a/internal/handler/os_package_identity.go b/internal/handler/os_package_identity.go
deleted file mode 100644
index 0e0400b..0000000
--- a/internal/handler/os_package_identity.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package handler
-
-// OSPackageIdentity returns the name, version and architecture the deb,
-// alpine and rpm download handlers cache a package file under, read from its
-// path below the repository the way they read it. It returns empty strings
-// for other ecosystems and for paths they would not cache.
-func OSPackageIdentity(ecosystem, path string) (name, version, arch string) {
- switch ecosystem {
- case "deb":
- return (*DebianHandler)(nil).parsePoolPath("/" + path)
- case apkEcosystem:
- return (*APKHandler)(nil).parseAPKPath(path)
- case "rpm":
- return (*RPMHandler)(nil).parseRPMPath(path)
- }
- return "", "", ""
-}
diff --git a/internal/handler/os_package_identity_test.go b/internal/handler/os_package_identity_test.go
deleted file mode 100644
index 3d487be..0000000
--- a/internal/handler/os_package_identity_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package handler
-
-import "testing"
-
-func TestOSPackageIdentity(t *testing.T) {
- tests := []struct {
- ecosystem, path string
- name, version, arch string
- }{
- {"deb", "pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb", "curl", "7.88.1-10+deb12u8", "amd64"},
- {"alpine", "v3.20/main/aarch64/curl-8.9.1-r0.apk", "curl", "8.9.1-r0", "aarch64"},
- {"rpm", "Packages/c/curl-8.6.0-10.fc40.x86_64.rpm", "curl", "8.6.0-10.fc40", "x86_64"},
- {"deb", "pool/main/c/curl/README", "", "", ""},
- {"npm", "lodash-4.17.21.tgz", "", "", ""},
- }
- for _, tt := range tests {
- name, version, arch := OSPackageIdentity(tt.ecosystem, tt.path)
- if name != tt.name || version != tt.version || arch != tt.arch {
- t.Errorf("OSPackageIdentity(%s, %s) = %q %q %q, want %q %q %q",
- tt.ecosystem, tt.path, name, version, arch, tt.name, tt.version, tt.arch)
- }
- }
-}
diff --git a/internal/handler/package_routes.go b/internal/handler/package_routes.go
deleted file mode 100644
index c56c3fb..0000000
--- a/internal/handler/package_routes.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package handler
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "path"
- "sort"
- "strings"
-)
-
-// routeIDLength is the number of hex characters of the upstream URL digest
-// that qualify cache entries of a routed package.
-const routeIDLength = 16
-
-// packageRoutes sends packages whose names match a pattern to a dedicated
-// upstream instead of the ecosystem's default registry. A matched package is
-// never looked up anywhere else, so a public package with the same name cannot
-// replace it.
-type packageRoutes struct {
- routes []packageRoute // longest pattern first
-}
-
-type packageRoute struct {
- pattern string
- url string
- id string
-}
-
-func newPackageRoutes(patterns map[string]string) packageRoutes {
- routes := make([]packageRoute, 0, len(patterns))
- for pattern, upstreamURL := range patterns {
- upstreamURL = strings.TrimSuffix(upstreamURL, "/")
- routes = append(routes, packageRoute{
- pattern: strings.ToLower(pattern),
- url: upstreamURL,
- id: routeID(upstreamURL),
- })
- }
- sort.Slice(routes, func(i, j int) bool {
- if len(routes[i].pattern) != len(routes[j].pattern) {
- return len(routes[i].pattern) > len(routes[j].pattern)
- }
- return routes[i].pattern < routes[j].pattern
- })
- return packageRoutes{routes: routes}
-}
-
-// routeID identifies a route's upstream in cache keys and filenames.
-func routeID(upstreamURL string) string {
- digest := sha256.Sum256([]byte(strings.TrimSuffix(upstreamURL, "/")))
- return hex.EncodeToString(digest[:])[:routeIDLength]
-}
-
-// RoutedFilenamePrefix returns what the npm and Composer handlers put in
-// front of the artifact filenames of a package routed to routeURL.
-func RoutedFilenamePrefix(routeURL string) string {
- return routeID(routeURL) + "/"
-}
-
-// match returns the route for a package name, if any pattern matches it.
-func (r packageRoutes) match(packageName string) (packageRoute, bool) {
- name := strings.ToLower(packageName)
- for _, route := range r.routes {
- if ok, _ := path.Match(route.pattern, name); ok {
- return route, true
- }
- }
- return packageRoute{}, false
-}
-
-// cacheKey qualifies a package's metadata cache key with the route's upstream,
-// so entries cached from the default registry, or from a route's previous
-// upstream, are never served for a routed package.
-func (r packageRoute) cacheKey(packageName string) string {
- return "routes/" + r.id + "/" + packageName
-}
-
-// cacheFilename qualifies a routed package's artifact filename the same way as
-// cacheKey, and leaves other packages' filenames unchanged.
-func (r packageRoutes) cacheFilename(packageName, filename string) string {
- if route, routed := r.match(packageName); routed {
- return route.id + "/" + filename
- }
- return filename
-}
-
-// RoutedPackageURL returns the upstream URL a route in routes sends
-// packageName to, as the npm and Composer handlers pick it.
-func RoutedPackageURL(routes map[string]string, packageName string) (string, bool) {
- route, ok := newPackageRoutes(routes).match(packageName)
- return route.url, ok
-}
diff --git a/internal/handler/package_routes_test.go b/internal/handler/package_routes_test.go
deleted file mode 100644
index dcb6f5f..0000000
--- a/internal/handler/package_routes_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package handler
-
-import "testing"
-
-func TestPackageRoutesMatch(t *testing.T) {
- routes := newPackageRoutes(map[string]string{
- "@example/*": "https://npm.example.com/scoped/",
- "@example/tool-*": "https://npm.example.com/tools",
- "example/*": "https://composer.example.com/packages",
- "single-package": "https://registry.example.com/single",
- })
-
- tests := []struct {
- name string
- pkg string
- wantURL string
- wantOK bool
- }{
- {"scoped npm package", "@example/widgets", "https://npm.example.com/scoped", true},
- {"longest pattern wins", "@example/tool-kit", "https://npm.example.com/tools", true},
- {"composer vendor", "example/library", "https://composer.example.com/packages", true},
- {"exact name", "single-package", "https://registry.example.com/single", true},
- {"names are case-insensitive", "Example/Library", "https://composer.example.com/packages", true},
- {"wildcard does not cross slash", "example/library/extra", "", false},
- {"similar vendor does not match", "example-fork/library", "", false},
- {"unrouted package", "lodash", "", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- route, ok := routes.match(tt.pkg)
- if ok != tt.wantOK {
- t.Fatalf("match(%q) ok = %t, want %t", tt.pkg, ok, tt.wantOK)
- }
- if ok && route.url != tt.wantURL {
- t.Errorf("match(%q) url = %q, want %q", tt.pkg, route.url, tt.wantURL)
- }
- })
- }
-}
-
-func TestPackageRoutesCacheKeysAreDistinct(t *testing.T) {
- routes := newPackageRoutes(map[string]string{
- "example/*": "https://one.example.com",
- "other-example/*": "https://two.example.com",
- })
-
- first, _ := routes.match("example/library")
- second, _ := routes.match("other-example/library")
-
- if first.cacheKey("example/library") == "example/library" {
- t.Error("routed cache key must differ from the unrouted package name")
- }
- if first.cacheKey("example/library") == second.cacheKey("example/library") {
- t.Error("routes with different upstreams must not share cache keys")
- }
- if containsPathTraversal(first.cacheKey("example/library")) {
- t.Error("routed cache key must be usable as a storage path")
- }
-}
diff --git a/internal/handler/path_traversal_test.go b/internal/handler/path_traversal_test.go
deleted file mode 100644
index 5ad68a5..0000000
--- a/internal/handler/path_traversal_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package handler
-
-import "testing"
-
-func TestContainsPathTraversal(t *testing.T) {
- tests := []struct {
- path string
- want bool
- }{
- {"pool/main/n/nginx/nginx_1.0.deb", false},
- {"releases/39/Packages/test.rpm", false},
- {"../etc/passwd", true},
- {"pool/../../etc/passwd", true},
- {"pool/main/../../../etc/shadow", true},
- {"pool/..hidden/file", false}, // ".." as a segment, not "..hidden"
- {"", false},
- {"%2e%2e/etc/passwd", true},
- {"%2e%2e%2fetc%2fpasswd", true},
- {"pool/%2e%2e/%2e%2e/etc/shadow", true},
- {"%2E%2E%2Fetc", true},
- {`..\\etc\\passwd`, true},
- {`pool\\..\\..\\etc`, true},
- {"%2e%2e%5cetc%5cpasswd", true},
- {"pool/%2e%2ehidden/file", false},
- {"pool/%zz/bad-encoding", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.path, func(t *testing.T) {
- got := containsPathTraversal(tt.path)
- if got != tt.want {
- t.Errorf("containsPathTraversal(%q) = %v, want %v", tt.path, got, tt.want)
- }
- })
- }
-}
diff --git a/internal/handler/profile.go b/internal/handler/profile.go
deleted file mode 100644
index ceff0a3..0000000
--- a/internal/handler/profile.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package handler
-
-import "time"
-
-// WithCooldown creates a policy view sharing storage, fetches and rewrite space.
-// Configure the original proxy before constructing its views.
-func (p *Proxy) WithCooldown(policy CooldownPolicy, shortest time.Duration) *Proxy {
- view := &Proxy{
- DB: p.DB, Storage: p.Storage, Fetcher: p.Fetcher, Resolver: p.Resolver,
- RouteFor: p.RouteFor, Logger: p.Logger,
- Cooldown: policy, ShortestCooldown: shortest, Denylist: p.Denylist,
- CacheMetadata: p.CacheMetadata, MetadataTTL: p.MetadataTTL,
- MetadataMaxSize: p.MetadataMaxSize, MetadataStreamMaxSize: p.MetadataStreamMaxSize,
- GradleReadOnly: p.GradleReadOnly, HostedEnabled: p.HostedEnabled,
- GradleMaxUploadSize: p.GradleMaxUploadSize, NPMFullMetadata: p.NPMFullMetadata,
- DirectServe: p.DirectServe, DirectServeTTL: p.DirectServeTTL,
- DirectServeBaseURL: p.DirectServeBaseURL, HTTPClient: p.HTTPClient,
- RateLimits: p.RateLimits, HostedNPMScopes: p.HostedNPMScopes,
- HostedComposerVendors: p.HostedComposerVendors, StreamArtifacts: p.StreamArtifacts,
- Scanners: p.Scanners, ScanSigningKey: p.ScanSigningKey, ScanFetchBaseURL: p.ScanFetchBaseURL,
- rewrites: p.rewrites, now: p.now, started: p.started, shared: p.sharedProxy(),
- }
- if view.cooldownEnabled() {
- view.shared.viewCooldown.Store(true)
- }
- return view
-}
-
-func (p *Proxy) sharedProxy() *Proxy {
- if p.shared != nil {
- return p.shared
- }
- return p
-}
-
-// anyCooldownEnabled reports whether cooldown is on for this proxy, the one
-// it is a view of, or another view of that one. What they keep in one cache
-// row has to suit all of them.
-func (p *Proxy) anyCooldownEnabled() bool {
- shared := p.sharedProxy()
- return p.cooldownEnabled() || shared.cooldownEnabled() || shared.viewCooldown.Load()
-}
diff --git a/internal/handler/profile_test.go b/internal/handler/profile_test.go
deleted file mode 100644
index ca163ee..0000000
--- a/internal/handler/profile_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "reflect"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/denylist"
- "github.com/git-pkgs/proxy/internal/scanner"
- registryclient "github.com/git-pkgs/registries/client"
-)
-
-func TestWithCooldownCopiesProxyFields(t *testing.T) {
- p, _, _, _ := setupTestProxy(t)
- p.RouteFor = func(string, string) *ArtifactRoute { return nil }
- p.Cooldown = profilePolicy(t, "1d")
- p.ShortestCooldown = 24 * time.Hour
- p.Denylist = &denylist.Policy{}
- p.CacheMetadata = true
- p.MetadataTTL = time.Hour
- p.MetadataMaxSize = 1024
- p.MetadataStreamMaxSize = 2048
- p.GradleReadOnly = true
- p.HostedEnabled = true
- p.GradleMaxUploadSize = 4096
- p.NPMFullMetadata = true
- p.DirectServe = true
- p.DirectServeTTL = time.Minute
- p.DirectServeBaseURL = "https://storage.example.com"
- p.RateLimits = registryclient.NewRateLimitGate(http.DefaultTransport)
- p.HostedNPMScopes = []string{"@example"}
- p.HostedComposerVendors = []string{"example"}
- p.StreamArtifacts = true
- p.Scanners = &scanner.Group{}
- p.ScanSigningKey = []byte("signing-key")
- p.ScanFetchBaseURL = "http://scanner.example.com"
- p.SetMetadataRewriteCacheSize(1024)
- p.inFlight = map[string]*inflightFetch{"artifact": {}}
- p.detachSlots = make(chan struct{}, 1)
- p.inFlightMeta = map[string]*inflightMetadata{"metadata": {}}
- p.publishIndexes.entries = map[string]publishIndexEntry{"document": {}}
- p.cooldownWatch.active = map[*cooldownRecorder]struct{}{{}: {}}
-
- policy := profilePolicy(t, "3d")
- view := p.WithCooldown(policy, 72*time.Hour)
- if view.Cooldown != policy || view.ShortestCooldown != 72*time.Hour || view.shared != p {
- t.Fatal("view did not select its policy and shared proxy")
- }
- original, copied := reflect.ValueOf(p).Elem(), reflect.ValueOf(view).Elem()
- for i := range original.NumField() {
- name := original.Type().Field(i).Name
- before, after := original.Field(i), copied.Field(i)
- switch name {
- case "Cooldown", "ShortestCooldown", "shared":
- continue
- case "fetchMu", "inFlight", "detachSlots", "metaMu", "inFlightMeta", "publishIndexes", "cooldownWatch", "viewCooldown":
- if !after.IsZero() {
- t.Errorf("view field %s is not empty", name)
- }
- continue
- }
- if before.IsZero() {
- t.Errorf("populate Proxy.%s to check whether WithCooldown copies it", name)
- continue
- }
- switch before.Kind() {
- case reflect.Func, reflect.Slice:
- if before.Pointer() != after.Pointer() {
- t.Errorf("WithCooldown did not copy %s", name)
- }
- if before.Kind() == reflect.Slice && (before.Len() != after.Len() || before.Cap() != after.Cap()) {
- t.Errorf("WithCooldown changed slice bounds for %s", name)
- }
- default:
- if !before.Equal(after) {
- t.Errorf("WithCooldown did not copy %s", name)
- }
- }
- }
-}
-
-func TestProfilesShareDownloadPublishIndexes(t *testing.T) {
- for _, c := range downloadCooldownCases() {
- if c.ecosystem != "conda" && c.ecosystem != "helm" {
- continue
- }
- t.Run(c.ecosystem, func(t *testing.T) {
- var calls atomic.Int64
- times := map[string]time.Time{cooldownRecentVersion: time.Now().Add(-4 * 24 * time.Hour)}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- calls.Add(1)
- c.metadata(w, r, times)
- }))
- t.Cleanup(upstream.Close)
- p, _, _, fetcher := setupTestProxy(t)
- p.HTTPClient = upstream.Client()
- p.Cooldown = profilePolicy(t, "1d")
- fetcher.fetchErr = errors.New("artifact must be served from the cache")
- c.seedCachedArtifact(t, p, cooldownRecentVersion)
- permissive := p.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour)
- strict := p.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour)
- nested := permissive.WithCooldown(profilePolicy(t, "5d"), 120*time.Hour)
- for _, request := range []struct {
- proxy *Proxy
- want int
- }{
- {permissive, http.StatusOK},
- {strict, http.StatusNotFound},
- {p, http.StatusOK},
- {nested, http.StatusNotFound},
- } {
- w := serveGet(t, c.handler(request.proxy, upstream.URL), c.path(cooldownRecentVersion))
- wantStatus(t, w, request.want, "")
- }
- if n := calls.Load(); n != 1 {
- t.Errorf("index downloads across profiles = %d, want 1", n)
- }
- if n := len(p.publishIndexes.entries); n != 1 {
- t.Errorf("shared publish indexes = %d, want 1", n)
- }
- if fetcher.fetchCalled {
- t.Error("cached artifact was fetched again")
- }
- })
- }
-}
-
-func profilePolicy(t *testing.T, duration string) *cooldown.Policy {
- t.Helper()
- policy, err := cooldown.NewPolicy(&cooldown.Config{Default: duration}, nil)
- if err != nil {
- t.Fatal(err)
- }
- return policy
-}
-
-func TestProfilesCoalesceUncachedMetadata(t *testing.T) {
- u := newMetadataUpstream(t, http.StatusOK)
- p := metadataTestProxy(t, u)
- views := []*Proxy{p.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour), p.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour)}
- first := startMetadataCalls(views[0], context.Background(), 1, u.URL, contentTypeJSON)
- <-u.entered
- second := startMetadataCalls(views[1], context.Background(), 1, u.URL, contentTypeJSON)
- key := metadataCoalesceKey("npm", "left-pad", u.URL, contentTypeJSON, "", false)
- var release sync.Once
- t.Cleanup(func() { release.Do(func() { close(u.release) }) })
- waitForMetadataWaiters(t, p, key, 1)
- release.Do(func() { close(u.release) })
- for _, calls := range [][]chan metadataCall{first, second} {
- result := <-calls[0]
- if result.err != nil || !strings.Contains(string(result.body), "accept") {
- t.Fatalf("metadata = %s, error %v", result.body, result.err)
- }
- }
- if n := u.calls.Load(); n != 1 {
- t.Fatalf("upstream requests = %d, want 1", n)
- }
-}
-
-func TestProfilesCoalesceArtifactRequests(t *testing.T) {
- proxy, db, _, _ := setupTestProxy(t)
- if err := db.SetVersionPublishedAt("pkg:cargo/demo@1.0.0", "pkg:cargo/demo", time.Now().Add(-10*24*time.Hour)); err != nil {
- t.Fatal(err)
- }
- fetcher := &countingFetcher{content: "crate bytes", delay: fetchHoldTime}
- proxy.Fetcher = fetcher
- views := []*Proxy{proxy.WithCooldown(profilePolicy(t, "3d"), 72*time.Hour), proxy.WithCooldown(profilePolicy(t, "7d"), 168*time.Hour)}
- var wg sync.WaitGroup
- start := make(chan struct{})
- for range 4 {
- for _, view := range views {
- wg.Go(func() {
- <-start
- h := NewCargoHandler(view, "http://proxy.test", "https://index.crates.io", "https://static.crates.io/crates")
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/crates/demo/1.0.0/download", nil))
- if w.Code != http.StatusOK || w.Body.String() != "crate bytes" {
- t.Errorf("artifact response %d: %s", w.Code, w.Body)
- }
- })
- }
- }
- close(start)
- wg.Wait()
- if n := fetcher.calls.Load(); n != 1 {
- t.Fatalf("upstream fetches = %d, want 1", n)
- }
-}
-
-type profileClockPolicy struct {
- *cooldown.Policy
- now func() time.Time
-}
-
-func (p profileClockPolicy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
- return p.Evaluate(ecosystem, packagePURL, publishedAt, p.now()).Allowed
-}
-
-func TestProfileExpiryReleasesVersionsIndependently(t *testing.T) {
- u, proxy, _ := gemCacheSetup(t)
- clock := &testClock{t: time.Now()}
- proxy.now = clock.now
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- view := proxy.WithCooldown(profileClockPolicy{profilePolicy(t, "3d"), clock.now}, 72*time.Hour)
- h := NewGemHandlerWithUpstream(view, "http://proxy.test/cooldown/3d", u.URL).Routes()
- strictView := proxy.WithCooldown(profileClockPolicy{profilePolicy(t, "7d"), clock.now}, 168*time.Hour)
- strict := NewGemHandlerWithUpstream(strictView, "http://proxy.test/cooldown/7d", u.URL).Routes()
- first := getWith(t, h, gemInfoPath, nil)
- if first.Code != http.StatusOK || first.Body.String() != gemFiltered {
- t.Fatalf("initial response %d: %s", first.Code, first.Body)
- }
- if first := getWith(t, strict, gemInfoPath, nil); first.Code != http.StatusOK || first.Body.String() != gemFiltered {
- t.Fatalf("initial strict response %d: %s", first.Code, first.Body)
- }
- clock.advance(4 * 24 * time.Hour)
- second := getWith(t, h, gemInfoPath, nil)
- if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "2.0.0 |") {
- t.Fatalf("version did not leave cooldown: %d %s", second.Code, second.Body)
- }
- if second := getWith(t, strict, gemInfoPath, nil); second.Code != http.StatusOK || second.Body.String() != gemFiltered {
- t.Fatalf("strict profile released version early: %d %s", second.Code, second.Body)
- }
- if n, _ := u.counts(gemInfoPath); n != 1 {
- t.Errorf("expiry refetched raw metadata %d times", n)
- }
-}
diff --git a/internal/handler/proxy_cached_encoding_test.go b/internal/handler/proxy_cached_encoding_test.go
deleted file mode 100644
index 45eaef3..0000000
--- a/internal/handler/proxy_cached_encoding_test.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strconv"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// gzipWhenAskedUpstream serves compressed bytes with Content-Encoding: gzip
-// when the request advertises gzip, plain bytes otherwise, like a CDN that
-// compresses on the fly. It records the last Accept-Encoding it saw and counts
-// every request before the availability gate so a cache-miss refetch during a
-// simulated outage is observable.
-type gzipWhenAskedUpstream struct {
- *httptest.Server
- available atomic.Bool
- requests atomic.Int32
- acceptEncoding atomic.Value // string
-}
-
-func newGzipWhenAskedUpstream(plain, compressed []byte) *gzipWhenAskedUpstream {
- u := &gzipWhenAskedUpstream{}
- u.available.Store(true)
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u.requests.Add(1)
- u.acceptEncoding.Store(r.Header.Get(headerAcceptEncoding))
- if !u.available.Load() {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set(headerContentType, contentTypeJSON)
- if strings.Contains(r.Header.Get(headerAcceptEncoding), "gzip") {
- w.Header().Set(headerContentEncoding, "gzip")
- _, _ = w.Write(compressed)
- return
- }
- _, _ = w.Write(plain)
- }))
- return u
-}
-
-func (u *gzipWhenAskedUpstream) sawAcceptEncoding() string {
- s, _ := u.acceptEncoding.Load().(string)
- return s
-}
-
-// serveGzip issues one request through proxyCachedWithEncoding asking the
-// upstream for gzip.
-func serveGzip(proxy *Proxy, upstreamURL string) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- r := httptest.NewRequest(http.MethodGet, "/index.json", nil)
- proxy.proxyCachedWithEncoding(w, r, upstreamURL, "gzip-test", "index", "gzip", nil, "*/*")
- return w
-}
-
-func assertGzipResponse(t *testing.T, label string, w *httptest.ResponseRecorder, compressed []byte) {
- t.Helper()
- if w.Code != http.StatusOK {
- t.Fatalf("%s: status = %d, want 200: %s", label, w.Code, w.Body.String())
- }
- if !bytes.Equal(w.Body.Bytes(), compressed) {
- t.Errorf("%s: body is not the compressed bytes (got %d, want %d)", label, w.Body.Len(), len(compressed))
- }
- if got := w.Header().Get(headerContentEncoding); got != "gzip" {
- t.Errorf("%s: Content-Encoding = %q, want %q", label, got, "gzip")
- }
- if got := w.Header().Get(headerContentLength); got != strconv.Itoa(len(compressed)) {
- t.Errorf("%s: Content-Length = %q, want %d", label, got, len(compressed))
- }
-}
-
-// TestProxyCachedWithEncoding_GzipCachesAndReplays covers the cached path:
-// requesting gzip upstream stores the compressed bytes plus Content-Encoding
-// and replays both from cache without contacting the upstream again.
-func TestProxyCachedWithEncoding_GzipCachesAndReplays(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
- upstream := newGzipWhenAskedUpstream(plain, compressed)
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
-
- first := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "first", first, compressed)
- if got := upstream.sawAcceptEncoding(); got != "gzip" {
- t.Errorf("upstream Accept-Encoding = %q, want %q", got, "gzip")
- }
-
- before := upstream.requests.Load()
- upstream.available.Store(false)
- cached := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "cached", cached, compressed)
- if upstream.requests.Load() != before {
- t.Errorf("cached replay hit upstream: requests %d -> %d", before, upstream.requests.Load())
- }
-}
-
-// TestProxyCachedWithEncoding_GzipStreamPath covers the cache_metadata=false
-// branch: the streaming path must request gzip and forward Content-Encoding.
-func TestProxyCachedWithEncoding_GzipStreamPath(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
- upstream := newGzipWhenAskedUpstream(plain, compressed)
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = false
- proxy.HTTPClient = upstream.Client()
-
- w := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "stream", w, compressed)
- if got := upstream.sawAcceptEncoding(); got != "gzip" {
- t.Errorf("stream path upstream Accept-Encoding = %q, want %q", got, "gzip")
- }
-}
-
-// TestProxyCachedWithEncoding_GzipSurvivesCacheWriteFailure covers the failure
-// the gzip mode makes reachable: when the metadata cache write fails the
-// freshly fetched body is still served, so its Content-Encoding must come from
-// the fetch and not from the (unwritten) cache row -- otherwise gzip bytes go
-// out labelled application/json with no Content-Encoding.
-func TestProxyCachedWithEncoding_GzipSurvivesCacheWriteFailure(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
- upstream := newGzipWhenAskedUpstream(plain, compressed)
- defer upstream.Close()
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- store.storeErr = errors.New("disk full")
-
- w := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "store-failure", w, compressed)
-}
-
-// TestProxyCachedWithEncoding_GzipStaleFallbackKeepsEncoding pins the
-// stale-fallback return: when the upstream fails after the entry has expired,
-// the stored gzip blob is served with its Content-Encoding taken from the
-// cache row.
-func TestProxyCachedWithEncoding_GzipStaleFallbackKeepsEncoding(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
- upstream := newGzipWhenAskedUpstream(plain, compressed)
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0 // every request revalidates; an upstream failure falls back to the stale row
- proxy.HTTPClient = upstream.Client()
-
- first := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "first", first, compressed)
-
- upstream.available.Store(false)
- stale := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "stale", stale, compressed)
-}
-
-// TestProxyCachedWithEncoding_UpsertFailureDiscardsBlob covers the row-write
-// failure: when the gzip blob is stored but the cache row cannot be updated,
-// the blob must be discarded so a later stale fallback cannot serve gzip
-// bytes with the previous row's encoding. The fresh response is still
-// correct because its encoding comes from the fetch.
-func TestProxyCachedWithEncoding_UpsertFailureDiscardsBlob(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
- upstream := newGzipWhenAskedUpstream(plain, compressed)
- defer upstream.Close()
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0 // every request revalidates
- proxy.HTTPClient = upstream.Client()
-
- // Seed an identity row + plain blob, as every key has before the gzip rollout.
- w := httptest.NewRecorder()
- proxy.proxyCachedWithEncoding(w, httptest.NewRequest(http.MethodGet, "/index.json", nil),
- upstream.URL+"/index.json", "gzip-test", "index", "identity", nil, "*/*")
- if w.Code != http.StatusOK {
- t.Fatalf("seed status = %d, want 200", w.Code)
- }
-
- // Now DB writes fail while reads keep working.
- db.SetMaxOpenConns(1)
- if _, err := db.Exec("PRAGMA query_only=1"); err != nil {
- t.Fatalf("PRAGMA query_only=1: %v", err)
- }
- fresh := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "fresh with failed row write", fresh, compressed)
-
- storagePath := metadataStoragePath("gzip-test", "index")
- if exists, _ := store.Exists(context.Background(), storagePath); exists {
- t.Fatalf("blob %s still present after the row write failed", storagePath)
- }
-
- // Upstream down: the stale fallback must not serve the orphaned gzip
- // blob under the old identity row.
- if _, err := db.Exec("PRAGMA query_only=0"); err != nil {
- t.Fatalf("PRAGMA query_only=0: %v", err)
- }
- upstream.available.Store(false)
- stale := serveGzip(proxy, upstream.URL+"/index.json")
- if stale.Code == http.StatusOK {
- t.Fatalf("stale fallback served status 200 (Content-Encoding=%q, %d bytes) from an orphaned blob; want an error",
- stale.Header().Get(headerContentEncoding), stale.Body.Len())
- }
-}
-
-// TestProxyCachedWithEncoding_StaleFallbackRereadsRow covers the rollout race:
-// a request that read the identity row, lost the upstream race to a request
-// that stored the gzip blob, and then failed upstream must label the blob
-// with the row as it is now, not with the row it read at the start.
-func TestProxyCachedWithEncoding_StaleFallbackRereadsRow(t *testing.T) {
- plain := []byte(`{"packages":{}}`)
- compressed := gzipPayload(t, plain)
-
- var proxy *Proxy
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if requests.Add(1) == 1 {
- w.Header().Set(headerContentType, contentTypeJSON)
- _, _ = w.Write(plain) // seed request: identity
- return
- }
- // Second request has already read the identity row. Simulate a
- // concurrent request finishing first: store the gzip blob and row,
- // then fail this request so it takes the stale fallback.
- proxy.cacheMetadataBlob(r.Context(), "gzip-test", "index", metadataStoragePath("gzip-test", "index"),
- &upstreamMetadata{body: compressed, contentType: contentTypeJSON, contentEncoding: "gzip"})
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ = setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0
- proxy.HTTPClient = upstream.Client()
-
- w := httptest.NewRecorder()
- proxy.proxyCachedWithEncoding(w, httptest.NewRequest(http.MethodGet, "/index.json", nil),
- upstream.URL+"/index.json", "gzip-test", "index", "identity", nil, "*/*")
- if w.Code != http.StatusOK {
- t.Fatalf("seed status = %d, want 200", w.Code)
- }
-
- raced := serveGzip(proxy, upstream.URL+"/index.json")
- assertGzipResponse(t, "stale fallback after concurrent gzip store", raced, compressed)
-}
diff --git a/internal/handler/pub.go b/internal/handler/pub.go
index 5a6d426..154dc45 100644
--- a/internal/handler/pub.go
+++ b/internal/handler/pub.go
@@ -2,35 +2,16 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
+ "io"
"net/http"
- "net/url"
- "regexp"
"strings"
- "time"
-
- "github.com/Masterminds/semver/v3"
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/retention"
)
const (
- pubUpstream = "https://pub.dev"
- pubPathParts = 2 // name + version in path split by /versions/
- pubArchiveExt = ".tar.gz"
+ pubUpstream = "https://pub.dev"
)
-// pubPackageName matches pub package names, which have to be Dart identifiers.
-var pubPackageName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
-
-// Packages are stored under pkg:pub/{name} with the name from the download
-// path, and purl keeps pub names as they are, so package rules use the
-// default key mapping.
-func init() {
- retention.Default.Register(retention.Spec{Key: "pub", CanonicalPackage: retention.DefaultCanonical("pub")})
-}
-
// PubHandler handles pub.dev registry protocol requests.
type PubHandler struct {
proxy *Proxy
@@ -47,13 +28,6 @@ func NewPubHandler(proxy *Proxy, proxyURL string) *PubHandler {
}
}
-// NewPubHandlerWithUpstream creates a pub handler with a custom upstream.
-func NewPubHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *PubHandler {
- h := NewPubHandler(proxy, proxyURL)
- h.upstreamURL = configuredUpstreamURL(upstreamURL, pubUpstream)
- return h
-}
-
// Routes returns the HTTP handler for pub requests.
func (h *PubHandler) Routes() http.Handler {
mux := http.NewServeMux()
@@ -72,104 +46,31 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
// Parse path: /packages/{name}/versions/{version}.tar.gz
path := strings.TrimPrefix(r.URL.Path, "/packages/")
parts := strings.Split(path, "/versions/")
- if len(parts) != pubPathParts {
+ if len(parts) != 2 {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
name := parts[0]
- // Only the archive is cached. Without the suffix the upstream path is the
- // HTML version page on pub.dev, which must not land under the archive key.
- version, ok := strings.CutSuffix(parts[1], pubArchiveExt)
- if !ok {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
+ version := strings.TrimSuffix(parts[1], ".tar.gz")
- if !pubPackageName.MatchString(name) || version == "" || strings.Contains(version, "/") {
+ if name == "" || version == "" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
- filename := name + "-" + version + pubArchiveExt
+ filename := fmt.Sprintf("%s-%s.tar.gz", name, version)
- h.proxy.Logger.Debug("pub download request",
+ h.proxy.Logger.Info("pub download request",
"name", name, "version", version)
- h.discardVersionPage(name, version, filename)
- if h.proxy.withholdInCooldown(w, r, downloadVersion{
- ecosystem: "pub", policyName: name, storedName: name, version: version, cacheName: name, filename: filename,
- lookup: func() (time.Time, bool, bool, error) {
- published, ok, err := h.publishTime(r, name, version)
- return published, ok, false, err
- },
- }) {
+ result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename)
+ if err != nil {
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, "failed to fetch package", http.StatusBadGateway)
return
}
- downloadURL := fmt.Sprintf("%s/packages/%s/versions/%s%s",
- h.upstreamURL, url.PathEscape(name), url.PathEscape(version), pubArchiveExt)
- result, err := h.proxy.GetOrFetchArtifactFromURL(
- r.Context(), "pub", name, version, filename, downloadURL,
- )
- if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch package")
- return
- }
-
- ServeArtifactRequest(w, r, result)
-}
-
-// discardVersionPage clears a cached archive that was fetched from a path
-// without the archive suffix. Proxy versions before the suffix check cached
-// pub.dev's HTML version page under the archive's name that way.
-func (h *PubHandler) discardVersionPage(name, version, filename string) {
- _, versionPURL, err := packagePURLStrings("pub", name, version)
- if err != nil {
- return
- }
- record, err := h.proxy.DB.GetArtifact(versionPURL, filename)
- if err != nil {
- h.proxy.Logger.Warn("failed to read pub cache record",
- "purl", versionPURL, "filename", filename, "error", err)
- return
- }
- if record == nil || !record.StoragePath.Valid || record.Source == database.SourceLocal ||
- strings.HasSuffix(record.UpstreamURL, pubArchiveExt) {
- return
- }
- h.proxy.Logger.Warn("cached pub archive was fetched without the archive suffix, discarding",
- "purl", versionPURL, "filename", filename, "upstream_url", record.UpstreamURL)
- if err := h.proxy.DB.DiscardArtifact(versionPURL, filename, record.StoragePath.String); err != nil {
- h.proxy.Logger.Warn("failed to discard pub cache record",
- "purl", versionPURL, "filename", filename, "error", err)
- }
-}
-
-// publishTime reads a version's published time from the package metadata,
-// sharing the metadata cache with handlePackageMetadata.
-func (h *PubHandler) publishTime(r *http.Request, name, version string) (time.Time, bool, error) {
- upstreamURL := fmt.Sprintf("%s/api/packages/%s", h.upstreamURL, name)
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pub", name, upstreamURL)
- if err != nil {
- return time.Time{}, false, err
- }
- var metadata struct {
- Versions []struct {
- Version string `json:"version"`
- Published string `json:"published"`
- } `json:"versions"`
- }
- if err := json.Unmarshal(body, &metadata); err != nil {
- return time.Time{}, false, err
- }
- for _, v := range metadata.Versions {
- if v.Version != version {
- continue
- }
- published, err := time.Parse(time.RFC3339, v.Published)
- return published, err == nil, nil
- }
- return time.Time{}, false, nil
+ ServeArtifact(w, result)
}
// handlePackageMetadata proxies package metadata and rewrites archive URLs.
@@ -180,69 +81,64 @@ func (h *PubHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
return
}
- h.proxy.Logger.Debug("pub metadata request", "package", name)
+ h.proxy.Logger.Info("pub metadata request", "package", name)
upstreamURL := fmt.Sprintf("%s/api/packages/%s", h.upstreamURL, name)
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pub", name, upstreamURL)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteMetadata(name, body)
if err != nil {
- if h.proxy.mustFilterMetadata("pub", name) {
- h.proxy.Logger.Error("failed to filter pub metadata", "package", name, "error", err)
- http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
- return
- }
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
- w.Header().Set(headerContentType, "application/json")
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return
}
- w.Header().Set(headerContentType, "application/json")
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
// rewriteMetadata rewrites archive_url fields to point at this proxy.
-// If cooldown is enabled, versions published too recently are filtered out.
func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
+ // Rewrite archive URLs in versions
versions, ok := metadata["versions"].([]any)
if !ok {
return body, nil
}
- packagePURL := canonicalPackagePURL("pub", name)
- filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
- metadata["versions"] = filtered
-
- h.updateLatestVersion(metadata, filtered)
-
- return json.Marshal(metadata)
-}
-
-// filterAndRewriteVersions applies cooldown filtering and rewrites archive URLs
-// for a package's version list.
-func (h *PubHandler) filterAndRewriteVersions(name, packagePURL string, versions []any) []any {
- filtered := versions[:0]
for _, vdata := range versions {
vmap, ok := vdata.(map[string]any)
if !ok {
@@ -254,87 +150,13 @@ func (h *PubHandler) filterAndRewriteVersions(name, packagePURL string, versions
continue
}
- if h.shouldFilterVersion(packagePURL, name, version, vmap) {
- continue
- }
-
+ // Rewrite archive_url
newURL := fmt.Sprintf("%s/pub/packages/%s/versions/%s.tar.gz", h.proxyURL, name, version)
vmap["archive_url"] = newURL
- filtered = append(filtered, vdata)
h.proxy.Logger.Debug("rewrote archive URL",
"package", name, "version", version, "new", newURL)
}
- return filtered
-}
-
-// shouldFilterVersion returns true if the version should be excluded due to cooldown.
-func (h *PubHandler) shouldFilterVersion(packagePURL, name, version string, vmap map[string]any) bool {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return false
- }
-
- publishedStr, ok := vmap["published"].(string)
- if !ok {
- return false
- }
-
- publishedAt, err := time.Parse(time.RFC3339, publishedStr)
- if err != nil {
- return false
- }
-
- if !h.proxy.cooldown().IsAllowed("pub", packagePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering pub version",
- "package", name, "version", version)
- return true
- }
-
- return false
-}
-
-// updateLatestVersion points latest at the kept entry for its version, so
-// its archive_url is the proxy's too. When cooldown removed that version,
-// latest becomes the newest stable version left, or the newest version left
-// if all are prereleases, and is dropped when none is left.
-func (h *PubHandler) updateLatestVersion(metadata map[string]any, filtered []any) {
- latest, ok := metadata["latest"].(map[string]any)
- if !ok {
- return
- }
- latestVer, _ := latest["version"].(string)
-
- var newest, newestStable *semver.Version
- var newestEntry, newestStableEntry any
- for _, vdata := range filtered {
- vmap, ok := vdata.(map[string]any)
- if !ok {
- continue
- }
- version, _ := vmap["version"].(string)
- if version == latestVer {
- latest["archive_url"] = vmap["archive_url"]
- return
- }
- parsed, err := semver.NewVersion(version)
- if err != nil {
- continue
- }
- if newest == nil || parsed.GreaterThan(newest) {
- newest, newestEntry = parsed, vdata
- }
- if parsed.Prerelease() == "" && (newestStable == nil || parsed.GreaterThan(newestStable)) {
- newestStable, newestStableEntry = parsed, vdata
- }
- }
-
- switch {
- case newestStableEntry != nil:
- metadata["latest"] = newestStableEntry
- case newestEntry != nil:
- metadata["latest"] = newestEntry
- default:
- delete(metadata, "latest")
- }
+ return json.Marshal(metadata)
}
diff --git a/internal/handler/pub_test.go b/internal/handler/pub_test.go
deleted file mode 100644
index 5311e25..0000000
--- a/internal/handler/pub_test.go
+++ /dev/null
@@ -1,263 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestPubRewriteMetadata(t *testing.T) {
- h := &PubHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "flutter_bloc",
- "versions": [
- {"version": "1.0.0", "archive_url": "https://pub.dev/packages/flutter_bloc/versions/1.0.0.tar.gz"},
- {"version": "2.0.0", "archive_url": "https://pub.dev/packages/flutter_bloc/versions/2.0.0.tar.gz"}
- ]
- }`
-
- output, err := h.rewriteMetadata("flutter_bloc", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].([]any)
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d", len(versions))
- }
-
- v1 := versions[0].(map[string]any)
- if v1["archive_url"] != "http://localhost:8080/pub/packages/flutter_bloc/versions/1.0.0.tar.gz" {
- t.Errorf("unexpected archive_url: %s", v1["archive_url"])
- }
-}
-
-func TestPubRewriteMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &PubHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "flutter_bloc",
- "latest": {"version": "2.0.0"},
- "versions": [
- {"version": "1.0.0", "published": "` + old + `", "archive_url": "https://pub.dev/1.0.0.tar.gz"},
- {"version": "2.0.0", "published": "` + recent + `", "archive_url": "https://pub.dev/2.0.0.tar.gz"}
- ]
- }`
-
- output, err := h.rewriteMetadata("flutter_bloc", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].([]any)
- if len(versions) != 1 {
- t.Fatalf("expected 1 version after cooldown, got %d", len(versions))
- }
-
- v := versions[0].(map[string]any)
- if v["version"] != "1.0.0" {
- t.Errorf("expected version 1.0.0, got %v", v["version"])
- }
-
- // latest should be updated
- latest := result["latest"].(map[string]any)
- if latest["version"] != "1.0.0" {
- t.Errorf("latest version = %v, want 1.0.0", latest["version"])
- }
-}
-
-// Without the .tar.gz suffix pub.dev answers with the HTML version page, which
-// would otherwise be cached under the archive's key.
-func TestPubDownloadRequiresArchiveSuffix(t *testing.T) {
- proxy, db, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("version page")),
- ContentType: "text/html",
- }
- h := NewPubHandlerWithUpstream(proxy, "http://localhost", "https://pub.example.com")
-
- req := httptest.NewRequest(http.MethodGet, "/packages/http/versions/1.2.0", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
- if fetcher.fetchCalled {
- t.Errorf("upstream fetched %q", fetcher.fetchedURL)
- }
- art, err := db.GetArtifact("pkg:pub/http@1.2.0", "http-1.2.0.tar.gz")
- if err != nil {
- t.Fatalf("GetArtifact: %v", err)
- }
- if art != nil {
- t.Errorf("artifact cached from %q", art.UpstreamURL)
- }
-}
-
-func TestPubDownloadRejectsEncodedQueryInName(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("archive")),
- ContentType: "application/octet-stream",
- }
- h := NewPubHandlerWithUpstream(proxy, "http://localhost", "https://pub.example.com")
-
- for _, path := range []string{
- "/packages/http%3Fx/versions/1.2.0.tar.gz",
- "/packages/http%23x/versions/1.2.0.tar.gz",
- "/packages/http/versions/1.2.0%3Fx.tar.gz",
- } {
- fetcher.fetchCalled = false
- fetcher.fetchedURL = ""
- req := httptest.NewRequest(http.MethodGet, path, nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if strings.ContainsAny(fetcher.fetchedURL, "?#") {
- t.Errorf("%s: upstream URL %q carries a query or fragment", path, fetcher.fetchedURL)
- }
- if strings.Contains(path, "/http%") && fetcher.fetchCalled {
- t.Errorf("%s: invalid package name reached upstream as %q", path, fetcher.fetchedURL)
- }
- }
-}
-
-// Older versions cached the HTML version page as the archive. Such an entry
-// is fetched again instead of being served.
-func TestPubDownloadRefetchesCachedVersionPage(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- seedPackage(t, db, store, "pub", "http", "1.2.0", "http-1.2.0.tar.gz", "version page")
- art, err := db.GetArtifact("pkg:pub/http@1.2.0", "http-1.2.0.tar.gz")
- if err != nil || art == nil {
- t.Fatalf("GetArtifact: %v", err)
- }
- art.UpstreamURL = "https://pub.dev/packages/http/versions/1.2.0"
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatalf("UpsertArtifact: %v", err)
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("archive")),
- ContentType: "application/octet-stream",
- }
- h := NewPubHandlerWithUpstream(proxy, "http://localhost", "https://pub.example.com")
-
- req := httptest.NewRequest(http.MethodGet, "/packages/http/versions/1.2.0.tar.gz", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if !fetcher.fetchCalled {
- t.Fatal("cached version page was served without fetching upstream")
- }
- if got := w.Body.String(); got != "archive" {
- t.Errorf("body = %q, want %q", got, "archive")
- }
-}
-
-func TestPubDownloadServesCachedArchive(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- seedPackage(t, db, store, "pub", "http", "1.2.0", "http-1.2.0.tar.gz", "cached archive")
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("refetched")),
- ContentType: "application/octet-stream",
- }
- h := NewPubHandlerWithUpstream(proxy, "http://localhost", "https://pub.example.com")
-
- req := httptest.NewRequest(http.MethodGet, "/packages/http/versions/1.2.0.tar.gz", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if fetcher.fetchCalled {
- t.Errorf("cached archive fetched again from %q", fetcher.fetchedURL)
- }
- if got := w.Body.String(); got != "cached archive" {
- t.Errorf("body = %q, want %q", got, "cached archive")
- }
-}
-
-// TestPubRewriteMetadataLatest checks that latest points at the proxy and
-// never names a version cooldown holds back, and that a held back latest is
-// replaced with the newest stable version left.
-func TestPubRewriteMetadataLatest(t *testing.T) {
- old := time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := time.Now().Add(-time.Hour).Format(time.RFC3339)
- entry := func(version, published string) string {
- return `{"version":"` + version + `","published":"` + published + `","archive_url":"https://pub.dev/packages/demo/versions/` + version + `.tar.gz"}`
- }
- for _, tc := range []struct {
- name, latest, versions string
- cooldown bool
- want, wantURL string
- }{
- {"kept", entry("1.0.0", old), entry("1.0.0", old), false, "1.0.0",
- "http://localhost:8080/pub/packages/demo/versions/1.0.0.tar.gz"},
- {"stable over prerelease", entry("2.0.0", recent),
- entry("1.0.0", old) + "," + entry("1.1.0", old) + "," + entry("2.0.0-dev.1", old) + "," + entry("2.0.0", recent), true, "1.1.0",
- "http://localhost:8080/pub/packages/demo/versions/1.1.0.tar.gz"},
- {"only prereleases", entry("2.0.0", recent), entry("2.0.0-dev.1", old) + "," + entry("2.0.0", recent), true, "2.0.0-dev.1",
- "http://localhost:8080/pub/packages/demo/versions/2.0.0-dev.1.tar.gz"},
- {"all held back", entry("2.0.0", recent), entry("2.0.0", recent), true, "", ""},
- } {
- t.Run(tc.name, func(t *testing.T) {
- proxy := &Proxy{Logger: slog.Default()}
- if tc.cooldown {
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- }
- h := &PubHandler{proxy: proxy, proxyURL: "http://localhost:8080"}
- output, err := h.rewriteMetadata("demo", []byte(`{"name":"demo","latest":`+tc.latest+`,"versions":[`+tc.versions+`]}`))
- if err != nil {
- t.Fatal(err)
- }
- var result struct {
- Latest *struct {
- Version string `json:"version"`
- ArchiveURL string `json:"archive_url"`
- } `json:"latest"`
- }
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatal(err)
- }
- if tc.want == "" {
- if result.Latest != nil {
- t.Errorf("latest = %+v, want none", *result.Latest)
- }
- return
- }
- if result.Latest == nil || result.Latest.Version != tc.want || result.Latest.ArchiveURL != tc.wantURL {
- t.Errorf("latest = %+v, want %s at %s", result.Latest, tc.want, tc.wantURL)
- }
- })
- }
-}
diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go
index 21e4374..cafbb52 100644
--- a/internal/handler/pypi.go
+++ b/internal/handler/pypi.go
@@ -1,82 +1,42 @@
package handler
import (
- "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
- "errors"
"fmt"
"io"
- "maps"
- "mime"
"net/http"
"net/url"
"regexp"
- "slices"
- "strconv"
"strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/retention"
)
const (
- pypiUpstream = "https://pypi.org"
- pypiDownloadUpstream = "https://files.pythonhosted.org"
- pypiSimpleJSON = "application/vnd.pypi.simple.v1+json"
- pypiSimpleHTML = "application/vnd.pypi.simple.v1+html"
- pypiSimpleLatestJSON = "application/vnd.pypi.simple.latest+json"
- pypiSimpleLatestHTML = "application/vnd.pypi.simple.latest+html"
- pypiLegacyHTML = "text/html"
- pypiExactSpecificity = 2
- minWheelParts = 5 // name + version + python + abi + platform
- minSubmatchParts = 2 // full match + first capture group
- minPyPIPathParts = 3 // hash_prefix + hash + filename
- minEggParts = 3 // name + version + python tag
-
- // PyPIMetadataSuffix is the PEP 658 core-metadata sidecar suffix that pip
- // appends to a distribution URL when the index advertises core metadata.
- // A sidecar resolves to the same name and version as the distribution it
- // describes, so it is cached alongside it; consumers that expect an openable
- // archive must skip these.
- PyPIMetadataSuffix = ".metadata"
+ pypiUpstream = "https://pypi.org"
)
// PyPIHandler handles PyPI registry protocol requests.
type PyPIHandler struct {
proxy *Proxy
upstreamURL string
- downloadURL string
proxyURL string
}
-// PyPI files are stored under pkg:pypi/{name}, the name taken from the
-// filename and normalized the way purl does it: lowercased, "_" turned into
-// "-". Package rules use the default key mapping, so pkg:pypi/Typing_Extensions
-// matches typing-extensions. Dots and runs of separators are kept, so files
-// named zope.interface-* and zope_interface-* end up under two packages.
-func init() {
- retention.Default.Register(retention.Spec{Key: "pypi", CanonicalPackage: retention.DefaultCanonical("pypi")})
-}
-
-// NewPyPIHandlerWithUpstreams creates a PyPI handler with custom API and
-// package download upstreams.
-func NewPyPIHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, downloadURL string) *PyPIHandler {
- h := &PyPIHandler{
+// NewPyPIHandler creates a new PyPI protocol handler.
+func NewPyPIHandler(proxy *Proxy, proxyURL string) *PyPIHandler {
+ return &PyPIHandler{
proxy: proxy,
- upstreamURL: configuredUpstreamURL(upstreamURL, pypiUpstream),
- downloadURL: configuredUpstreamURL(downloadURL, pypiDownloadUpstream),
+ upstreamURL: pypiUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
- return h
}
// Routes returns the HTTP handler for PyPI requests.
func (h *PyPIHandler) Routes() http.Handler {
mux := http.NewServeMux()
- // Simple API
+ // Simple API (used by pip)
mux.HandleFunc("GET /simple/", h.handleSimpleIndex)
mux.HandleFunc("GET /simple/{name}/", h.handleSimplePackage)
@@ -87,7 +47,7 @@ func (h *PyPIHandler) Routes() http.Handler {
// Package downloads (cache these)
mux.HandleFunc("GET /packages/{path...}", h.handleDownload)
- return rejectQueryChars(mux)
+ return mux
}
// handleSimpleIndex serves the simple API index.
@@ -104,403 +64,69 @@ func (h *PyPIHandler) handleSimplePackage(w http.ResponseWriter, r *http.Request
return
}
- h.proxy.Logger.Debug("pypi simple request", "package", name)
+ h.proxy.Logger.Info("pypi simple request", "package", name)
upstreamURL := fmt.Sprintf("%s/simple/%s/", h.upstreamURL, name)
- accept := selectPyPISimpleRepresentation(r.Header.Get("Accept"))
- cacheKey := pypiSimpleCacheKey(name, accept)
- // Rewritten pages go through the rewrite cache, keyed by the upstream
- // page, its content type and the versions filtered out. Without cooldown
- // the filtered versions are known up front, so a repeat request can be
- // answered without reading the stored page.
- cooldown := h.proxy.cooldownApplies("pypi", name)
- if !cooldown {
- denied := h.addDeniedVersions(name, nil)
- if stored, contentType, ok := h.proxy.storedRewriteVariant("pypi", cacheKey, h.proxyURL, cacheKey, func(contentType string) []string {
- return simpleRewriteVariant(contentType, denied)
- }); ok {
- ensureVaryAccept(w.Header())
- serveRewrittenMetadataAs(w, r, stored, contentType)
- return
- }
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
}
+ req.Header.Set("Accept", "text/html")
- upstream := h.proxy.fetchMetadataDocument(r.Context(), "pypi", cacheKey, upstreamURL, accept, nil)
- if err := upstream.err; err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
- contentType := upstream.contentType
+ defer func() { _ = resp.Body.Close() }()
- // When cooldown applies, fetch JSON metadata to get version timestamps
- var filteredVersions map[string]bool
- if cooldown {
- var err error
- filteredVersions, err = h.fetchFilteredVersions(r, name)
- if err != nil {
- h.proxy.writeCooldownLookupError(w, "pypi", name, err)
- return
- }
- }
- filteredVersions = h.addDeniedVersions(name, filteredVersions)
-
- isJSON := isJSONMediaType(contentType)
- digest := variantDigest(upstream.digest, upstream.body, simpleRewriteVariant(contentType, filteredVersions)...)
- rewritten, err := h.proxy.cachedRewrite(r.Context(), "pypi", h.proxyURL, cacheKey, upstream.body, digest, func(body []byte) ([]byte, []string, error) {
- if isJSON {
- out, err := h.rewriteSimpleJSON(body, filteredVersions)
- return out, nil, err
- }
- body = h.filterSimpleHTMLLinks(body, filteredVersions)
- return h.rewriteSimpleHTML(body, filteredVersions), nil, nil
- })
- if err != nil {
- if r.Context().Err() != nil {
- return // the client left while waiting on a shared rewrite
- }
- if len(filteredVersions) != 0 {
- h.proxy.Logger.Error("failed to filter pypi simple json", "package", name, "error", err)
- http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
- return
- }
- h.proxy.Logger.Warn("failed to rewrite pypi simple json, proxying original", "error", err)
- w.Header().Set(headerContentType, contentType)
- ensureVaryAccept(w.Header())
- markStale(w, upstream.stale)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(upstream.body)
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
return
}
- ensureVaryAccept(w.Header())
- markStale(w, upstream.stale)
- serveRewrittenMetadataAs(w, r, rewritten, contentType)
-}
-
-// addDeniedVersions adds the package's denylisted versions to filtered,
-// allocating it when there are any and it is nil.
-func (h *PyPIHandler) addDeniedVersions(name string, filtered map[string]bool) map[string]bool {
- for version := range h.proxy.Denylist.Versions(canonicalPackagePURL("pypi", name)) {
- if filtered == nil {
- filtered = make(map[string]bool)
- }
- filtered[version] = true
- }
- return filtered
-}
-
-// simpleRewriteVariant returns what a simple page rewrite depends on besides
-// the upstream page: its content type, which picks the HTML or JSON rewrite,
-// and the versions filtered out.
-func simpleRewriteVariant(contentType string, filtered map[string]bool) []string {
- return append([]string{contentType}, slices.Sorted(maps.Keys(filtered))...)
-}
-
-func selectPyPISimpleRepresentation(accept string) string {
- if strings.TrimSpace(accept) == "" {
- return pypiLegacyHTML
- }
-
- type score struct {
- quality float64
- specificity int
- matched bool
- }
-
- scores := map[string]score{
- pypiSimpleJSON: {},
- pypiSimpleHTML: {},
- pypiLegacyHTML: {},
- }
-
- update := func(representation string, quality float64, specificity int) {
- current := scores[representation]
- if !current.matched || specificity > current.specificity ||
- (specificity == current.specificity && quality > current.quality) {
- scores[representation] = score{quality: quality, specificity: specificity, matched: true}
- }
- }
-
- for part := range strings.SplitSeq(accept, ",") {
- mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(part))
- if err != nil {
- continue
- }
-
- quality := 1.0
- if value, ok := params["q"]; ok {
- quality, err = strconv.ParseFloat(value, 64)
- if err != nil || quality < 0 || quality > 1 {
- continue
- }
- }
-
- switch mediaType {
- case pypiSimpleJSON, pypiSimpleLatestJSON:
- update(pypiSimpleJSON, quality, pypiExactSpecificity)
- case pypiSimpleHTML, pypiSimpleLatestHTML:
- update(pypiSimpleHTML, quality, pypiExactSpecificity)
- case pypiLegacyHTML:
- update(pypiLegacyHTML, quality, pypiExactSpecificity)
- case "application/*":
- update(pypiSimpleJSON, quality, 1)
- update(pypiSimpleHTML, quality, 1)
- case "text/*":
- update(pypiLegacyHTML, quality, 1)
- case "*/*":
- update(pypiSimpleJSON, quality, 0)
- update(pypiSimpleHTML, quality, 0)
- update(pypiLegacyHTML, quality, 0)
- }
- }
-
- bestMediaType := ""
- bestScore := score{}
- for _, mediaType := range []string{pypiSimpleJSON, pypiSimpleHTML, pypiLegacyHTML} {
- candidate := scores[mediaType]
- if !candidate.matched || candidate.quality == 0 {
- continue
- }
- if bestMediaType == "" || candidate.quality > bestScore.quality ||
- (candidate.quality == bestScore.quality && candidate.specificity > bestScore.specificity) {
- bestMediaType = mediaType
- bestScore = candidate
- }
- }
-
- if bestMediaType == "" || bestScore.specificity == 0 {
- return pypiLegacyHTML
- }
- return bestMediaType
-}
-
-func pypiSimpleCacheKey(name, mediaType string) string {
- switch mediaType {
- case pypiSimpleJSON:
- return name + "/simple/json"
- case pypiSimpleHTML:
- return name + "/simple/html"
- default:
- return name + "/simple"
- }
-}
-
-func isJSONMediaType(contentType string) bool {
- mediaType, _, err := mime.ParseMediaType(contentType)
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- return false
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
}
- return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")
+
+ rewritten := h.rewriteSimpleHTML(body)
+
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(rewritten)
}
-func ensureVaryAccept(header http.Header) {
- ensureVary(header, "Accept")
-}
-
-// fetchFilteredVersions fetches JSON metadata and returns a set of version
-// strings that should be filtered out due to cooldown. A package the JSON API
-// doesn't know has no publish times to go by and gets an empty set; any other
-// failure to read them is an error.
-func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) {
- times, err := h.fetchReleaseTimes(r, name)
- if err != nil {
- return nil, err
- }
-
- packagePURL := canonicalPackagePURL("pypi", name)
- filtered := make(map[string]bool)
-
- for version, publishedAt := range times {
- if !publishedAt.IsZero() && !h.proxy.cooldown().IsAllowed("pypi", packagePURL, publishedAt) {
- filtered[version] = true
- }
- }
-
- if len(filtered) == 0 {
- return nil, nil
- }
- return filtered, nil
-}
-
-// fetchReleaseTimes reads the JSON API and returns each release's newest
-// upload time, zero for a release without one. A package the JSON API
-// doesn't know gets nil; any other failure is an error.
-func (h *PyPIHandler) fetchReleaseTimes(r *http.Request, name string) (map[string]time.Time, error) {
- jsonURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
-
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", name+"/json", jsonURL)
- if errors.Is(err, ErrUpstreamNotFound) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
-
- return h.parseReleaseTimes(body)
-}
-
-// pypiReleaseFiles is the part of a JSON API document the release times are
-// read from. Decoding into it skips every other field, where a generic
-// map would hold the whole document, several times its size, in memory.
-type pypiReleaseFiles struct {
- Releases map[string][]struct {
- UploadTime string `json:"upload_time_iso_8601"`
- } `json:"releases"`
-}
-
-// parseReleaseTimes returns each release's newest upload time from a JSON
-// API document, zero for a release without one.
-func (h *PyPIHandler) parseReleaseTimes(body []byte) (map[string]time.Time, error) {
- var metadata pypiReleaseFiles
- // A value of another type than expected is skipped and the rest is
- // still decoded, so a file entry without a usable time doesn't count.
- var mismatch *json.UnmarshalTypeError
- if err := json.Unmarshal(body, &metadata); err != nil && !errors.As(err, &mismatch) {
- return nil, fmt.Errorf("parsing pypi json metadata: %w", err)
- }
- if metadata.Releases == nil {
- return nil, errors.New("pypi json metadata has no releases")
- }
-
- times := make(map[string]time.Time, len(metadata.Releases))
- for version, files := range metadata.Releases {
- var newest time.Time
- for _, file := range files {
- if t, err := time.Parse(time.RFC3339, file.UploadTime); err == nil && t.After(newest) {
- newest = t
- }
- }
- times[version] = newest
- }
- return times, nil
-}
-
-var (
- // simpleLinkRe matches a whole anchor tag: filename.
- simpleLinkRe = regexp.MustCompile(`]+href="[^"]*"[^>]*>[^<]+`)
- // simpleLinkTextRe captures an anchor's text.
- simpleLinkTextRe = regexp.MustCompile(`>([^<]+)`)
-)
-
// rewriteSimpleHTML rewrites package URLs in simple API HTML to point at this proxy.
-// If filteredVersions is non-nil, links for those versions are removed entirely.
-func (h *PyPIHandler) rewriteSimpleHTML(body []byte, filteredVersions map[string]bool) []byte {
- // If cooldown filtering is active, remove entire tags for filtered versions
- if len(filteredVersions) > 0 {
- body = simpleLinkRe.ReplaceAllFunc(body, func(match []byte) []byte {
- // Extract filename from between tags
- innerMatch := simpleLinkTextRe.FindSubmatch(match)
- if len(innerMatch) < minSubmatchParts {
- return match
- }
- filename := string(innerMatch[1])
- _, version := h.parseFilename(strings.TrimSpace(filename))
- if version != "" && filteredVersions[version] {
- return nil
- }
+func (h *PyPIHandler) rewriteSimpleHTML(body []byte) []byte {
+ // Match href attributes pointing to packages
+ // PyPI URLs look like: https://files.pythonhosted.org/packages/...
+ re := regexp.MustCompile(`href="(https://files\.pythonhosted\.org/packages/[^"]+)"`)
+
+ return re.ReplaceAllFunc(body, func(match []byte) []byte {
+ // Extract the URL
+ submatch := re.FindSubmatch(match)
+ if len(submatch) < 2 {
return match
- })
- }
-
- return h.rewriteDownloadHrefs(body)
-}
-
-// rewriteDownloadHrefs points href attributes for files on the configured
-// download host at the proxy. It makes the same replacements as the regular
-// expression `href="(/packages/[^"]+)"` would: an href
-// whose URL starts with the download host's /packages/ and has at least one
-// more character before the closing quote. Finding the literal prefix is many
-// times faster than running the regular expression over every link.
-func (h *PyPIHandler) rewriteDownloadHrefs(body []byte) []byte {
- from := []byte(`href="` + h.downloadURL + `/packages/`)
- // The download route is /pypi/packages/{path...} and keeps the path
- // from the download host, /packages/ included.
- to := []byte(`href="` + h.proxyURL + `/pypi/packages/packages/`)
-
- var out []byte
- done := 0 // body[:done] is in out already
- for search := 0; ; {
- i := bytes.Index(body[search:], from)
- if i < 0 {
- break
- }
- start := search + i
- rest := start + len(from)
- end := bytes.IndexByte(body[rest:], '"')
- if end <= 0 {
- // No closing quote, or nothing before it: not a match. The
- // regular expression would go on looking from the next byte.
- search = start + 1
- continue
- }
- if out == nil {
- out = make([]byte, 0, len(body)+len(body)/8)
- }
- out = append(out, body[done:start]...)
- out = append(out, to...)
- done = rest
- search = rest + end + 1
- }
- if out == nil {
- return body
- }
- return append(out, body[done:]...)
-}
-
-func (h *PyPIHandler) rewriteSimpleJSON(body []byte, filteredVersions map[string]bool) ([]byte, error) {
- var metadata map[string]any
- if err := json.Unmarshal(body, &metadata); err != nil {
- return nil, err
- }
-
- files, ok := metadata["files"].([]any)
- if !ok {
- return nil, errors.New("pypi simple json response has no files array")
- }
-
- rewrittenFiles := make([]any, 0, len(files))
- for _, file := range files {
- entry, ok := file.(map[string]any)
- if !ok {
- rewrittenFiles = append(rewrittenFiles, file)
- continue
}
- if filename, ok := entry["filename"].(string); ok {
- _, version := h.parseFilename(filename)
- if version != "" && filteredVersions[version] {
- continue
- }
+ origURL := string(submatch[1])
+
+ // Parse the URL to get the path
+ u, err := url.Parse(origURL)
+ if err != nil {
+ return match
}
- h.rewriteURLEntry(entry)
- rewrittenFiles = append(rewrittenFiles, entry)
- }
-
- metadata["files"] = rewrittenFiles
-
- // The versions list (PEP 700) loses the filtered versions too, whose
- // files were all left out above.
- if versions, ok := metadata["versions"].([]any); ok && len(filteredVersions) > 0 {
- keptVersions := make([]any, 0, len(versions))
- for _, v := range versions {
- if version, ok := v.(string); ok && filteredVersions[version] {
- continue
- }
- keptVersions = append(keptVersions, v)
- }
- metadata["versions"] = keptVersions
- }
- return json.Marshal(metadata)
+ // Rewrite to our proxy
+ newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
+ return []byte(fmt.Sprintf(`href="%s"`, newURL))
+ })
}
// handleJSON serves the JSON API package metadata.
@@ -511,10 +137,10 @@ func (h *PyPIHandler) handleJSON(w http.ResponseWriter, r *http.Request) {
return
}
- h.proxy.Logger.Debug("pypi json request", "package", name)
+ h.proxy.Logger.Info("pypi json request", "package", name)
upstreamURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
- h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/json")
+ h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// handleVersionJSON serves the JSON API version metadata.
@@ -526,224 +152,62 @@ func (h *PyPIHandler) handleVersionJSON(w http.ResponseWriter, r *http.Request)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
- if h.proxy.versionDenied("pypi", name, version) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- h.proxy.Logger.Debug("pypi version json request", "package", name, "version", version)
+ h.proxy.Logger.Info("pypi version json request", "package", name, "version", version)
upstreamURL := fmt.Sprintf("%s/pypi/%s/%s/json", h.upstreamURL, name, version)
- h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/"+version)
+ h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// proxyAndRewriteJSON fetches JSON metadata and rewrites download URLs.
-// Rewrites go through the rewrite cache, as for npm.
-func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL, cacheKey string) {
- if stored, ok := h.proxy.storedRewrite("pypi", cacheKey, h.proxyURL, cacheKey); ok {
- serveRewrittenMetadata(w, r, stored)
+func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL string) {
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
+ req.Header.Set("Accept", "application/json")
- upstream := h.proxy.fetchMetadataDocument(r.Context(), "pypi", cacheKey, upstreamURL, contentTypeJSON, nil)
- if err := upstream.err; err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
- rewritten, err := h.proxy.cachedRewrite(r.Context(), "pypi", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteJSONMetadata)
- if err != nil {
- if r.Context().Err() != nil {
- return // the client left while waiting on a shared rewrite
- }
- if h.proxy.mustFilterMetadata("pypi", r.PathValue("name")) {
- h.proxy.Logger.Error("failed to filter pypi metadata", "url", upstreamURL, "error", err)
- http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
- return
- }
- h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
- markStale(w, upstream.stale)
- w.Header().Set(headerContentType, "application/json")
- _, _ = w.Write(upstream.body)
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
return
}
- markStale(w, upstream.stale)
- serveRewrittenMetadata(w, r, rewritten)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
+
+ rewritten, err := h.rewriteJSONMetadata(body)
+ if err != nil {
+ h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(body)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(rewritten)
}
// rewriteJSONMetadata rewrites download URLs in PyPI JSON metadata.
-// If cooldown is enabled, versions published too recently are filtered out.
-// It also returns what it kept: the releases, and the current version's files
-// unless they were dropped.
-func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, []string, error) {
+func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
- return nil, nil, err
- }
-
- packageName, _ := extractPyPIName(metadata)
- packagePURL := ""
- if packageName != "" {
- packagePURL = canonicalPackagePURL("pypi", packageName)
- }
-
- h.filterAndRewriteReleases(metadata, packageName, packagePURL)
- h.filterAndRewriteURLs(metadata, packagePURL)
-
- var kept []string
- if releases, ok := metadata["releases"].(map[string]any); ok {
- kept = slices.Collect(maps.Keys(releases))
- }
- if urls, ok := metadata["urls"].([]any); ok && len(urls) > 0 {
- // Not a valid release key, so it can't be confused with one.
- kept = append(kept, "\x00urls")
- }
-
- out, err := json.Marshal(metadata)
- return out, kept, err
-}
-
-// filterAndRewriteReleases applies cooldown filtering and URL rewriting to the
-// releases map in PyPI metadata.
-func (h *PyPIHandler) filterAndRewriteReleases(metadata map[string]any, packageName, packagePURL string) {
- releases, ok := metadata["releases"].(map[string]any)
- if !ok {
- return
- }
-
- for version, files := range releases {
- if h.proxy.versionDenied("pypi", packageName, version) || h.shouldFilterRelease(packagePURL, files) {
- h.proxy.Logger.Info("policy: filtering pypi version",
- "package", packageName, "version", version)
- delete(releases, version)
- continue
- }
-
- h.rewriteFileEntries(files)
- }
-}
-
-// shouldFilterRelease returns true if a release should be excluded due to cooldown.
-func (h *PyPIHandler) shouldFilterRelease(packagePURL string, files any) bool {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() || packagePURL == "" {
- return false
- }
-
- filesArr, ok := files.([]any)
- if !ok {
- return false
- }
-
- publishedAt := h.newestUploadTime(filesArr)
- return !publishedAt.IsZero() && !h.proxy.cooldown().IsAllowed("pypi", packagePURL, publishedAt)
-}
-
-// versionInCooldown reports whether a version is still inside the cooldown
-// window. Filtering the simple index is not enough on its own: file URLs are
-// recorded in lockfiles and requirements pins, so pip can reach the download
-// path without ever reading the index.
-//
-// A release whose upload time cannot be determined is allowed through, matching
-// how fetchFilteredVersions treats it. Failing to read the times is an error.
-// The time read is stored for storedCooldown.
-func (h *PyPIHandler) versionInCooldown(r *http.Request, name, version string) (bool, error) {
- if !h.proxy.cooldownApplies("pypi", name) {
- return false, nil
- }
-
- times, err := h.fetchReleaseTimes(r, name)
- if err != nil {
- return false, err
- }
- publishedAt := times[version]
- if publishedAt.IsZero() {
- return false, nil
- }
- h.storeUploadTime(name, version, publishedAt)
- return !h.proxy.cooldown().IsAllowed("pypi", canonicalPackagePURL("pypi", name), publishedAt), nil
-}
-
-// storeUploadTime records a release's newest upload time under the version
-// PURL the file is cached under, so storedCooldown finds it.
-func (h *PyPIHandler) storeUploadTime(name, version string, publishedAt time.Time) {
- if h.proxy.DB == nil {
- return
- }
- packagePURL, versionPURL, err := packagePURLStrings("pypi", name, version)
- if err != nil {
- return
- }
- if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, publishedAt); err != nil {
- h.proxy.Logger.Warn("cooldown: could not store pypi upload time",
- "package", name, "version", version, "error", err)
- }
-}
-
-// storedCooldown answers the cooldown check for a download from the upload
-// time stored for its release, without reading the JSON API, and reports
-// whether it could. A release's time is its newest file's, and files can be
-// added to a release later, so the stored time only ever moves forward:
-// - a stored time inside the window withholds every file of the release;
-// - a stored time outside it only lets through a file that is already
-// cached. A file that is not cached may be one uploaded since, so it goes
-// to the JSON API, which also stores the release's current time.
-func (h *PyPIHandler) storedCooldown(r *http.Request, name, version, filename string) (inCooldown, answered bool) {
- if h.proxy.DB == nil || !h.proxy.cooldownApplies("pypi", name) {
- return false, false
- }
- packagePURL, versionPURL, err := packagePURLStrings("pypi", name, version)
- if err != nil {
- return false, false
- }
- ver, err := h.proxy.DB.GetVersionByPURL(versionPURL)
- if err != nil || ver == nil || !ver.PublishedAt.Valid {
- return false, false
- }
- if !h.proxy.cooldown().IsAllowed("pypi", packagePURL, ver.PublishedAt.Time) {
- return true, true
- }
- cached, err := h.proxy.HasCachedArtifact(r.Context(), "pypi", name, version, filename)
- return false, err == nil && cached
-}
-
-// rewriteFileEntries rewrites URLs in a list of file entries.
-func (h *PyPIHandler) rewriteFileEntries(files any) {
- filesArr, ok := files.([]any)
- if !ok {
- return
- }
-
- for _, f := range filesArr {
- if fmap, ok := f.(map[string]any); ok {
- h.rewriteURLEntry(fmap)
- }
- }
-}
-
-// filterAndRewriteURLs applies cooldown filtering and URL rewriting to the
-// urls array (current version files) in PyPI metadata.
-func (h *PyPIHandler) filterAndRewriteURLs(metadata map[string]any, packagePURL string) {
- urls, ok := metadata["urls"].([]any)
- if !ok {
- return
- }
-
- info, _ := metadata["info"].(map[string]any)
- name, _ := info["name"].(string)
- version, _ := info["version"].(string)
- if h.proxy.versionDenied("pypi", name, version) || h.shouldFilterRelease(packagePURL, urls) {
- metadata["urls"] = []any{}
+ return nil, err
}
+ // Rewrite URLs in urls array
if urls, ok := metadata["urls"].([]any); ok {
for _, u := range urls {
if umap, ok := u.(map[string]any); ok {
@@ -751,39 +215,21 @@ func (h *PyPIHandler) filterAndRewriteURLs(metadata map[string]any, packagePURL
}
}
}
-}
-// extractPyPIName extracts the package name from PyPI JSON metadata.
-func extractPyPIName(metadata map[string]any) (string, bool) {
- info, ok := metadata["info"].(map[string]any)
- if !ok {
- return "", false
- }
- name, ok := info["name"].(string)
- return name, ok
-}
-
-// newestUploadTime returns the most recent upload_time_iso_8601 from a list of file entries.
-func (h *PyPIHandler) newestUploadTime(files []any) time.Time {
- var newest time.Time
- for _, f := range files {
- fmap, ok := f.(map[string]any)
- if !ok {
- continue
- }
- ts, ok := fmap["upload_time_iso_8601"].(string)
- if !ok {
- continue
- }
- t, err := time.Parse(time.RFC3339, ts)
- if err != nil {
- continue
- }
- if t.After(newest) {
- newest = t
+ // Rewrite URLs in releases map
+ if releases, ok := metadata["releases"].(map[string]any); ok {
+ for _, files := range releases {
+ if filesArr, ok := files.([]any); ok {
+ for _, f := range filesArr {
+ if fmap, ok := f.(map[string]any); ok {
+ h.rewriteURLEntry(fmap)
+ }
+ }
+ }
}
}
- return newest
+
+ return json.Marshal(metadata)
}
// rewriteURLEntry rewrites a single URL entry in PyPI metadata.
@@ -793,8 +239,15 @@ func (h *PyPIHandler) rewriteURLEntry(entry map[string]any) {
return
}
- if strings.HasPrefix(urlStr, h.downloadURL+"/packages/") {
- entry["url"] = h.proxyURL + "/pypi/packages" + strings.TrimPrefix(urlStr, h.downloadURL)
+ u, err := url.Parse(urlStr)
+ if err != nil {
+ return
+ }
+
+ // Only rewrite pythonhosted.org URLs
+ if u.Host == "files.pythonhosted.org" {
+ newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
+ entry["url"] = newURL
}
}
@@ -809,7 +262,7 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
// Path format: /packages/{hash_prefix}/{hash}/{filename}
// e.g., /packages/ab/cd/abc123.../requests-2.31.0.tar.gz
parts := strings.Split(path, "/")
- if len(parts) < minPyPIPathParts {
+ if len(parts) < 3 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
@@ -817,211 +270,78 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
filename := parts[len(parts)-1]
name, version := h.parseFilename(filename)
- if name != "" {
- inCooldown, answered := h.storedCooldown(r, name, version, filename)
- var err error
- if !answered {
- inCooldown, err = h.versionInCooldown(r, name, version)
- }
- if err != nil {
- if !h.proxy.serveCachedDespiteCooldownError(w, r, "pypi", name, version, filename, err) {
- h.proxy.writeCooldownLookupError(w, "pypi", name, err)
- }
- return
- }
- if inCooldown {
- h.proxy.Logger.Info("cooldown: withholding pypi file",
- "name", name, "version", version, "filename", filename)
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- }
-
if name == "" {
// Can't determine name/version, use hash as identifier
name = fmt.Sprintf("_hash_%s", hashPath(path))
version = "0"
}
- h.proxy.Logger.Debug("pypi download request",
+ h.proxy.Logger.Info("pypi download request",
"name", name, "version", version, "filename", filename)
- // The path value starts with 'packages/' (no leading slash), so add
- // the separator here.
- upstreamURL := fmt.Sprintf("%s/%s", h.downloadURL, path)
+ // Construct upstream URL
+ upstreamURL := fmt.Sprintf("https://files.pythonhosted.org/packages/%s", path)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch package")
+ h.proxy.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, "failed to fetch package", http.StatusBadGateway)
return
}
- ServeArtifactRequest(w, r, result)
+ ServeArtifact(w, result)
}
-// archiveExtensions are sdist formats of the form {name}-{version}{ext}. They
-// carry no trailing tags, but legacy sdist names may contain hyphens.
-var archiveExtensions = []string{".tar.gz", ".tar.bz2", ".tar.xz", ".tar.Z", ".tgz", ".tar", ".zip"}
-
-// windowsInstallerExtensions are the legacy distutils bdist_wininst and
-// bdist_msi formats, which share a filename layout.
-var windowsInstallerExtensions = []string{".exe", ".msi"}
-
-func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
- return PyPIFilenameIdentity(filename)
-}
-
-// PyPIFilenameIdentity extracts the package name and version a PyPI file is
-// cached under from its filename, so proxy mirror stores files where the
-// download handler looks for them. Handles wheels, sdists and legacy bdist
-// formats:
+// parseFilename extracts package name and version from a PyPI filename.
+// Handles both wheels and sdists:
// - requests-2.31.0-py3-none-any.whl
// - requests-2.31.0.tar.gz
-// - numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg
-// - numpy-1.8.0.win32-py2.7.exe
-func PyPIFilenameIdentity(filename string) (name, version string) {
- // PEP 658/714 core-metadata sidecars are the distribution filename plus
- // ".metadata"; they describe the same name and version. Without this, pip's
- // metadata-only fetches fall back to a hash-derived package identifier.
- filename = strings.TrimSuffix(filename, PyPIMetadataSuffix)
-
- switch {
- case strings.HasSuffix(filename, ".whl"):
- return parseWheelFilename(strings.TrimSuffix(filename, ".whl"))
- case strings.HasSuffix(filename, ".egg"):
- return parseEggFilename(strings.TrimSuffix(filename, ".egg"))
- }
-
- for _, ext := range windowsInstallerExtensions {
- if strings.HasSuffix(filename, ext) {
- return parseWindowsInstallerFilename(strings.TrimSuffix(filename, ext))
+func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
+ // Try wheel format first: {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl
+ if strings.HasSuffix(filename, ".whl") {
+ base := strings.TrimSuffix(filename, ".whl")
+ parts := strings.Split(base, "-")
+ if len(parts) >= 5 {
+ // Find where version ends (version followed by python tag)
+ for i := 1; i < len(parts)-2; i++ {
+ // Check if this looks like a python tag (py2, py3, cp39, etc)
+ if isPythonTag(parts[i]) {
+ name = strings.Join(parts[:i-1], "-")
+ version = parts[i-1]
+ return
+ }
+ }
}
}
- for _, ext := range archiveExtensions {
+ // Try sdist formats: {name}-{version}.tar.gz, {name}-{version}.zip
+ for _, ext := range []string{".tar.gz", ".tar.bz2", ".zip", ".tar"} {
if strings.HasSuffix(filename, ext) {
- return splitNameVersion(strings.TrimSuffix(filename, ext))
+ base := strings.TrimSuffix(filename, ext)
+ // Find last hyphen followed by version
+ for i := len(base) - 1; i >= 0; i-- {
+ if base[i] == '-' && i+1 < len(base) && isVersionStart(base[i+1]) {
+ return base[:i], base[i+1:]
+ }
+ }
}
}
return "", ""
}
-// parseWheelFilename parses the PEP 427 layout
-// {name}-{version}(-{build})?-{python}-{abi}-{platform}, base being the
-// filename without its ".whl" suffix. The spec escapes every hyphen in the name
-// and version to '_', so the first two fields are authoritative even when the
-// optional build tag is present.
-func parseWheelFilename(base string) (name, version string) {
- parts := strings.Split(base, "-")
- if len(parts) < minWheelParts {
- return "", ""
- }
-
- return parts[0], parts[1]
-}
-
-// parseEggFilename parses the setuptools bdist_egg layout
-// {name}-{version}-py{X.Y}(-{platform})?, base being the filename without its
-// ".egg" suffix. setuptools escapes hyphens in the name and version to '_', but
-// eggs built by other tooling do not always, so the version is located relative
-// to the interpreter field rather than assumed to be the second field.
-func parseEggFilename(base string) (name, version string) {
- parts := strings.Split(base, "-")
- // Scan from the end: the trailing platform fields never look like an
- // interpreter tag, so the last match is the real one even when the package
- // name itself carries a "py{N}" component. Stop before index 1, since a tag
- // any earlier would leave no room for both a name and a version.
- for i := len(parts) - 1; i >= minEggParts-1; i-- {
- if !isEggPythonTag(parts[i]) || !isVersionField(parts[i-1]) {
- continue
- }
-
- return strings.Join(parts[:i-1], "-"), parts[i-1]
- }
-
- // No interpreter field: {name}-{version}.
- return splitNameVersion(base)
-}
-
-// parseWindowsInstallerFilename parses the distutils bdist_wininst and
-// bdist_msi layout {name}-{version}.{platform}(-py{X.Y})?, base being the
-// filename without its ".exe" or ".msi" suffix. The platform is joined to the
-// version with a '.' rather than a '-' and may itself contain a hyphen
-// ("win-amd64"), so both trailing fields are stripped before the name and
-// version are split apart.
-func parseWindowsInstallerFilename(base string) (name, version string) {
- if i := strings.LastIndex(base, "-py"); i >= 0 && isDottedNumber(base[i+len("-py"):]) {
- base = base[:i]
- }
-
- // The platform is the final '.'-separated field. Requiring it to start with
- // a non-digit keeps a dotted version from being truncated when a filename
- // carries no platform tag.
- i := strings.LastIndex(base, ".")
- if i < 0 || i+1 >= len(base) || isVersionStart(base[i+1]) {
- return "", ""
- }
-
- return splitFullname(base[:i])
-}
-
-// splitFullname splits the distutils fullname {name}-{version} that precedes a
-// Windows installer's platform field. Unlike an sdist, a wininst fullname may
-// carry a trailing build variant ("cx_Oracle-5.1.2-11g"), which belongs to
-// neither the name nor the version, so the first purely numeric field wins and
-// anything after it is discarded.
-func splitFullname(fullname string) (name, version string) {
- parts := strings.Split(fullname, "-")
- for i := 1; i < len(parts); i++ {
- if isDottedNumber(parts[i]) {
- return strings.Join(parts[:i], "-"), parts[i]
- }
- }
-
- // No purely numeric field, e.g. a prerelease version like "1.0b1".
- return splitNameVersion(fullname)
-}
-
-// splitNameVersion splits a {name}-{version} pair at the last hyphen that
-// starts a version, leaving hyphens inside the name intact.
-func splitNameVersion(base string) (name, version string) {
- for i := len(base) - 1; i >= 0; i-- {
- if base[i] == '-' && i+1 < len(base) && isVersionStart(base[i+1]) {
- return base[:i], base[i+1:]
- }
- }
-
- return "", ""
-}
-
-// isEggPythonTag reports whether field is the py{X.Y} interpreter field that
-// setuptools places directly after the version in an egg filename.
-func isEggPythonTag(field string) bool {
- const prefix = "py"
-
- return len(field) > len(prefix) && strings.HasPrefix(field, prefix) && isVersionStart(field[len(prefix)])
-}
-
-// isVersionField reports whether field can be a version, i.e. it is non-empty
-// and starts with a digit as every PEP 440 release segment does.
-func isVersionField(field string) bool {
- return field != "" && isVersionStart(field[0])
-}
-
-// isDottedNumber reports whether s is a dotted numeric version such as "2.7".
-func isDottedNumber(s string) bool {
- if s == "" || !isVersionStart(s[0]) {
+func isPythonTag(s string) bool {
+ if len(s) < 2 {
return false
}
-
- for i := range len(s) {
- if !isVersionStart(s[i]) && s[i] != '.' {
- return false
+ // Python tags start with py, cp, pp, ip, jy
+ prefixes := []string{"py", "cp", "pp", "ip", "jy"}
+ for _, p := range prefixes {
+ if strings.HasPrefix(s, p) {
+ return true
}
}
-
- return true
+ return false
}
func isVersionStart(c byte) bool {
@@ -1042,132 +362,22 @@ func (h *PyPIHandler) proxySimple(w http.ResponseWriter, r *http.Request, path s
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
- req.Header.Set("Accept", selectPyPISimpleRepresentation(r.Header.Get("Accept")))
+ req.Header.Set("Accept", "text/html")
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if writeUpstreamHeld(w, err) {
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
- // The HTML root index links projects by absolute path, such as
- // /simple//, which must point at the proxy's /pypi mount instead.
- // It is large, so it is rewritten as it streams.
- if resp.StatusCode == http.StatusOK && resp.Header.Get("Content-Encoding") == "" &&
- !strings.Contains(resp.Header.Get(headerContentType), "json") {
- prefix := "/simple/"
- if u, err := url.Parse(h.upstreamURL); err == nil {
- prefix = strings.TrimSuffix(u.Path, "/") + prefix
- }
- to := h.proxyURL + "/pypi/simple/"
- resp.Body = &replacingReader{
- src: resp.Body,
- replacements: []replacement{
- {from: []byte(`href="` + prefix), to: []byte(`href="` + to)},
- {from: []byte(`href='` + prefix), to: []byte(`href='` + to)},
- },
- }
- resp.Header.Del(headerContentLength)
- resp.Header.Del("ETag") // it names the upstream body
- resp.ContentLength = -1
- }
-
- h.proxy.relayResponse(w, r, resp, func(dst, src http.Header) {
- copyRelayHeaders(dst, src)
- ensureVaryAccept(dst)
- })
-}
-
-const replacingReaderBufferSize = 32 << 10
-
-// replacingReader makes each replacement in src as it is read.
-type replacingReader struct {
- src io.ReadCloser
- replacements []replacement
- in []byte // read from src, not yet scanned
- out []byte // scanned, not yet returned
- buf []byte
- err error
-}
-
-type replacement struct{ from, to []byte }
-
-func (r *replacingReader) Read(p []byte) (int, error) {
- for len(r.out) == 0 {
- if r.err != nil {
- return 0, r.err
- }
- if r.buf == nil {
- r.buf = make([]byte, replacingReaderBufferSize)
- }
- n, err := r.src.Read(r.buf)
- r.in = append(r.in, r.buf[:n]...)
- r.err = err
- // Keep a tail that could be the start of a match split across reads.
- keep := 0
- if err == nil {
- for _, rep := range r.replacements {
- keep = max(keep, len(rep.from)-1)
- }
- }
- scan := max(len(r.in)-keep, 0)
- out, done := r.replace(scan)
- if done < scan {
- out = append(out, r.in[done:scan]...)
- done = scan
- }
- r.out = out
- r.in = append([]byte(nil), r.in[done:]...)
- }
- n := copy(p, r.out)
- r.out = r.out[n:]
- return n, nil
-}
-
-// replace makes the replacements that start before scan in r.in, returning
-// the output and how much of r.in it covers.
-func (r *replacingReader) replace(scan int) (out []byte, done int) {
- // at holds each replacement's next match at or after done, or -1.
- // Only a match the last replacement consumed is searched for again,
- // so each pass over r.in stays linear.
- at := make([]int, len(r.replacements))
- for i := range at {
- at[i] = indexFrom(r.in, r.replacements[i].from, 0)
- }
- for {
- k := -1
- for i, j := range at {
- if j >= 0 && (k < 0 || j < at[k]) {
- k = i
- }
- }
- if k < 0 || at[k] >= scan {
- break
- }
- rep := &r.replacements[k]
- out = append(out, r.in[done:at[k]]...)
- out = append(out, rep.to...)
- done = at[k] + len(rep.from)
- for i, j := range at {
- if j >= 0 && j < done {
- at[i] = indexFrom(r.in, r.replacements[i].from, done)
- }
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
}
}
- return out, done
-}
-func (r *replacingReader) Close() error { return r.src.Close() }
-
-// indexFrom returns the index of the first sep in b at or after from, or -1.
-func indexFrom(b, sep []byte, from int) int {
- if i := bytes.Index(b[from:], sep); i >= 0 {
- return from + i
- }
- return -1
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
diff --git a/internal/handler/pypi_denylist.go b/internal/handler/pypi_denylist.go
deleted file mode 100644
index f39c0d8..0000000
--- a/internal/handler/pypi_denylist.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package handler
-
-import (
- "bytes"
- "net/url"
- "path"
-
- "golang.org/x/net/html"
-)
-
-// filterSimpleHTMLLinks reads hrefs rather than display text. Tokenization
-// handles single quotes, entity escaping, and nested link text without
-// reserializing the rest of the index.
-func (h *PyPIHandler) filterSimpleHTMLLinks(body []byte, versions map[string]bool) []byte {
- if len(versions) == 0 {
- return body
- }
- var result bytes.Buffer
- z := html.NewTokenizer(bytes.NewReader(body))
- skip := false
- for {
- tokenType := z.Next()
- if tokenType == html.ErrorToken {
- return result.Bytes()
- }
- raw := append([]byte(nil), z.Raw()...)
- if tokenType == html.StartTagToken || tokenType == html.SelfClosingTagToken {
- token := z.Token()
- if token.Data == "a" {
- skip = h.deniedSimpleLink(token, versions)
- }
- } else if tokenType == html.EndTagToken && z.Token().Data == "a" {
- if skip {
- skip = false
- continue
- }
- }
- if !skip {
- result.Write(raw)
- }
- }
-}
-
-func (h *PyPIHandler) deniedSimpleLink(token html.Token, versions map[string]bool) bool {
- for _, attr := range token.Attr {
- if attr.Key != "href" {
- continue
- }
- u, err := url.Parse(attr.Val)
- if err != nil {
- continue
- }
- _, version := h.parseFilename(path.Base(u.Path))
- if versions[version] {
- return true
- }
- }
- return false
-}
diff --git a/internal/handler/pypi_release_times_test.go b/internal/handler/pypi_release_times_test.go
deleted file mode 100644
index 37e8447..0000000
--- a/internal/handler/pypi_release_times_test.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package handler
-
-import (
- "fmt"
- "runtime"
- "strings"
- "testing"
- "time"
-)
-
-func TestPyPIParseReleaseTimes(t *testing.T) {
- h := &PyPIHandler{}
- body := `{
- "info": {"name": "pkg", "version": "3.0.0"},
- "releases": {
- "1.0.0": [
- {"filename": "pkg-1.0.0.tar.gz", "upload_time_iso_8601": "2024-01-01T10:00:00Z"},
- {"filename": "pkg-1.0.0-py3-none-any.whl", "upload_time_iso_8601": "2024-01-02T10:00:00.123456Z"}
- ],
- "2.0.0": [],
- "3.0.0": [
- 42,
- {"upload_time_iso_8601": 5},
- {"upload_time_iso_8601": null},
- {"upload_time_iso_8601": "not a time"},
- {"upload_time": "2024-06-01T00:00:00"},
- {"upload_time_iso_8601": "2024-03-01T00:00:00Z"}
- ],
- "4.0.0": "not a list"
- },
- "urls": [{"upload_time_iso_8601": "2030-01-01T00:00:00Z"}]
- }`
-
- times, err := h.parseReleaseTimes([]byte(body))
- if err != nil {
- t.Fatalf("parseReleaseTimes failed: %v", err)
- }
- want := map[string]time.Time{
- "1.0.0": time.Date(2024, 1, 2, 10, 0, 0, 123456000, time.UTC),
- "2.0.0": {},
- "3.0.0": time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC),
- "4.0.0": {},
- }
- for version, wantTime := range want {
- if got := times[version]; !got.Equal(wantTime) {
- t.Errorf("times[%q] = %v, want %v", version, got, wantTime)
- }
- }
- for version := range times {
- if _, ok := want[version]; !ok {
- t.Errorf("unexpected release %q", version)
- }
- }
-}
-
-func TestPyPIParseReleaseTimesRejectsDocumentWithoutReleases(t *testing.T) {
- h := &PyPIHandler{}
- for _, body := range []string{
- `{"info": {"name": "pkg"}}`,
- `{"releases": null}`,
- `{"releases": []}`,
- `[]`,
- `{"releases": {"1.0.0": [`,
- `not json`,
- } {
- if times, err := h.parseReleaseTimes([]byte(body)); err == nil {
- t.Errorf("parseReleaseTimes(%s) = %v, want an error", body, times)
- }
- }
-
- times, err := h.parseReleaseTimes([]byte(`{"releases": {}}`))
- if err != nil || len(times) != 0 {
- t.Errorf("parseReleaseTimes with no releases = %v, %v; want an empty result", times, err)
- }
-}
-
-// pypiJSONDocument builds a JSON API document shaped like PyPI's, with the
-// file fields that make up most of a real one.
-func pypiJSONDocument(releases, filesPerRelease int) []byte {
- var b strings.Builder
- b.WriteString(`{"info":{"name":"pkg","summary":"A package"},"releases":{`)
- for r := range releases {
- if r > 0 {
- b.WriteByte(',')
- }
- fmt.Fprintf(&b, `"1.%d.0":[`, r)
- for f := range filesPerRelease {
- if f > 0 {
- b.WriteByte(',')
- }
- fmt.Fprintf(&b, `{"comment_text":"","digests":{"blake2b_256":"%064x","md5":"%032x","sha256":"%064x"},`+
- `"downloads":-1,"filename":"pkg-1.%d.0-cp3%d-cp3%d-manylinux_2_17_x86_64.whl","has_sig":false,`+
- `"md5_digest":"%032x","packagetype":"bdist_wheel","python_version":"cp3%d","requires_python":">=3.8",`+
- `"size":123456,"upload_time":"2024-01-02T03:04:05","upload_time_iso_8601":"2024-01-02T03:04:05.123456Z",`+
- `"url":"https://files.pythonhosted.org/packages/ab/cd/%060x/pkg-1.%d.0-cp3%d-cp3%d-manylinux_2_17_x86_64.whl",`+
- `"yanked":false,"yanked_reason":null}`, r, f, r, r, f, f, f, f, r, r, f, f)
- }
- b.WriteByte(']')
- }
- b.WriteString(`},"urls":[],"vulnerabilities":[]}`)
- return []byte(b.String())
-}
-
-// Reading the release times must not build the whole document in memory: it
-// runs on every simple index request for a package under cooldown.
-func TestPyPIParseReleaseTimesAllocatesLessThanDocument(t *testing.T) {
- h := &PyPIHandler{}
- body := pypiJSONDocument(300, 12)
-
- const runs = 5
- var before, after runtime.MemStats
- runtime.ReadMemStats(&before)
- for range runs {
- times, err := h.parseReleaseTimes(body)
- if err != nil || len(times) != 300 {
- t.Fatalf("parseReleaseTimes = %d releases, %v", len(times), err)
- }
- }
- runtime.ReadMemStats(&after)
-
- perRun := (after.TotalAlloc - before.TotalAlloc) / runs
- if perRun > uint64(len(body)) {
- t.Errorf("parseReleaseTimes allocated %d bytes for a %d byte document", perRun, len(body))
- }
-}
diff --git a/internal/handler/pypi_test.go b/internal/handler/pypi_test.go
index a80e95b..70a8266 100644
--- a/internal/handler/pypi_test.go
+++ b/internal/handler/pypi_test.go
@@ -1,311 +1,10 @@
package handler
import (
- "encoding/json"
- "fmt"
- "io"
"log/slog"
- "math/rand/v2"
- "net/http"
- "net/http/httptest"
- "strconv"
- "strings"
- "sync/atomic"
"testing"
- "testing/iotest"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
)
-const uvPyPIAccept = "application/vnd.pypi.simple.v1+json, application/vnd.pypi.simple.v1+html;q=0.2, text/html;q=0.01"
-
-type pypiRoundTripFunc func(*http.Request) (*http.Response, error)
-
-func (f pypiRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
- return f(r)
-}
-
-func pypiHTTPResponse(r *http.Request, contentType, body string) *http.Response {
- return &http.Response{
- StatusCode: http.StatusOK,
- Status: "200 OK",
- Header: http.Header{"Content-Type": []string{contentType}},
- Body: io.NopCloser(strings.NewReader(body)),
- ContentLength: int64(len(body)),
- Request: r,
- }
-}
-
-func setupPyPIHandler(t testing.TB, transport pypiRoundTripFunc) (*PyPIHandler, *Proxy) {
- t.Helper()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = &http.Client{Transport: transport}
- h := NewPyPIHandlerWithUpstreams(proxy, "http://proxy.test", "", "")
- h.upstreamURL = "https://pypi.test"
- return h, proxy
-}
-
-func TestSelectPyPISimpleRepresentation(t *testing.T) {
- tests := []struct {
- name string
- accept string
- want string
- }{
- {"missing header uses legacy html", "", "text/html"},
- {"wildcard uses legacy html", "*/*", "text/html"},
- {"uv prefers json", uvPyPIAccept, pypiSimpleJSON},
- {"json only", pypiSimpleJSON, pypiSimpleJSON},
- {"latest json", pypiSimpleLatestJSON, pypiSimpleJSON},
- {"vendor html", pypiSimpleHTML, pypiSimpleHTML},
- {"higher html quality", pypiSimpleJSON + ";q=0.2, text/html;q=0.8", "text/html"},
- {"json excluded", pypiSimpleJSON + ";q=0, text/html", "text/html"},
- {"application wildcard", "application/*", pypiSimpleJSON},
- {"unsupported type uses legacy html", "application/xml", "text/html"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := selectPyPISimpleRepresentation(tt.accept); got != tt.want {
- t.Errorf("selectPyPISimpleRepresentation(%q) = %q, want %q", tt.accept, got, tt.want)
- }
- })
- }
-}
-
-func TestPyPISimplePackageNegotiatesJSON(t *testing.T) {
- const upstreamBody = `{
- "meta":{"api-version":"1.4"},
- "name":"ruff",
- "files":[{
- "filename":"ruff-0.16.0-py3-none-any.whl",
- "url":"https://files.pythonhosted.org/packages/ab/cd/ruff-0.16.0-py3-none-any.whl",
- "hashes":{"sha256":"abc123"},
- "upload-time":"2026-08-01T12:00:00Z"
- }]
- }`
-
- var upstreamAccept string
- h, _ := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- upstreamAccept = r.Header.Get("Accept")
- if r.URL.Path != "/simple/ruff/" {
- t.Fatalf("upstream path = %q, want %q", r.URL.Path, "/simple/ruff/")
- }
- return pypiHTTPResponse(r, pypiSimpleJSON, upstreamBody), nil
- })
-
- req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
- req.Header.Set("Accept", uvPyPIAccept)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- if upstreamAccept != pypiSimpleJSON {
- t.Errorf("upstream Accept = %q, want %q", upstreamAccept, pypiSimpleJSON)
- }
- if got := w.Header().Get("Content-Type"); got != pypiSimpleJSON {
- t.Errorf("Content-Type = %q, want %q", got, pypiSimpleJSON)
- }
- if got := w.Header().Get("Vary"); !strings.Contains(got, "Accept") {
- t.Errorf("Vary = %q, want Accept", got)
- }
-
- var result struct {
- Meta map[string]string `json:"meta"`
- Files []struct {
- URL string `json:"url"`
- UploadTime string `json:"upload-time"`
- } `json:"files"`
- }
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if result.Meta["api-version"] != "1.4" {
- t.Errorf("api-version = %q, want 1.4", result.Meta["api-version"])
- }
- if len(result.Files) != 1 {
- t.Fatalf("files = %d, want 1", len(result.Files))
- }
- if got, want := result.Files[0].URL, "http://proxy.test/pypi/packages/packages/ab/cd/ruff-0.16.0-py3-none-any.whl"; got != want {
- t.Errorf("file URL = %q, want %q", got, want)
- }
- if got := result.Files[0].UploadTime; got != "2026-08-01T12:00:00Z" {
- t.Errorf("upload-time = %q, want %q", got, "2026-08-01T12:00:00Z")
- }
-}
-
-func TestPyPISimpleIndexNegotiatesJSON(t *testing.T) {
- const upstreamBody = `{"meta":{"api-version":"1.4"},"projects":[{"name":"ruff"}]}`
-
- var upstreamAccept string
- h, _ := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- upstreamAccept = r.Header.Get("Accept")
- return pypiHTTPResponse(r, pypiSimpleJSON, upstreamBody), nil
- })
-
- req := httptest.NewRequest(http.MethodGet, "/simple/", nil)
- req.Header.Set("Accept", uvPyPIAccept)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- if upstreamAccept != pypiSimpleJSON {
- t.Errorf("upstream Accept = %q, want %q", upstreamAccept, pypiSimpleJSON)
- }
- if got := w.Header().Get("Content-Type"); got != pypiSimpleJSON {
- t.Errorf("Content-Type = %q, want %q", got, pypiSimpleJSON)
- }
- if got := w.Header().Get("Vary"); !strings.Contains(got, "Accept") {
- t.Errorf("Vary = %q, want Accept", got)
- }
- if got := w.Body.String(); got != upstreamBody {
- t.Errorf("body = %q, want %q", got, upstreamBody)
- }
-}
-
-func TestPyPISimplePackageKeepsHTMLDefault(t *testing.T) {
- const upstreamBody = `ruff-0.16.0.tar.gz`
-
- var upstreamAccept string
- h, _ := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- upstreamAccept = r.Header.Get("Accept")
- return pypiHTTPResponse(r, "text/html", upstreamBody), nil
- })
-
- req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- if upstreamAccept != "text/html" {
- t.Errorf("upstream Accept = %q, want text/html", upstreamAccept)
- }
- if got := w.Header().Get("Content-Type"); got != "text/html" {
- t.Errorf("Content-Type = %q, want text/html", got)
- }
- if !strings.Contains(w.Body.String(), `href="http://proxy.test/pypi/packages/packages/ab/cd/ruff-0.16.0.tar.gz"`) {
- t.Errorf("download URL was not rewritten: %s", w.Body.String())
- }
-}
-
-func TestPyPISimplePackageCachesRepresentationsSeparately(t *testing.T) {
- hits := make(map[string]int)
- h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- accept := r.Header.Get("Accept")
- hits[accept]++
- if accept == pypiSimpleJSON {
- body := `{"meta":{"api-version":"1.4"},"name":"ruff","files":[]}`
- return pypiHTTPResponse(r, pypiSimpleJSON, body), nil
- }
- return pypiHTTPResponse(r, accept, `ruff.tar.gz`), nil
- })
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
-
- for range 2 {
- for _, accept := range []string{pypiLegacyHTML, pypiSimpleHTML, uvPyPIAccept} {
- req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
- req.Header.Set("Accept", accept)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("Accept %q: status = %d, want 200: %s", accept, w.Code, w.Body.String())
- }
- }
- }
-
- if got := hits[pypiLegacyHTML]; got != 1 {
- t.Errorf("HTML upstream requests = %d, want 1", got)
- }
- if got := hits[pypiSimpleHTML]; got != 1 {
- t.Errorf("vendor HTML upstream requests = %d, want 1", got)
- }
- if got := hits[pypiSimpleJSON]; got != 1 {
- t.Errorf("JSON upstream requests = %d, want 1", got)
- }
-}
-
-func TestPyPISimpleJSONCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-time.Hour).Format(time.RFC3339)
-
- h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- switch r.URL.Path {
- case "/simple/ruff/":
- body := `{"meta":{"api-version":"1.4"},"name":"ruff","files":[` +
- `{"filename":"ruff-1.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/ab/ruff-1.0.0.tar.gz","upload-time":"` + old + `"},` +
- `{"filename":"ruff-2.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/cd/ruff-2.0.0.tar.gz","upload-time":"` + recent + `"}` +
- `]}`
- return pypiHTTPResponse(r, pypiSimpleJSON, body), nil
- case "/pypi/ruff/json":
- body := `{"releases":{` +
- `"1.0.0":[{"upload_time_iso_8601":"` + old + `"}],` +
- `"2.0.0":[{"upload_time_iso_8601":"` + recent + `"}]` +
- `}}`
- return pypiHTTPResponse(r, "application/json", body), nil
- default:
- t.Fatalf("unexpected upstream path: %s", r.URL.Path)
- return nil, nil
- }
- })
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
-
- req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
- req.Header.Set("Accept", uvPyPIAccept)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
- }
- var result struct {
- Files []struct {
- Filename string `json:"filename"`
- } `json:"files"`
- }
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if len(result.Files) != 1 || result.Files[0].Filename != "ruff-1.0.0.tar.gz" {
- t.Errorf("files = %#v, want only ruff-1.0.0.tar.gz", result.Files)
- }
-}
-
-// TestPyPISimpleJSONFiltersVersions checks that the PEP 700 versions list
-// loses a version whose files were all filtered out, and keeps one that
-// still has files.
-func TestPyPISimpleJSONFiltersVersions(t *testing.T) {
- h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}, proxyURL: "http://proxy.test"}
- body := `{"meta":{"api-version":"1.1"},"name":"ruff","versions":["1.0.0","2.0.0","3.0.0"],"files":[` +
- `{"filename":"ruff-1.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/ab/ruff-1.0.0.tar.gz"},` +
- `{"filename":"ruff-2.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/cd/ruff-2.0.0.tar.gz"},` +
- `{"filename":"ruff-2.0.0-py3-none-any.whl","url":"https://files.pythonhosted.org/packages/ef/ruff-2.0.0-py3-none-any.whl"}` +
- `]}`
- out, err := h.rewriteSimpleJSON([]byte(body), map[string]bool{"2.0.0": true, "3.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
- var result struct {
- Versions []string `json:"versions"`
- }
- if err := json.Unmarshal(out, &result); err != nil {
- t.Fatal(err)
- }
- // 3.0.0 has no files to filter, but it is still held back.
- if strings.Join(result.Versions, ",") != "1.0.0" {
- t.Errorf("versions = %v, want [1.0.0]", result.Versions)
- }
-}
-
func TestPyPIParseFilename(t *testing.T) {
h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
@@ -320,58 +19,13 @@ func TestPyPIParseFilename(t *testing.T) {
{"aws-sdk-1.0.0.tar.gz", "aws-sdk", "1.0.0"},
{"zipp-3.17.0.zip", "zipp", "3.17.0"},
- // Additional sdist archive formats
- {"lxml-4.9.3.tar.xz", "lxml", "4.9.3"},
- {"docutils-0.20.1.tgz", "docutils", "0.20.1"},
- {"psycopg2-2.9.9.tar.bz2", "psycopg2", "2.9.9"},
-
// Wheel formats
{"requests-2.31.0-py3-none-any.whl", "requests", "2.31.0"},
{"numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.whl", "numpy", "1.26.2"},
{"cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", "cryptography", "41.0.5"},
- // Wheels with a build tag must not fold the tag into the version
- {"foo-1.0-1-py3-none-any.whl", "foo", "1.0"},
- {"tensorflow-2.15.0-2-cp311-cp311-manylinux_2_17_x86_64.whl", "tensorflow", "2.15.0"},
-
- // PEP 658 core-metadata sidecars resolve to the distribution they describe
- {"backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata", "backports_asyncio_runner", "1.2.0"},
- {"requests-2.31.0-py3-none-any.whl.metadata", "requests", "2.31.0"},
- {"requests-2.31.0.tar.gz.metadata", "requests", "2.31.0"},
-
- // Eggs: {name}-{version}-py{X.Y}(-{platform})?.egg. Unescaped hyphens in
- // the name must not be mistaken for the field separator before the version.
- {"numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg", "numpy", "1.8.0"},
- {"aws-sdk-1.0.0-py3.11.egg", "aws-sdk", "1.0.0"},
- {"aws-sdk-1.0.0-py2.7-macosx-10.9-x86_64.egg", "aws-sdk", "1.0.0"},
- {"aws-sdk-1.0.0.egg", "aws-sdk", "1.0.0"},
- // A "py{N}" component inside the name is not the interpreter field, so
- // the interpreter must be located from the end of the filename.
- {"django-rest-py3-1.0-py3.6.egg", "django-rest-py3", "1.0"},
-
- // Windows installers: {name}-{version}.{platform}(-py{X.Y})?.{exe,msi}.
- // The platform is not part of the version, and may contain a hyphen.
- {"foo-1.0.win32-py2.0.exe", "foo", "1.0"},
- {"pywin32-223.win32-py2.7.exe", "pywin32", "223"},
- {"numpy-1.8.0.win-amd64-py2.7.exe", "numpy", "1.8.0"},
- {"aws-sdk-1.0.0.win32-py2.7.exe", "aws-sdk", "1.0.0"},
- {"pywin32-223.win32.exe", "pywin32", "223"},
- {"cx_Oracle-5.1.2.win32-py2.7.msi", "cx_Oracle", "5.1.2"},
- {"numpy-1.8.0.win-amd64.msi", "numpy", "1.8.0"},
- // A trailing build variant belongs to neither the name nor the version.
- {"cx_Oracle-5.1.2-11g.win32-py2.7.exe", "cx_Oracle", "5.1.2"},
- // A prerelease version has no purely numeric field to anchor on.
- {"foo-1.0b1.win32-py2.7.exe", "foo", "1.0b1"},
-
// Invalid
{"invalid", "", ""},
- {"invalid.metadata", "", ""},
- {"backports.ssl_match_hostname-3.4.0.2-py2.7.whl", "", ""},
- {"invalid.exe", "", ""},
- {"foo-1.0.exe", "", ""},
- // An egg with an interpreter field but no version must not promote the
- // trailing component of a hyphenated name to the version.
- {"aws-sdk-py2.7.egg", "", ""},
}
for _, tt := range tests {
@@ -383,387 +37,25 @@ func TestPyPIParseFilename(t *testing.T) {
}
}
-func TestPyPIRewriteJSONMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &PyPIHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "info": {"name": "requests"},
- "releases": {
- "2.30.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.30.0.tar.gz", "upload_time_iso_8601": "` + old + `"}],
- "2.31.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
- },
- "urls": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
- }`
-
- output, _, err := h.rewriteJSONMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteJSONMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- releases := result["releases"].(map[string]any)
-
- if _, ok := releases["2.30.0"]; !ok {
- t.Error("version 2.30.0 should not be filtered")
- }
- if _, ok := releases["2.31.0"]; ok {
- t.Error("version 2.31.0 should be filtered by cooldown")
- }
-
- // urls array should be empty since the current version is filtered
- urls := result["urls"].([]any)
- if len(urls) != 0 {
- t.Errorf("urls should be empty, got %d entries", len(urls))
- }
-}
-
-// TestPyPIParseFilenameNoHashFallback guards the identifier used for caching:
-// a filename that parses to an empty name makes handleDownload fall back to a
-// "_hash_" package name, which surfaces as a bogus PURL in the package
-// overview.
-func TestPyPIParseFilenameNoHashFallback(t *testing.T) {
- h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
-
- filenames := []string{
- "backports_asyncio_runner-1.2.0-py3-none-any.whl",
- "backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata",
- "backports_asyncio_runner-1.2.0.tar.gz",
- }
-
- for _, filename := range filenames {
- name, version := h.parseFilename(filename)
- if name != "backports_asyncio_runner" || version != "1.2.0" {
- t.Errorf("parseFilename(%q) = (%q, %q), want (%q, %q)",
- filename, name, version, "backports_asyncio_runner", "1.2.0")
- }
- }
-}
-
-func TestPyPIHandler_DownloadUpstreamURL(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("wheel data")),
- ContentType: "application/octet-stream",
- }
-
- h := NewPyPIHandlerWithUpstreams(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- // The path wildcard {path...} captures everything after /packages/,
- // which includes "packages/" from the rewritten URL. The upstream URL
- // must not double the "packages" segment.
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Fatal("expected fetcher to be called on cache miss")
- }
-
- want := "https://files.pythonhosted.org/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestPyPIHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "pypi", "requests", "2.31.0",
- "requests-2.31.0-py3-none-any.whl", "wheel binary data")
-
- h := NewPyPIHandlerWithUpstreams(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "wheel binary data" {
- t.Errorf("body = %q, want %q", body, "wheel binary data")
- }
-}
-
-func TestPyPIHandler_DownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched wheel")),
- ContentType: "application/octet-stream",
- }
-
- h := NewPyPIHandlerWithUpstreams(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-}
-
-func TestPyPIDownloadCooldown(t *testing.T) {
- now := time.Now()
- releases := `{"releases": {
- "1.0.0": [{"upload_time_iso_8601": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `"}],
- "2.0.0": [{"upload_time_iso_8601": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"}]
- }}`
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = io.WriteString(w, releases)
- }))
- defer upstream.Close()
-
+func TestIsPythonTag(t *testing.T) {
tests := []struct {
- name string
- filename string
- wantStatus int
+ tag string
+ want bool
}{
- {"published before the window serves the file", "newpkg-1.0.0.tar.gz", http.StatusOK},
- {"published inside the window is withheld", "newpkg-2.0.0.tar.gz", http.StatusNotFound},
+ {"py3", true},
+ {"py2", true},
+ {"cp311", true},
+ {"cp37", true},
+ {"pp39", true},
+ {"none", false},
+ {"any", false},
+ {"manylinux", false},
}
for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("sdist data")),
- ContentType: "application/octet-stream",
- }
-
- h := &PyPIHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/" + tt.filename)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != tt.wantStatus {
- t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
- }
- if tt.wantStatus == http.StatusNotFound && fetcher.fetchCalled {
- t.Error("fetched a version that is still inside the cooldown window")
- }
- })
- }
-}
-
-// TestPyPIDownloadCooldownMetadataCache ensures that repeated downloads that
-// trigger cooldown filtering reuse the cached PyPI JSON metadata instead of
-// fetching it from upstream once per download.
-func TestPyPIDownloadCooldownMetadataCache(t *testing.T) {
- now := time.Now()
- releases := `{"releases": {
- "1.0.0": [{"upload_time_iso_8601": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `"}],
- "2.0.0": [{"upload_time_iso_8601": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"}]
- }}`
-
- var metadataRequests atomic.Int64
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/pypi/newpkg/json" {
- metadataRequests.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, releases)
- return
- }
- w.Header().Set("Content-Type", "application/octet-stream")
- _, _ = io.WriteString(w, "package data")
- }))
- defer upstream.Close()
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("package data")),
- ContentType: "application/octet-stream",
- }
-
- h := &PyPIHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- // Two downloads of the same package: one outside the cooldown window
- // (served) and one inside (withheld). Both go through the download path
- // that resolves filtered versions.
- for _, filename := range []string{"newpkg-1.0.0.tar.gz", "newpkg-2.0.0.tar.gz"} {
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/" + filename)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
- }
-
- if got := metadataRequests.Load(); got != 1 {
- t.Errorf("upstream metadata JSON requests = %d, want 1 (repeated downloads should reuse the cached metadata)", got)
- }
-}
-
-func TestPyPISimpleIndexRewritesProjectLinks(t *testing.T) {
- for _, tc := range []struct{ name, upstream, prefix string }{
- {"root", "https://pypi.test", "/simple/"},
- {"subpath", "https://nexus.test/repository/pypi", "/repository/pypi/simple/"},
- } {
- t.Run(tc.name, func(t *testing.T) {
- // Long enough that links cross the rewriter's read boundaries.
- var upstream, want strings.Builder
- upstream.WriteString(``)
- want.WriteString(``)
- for i := range 3000 {
- fmt.Fprintf(&upstream, `pkg-%d`+"\n", tc.prefix, i, i)
- fmt.Fprintf(&want, `pkg-%d`+"\n", i, i)
- }
- fmt.Fprintf(&upstream, `q`, tc.prefix)
- want.WriteString(`q`)
- upstream.WriteString(``)
- want.WriteString(``)
-
- h, _ := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
- resp := pypiHTTPResponse(r, "text/html", upstream.String())
- resp.Header.Set("Content-Length", strconv.Itoa(upstream.Len()))
- resp.Header.Set("ETag", `"upstream"`)
- return resp, nil
- })
- h.upstreamURL = tc.upstream
-
- req := httptest.NewRequest(http.MethodGet, "/simple/", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- if got := w.Body.String(); got != want.String() {
- t.Errorf("body not rewritten; starts %q", got[:min(len(got), 120)])
- }
- if got := w.Header().Get("Content-Length"); got != "" && got != strconv.Itoa(want.Len()) {
- t.Errorf("Content-Length = %q, want %d or unset", got, want.Len())
- }
- // The upstream's ETag names the body before the rewrite.
- if got := w.Header().Get("ETag"); got != "" {
- t.Errorf("ETag = %q, want none on a rewritten body", got)
- }
- })
- }
-}
-
-func TestReplacingReaderSplitReads(t *testing.T) {
- const in = `abxhref="/simplehref="/simple/href='/simple/y/'`
- const want = `abxhref="/simplehref="P/href='Q/y/'`
- r := &replacingReader{
- src: io.NopCloser(iotest.OneByteReader(strings.NewReader(in))),
- replacements: []replacement{
- {from: []byte(`href="/simple/`), to: []byte(`href="P/`)},
- {from: []byte(`href='/simple/`), to: []byte(`href='Q/`)},
- },
- }
- got, err := io.ReadAll(r)
- if err != nil || string(got) != want {
- t.Errorf("got %q, %v; want %q", got, err, want)
- }
-}
-
-// replaceReference makes the replacements the way replacingReader should:
-// left to right, the earliest match first, and the first listed on a tie.
-func replaceReference(in string, reps []replacement) string {
- var out strings.Builder
- for len(in) > 0 {
- at, found := -1, -1
- for i, rep := range reps {
- if j := strings.Index(in, string(rep.from)); j >= 0 && (at < 0 || j < at) {
- at, found = j, i
- }
- }
- if at < 0 {
- out.WriteString(in)
- break
- }
- out.WriteString(in[:at])
- out.Write(reps[found].to)
- in = in[at+len(reps[found].from):]
- }
- return out.String()
-}
-
-func TestReplacingReaderMatchesReference(t *testing.T) {
- reps := []replacement{
- {from: []byte(`href="/s/`), to: []byte(`href="P/`)},
- {from: []byte(`href='/s/`), to: []byte(`href='Q/`)},
- }
- pieces := []string{`href="/s/`, `href='/s/`, `href="/s`, `href=`, `x`, `/s/`, `'`, `"`}
- rng := rand.New(rand.NewPCG(1, 2))
- for range 500 {
- var b strings.Builder
- for range rng.IntN(40) {
- b.WriteString(pieces[rng.IntN(len(pieces))])
- }
- in := b.String()
- for _, src := range []io.Reader{strings.NewReader(in), iotest.OneByteReader(strings.NewReader(in))} {
- got, err := io.ReadAll(&replacingReader{src: io.NopCloser(src), replacements: reps})
- if want := replaceReference(in, reps); err != nil || string(got) != want {
- t.Fatalf("in %q: got %q, %v; want %q", in, got, err, want)
- }
+ got := isPythonTag(tt.tag)
+ if got != tt.want {
+ t.Errorf("isPythonTag(%q) = %v, want %v", tt.tag, got, tt.want)
}
}
}
-
-// BenchmarkReplacingReaderRootIndex reads a root index of double-quoted
-// links, where the single-quote pattern never matches.
-func BenchmarkReplacingReaderRootIndex(b *testing.B) {
- var page strings.Builder
- for i := range 50000 {
- fmt.Fprintf(&page, "project-%d\n", i, i)
- }
- in := page.String()
- reps := []replacement{
- {from: []byte(`href="/simple/`), to: []byte(`href="http://proxy/pypi/simple/`)},
- {from: []byte(`href='/simple/`), to: []byte(`href='http://proxy/pypi/simple/`)},
- }
- b.SetBytes(int64(len(in)))
- for b.Loop() {
- _, _ = io.Copy(io.Discard, &replacingReader{src: io.NopCloser(strings.NewReader(in)), replacements: reps})
- }
-}
diff --git a/internal/handler/pypi_upload_time_test.go b/internal/handler/pypi_upload_time_test.go
deleted file mode 100644
index f61a5f0..0000000
--- a/internal/handler/pypi_upload_time_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package handler
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// pypiUploadTimeSetup serves the JSON API for newpkg from releases, which the
-// test may change, and counts its requests. cache_metadata is off, so every
-// lookup reaches upstream.
-func pypiUploadTimeSetup(t *testing.T) (h http.Handler, setReleases func(string), jsonRequests *atomic.Int64, fetcher *mockFetcher) {
- t.Helper()
- var mu sync.Mutex
- releases := ""
- jsonRequests = &atomic.Int64{}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/pypi/newpkg/json" {
- http.NotFound(w, r)
- return
- }
- jsonRequests.Add(1)
- mu.Lock()
- defer mu.Unlock()
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = io.WriteString(w, releases)
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
- setReleases = func(s string) {
- mu.Lock()
- defer mu.Unlock()
- releases = s
- }
- return (&PyPIHandler{proxy: proxy, upstreamURL: upstream.URL, downloadURL: upstream.URL, proxyURL: "http://localhost"}).Routes(),
- setReleases, jsonRequests, fetcher
-}
-
-func uploaded(ago time.Duration) string {
- return time.Now().Add(-ago).Format(time.RFC3339)
-}
-
-// TestPyPICachedDownloadUsesStoredUploadTime checks that once a release's
-// upload time is stored, downloads of its cached files don't read the JSON
-// API again.
-func TestPyPICachedDownloadUsesStoredUploadTime(t *testing.T) {
- h, setReleases, jsonRequests, fetcher := pypiUploadTimeSetup(t)
- setReleases(`{"releases": {"1.0.0": [{"upload_time_iso_8601": "` + uploaded(30*24*time.Hour) + `"}]}}`)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("sdist")), ContentType: "application/octet-stream"}
-
- for range 3 {
- w := serveGet(t, h, "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz")
- wantStatus(t, w, http.StatusOK, "")
- if w.Body.String() != "sdist" {
- t.Fatalf("body = %q", w.Body.String())
- }
- }
- if got := jsonRequests.Load(); got != 1 {
- t.Errorf("JSON API requests = %d, want 1", got)
- }
-}
-
-// TestPyPIFileAddedToReleaseIsChecked checks that a file uploaded to a
-// release after its time was stored is not let through on the stored time:
-// it isn't cached, so the JSON API is read, the newer time is stored, and
-// from then on the whole release is withheld until it is out of cooldown.
-func TestPyPIFileAddedToReleaseIsChecked(t *testing.T) {
- h, setReleases, jsonRequests, fetcher := pypiUploadTimeSetup(t)
- sdist := `{"upload_time_iso_8601": "` + uploaded(30*24*time.Hour) + `"}`
- setReleases(`{"releases": {"1.0.0": [` + sdist + `]}}`)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("sdist")), ContentType: "application/octet-stream"}
-
- const (
- sdistPath = "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz"
- wheelPath = "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0-py3-none-any.whl"
- )
- wantStatus(t, serveGet(t, h, sdistPath), http.StatusOK, "")
-
- // A wheel is added to the release an hour ago.
- setReleases(`{"releases": {"1.0.0": [` + sdist + `, {"upload_time_iso_8601": "` + uploaded(time.Hour) + `"}]}}`)
- fetcher.fetchCalled = false
- wantStatus(t, serveGet(t, h, wheelPath), http.StatusNotFound, "")
- if fetcher.fetchCalled {
- t.Error("fetched a file that is inside the cooldown window")
- }
- if got := jsonRequests.Load(); got != 2 {
- t.Errorf("JSON API requests = %d, want 2 (the new file is looked up)", got)
- }
-
- // The stored time is now the wheel's, so the cached sdist is withheld
- // too, as the JSON API lookup would do, without asking it.
- wantStatus(t, serveGet(t, h, sdistPath), http.StatusNotFound, "")
- if got := jsonRequests.Load(); got != 2 {
- t.Errorf("JSON API requests = %d, want 2", got)
- }
-}
diff --git a/internal/handler/query_in_path_test.go b/internal/handler/query_in_path_test.go
deleted file mode 100644
index d9f412f..0000000
--- a/internal/handler/query_in_path_test.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package handler
-
-import (
- "errors"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
-)
-
-// TestHandlersRejectQueryCharsInPath checks that handlers which build upstream
-// URLs from the decoded path refuse an encoded '?' or '#' themselves, without
-// relying on the server middleware, and never contact the upstream or fetch
-// an artifact.
-func TestHandlersRejectQueryCharsInPath(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- _, _ = w.Write([]byte("upstream"))
- }))
- defer upstream.Close()
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- fetcher.fetchErr = errors.New("artifact fetched")
-
- handlers := map[string]struct {
- routes http.Handler
- paths []string
- }{
- "debian": {NewDebianHandler(proxy, testProxyURL, upstream.URL, nil).Routes(), []string{
- "/pool/main/h/hello/hello_2.10-3_amd64.deb%3F/foo_9.9_amd64.deb",
- "/dists/trixie/InRelease%23x",
- }},
- "rpm": {NewRPMHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes(), []string{
- "/Packages/n/nginx-1.24.0-2.fc39.x86_64.rpm%3F/foo-9.9-1.fc39.x86_64.rpm",
- "/repodata/repomd.xml%3Fx",
- }},
- "apk": {NewAPKHandler(proxy, testProxyURL, map[string]string{"alpine": upstream.URL}).Routes(), []string{
- "/alpine/v3.20/main/x86_64/busybox-1.36.1-r29.apk%3F/foo-9.9-r0.apk",
- "/alpine/v3.20/main/x86_64/APKINDEX.tar.gz%23x",
- }},
- "pypi": {NewPyPIHandlerWithUpstreams(proxy, testProxyURL, upstream.URL, upstream.URL).Routes(), []string{
- "/packages/aa/bb/requests-2.31.0.tar.gz%3F/foo-9.9.tar.gz",
- "/simple/requests%3Fx/",
- }},
- "cran": {NewCRANHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes(), []string{
- "/src/contrib/ggplot2_3.4.4.tar.gz%3Ffoo_9.9.tar.gz",
- "/bin/macosx%23x/contrib/4.3/PACKAGES",
- }},
- "maven": {NewMavenHandler(proxy, testProxyURL, upstream.URL, upstream.URL).Routes(), []string{
- "/com/example/lib/1.0/lib-1.0.jar%3F/com/example/foo/9.9/foo-9.9.jar",
- "/com/example/lib/maven-metadata.xml%23x",
- }},
- }
- for name, tc := range handlers {
- for _, target := range tc.paths {
- w := httptest.NewRecorder()
- tc.routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
- if w.Code != http.StatusBadRequest {
- t.Errorf("%s: GET %s = %d, want %d", name, target, w.Code, http.StatusBadRequest)
- }
- }
- }
- if got := requests.Load(); got != 0 {
- t.Errorf("upstream requests = %d, want 0", got)
- }
- if fetcher.fetchCalled {
- t.Errorf("artifact fetched from %s", fetcher.fetchedURL)
- }
-}
diff --git a/internal/handler/read_metadata_test.go b/internal/handler/read_metadata_test.go
deleted file mode 100644
index d87d416..0000000
--- a/internal/handler/read_metadata_test.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package handler
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "runtime"
- "strings"
- "testing"
- "testing/iotest"
-)
-
-func TestReadMetadata(t *testing.T) {
- const limit = 1024
- p := &Proxy{MetadataMaxSize: limit}
-
- t.Run("small body", func(t *testing.T) {
- data := []byte("hello world")
- got, err := p.ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if !bytes.Equal(got, data) {
- t.Errorf("got %q, want %q", got, data)
- }
- })
-
- t.Run("exactly at limit", func(t *testing.T) {
- data := make([]byte, limit)
- for i := range data {
- data[i] = 'x'
- }
- got, err := p.ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(got) != limit {
- t.Errorf("got length %d, want %d", len(got), limit)
- }
- })
-
- t.Run("over limit returns error", func(t *testing.T) {
- data := make([]byte, limit+100)
- for i := range data {
- data[i] = 'x'
- }
- _, err := p.ReadMetadata(bytes.NewReader(data))
- if !errors.Is(err, ErrMetadataTooLarge) {
- t.Errorf("got error %v, want ErrMetadataTooLarge", err)
- }
- })
-
- t.Run("zero limit uses default", func(t *testing.T) {
- p := &Proxy{}
- data := make([]byte, 1<<20)
- got, err := p.ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(got) != len(data) {
- t.Errorf("got length %d, want %d", len(got), len(data))
- }
- })
-}
-
-func TestReadMetadataSized(t *testing.T) {
- const limit = 1024
- p := &Proxy{MetadataMaxSize: limit}
- body := bytes.Repeat([]byte("x"), 100)
-
- for _, tt := range []struct {
- name string
- body []byte
- size int64
- }{
- {"exact size", body, 100},
- {"unknown size", body, -1},
- {"size too small", body, 10},
- {"size too large", body, 900},
- {"size above limit", body, limit * 4},
- {"empty body", nil, 0},
- {"empty body with size", nil, 50},
- {"body at limit", bytes.Repeat([]byte("x"), limit), limit},
- {"body at limit, size too small", bytes.Repeat([]byte("x"), limit), 1},
- } {
- t.Run(tt.name, func(t *testing.T) {
- // OneByteReader makes sure short reads are not taken for EOF.
- got, err := p.ReadMetadataSized(iotest.OneByteReader(bytes.NewReader(tt.body)), tt.size)
- if err != nil {
- t.Fatalf("ReadMetadataSized: %v", err)
- }
- if !bytes.Equal(got, tt.body) {
- t.Errorf("got %d bytes, want %d", len(got), len(tt.body))
- }
- })
- }
-
- for _, size := range []int64{-1, 10, limit, limit + 1, limit * 4} {
- t.Run(fmt.Sprintf("over limit with size %d", size), func(t *testing.T) {
- _, err := p.ReadMetadataSized(bytes.NewReader(make([]byte, limit+1)), size)
- if !errors.Is(err, ErrMetadataTooLarge) {
- t.Errorf("got error %v, want ErrMetadataTooLarge", err)
- }
- })
- }
-
- t.Run("read error", func(t *testing.T) {
- errRead := errors.New("read failed")
- r := io.MultiReader(bytes.NewReader(body), iotest.ErrReader(errRead))
- if _, err := p.ReadMetadataSized(r, 100); !errors.Is(err, errRead) {
- t.Errorf("got error %v, want %v", err, errRead)
- }
- })
-}
-
-// TestReadMetadataSizedAllocatesOnce checks that a correct size hint reads
-// into a single buffer of that size rather than a growing one.
-func TestReadMetadataSizedAllocatesOnce(t *testing.T) {
- const size = 8 << 20
- p := &Proxy{MetadataMaxSize: 64 << 20}
- payload := bytes.Repeat([]byte("x"), size)
-
- var before, after runtime.MemStats
- runtime.ReadMemStats(&before)
- data, err := p.ReadMetadataSized(bytes.NewReader(payload), size)
- runtime.ReadMemStats(&after)
- if err != nil || len(data) != size {
- t.Fatalf("read %d bytes, %v", len(data), err)
- }
- if allocated := after.TotalAlloc - before.TotalAlloc; allocated > size*3/2 {
- t.Errorf("allocated %d bytes reading %d", allocated, size)
- }
-}
-
-// TestReadMetadataSizedTruncatedUpstream checks that an upstream body cut
-// off before its Content-Length fails, while a body shorter than a size
-// hint is still read.
-func TestReadMetadataSizedTruncatedUpstream(t *testing.T) {
- p := &Proxy{}
- cut := io.MultiReader(strings.NewReader(`{"name":`), iotest.ErrReader(io.ErrUnexpectedEOF))
- if _, err := p.ReadMetadataSized(upstreamBodyReader{cut}, 100); !errors.Is(err, io.ErrUnexpectedEOF) {
- t.Errorf("cut off body: error %v, want io.ErrUnexpectedEOF", err)
- }
- got, err := p.ReadMetadataSized(strings.NewReader(`{"name":"x"}`), 100)
- if err != nil || string(got) != `{"name":"x"}` {
- t.Errorf("short body = %q, %v", got, err)
- }
-}
diff --git a/internal/handler/relay.go b/internal/handler/relay.go
deleted file mode 100644
index 75b859a..0000000
--- a/internal/handler/relay.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package handler
-
-import (
- "io"
- "net/http"
- "strings"
-
- "golang.org/x/net/http/httpguts"
-)
-
-// relayResponse forwards an unmodified upstream body. Callers still own and
-// close resp.Body, including when a failed transfer aborts the handler. A custom
-// header copier may select or rewrite end-to-end headers; it only sees headers
-// after hop-by-hop fields have been removed.
-func (p *Proxy) relayResponse(w http.ResponseWriter, r *http.Request, resp *http.Response, copyHeaders func(http.Header, http.Header)) {
- blocked := relayHopHeaders(resp.Header)
- headers := resp.Header.Clone()
- for name := range blocked {
- headers.Del(name)
- }
- bodyAllowed := r.Method != http.MethodHead && resp.StatusCode >= http.StatusOK &&
- resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusNotModified
- if resp.StatusCode < http.StatusOK || resp.StatusCode == http.StatusNoContent {
- headers.Del(headerContentLength)
- } else if resp.StatusCode == http.StatusResetContent {
- headers.Set(headerContentLength, "0")
- }
- if copyHeaders == nil {
- copyHeaders = copyRelayHeaders
- }
- copyHeaders(w.Header(), headers)
-
- announced := make(map[string]bool)
- if bodyAllowed {
- for name := range resp.Trailer {
- name = http.CanonicalHeaderKey(name)
- if validRelayTrailer(name, blocked) {
- announced[name] = true
- w.Header().Add("Trailer", name)
- }
- }
- if len(announced) > 0 {
- // HTTP/1 trailers require chunking, not a fixed Content-Length.
- w.Header().Del(headerContentLength)
- }
- }
- w.WriteHeader(resp.StatusCode)
- if !bodyAllowed || resp.Body == nil {
- return
- }
-
- written, err := io.Copy(w, resp.Body)
- if err != nil {
- upstreamURL := ""
- if resp.Request != nil && resp.Request.URL != nil {
- upstreamURL = resp.Request.URL.Redacted()
- }
- p.Logger.Warn("upstream response relay failed", "url", upstreamURL,
- "status", resp.StatusCode, "bytes", written, "error", err)
- // Headers are already committed: an error page would turn a truncated
- // download into a seemingly successful response. Let net/http close the
- // HTTP/1 connection or reset the HTTP/2 stream instead.
- panic(http.ErrAbortHandler)
- }
- relayTrailers(w, resp.Trailer, announced, blocked)
-}
-
-func copyRelayHeaders(dst, src http.Header) {
- for name, values := range src {
- for _, value := range values {
- dst.Add(name, value)
- }
- }
-}
-
-func relayHopHeaders(headers http.Header) map[string]bool {
- blocked := map[string]bool{
- "Connection": true, "Proxy-Connection": true, "Keep-Alive": true,
- "Proxy-Authenticate": true, "Proxy-Authorization": true, "Te": true,
- "Trailer": true, "Transfer-Encoding": true, "Upgrade": true,
- }
- for _, value := range headers.Values("Connection") {
- for _, name := range strings.Split(value, ",") {
- if name = strings.TrimSpace(name); name != "" {
- blocked[http.CanonicalHeaderKey(name)] = true
- }
- }
- }
- return blocked
-}
-
-func validRelayTrailer(name string, blocked map[string]bool) bool {
- return !blocked[name] && httpguts.ValidHeaderFieldName(name) && httpguts.ValidTrailerHeader(name)
-}
-
-func relayTrailers(w http.ResponseWriter, trailers http.Header, announced, blocked map[string]bool) {
- for name, values := range trailers {
- name = http.CanonicalHeaderKey(name)
- if !validRelayTrailer(name, blocked) {
- continue
- }
- if !announced[name] {
- // A trailer discovered only at EOF must not become a regular header
- // or trigger automatic Content-Length on a short buffered response.
- _ = http.NewResponseController(w).Flush()
- name = http.TrailerPrefix + name
- }
- w.Header()[name] = append([]string(nil), values...)
- }
-}
diff --git a/internal/handler/relay_test.go b/internal/handler/relay_test.go
deleted file mode 100644
index 33db80b..0000000
--- a/internal/handler/relay_test.go
+++ /dev/null
@@ -1,275 +0,0 @@
-package handler
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "testing/iotest"
-
- "github.com/git-pkgs/cooldown"
- "github.com/go-chi/chi/v5/middleware"
-)
-
-// Exercise the actual routes as well as the two shared entry points. Error
-// responses also reach relay branches in handlers that normally parse metadata.
-func relayTestRoutes(proxy *Proxy, upstream string) http.Handler {
- mux := http.NewServeMux()
- mux.HandleFunc("/upstream", func(w http.ResponseWriter, r *http.Request) {
- proxy.ProxyUpstream(w, r, upstream, nil)
- })
- mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
- proxy.ProxyFile(w, r, upstream)
- })
- mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
- proxy.ProxyCached(w, r, upstream, "test", "index")
- })
- const proxyURL = "http://proxy.local"
- mount := func(prefix string, h http.Handler) {
- mux.Handle(prefix+"/", http.StripPrefix(prefix, h))
- }
- mount("/nuget", NewNuGetHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
- mount("/conan", NewConanHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
- mount("/composer", NewComposerHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
- mount("/pypi", NewPyPIHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
- mount("/gem", NewGemHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
- mount("/conda", NewCondaHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
- mount("/hex", NewHexHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
- mount("/swift", NewSwiftHandler(proxy, proxyURL, upstream).Routes())
- mount("/v2", NewContainerHandlerWithRegistry(proxy, proxyURL, upstream).Routes())
- mount("/npm", NewNPMHandler(proxy, proxyURL, upstream).Routes())
- // Match the production recovery middleware: it must not swallow the abort.
- return middleware.Recoverer(mux)
-}
-
-func TestRelayRoutes(t *testing.T) {
- for _, route := range []string{
- "/upstream", "/file", "/metadata", "/nuget/query",
- "/conan/v2/conans/demo/1.0/user/stable/revisions/rev/files/other.txt",
- "/composer/search.json", "/pypi/simple/", "/gem/api/v1/dependencies",
- "/conda/conda-forge/noarch/repodata.json",
- "/v2/library/demo/manifests/latest", "/v2/library/demo/tags/list",
- "/npm/-/npm/v1/keys",
- } {
- t.Run(route, func(t *testing.T) {
- for _, truncated := range []bool{false, true} {
- t.Run(fmt.Sprintf("truncated=%v", truncated), func(t *testing.T) {
- testRelayRoute(t, route, truncated)
- })
- }
- })
- }
-}
-
-func testRelayRoute(t *testing.T, route string, truncated bool) {
- t.Helper()
- // Large enough to commit downstream headers before a body-copy failure.
- body := strings.Repeat("x", 64*1024)
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- conn, rw, err := w.(http.Hijacker).Hijack()
- if err != nil {
- t.Error(err)
- return
- }
- defer func() { _ = conn.Close() }()
- _, _ = fmt.Fprintf(rw, "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nConnection: X-Private\r\nX-Private: secret\r\nTransfer-Encoding: chunked\r\nTrailer: X-Checksum, X-Private\r\n\r\n%x\r\n%s\r\n", len(body), body)
- if !truncated {
- _, _ = fmt.Fprint(rw, "0\r\nX-Checksum: verified\r\nX-Late: discovered-at-eof\r\nX-Private: still-secret\r\n\r\n")
- }
- // The proxy may drop the connection before reading the whole
- // response. What it made of the response under test is asserted
- // downstream.
- _ = rw.Flush()
- }))
- defer upstream.Close()
- proxy := testProxy()
- proxy.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- downstream := httptest.NewServer(relayTestRoutes(proxy, upstream.URL))
- defer downstream.Close()
-
- resp, err := downstream.Client().Get(downstream.URL + route)
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = resp.Body.Close() }()
- got, readErr := io.ReadAll(resp.Body)
- if resp.StatusCode != http.StatusBadGateway {
- t.Fatalf("status = %d, want 502", resp.StatusCode)
- }
- if resp.Header.Get("Connection") != "" || resp.Header.Get("X-Private") != "" || resp.Trailer.Get("X-Private") != "" {
- t.Fatalf("connection-scoped fields leaked: headers=%v trailers=%v", resp.Header, resp.Trailer)
- }
- if truncated {
- if readErr == nil {
- t.Fatal("truncated upstream was delivered as a complete response")
- }
- return
- }
- if readErr != nil || string(got) != body {
- t.Fatalf("body length = %d, want %d; error = %v", len(got), len(body), readErr)
- }
- if resp.Trailer.Get("X-Checksum") != "verified" || resp.Trailer.Get("X-Late") != "discovered-at-eof" {
- t.Fatalf("trailers not relayed: %v", resp.Trailer)
- }
- if route == "/pypi/simple/" && !strings.Contains(resp.Header.Get("Vary"), "Accept") {
- t.Error("PyPI lost Vary: Accept")
- }
-}
-
-func TestRelayResponseHeaders(t *testing.T) {
- headers := http.Header{
- "Connection": {"keep-alive, x-private", " X-Second, Content-Length "},
- "X-Private": {"secret"}, "X-Second": {"secret"},
- "Keep-Alive": {"timeout=5"}, "Proxy-Connection": {"keep-alive"},
- "Proxy-Authenticate": {"challenge"}, "Proxy-Authorization": {"credentials"},
- "Te": {"trailers"}, "Trailer": {"X-Untrusted"},
- "Transfer-Encoding": {"chunked"}, "Upgrade": {"websocket"},
- "Content-Length": {"999"}, "Content-Type": {"application/octet-stream"},
- "Content-Encoding": {"gzip"}, "Etag": {`"v1"`},
- "Set-Cookie": {"a=1", "b=2"}, "Www-Authenticate": {"Bearer realm=test"},
- }
- resp := &http.Response{StatusCode: http.StatusOK, Header: headers, Body: io.NopCloser(strings.NewReader("body"))}
- w := httptest.NewRecorder()
- w.Header().Set("X-Request-ID", "local")
- testProxy().relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
- for _, name := range []string{
- "Connection", "Keep-Alive", "Proxy-Connection", "Proxy-Authenticate",
- "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
- "X-Private", "X-Second", "Content-Length",
- } {
- if got := w.Header().Get(name); got != "" {
- t.Errorf("hop-by-hop header %s survived: %q", name, got)
- }
- }
- for _, name := range []string{"Content-Type", "Content-Encoding", "Etag", "Set-Cookie", "Www-Authenticate"} {
- if got := strings.Join(w.Header().Values(name), ","); got != strings.Join(headers.Values(name), ",") {
- t.Errorf("end-to-end header %s changed: %q", name, got)
- }
- }
- if w.Header().Get("X-Request-ID") != "local" || w.Body.String() != "body" {
- t.Fatal("local header or response body changed")
- }
- if headers.Get("X-Private") != "secret" {
- t.Fatal("relay mutated the upstream response headers")
- }
-}
-
-func TestRelayResponseBodiless(t *testing.T) {
- for _, tt := range []struct {
- method string
- status int
- length string
- }{
- {http.MethodHead, http.StatusOK, "99"},
- {http.MethodGet, http.StatusNoContent, ""},
- {http.MethodGet, http.StatusResetContent, "0"},
- {http.MethodGet, http.StatusNotModified, "99"},
- {http.MethodGet, http.StatusEarlyHints, ""},
- } {
- t.Run(fmt.Sprintf("%s/%d", tt.method, tt.status), func(t *testing.T) {
- resp := &http.Response{
- StatusCode: tt.status, Header: http.Header{"Content-Length": {"99"}},
- Body: io.NopCloser(iotest.ErrReader(errors.New("body must not be read"))),
- Trailer: http.Header{"X-Checksum": {"ignored"}},
- }
- w := httptest.NewRecorder()
- testProxy().relayResponse(w, httptest.NewRequest(tt.method, "/", nil), resp, nil)
- if w.Code != tt.status || w.Body.Len() != 0 || w.Header().Get("Trailer") != "" {
- t.Fatalf("unexpected bodiless response: %d %v %q", w.Code, w.Header(), w.Body.String())
- }
- if got := w.Header().Get("Content-Length"); got != tt.length {
- t.Errorf("Content-Length = %q, want %q", got, tt.length)
- }
- })
- }
-}
-
-type relayFailWriter struct{ http.ResponseWriter }
-
-func (w relayFailWriter) Write([]byte) (int, error) {
- return 0, errors.New("downstream disconnected")
-}
-
-func TestRelayResponseCopyFailure(t *testing.T) {
- for _, downstreamError := range []bool{false, true} {
- t.Run(fmt.Sprintf("downstreamError=%v", downstreamError), func(t *testing.T) {
- var logs bytes.Buffer
- proxy := testProxy()
- proxy.Logger = slog.New(slog.NewTextHandler(&logs, nil))
- resp := &http.Response{
- StatusCode: http.StatusOK, Header: make(http.Header),
- Request: httptest.NewRequest(http.MethodGet, "http://upstream.test/file", nil),
- Body: io.NopCloser(io.MultiReader(strings.NewReader("part"), iotest.ErrReader(io.ErrUnexpectedEOF))),
- }
- var w http.ResponseWriter = httptest.NewRecorder()
- wantBytes := "bytes=4"
- if downstreamError {
- w = relayFailWriter{httptest.NewRecorder()}
- wantBytes = "bytes=0"
- }
- defer func() {
- if got := recover(); got != http.ErrAbortHandler {
- t.Errorf("panic = %v, want http.ErrAbortHandler", got)
- }
- for _, field := range []string{"url=http://upstream.test/file", "status=200", wantBytes, "error="} {
- if !strings.Contains(logs.String(), field) {
- t.Errorf("log missing %q: %s", field, logs.String())
- }
- }
- }()
- proxy.relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
- })
- }
-}
-
-func TestRelayResponseTrailerValidation(t *testing.T) {
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Connection": {"X-Private"}, "Content-Length": {"4"}},
- Body: io.NopCloser(strings.NewReader("body")),
- Trailer: http.Header{
- "X-Checksum": {"verified"}, "X-Private": {"secret"},
- "Content-Length": {"999"}, "Content-Type": {"bad/type"},
- "Authorization": {"secret"}, "Transfer-Encoding": {"chunked"},
- "If-Match": {"secret"}, "Invalid Name": {"invalid"},
- },
- }
- w := httptest.NewRecorder()
- testProxy().relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
- result := w.Result()
- defer func() { _ = result.Body.Close() }()
- if result.Header.Get("Content-Length") != "" {
- t.Error("declared trailers must remove Content-Length")
- }
- if len(result.Trailer) != 1 || result.Trailer.Get("X-Checksum") != "verified" {
- t.Fatalf("invalid trailers leaked: %v", result.Trailer)
- }
-}
-
-func TestRelayClosesBodyOnAbort(t *testing.T) {
- for _, mode := range []string{"upstream", "file", "metadata"} {
- t.Run(mode, func(t *testing.T) {
- body := &closeTrackingReader{Reader: iotest.ErrReader(io.ErrUnexpectedEOF)}
- proxy := testProxy()
- proxy.HTTPClient = &http.Client{Transport: pypiRoundTripFunc(func(r *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: body, Request: r}, nil
- })}
- defer func() {
- if got := recover(); got != http.ErrAbortHandler {
- t.Errorf("panic = %v, want http.ErrAbortHandler", got)
- }
- if !body.closed {
- t.Error("upstream body was not closed on abort")
- }
- }()
- relayTestRoutes(proxy, "http://upstream.test").ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/"+mode, nil))
- })
- }
-}
diff --git a/internal/handler/release_allowed.go b/internal/handler/release_allowed.go
deleted file mode 100644
index 4c70214..0000000
--- a/internal/handler/release_allowed.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package handler
-
-import (
- "time"
-)
-
-// ReleaseAllowed reports whether clients would be served a version published
-// at publishedAt: the denylist doesn't name it, and cooldown, where the
-// ecosystem applies it, doesn't withhold it. Without a publish time a version
-// counts as withheld wherever cooldown applies.
-func (p *Proxy) ReleaseAllowed(ecosystem, name, version string, publishedAt time.Time) bool {
- if p.versionDenied(ecosystem, name, version) {
- return false
- }
- if !CooldownSupported(ecosystem) || !p.cooldownApplies(ecosystem, name) {
- return true
- }
- return !publishedAt.IsZero() && p.cooldown().IsAllowed(ecosystem, canonicalPackagePURL(ecosystem, name), publishedAt)
-}
diff --git a/internal/handler/release_allowed_test.go b/internal/handler/release_allowed_test.go
deleted file mode 100644
index 0d8dd88..0000000
--- a/internal/handler/release_allowed_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package handler
-
-import (
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/denylist"
-)
-
-func TestReleaseAllowed(t *testing.T) {
- deny, err := denylist.New([]string{"pkg:npm/lodash@4.17.22"})
- if err != nil {
- t.Fatal(err)
- }
- p := &Proxy{Cooldown: &cooldown.Config{Default: "3d"}, Denylist: deny}
- old := time.Now().Add(-10 * 24 * time.Hour)
- fresh := time.Now().Add(-time.Hour)
-
- tests := []struct {
- name string
- ecosystem, pkg, version string
- published time.Time
- want bool
- }{
- {"old enough", "npm", "lodash", "4.17.21", old, true},
- {"within cooldown", "npm", "lodash", "4.17.23", fresh, false},
- {"denied", "npm", "lodash", "4.17.22", old, false},
- {"no publish time under cooldown", "npm", "lodash", "4.17.24", time.Time{}, false},
- // deb has no publish times, so cooldown never applies to it.
- {"ecosystem without cooldown", "deb", "curl", "8.5.0-2", time.Time{}, true},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := p.ReleaseAllowed(tt.ecosystem, tt.pkg, tt.version, tt.published); got != tt.want {
- t.Errorf("ReleaseAllowed = %v, want %v", got, tt.want)
- }
- })
- }
-
- if !(&Proxy{}).ReleaseAllowed("npm", "lodash", "4.17.23", fresh) {
- t.Error("a proxy without cooldown or denylist refused a release")
- }
-}
diff --git a/internal/handler/release_index.go b/internal/handler/release_index.go
deleted file mode 100644
index 626792a..0000000
--- a/internal/handler/release_index.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package handler
-
-import (
- "bufio"
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/xml"
- "net/http"
- "strings"
- "time"
-)
-
-// APT and RPM repositories have a release file (InRelease, Release,
-// repomd.xml) that lists the sha256 of each index file. Clients fetch the
-// release file first and reject an index that doesn't match it. Each file
-// has its own metadata cache entry and TTL, so without care a client could
-// get a cached release file and a freshly fetched index, or the reverse.
-//
-// An index the cached release file lists is therefore served by that
-// listing rather than by its own TTL: the cached copy when it matches,
-// whatever its age, and otherwise a copy fetched from upstream. If upstream
-// only has an index the cached release file doesn't list, upstream has moved
-// on; that index is served and the release file is expired, so the next
-// update gets a matching pair.
-
-// cachedRelease is a release file in the metadata cache.
-type cachedRelease struct {
- ecosystem string
- cacheKey string
- body []byte
- fetchedAt time.Time
-}
-
-// readCachedRelease returns the cached release file stored under cacheKey,
-// or nil.
-func (p *Proxy) readCachedRelease(ctx context.Context, ecosystem, cacheKey string) *cachedRelease {
- if !p.CacheMetadata || p.DB == nil || p.Storage == nil {
- return nil
- }
- entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || entry == nil || entry.ContentEncoding.String != "" {
- return nil
- }
- body, _, err := p.readCachedMetadata(ctx, entry, nil)
- // Bytes another instance replaced since the row was read don't describe
- // the release the row records.
- if err != nil || !matchesEntryDigest(entry, body) {
- return nil
- }
- return &cachedRelease{ecosystem: ecosystem, cacheKey: cacheKey, body: body, fetchedAt: entry.FetchedAt.Time}
-}
-
-// proxyListedIndex serves an index file that release lists with the sha256
-// hex want. Without a listing it is served as other metadata.
-func (p *Proxy) proxyListedIndex(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey string, release *cachedRelease, want string) {
- if release == nil || want == "" {
- p.ProxyCached(w, r, upstreamURL, ecosystem, cacheKey, "*/*")
- return
- }
- if entry, _ := p.DB.GetMetadataCache(ecosystem, cacheKey); entry != nil && p.streamableMetadata(entry) &&
- entry.ContentDigest.String == "sha256:"+want && entry.ContentEncoding.String == "" {
- if p.serveMetadataEntry(w, r, ecosystem, cacheKey, entry, true) {
- return
- }
- }
- p.recordMetadataCacheMiss(ecosystem)
- res := p.refreshMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, "*/*", "identity", nil)
- if res.err != nil {
- p.writeMetadataFetchError(w, res.err)
- return
- }
- if sum := sha256.Sum256(res.body); !res.stale && hex.EncodeToString(sum[:]) != want {
- p.Logger.Info("index does not match the cached release file, expiring it",
- "ecosystem", ecosystem, "url", upstreamURL)
- if _, err := p.DB.ExpireMetadataCache(ecosystem, []string{release.cacheKey}, time.Now()); err != nil {
- p.Logger.Warn("failed to expire cached release file", "ecosystem", ecosystem, "error", err)
- }
- }
- writeMetadataResult(w, r, res)
-}
-
-// releaseLineMax bounds a line of a Release file.
-const releaseLineMax = 1 << 20
-
-// debianReleaseSHA256 returns the sha256 an APT Release or InRelease file
-// lists for rel, a path relative to the release file's directory, or "".
-func debianReleaseSHA256(release []byte, rel string) string {
- inSection := false
- scanner := bufio.NewScanner(bytes.NewReader(release))
- scanner.Buffer(nil, releaseLineMax)
- for scanner.Scan() {
- line := strings.TrimRight(scanner.Text(), "\r")
- if !strings.HasPrefix(line, " ") {
- inSection = line == "SHA256:"
- continue
- }
- if !inSection {
- continue
- }
- if f := strings.Fields(line); len(f) == 3 && f[2] == rel {
- return strings.ToLower(f[0])
- }
- }
- return ""
-}
-
-// repomdSHA256 returns the sha256 an RPM repomd.xml lists for href, a path
-// relative to the repository root, or "" when it lists none or another
-// checksum type.
-func repomdSHA256(repomd []byte, href string) string {
- var doc struct {
- Data []struct {
- Checksum struct {
- Type string `xml:"type,attr"`
- Value string `xml:",chardata"`
- } `xml:"checksum"`
- Location struct {
- Href string `xml:"href,attr"`
- } `xml:"location"`
- } `xml:"data"`
- }
- if xml.Unmarshal(repomd, &doc) != nil {
- return ""
- }
- for _, d := range doc.Data {
- if d.Location.Href == href && d.Checksum.Type == "sha256" {
- return strings.ToLower(strings.TrimSpace(d.Checksum.Value))
- }
- }
- return ""
-}
diff --git a/internal/handler/release_index_test.go b/internal/handler/release_index_test.go
deleted file mode 100644
index b69f4ed..0000000
--- a/internal/handler/release_index_test.go
+++ /dev/null
@@ -1,265 +0,0 @@
-package handler
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// releaseArchive is an upstream repository whose release file and index
-// change together, one generation at a time.
-type releaseArchive struct {
- gen atomic.Int32
- // releasePath and indexPath are the request paths, release renders the
- // release file listing index's sha256 for a generation.
- releasePath, indexPath string
- release func(indexSHA256 string, size int) string
-}
-
-func indexBody(gen int32) string { return fmt.Sprintf("index generation %d\n", gen) }
-
-func (a *releaseArchive) serve(w http.ResponseWriter, r *http.Request) {
- gen := a.gen.Load()
- index := indexBody(gen)
- switch r.URL.Path {
- case "/" + a.releasePath:
- _, _ = w.Write([]byte(a.release(sha256Hex(index), len(index))))
- case "/" + a.indexPath:
- _, _ = w.Write([]byte(index))
- default:
- http.NotFound(w, r)
- }
-}
-
-type releaseIndexCase struct {
- name string
- ecosystem string
- archive *releaseArchive
- handler func(p *Proxy, upstreamURL string) (http.Handler, func(path string) string)
-}
-
-func releaseIndexCases() []releaseIndexCase {
- return []releaseIndexCase{
- {
- name: "debian InRelease",
- ecosystem: "debian",
- archive: &releaseArchive{
- releasePath: "dists/stable/InRelease",
- indexPath: "dists/stable/main/binary-amd64/Packages",
- release: func(sum string, size int) string {
- return "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\nSuite: stable\nMD5Sum:\n 00000000000000000000000000000000 1 main/binary-amd64/Packages\nSHA256:\n" +
- fmt.Sprintf(" %s %d main/binary-amd64/Packages\n", sum, size) +
- "-----BEGIN PGP SIGNATURE-----\n\nxx\n-----END PGP SIGNATURE-----\n"
- },
- },
- handler: func(p *Proxy, upstreamURL string) (http.Handler, func(string) string) {
- h := NewDebianHandler(p, "http://proxy.test", upstreamURL, nil)
- return h.Routes(), func(path string) string { return h.metadataCacheKeyFor("", h.upstreamURL, path) }
- },
- },
- {
- name: "rpm repomd.xml",
- ecosystem: "rpm",
- archive: &releaseArchive{
- releasePath: "f/39/os/repodata/repomd.xml",
- indexPath: "f/39/os/repodata/primary.xml.gz",
- release: func(sum string, _ int) string {
- return `
-1
-` + sum + `
-`
- },
- },
- handler: func(p *Proxy, upstreamURL string) (http.Handler, func(string) string) {
- h := NewRPMHandlerWithUpstream(p, "http://proxy.test", upstreamURL)
- return h.Routes(), h.metadataCacheKey
- },
- },
- }
-}
-
-func getBody(t *testing.T, h http.Handler, path string) string {
- t.Helper()
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+path, nil))
- if w.Code != http.StatusOK {
- t.Fatalf("GET %s = %d %s", path, w.Code, w.Body)
- }
- return w.Body.String()
-}
-
-// TestIndexFollowsCachedRelease checks that the index files a release file
-// lists are served as that release file lists them, whatever the index's own
-// cache entry says.
-func TestIndexFollowsCachedRelease(t *testing.T) {
- for _, tc := range releaseIndexCases() {
- t.Run(tc.name, func(t *testing.T) {
- a := tc.archive
- upstream := httptest.NewServer(http.HandlerFunc(a.serve))
- t.Cleanup(upstream.Close)
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h, key := tc.handler(proxy, upstream.URL)
- expire := func(path string) {
- t.Helper()
- if _, err := db.ExpireMetadataCache(tc.ecosystem, []string{key(path)}, time.Now().Add(time.Second)); err != nil {
- t.Fatal(err)
- }
- }
-
- getBody(t, h, a.releasePath)
- if got := getBody(t, h, a.indexPath); got != indexBody(0) {
- t.Fatalf("index = %q", got)
- }
-
- // The index entry expires before the release file's while
- // upstream moves on: the cached release still lists generation 0.
- a.gen.Store(1)
- expire(a.indexPath)
- if got := getBody(t, h, a.indexPath); got != indexBody(0) {
- t.Errorf("index after its entry expired = %q, want the one the cached release lists", got)
- }
-
- // The release file is refetched while the index is still cached:
- // the index must follow it.
- expire(a.releasePath)
- getBody(t, h, a.releasePath)
- if got := getBody(t, h, a.indexPath); got != indexBody(1) {
- t.Errorf("index after release refetch = %q, want %q", got, indexBody(1))
- }
- })
- }
-}
-
-// TestIndexMismatchExpiresRelease checks that when upstream only has an
-// index newer than the cached release file, the release file is refetched
-// on its next request, so the next update gets a matching pair.
-func TestIndexMismatchExpiresRelease(t *testing.T) {
- for _, tc := range releaseIndexCases() {
- t.Run(tc.name, func(t *testing.T) {
- a := tc.archive
- upstream := httptest.NewServer(http.HandlerFunc(a.serve))
- t.Cleanup(upstream.Close)
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h, _ := tc.handler(proxy, upstream.URL)
-
- first := getBody(t, h, a.releasePath)
- a.gen.Store(1)
- if got := getBody(t, h, a.indexPath); got != indexBody(1) {
- t.Fatalf("index = %q", got)
- }
- if got := getBody(t, h, a.releasePath); got == first {
- t.Errorf("release file still the cached one, which does not list the index served")
- }
- })
- }
-}
-
-// debianSuite serves an APT suite with both InRelease and Release, and the
-// legacy per-component Release that lists no checksums.
-func debianSuite(t *testing.T) (*atomic.Int32, http.Handler, func(path string)) {
- t.Helper()
- const index = "main/binary-amd64/Packages"
- var gen atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body := indexBody(gen.Load())
- release := fmt.Sprintf("Suite: stable\nSHA256:\n %s %d %s\n", sha256Hex(body), len(body), index)
- switch r.URL.Path {
- case "/dists/stable/InRelease":
- _, _ = w.Write([]byte("-----BEGIN PGP SIGNED MESSAGE-----\n\n" + release + "-----BEGIN PGP SIGNATURE-----\n"))
- case "/dists/stable/Release":
- _, _ = w.Write([]byte(release))
- case "/dists/stable/main/binary-amd64/Release":
- _, _ = w.Write([]byte("Archive: stable\nComponent: main\nArchitecture: amd64\n"))
- case "/dists/stable/" + index:
- _, _ = w.Write([]byte(body))
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(upstream.Close)
- proxy, db, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- proxy.HTTPClient = upstream.Client()
- h := NewDebianHandler(proxy, "http://proxy.test", upstream.URL, nil)
- expire := func(path string) {
- t.Helper()
- key := h.metadataCacheKeyFor("", h.upstreamURL, path)
- if _, err := db.ExpireMetadataCache("debian", []string{key}, time.Now().Add(time.Second)); err != nil {
- t.Fatal(err)
- }
- }
- return &gen, h.Routes(), expire
-}
-
-// TestDebianIndexFollowsLatestReleaseFile checks that with both InRelease
-// and Release cached, the index follows the one fetched last, which is what
-// the client asking for the index just read.
-func TestDebianIndexFollowsLatestReleaseFile(t *testing.T) {
- const index = "dists/stable/main/binary-amd64/Packages"
- gen, h, expire := debianSuite(t)
-
- getBody(t, h, "dists/stable/InRelease")
- getBody(t, h, index)
- gen.Store(1)
- time.Sleep(time.Millisecond)
- getBody(t, h, "dists/stable/Release")
- if got := getBody(t, h, index); got != indexBody(1) {
- t.Errorf("index after a fresh Release = %q, want %q", got, indexBody(1))
- }
-
- // And back: a fresh InRelease wins over the older Release.
- gen.Store(2)
- expire("dists/stable/InRelease")
- time.Sleep(time.Millisecond)
- getBody(t, h, "dists/stable/InRelease")
- if got := getBody(t, h, index); got != indexBody(2) {
- t.Errorf("index after a fresh InRelease = %q, want %q", got, indexBody(2))
- }
-}
-
-// TestDebianIndexSkipsReleaseWithoutListing checks that a cached release
-// file that lists nothing for the index, such as the legacy per-component
-// Release, does not stop the lookup at the suite's release file.
-func TestDebianIndexSkipsReleaseWithoutListing(t *testing.T) {
- const index = "dists/stable/main/binary-amd64/Packages"
- gen, h, expire := debianSuite(t)
-
- getBody(t, h, "dists/stable/InRelease")
- getBody(t, h, "dists/stable/main/binary-amd64/Release")
- getBody(t, h, index)
- gen.Store(1)
- expire("dists/stable/InRelease")
- getBody(t, h, "dists/stable/InRelease")
- if got := getBody(t, h, index); got != indexBody(1) {
- t.Errorf("index after InRelease refetch = %q, want %q", got, indexBody(1))
- }
-}
-
-// TestCachedReleaseChecksStoredBytes checks that a release file whose stored
-// bytes no longer match its row, because another instance replaced them, is
-// not used to decide which index to serve.
-func TestCachedReleaseChecksStoredBytes(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- path := seedMetadataRow(t, db, store)
- if proxy.readCachedRelease(context.Background(), "test", "pkg") == nil {
- t.Fatal("matching release file not read")
- }
- store.mu.Lock()
- store.files[path] = []byte(`{"v":2}`)
- store.mu.Unlock()
- if release := proxy.readCachedRelease(context.Background(), "test", "pkg"); release != nil {
- t.Errorf("read a release file whose bytes don't match its row: %q", release.body)
- }
-}
diff --git a/internal/handler/resolved_artifact_test.go b/internal/handler/resolved_artifact_test.go
deleted file mode 100644
index 2a8f286..0000000
--- a/internal/handler/resolved_artifact_test.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
-
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
-)
-
-type artifactTestRegistry struct {
- calls atomic.Int64
- err error
- artifacts []registries.Artifact
-}
-
-func (*artifactTestRegistry) Ecosystem() string { return "maven" }
-func (*artifactTestRegistry) URLs() client.URLBuilder { //nolint:ireturn // required by fetch.Registry
- return &client.BaseURLs{}
-}
-func (r *artifactTestRegistry) FetchVersions(ctx context.Context, _ string) ([]registries.Version, error) {
- r.calls.Add(1)
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- if r.err != nil {
- return nil, r.err
- }
- return []registries.Version{{Number: "1.0.0", Artifacts: r.artifacts}}, nil
-}
-
-func TestGetOrFetchArtifactResolvedCacheHit(t *testing.T) {
- p, db, store, fetcher := setupTestProxy(t)
- fetcher.fetchErr = errors.New("unexpected artifact fetch on a cache hit")
- seedPackage(t, db, store, "maven", "org.example:demo", "1.0.0", "demo-1.0.0.pom", "pom")
- seedPackage(t, db, store, "maven", "org.example:demo", "1.0.0", "demo-1.0.0.jar", "jar")
- registry := &artifactTestRegistry{artifacts: []registries.Artifact{{URL: "https://registry.example/demo-1.0.0.jar", Filename: "demo-1.0.0.jar"}}}
- p.Resolver.RegisterRegistry(registry)
- result, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = result.Reader.Close() }()
- body, err := io.ReadAll(result.Reader)
- if err != nil || string(body) != "jar" || !result.Cached || result.Artifact.Filename != "demo-1.0.0.jar" {
- t.Fatalf("result=%+v body=%q err=%v", result, body, err)
- }
- if fetcher.fetchCalled || registry.calls.Load() != 1 {
- t.Fatalf("fetchCalled=%t resolutions=%d", fetcher.fetchCalled, registry.calls.Load())
- }
-}
-
-func TestGetOrFetchArtifactResolvedMiss(t *testing.T) {
- p, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "maven", "org.example:demo", "1.0.0", "demo-1.0.0.pom", "pom")
- registry := &artifactTestRegistry{artifacts: []registries.Artifact{{URL: "https://registry.example/demo-1.0.0.jar", Filename: "demo-1.0.0.jar"}}}
- p.Resolver.RegisterRegistry(registry)
- fetcher := &countingFetcher{content: "jar"}
- p.Fetcher = fetcher
- for i, cached := range []bool{false, true} {
- result, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "")
- if err != nil {
- t.Fatal(err)
- }
- body, readErr := io.ReadAll(result.Reader)
- _ = result.Reader.Close()
- if readErr != nil || string(body) != "jar" || result.Cached != cached {
- t.Fatalf("result=%+v body=%q err=%v; want cached=%t", result, body, readErr, cached)
- }
- if registry.calls.Load() != int64(i+1) {
- t.Fatalf("resolutions=%d after request %d; want one per request", registry.calls.Load(), i+1)
- }
- }
- if fetcher.calls.Load() != 1 || registry.calls.Load() != 2 {
- t.Fatalf("downloads=%d resolutions=%d; want 1 and 2", fetcher.calls.Load(), registry.calls.Load())
- }
-}
-
-func TestGetOrFetchArtifactNamedCacheHitDoesNotResolve(t *testing.T) {
- p, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "maven", "org.example:demo", "1.0.0", "demo-1.0.0.pom", "pom")
- p.Resolver = nil // A named cache hit must not need a resolver.
- result, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "demo-1.0.0.pom")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = result.Reader.Close() }()
- if !result.Cached {
- t.Fatal("expected cache hit")
- }
-}
-
-func TestGetOrFetchArtifactResolvedErrors(t *testing.T) {
- for _, want := range []error{fetch.ErrNotFound, context.Canceled, errors.New("registry unavailable")} {
- t.Run(want.Error(), func(t *testing.T) {
- p, _, _, fetcher := setupTestProxy(t)
- registry := &artifactTestRegistry{err: want}
- p.Resolver.RegisterRegistry(registry)
- _, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "")
- if !errors.Is(err, want) || fetcher.fetchCalled {
- t.Fatalf("err=%v fetched=%t", err, fetcher.fetchCalled)
- }
- if errors.Is(want, fetch.ErrNotFound) && !errors.Is(err, ErrUpstreamNotFound) {
- t.Fatalf("missing upstream not-found classification: %v", err)
- }
- })
- }
-}
-
-func TestGetOrFetchArtifactResolvedMissingStorage(t *testing.T) {
- p, db, store, fetcher := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "demo", "1.0.0", "demo-1.0.0.tgz", "old")
- clear(store.files)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("new"))}
- result, err := p.GetOrFetchArtifact(context.Background(), "npm", "demo", "1.0.0", "")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = result.Reader.Close() }()
- if result.Cached || !fetcher.fetchCalled {
- t.Fatalf("expected refetch: result=%+v fetched=%t", result, fetcher.fetchCalled)
- }
-}
-
-func TestGetOrFetchArtifactResolvedDenyBeforeResolution(t *testing.T) {
- p, _, _, fetcher := setupTestProxy(t)
- registry := &artifactTestRegistry{err: errors.New("must not resolve")}
- p.Resolver.RegisterRegistry(registry)
- setTestDenylist(t, p, "pkg:maven/org.example/demo@1.0.0")
- _, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "")
- if !errors.Is(err, ErrVersionDenied) || registry.calls.Load() != 0 || fetcher.fetchCalled {
- t.Fatalf("err=%v resolutions=%d fetched=%t", err, registry.calls.Load(), fetcher.fetchCalled)
- }
-}
-
-func TestGetOrFetchArtifactResolvedConcurrentMisses(t *testing.T) {
- p, _, _, _ := setupTestProxy(t)
- fetcher := &countingFetcher{content: "tarball", delay: fetchHoldTime}
- p.Fetcher = fetcher
- start := make(chan struct{})
- var wg sync.WaitGroup
- for range 8 {
- wg.Go(func() {
- <-start
- result, err := p.GetOrFetchArtifact(context.Background(), "npm", "demo", "1.0.0", "")
- if err != nil {
- t.Error(err)
- return
- }
- defer func() { _ = result.Reader.Close() }()
- body, err := io.ReadAll(result.Reader)
- if err != nil || string(body) != "tarball" {
- t.Errorf("body=%q err=%v", body, err)
- }
- })
- }
- close(start)
- wg.Wait()
- if fetcher.calls.Load() != 1 {
- t.Fatalf("downloads=%d, want 1", fetcher.calls.Load())
- }
-}
-
-func TestGetOrFetchArtifactResolvedWithoutFilename(t *testing.T) {
- p, _, _, fetcher := setupTestProxy(t)
- registry := &artifactTestRegistry{artifacts: []registries.Artifact{{URL: "https://registry.example/"}}}
- p.Resolver.RegisterRegistry(registry)
- _, err := p.GetOrFetchArtifact(context.Background(), "maven", "org.example:demo", "1.0.0", "")
- if err == nil || !strings.Contains(err.Error(), "no filename") || fetcher.fetchCalled {
- t.Fatalf("err=%v fetched=%t; expected rejection before download", err, fetcher.fetchCalled)
- }
-}
-
-func TestGetOrFetchArtifactResolvedInvalidCacheHash(t *testing.T) {
- p, db, store, fetcher := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "demo", "1.0.0", "demo-1.0.0.tgz", "old")
- art, err := db.GetArtifact("pkg:npm/demo@1.0.0", "demo-1.0.0.tgz")
- if err != nil {
- t.Fatal(err)
- }
- art.ContentHash.String = "invalid-hash"
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatal(err)
- }
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("new"))}
- result, err := p.GetOrFetchArtifact(context.Background(), "npm", "demo", "1.0.0", "")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = result.Reader.Close() }()
- body, err := io.ReadAll(result.Reader)
- if err != nil || string(body) != "new" || result.Cached || !fetcher.fetchCalled {
- t.Fatalf("result=%+v body=%q err=%v fetched=%t", result, body, err, fetcher.fetchCalled)
- }
-}
diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go
deleted file mode 100644
index a624dba..0000000
--- a/internal/handler/rewrite_cache.go
+++ /dev/null
@@ -1,662 +0,0 @@
-package handler
-
-import (
- "bytes"
- "compress/gzip"
- "container/list"
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/binary"
- "encoding/hex"
- "errors"
- "io"
- "net/http"
- "slices"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// rewriteCache keeps metadata documents after a handler has rewritten them.
-// Rewriting means decoding the whole document into generic maps, changing its
-// download URLs and encoding it again, which for a large npm packument or an
-// expanded Composer document costs milliseconds to hundreds of milliseconds
-// and megabytes of allocations. It ran on every request, cached metadata
-// included. The cache keeps one rewrite per distinct upstream document, and
-// concurrent requests for a document that is not cached yet share a single
-// rewrite.
-//
-// Entries are keyed by a hash of the raw document, so new bytes from upstream
-// are rewritten again rather than served stale. Everything else a rewrite
-// depends on is fixed for the life of the process: the proxy URL is part of
-// the key, and the denylist is loaded at startup. The exception is cooldown,
-// which depends on the time of the request: with cooldown on, entries expire
-// when the first version they held back leaves cooldown (see
-// cooldown_expiry.go).
-//
-// Each entry keeps the ETag of its output, so clients can revalidate their
-// copy without the output being hashed again on every request. Dates are not
-// the cache's to give: they must agree across replicas, so the database holds
-// them (see metadataDate).
-//
-// Each entry also keeps its output gzip-compressed, when that is smaller, so
-// clients accepting gzip get it without the document being compressed again
-// on every request, by the proxy or by a reverse proxy in front of it.
-type rewriteCache struct {
- maxBytes int64
- // now reads the time entries expire by; nil means time.Now.
- now func() time.Time
-
- mu sync.Mutex
- size int64
- order *list.List // most recently used at the front
- entries map[string]*list.Element
- inFlight map[string]*inflightRewrite
-}
-
-type rewriteEntry struct {
- key string
- rewrittenMetadata
- // expires is when the entry stops being served, or zero for never.
- expires time.Time
-}
-
-// size is how much of the cache's room the entry takes.
-func (e *rewriteEntry) size() int64 {
- return int64(len(e.plain) + len(e.gzipped))
-}
-
-// inflightRewrite is one rewrite that concurrent callers share. m and err are
-// written before done closes and read only after, so the close is the
-// handoff.
-type inflightRewrite struct {
- done chan struct{}
- m rewrittenMetadata
- err error
-}
-
-// metadataValidators are what a client revalidates its copy of rewritten
-// metadata with. lastModified is zero when there is none to give.
-type metadataValidators struct {
- etag string
- lastModified time.Time
-}
-
-// rewrittenMetadata is a rewritten metadata document with its validators.
-// gzipped is the document gzip-compressed, or nil when there is no
-// compressed copy to serve. The cache keeps only gzipped when there is one,
-// so plain is nil then; content returns the document either way.
-type rewrittenMetadata struct {
- plain []byte
- metadataValidators
- gzipped []byte
- // size is the length of the uncompressed document.
- size int
-}
-
-// content returns the document uncompressed. A cached document is kept
-// only gzip-compressed, and is decompressed for each caller that needs the
-// plain bytes: clients not accepting gzip, and handlers reading the document.
-// The compressed copy was made in memory by gzipIfSmaller, so reading it back
-// fails only on a bug; content returns nil then.
-func (m rewrittenMetadata) content() []byte {
- if m.plain != nil || m.gzipped == nil {
- return m.plain
- }
- zr, err := gzip.NewReader(bytes.NewReader(m.gzipped))
- if err != nil {
- return nil
- }
- out := bytes.NewBuffer(make([]byte, 0, m.size))
- if _, err := io.Copy(out, zr); err != nil {
- return nil
- }
- return out.Bytes()
-}
-
-// identity returns m to be served without its gzip copy.
-func (m rewrittenMetadata) identity() rewrittenMetadata {
- m.plain = m.content()
- m.gzipped = nil
- return m
-}
-
-// compact returns m as the cache keeps it: only the gzip copy when there is
-// one.
-func (m rewrittenMetadata) compact() rewrittenMetadata {
- if m.gzipped != nil {
- m.plain = nil
- }
- return m
-}
-
-// metadataETag returns the strong ETag of a rewritten document.
-func metadataETag(out []byte) string {
- sum := sha256.Sum256(out)
- return `"` + hex.EncodeToString(sum[:]) + `"`
-}
-
-// rewriteConfigNonce stands for the configuration a rewrite reads at startup,
-// such as the denylist and cooldown rules, in ETags derived from a rewrite's
-// inputs. It changes on every start, so no ETag outlives the configuration it
-// was derived under.
-var rewriteConfigNonce = rand.Text()
-
-// keptVersionsETag returns the strong ETag of a rewrite from what fixes its
-// output: the upstream document's digest, the proxy URL and package name its
-// download URLs carry, the versions cooldown and the denylist kept, and the
-// startup configuration. Hashing these costs far less than hashing the
-// rewritten document. Each field is length-prefixed, so different inputs never
-// hash the same bytes.
-func keptVersionsETag(digest, proxyURL, name string, kept []string) string {
- h := sha256.New()
- var length []byte
- for _, field := range append([]string{rewriteConfigNonce, digest, proxyURL, name}, slices.Sorted(slices.Values(kept))...) {
- length = binary.AppendUvarint(length[:0], uint64(len(field)))
- _, _ = h.Write(length)
- _, _ = io.WriteString(h, field)
- }
- return `"` + hex.EncodeToString(h.Sum(nil)) + `"`
-}
-
-// keepingRewrite rewrites a metadata document and reports the versions it
-// kept. Together with the document they fix the rewrite's output.
-type keepingRewrite func([]byte) ([]byte, []string, error)
-
-// errSharedRewriteAbandoned is what waiters see if the caller running a
-// shared rewrite panicked out of it.
-var errSharedRewriteAbandoned = errors.New("shared metadata rewrite did not complete")
-
-// newRewriteCache returns a cache holding up to maxBytes of rewritten output.
-// A size of zero or less disables it.
-func newRewriteCache(maxBytes int64) *rewriteCache {
- if maxBytes <= 0 {
- return nil
- }
- return &rewriteCache{
- maxBytes: maxBytes,
- order: list.New(),
- entries: make(map[string]*list.Element),
- inFlight: make(map[string]*inflightRewrite),
- }
-}
-
-// rewriteCacheKey identifies one rewrite: the ecosystem and proxy URL the
-// handler rewrites for, the package, and the exact upstream bytes.
-func rewriteCacheKey(ecosystem, proxyURL, name string, in []byte) string {
- sum := sha256.Sum256(in)
- return rewriteCacheKeyForDigest(ecosystem, proxyURL, name, "sha256:"+hex.EncodeToString(sum[:]))
-}
-
-// rewriteCacheKeyForDigest is rewriteCacheKey for upstream bytes known only by
-// their digest, in the form the metadata cache records it.
-func rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest string) string {
- return strings.Join([]string{ecosystem, proxyURL, name, digest}, "\x00")
-}
-
-// get returns the cached rewrite for key, if there is one.
-func (c *rewriteCache) get(key string) (rewrittenMetadata, bool) {
- if c == nil {
- return rewrittenMetadata{}, false
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- e, ok := c.lookup(key, c.clock())
- if !ok {
- return rewrittenMetadata{}, false
- }
- return e.rewrittenMetadata, true
-}
-
-// lookup returns the live entry for key and marks it recently used. An
-// expired entry is dropped. Called with mu held.
-func (c *rewriteCache) lookup(key string, now time.Time) (*rewriteEntry, bool) {
- el, ok := c.entries[key]
- if !ok {
- return nil, false
- }
- e := el.Value.(*rewriteEntry)
- if !e.expires.IsZero() && !now.Before(e.expires) {
- c.remove(el)
- return nil, false
- }
- c.order.MoveToFront(el)
- return e, true
-}
-
-func (c *rewriteCache) clock() time.Time {
- if c.now == nil {
- return time.Now()
- }
- return c.now()
-}
-
-// remove drops an entry. Called with mu held.
-func (c *rewriteCache) remove(el *list.Element) {
- e := el.Value.(*rewriteEntry)
- c.order.Remove(el)
- delete(c.entries, e.key)
- c.size -= e.size()
-}
-
-// rewrite returns rewrite(in) with its ETag and gzip copy, from the cache
-// when it can. A ttl above zero makes the result expire after that long.
-// Callers must treat the returned bytes as read-only: a cached result is
-// shared. A caller waiting on another's rewrite leaves when ctx ends; the
-// rewrite itself always runs to completion, so the result is cached for the
-// next request.
-func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, expiry func(start time.Time) time.Time, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) {
- if c == nil {
- return rewriteWithETag(in, rewrite)
- }
- return c.produce(ctx, key, expiry, func() (rewrittenMetadata, error) {
- m, err := rewriteWithETag(in, rewrite)
- if err == nil {
- m.gzipped = gzipIfSmaller(m.plain)
- }
- return m, err
- })
-}
-
-// produce is rewrite for a document that build makes with its validators
-// and gzip copy.
-func (c *rewriteCache) produce(ctx context.Context, key string, expiry func(start time.Time) time.Time, build func() (rewrittenMetadata, error)) (rewrittenMetadata, error) {
-
- c.mu.Lock()
- if e, ok := c.lookup(key, c.clock()); ok {
- c.mu.Unlock()
- return e.rewrittenMetadata, nil
- }
- if f, ok := c.inFlight[key]; ok {
- c.mu.Unlock()
- select {
- case <-ctx.Done():
- return rewrittenMetadata{}, ctx.Err()
- case <-f.done:
- return f.m, f.err
- }
- }
- f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned}
- c.inFlight[key] = f
- c.mu.Unlock()
-
- // expiry is given the time from before the rewrite, so an entry never
- // outlives the time its cooldown decisions were made at.
- start := c.clock()
- defer func() {
- c.mu.Lock()
- delete(c.inFlight, key)
- if f.err == nil {
- var expires time.Time
- if expiry != nil {
- expires = expiry(start)
- }
- c.add(&rewriteEntry{key: key, rewrittenMetadata: f.m.compact(), expires: expires})
- }
- c.mu.Unlock()
- close(f.done)
- }()
- f.m, f.err = build()
- return f.m, f.err
-}
-
-// rewriteWithETag runs rewrite and computes the ETag of what it returns.
-func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) {
- out, err := rewrite(in)
- if err != nil {
- return rewrittenMetadata{}, err
- }
- return rewrittenMetadata{plain: out, metadataValidators: metadataValidators{etag: metadataETag(out)}, size: len(out)}, nil
-}
-
-// gzipIfSmaller returns body gzip-compressed, or nil when compressing does
-// not make it smaller.
-func gzipIfSmaller(body []byte) []byte {
- var buf bytes.Buffer
- zw := gzip.NewWriter(&buf)
- if _, err := zw.Write(body); err != nil {
- return nil
- }
- if err := zw.Close(); err != nil || buf.Len() >= len(body) {
- return nil
- }
- return buf.Bytes()
-}
-
-// add stores e and evicts least recently used entries until the cache is
-// back under its limit. An entry larger than the whole cache is not stored.
-// Called with mu held.
-func (c *rewriteCache) add(e *rewriteEntry) {
- n := e.size()
- if n > c.maxBytes {
- return
- }
- if old, ok := c.entries[e.key]; ok {
- c.remove(old)
- }
- c.entries[e.key] = c.order.PushFront(e)
- c.size += n
- for c.size > c.maxBytes {
- c.remove(c.order.Back())
- }
-}
-
-// cachedRewrite rewrites a metadata document through the proxy's rewrite
-// cache and returns it with its validators. digest identifies in when the
-// metadata cache stored it, and is empty otherwise; when known it keys the
-// cache, so the document isn't hashed again.
-//
-// With cooldown on, the cached rewrite expires when the first version it
-// held back leaves cooldown, and after p.cooldownRewriteTTL at the latest. Its
-// ETag is the hash of its output, and it is dated like any other, so clients
-// revalidate with either validator and get the gzip copy.
-//
-// Without the rewrite cache, cooldown rewrites run on every request and go
-// undated: when digest is known their ETag comes from their inputs rather
-// than from hashing each output again, and that ETag changes with every
-// restart, so replicas would never agree on a date for it.
-func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) (rewrittenMetadata, error) {
- rewriteOnly := func(b []byte) ([]byte, error) {
- out, _, err := rewrite(b)
- return out, err
- }
- cooldown := p.cooldownEnabled()
-
- if p.rewrites != nil {
- key := rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest)
- if digest == "" {
- key = rewriteCacheKey(ecosystem, proxyURL, name, in)
- }
- var expiry func(time.Time) time.Time
- if cooldown {
- // Recording runs from before the rewrite until after it, so it
- // hears of every version the rewrite holds back.
- rec := p.watchCooldown()
- defer p.unwatchCooldown(rec)
- expiry = func(start time.Time) time.Time { return p.cooldownExpiry(rec, start) }
- }
- m, err := p.rewrites.rewrite(ctx, key, in, expiry, rewriteOnly)
- if err == nil {
- m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
- }
- return m, err
- }
-
- if cooldown && digest != "" {
- out, kept, err := rewrite(in)
- if err != nil {
- return rewrittenMetadata{}, err
- }
- return rewrittenMetadata{plain: out, metadataValidators: metadataValidators{etag: keptVersionsETag(digest, proxyURL, name, kept)}, size: len(out)}, nil
- }
- m, err := rewriteWithETag(in, rewriteOnly)
- if err == nil && !cooldown {
- m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
- }
- return m, err
-}
-
-// streamedRewrite rewrites a document stored in storage, through the
-// proxy's rewrite cache, without holding the document or its rewrite in
-// memory uncompressed. rewrite is given a way to open the stored document,
-// as often as it needs to read it, and writes the rewritten document to w.
-// The result is cached under the same key cachedRewrite uses for the
-// document, and expires the same way with cooldown on.
-//
-// It returns errMetadataNotStored without the rewrite cache, which the
-// result has to be kept in.
-func (p *Proxy) streamedRewrite(ctx context.Context, ecosystem, proxyURL, name string, entry *database.MetadataCacheEntry, rewrite func(open func() (io.ReadCloser, error), w io.Writer) error) (rewrittenMetadata, error) {
- if p.rewrites == nil {
- return rewrittenMetadata{}, errMetadataNotStored
- }
- var expiry func(time.Time) time.Time
- if p.cooldownEnabled() {
- rec := p.watchCooldown()
- defer p.unwatchCooldown(rec)
- expiry = func(start time.Time) time.Time { return p.cooldownExpiry(rec, start) }
- }
- key := rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)
- m, err := p.rewrites.produce(ctx, key, expiry, func() (rewrittenMetadata, error) {
- // The rewrite runs to completion for the cache even when the
- // caller leaves.
- readCtx := context.WithoutCancel(ctx)
- return gzipRewrite(func(w io.Writer) error {
- return rewrite(func() (io.ReadCloser, error) { return p.openStoredMetadata(readCtx, entry) }, w)
- })
- })
- if err == nil {
- m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
- }
- return m, err
-}
-
-// gzipRewrite runs write and keeps what it writes gzip-compressed only, with
-// the ETag and length of the uncompressed output.
-func gzipRewrite(write func(w io.Writer) error) (rewrittenMetadata, error) {
- var buf bytes.Buffer
- zw := gzip.NewWriter(&buf)
- sum := sha256.New()
- n := &countingWriter{w: io.Discard}
- if err := write(io.MultiWriter(zw, sum, n)); err != nil {
- return rewrittenMetadata{}, err
- }
- if err := zw.Close(); err != nil {
- return rewrittenMetadata{}, err
- }
- return rewrittenMetadata{
- metadataValidators: metadataValidators{etag: `"` + hex.EncodeToString(sum.Sum(nil)) + `"`},
- gzipped: buf.Bytes(),
- size: int(n.n),
- }, nil
-}
-
-// uncachedRewrite gives a rewrite made outside the cache its validators: an
-// ETag of the output itself and, as cachedRewrite does, a date that replicas
-// agree on.
-func (p *Proxy) uncachedRewrite(ecosystem, proxyURL, name string, out []byte) rewrittenMetadata {
- v := metadataValidators{etag: metadataETag(out)}
- if !p.cooldownEnabled() {
- v.lastModified = p.metadataDate(ecosystem, proxyURL, name, v.etag)
- }
- return rewrittenMetadata{plain: out, metadataValidators: v, size: len(out)}
-}
-
-func (p *Proxy) cooldownEnabled() bool {
- return p.Cooldown != nil && p.Cooldown.Enabled()
-}
-
-// storedRewrite returns the cached rewrite of the metadata stored for
-// ecosystem and cacheKey, when that metadata is still within its TTL and a
-// rewrite of it is cached and has not expired. It reads only the cache row,
-// never the stored document, so a repeated request for a large packument
-// costs a database lookup instead of reading and hashing the whole document
-// again. When it reports false the caller takes the usual fetch and rewrite
-// path.
-func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) (rewrittenMetadata, bool) {
- m, _, ok := p.storedRewriteVariant(ecosystem, cacheKey, proxyURL, name, nil)
- return m, ok
-}
-
-// storedRewriteVariant is storedRewrite for a rewrite whose output depends on
-// more than the stored document. variant is given the stored content type
-// and returns the rest of what the rewrite depends on, as passed to
-// variantDigest; nil means nothing else. It also returns the stored content
-// type, as a fetch of the document would.
-func (p *Proxy) storedRewriteVariant(ecosystem, cacheKey, proxyURL, name string, variant func(contentType string) []string) (rewrittenMetadata, string, bool) {
- if p.rewrites == nil {
- return rewrittenMetadata{}, "", false
- }
- if !p.CacheMetadata || p.DB == nil || p.MetadataTTL <= 0 {
- return rewrittenMetadata{}, "", false
- }
- entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || entry == nil || !entry.ContentDigest.Valid || entry.ContentEncoding.String != "" {
- return rewrittenMetadata{}, "", false
- }
- if !entry.FetchedAt.Valid || time.Since(entry.FetchedAt.Time) >= p.MetadataTTL {
- return rewrittenMetadata{}, "", false
- }
- contentType := contentTypeJSON
- if entry.ContentType.Valid {
- contentType = entry.ContentType.String
- }
- digest := entry.ContentDigest.String
- if variant != nil {
- digest = variantDigest(digest, nil, variant(contentType)...)
- }
- m, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest))
- if !ok {
- return rewrittenMetadata{}, "", false
- }
- m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
- metrics.RecordMetadataCacheHit(ecosystem, m.size)
- p.recordMetadataRead(entry)
- return m, contentType, true
-}
-
-// variantDigest returns a digest for a rewrite of in that also depends on
-// parts, such as the content type or the versions a filter removes, so that
-// rewrites differing in them are cached apart. digest is in's digest as the
-// metadata cache records it, or empty to hash in. Passed to cachedRewrite in
-// place of the document's digest, it keeps the metadata date keyed by the
-// package alone.
-func variantDigest(digest string, in []byte, parts ...string) string {
- if digest == "" {
- sum := sha256.Sum256(in)
- digest = "sha256:" + hex.EncodeToString(sum[:])
- }
- h := sha256.New()
- var length []byte
- for _, part := range parts {
- length = binary.AppendUvarint(length[:0], uint64(len(part)))
- _, _ = h.Write(length)
- _, _ = io.WriteString(h, part)
- }
- return digest + "+" + hex.EncodeToString(h.Sum(nil))
-}
-
-// serveRewrittenMetadata writes rewritten metadata with its validators, or
-// 304 Not Modified when they show the client's copy is current. A client
-// accepting gzip gets the compressed copy when there is one. Being another
-// representation, it has an ETag of its own.
-func serveRewrittenMetadata(w http.ResponseWriter, r *http.Request, m rewrittenMetadata) {
- serveRewrittenMetadataAs(w, r, m, contentTypeJSON)
-}
-
-// serveRewrittenMetadataAs is serveRewrittenMetadata for a document of
-// another content type.
-func serveRewrittenMetadataAs(w http.ResponseWriter, r *http.Request, m rewrittenMetadata, contentType string) {
- etag := m.etag
- compressed := m.gzipped != nil && acceptsGzip(r)
- if compressed {
- etag = gzipETag(m.etag)
- }
- ensureVary(w.Header(), headerAcceptEncoding)
- w.Header().Set(headerETag, etag)
- if !m.lastModified.IsZero() {
- w.Header().Set(headerLastModified, m.lastModified.UTC().Format(http.TimeFormat))
- }
- if notModified(r, etag, m.lastModified) {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentType, contentType)
- if compressed {
- w.Header().Set(headerContentEncoding, "gzip")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(m.gzipped)
- return
- }
- if m.plain == nil && m.gzipped != nil {
- // Decompressed as it is sent, rather than into a copy of the
- // whole document.
- zr, err := gzip.NewReader(bytes.NewReader(m.gzipped))
- if err != nil {
- http.Error(w, "failed to read cached metadata", http.StatusInternalServerError)
- return
- }
- w.Header().Set(headerContentLength, strconv.Itoa(m.size))
- w.WriteHeader(http.StatusOK)
- _, _ = io.Copy(w, zr)
- return
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(m.plain)
-}
-
-// ensureVary adds field to the Vary header unless it is listed already.
-func ensureVary(header http.Header, field string) {
- for _, value := range header.Values("Vary") {
- for listed := range strings.SplitSeq(value, ",") {
- if strings.EqualFold(strings.TrimSpace(listed), field) {
- return
- }
- }
- }
- header.Add("Vary", field)
-}
-
-// gzipETag returns the ETag of the gzip representation of the document
-// whose ETag is etag.
-func gzipETag(etag string) string {
- return strings.TrimSuffix(etag, `"`) + `-gzip"`
-}
-
-// acceptsGzip reports whether the request's Accept-Encoding allows a gzip
-// response: gzip or x-gzip, or else "*", with a quality above zero.
-func acceptsGzip(r *http.Request) bool {
- wildcard := false
- for _, value := range r.Header.Values(headerAcceptEncoding) {
- for coding := range strings.SplitSeq(value, ",") {
- name, params, _ := strings.Cut(coding, ";")
- switch strings.ToLower(strings.TrimSpace(name)) {
- case "gzip", "x-gzip":
- return codingQualityPositive(params)
- case "*":
- wildcard = codingQualityPositive(params)
- }
- }
- }
- return wildcard
-}
-
-// codingQualityPositive reports whether an Accept-Encoding entry's
-// parameters give it a quality above zero. No q parameter means 1.
-func codingQualityPositive(params string) bool {
- for param := range strings.SplitSeq(params, ";") {
- key, value, _ := strings.Cut(param, "=")
- if strings.TrimSpace(key) != "q" {
- continue
- }
- q, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
- return err == nil && q > 0
- }
- return true
-}
-
-// notModified reports whether a conditional request's validators match.
-// If-Modified-Since only counts when the request has no If-None-Match.
-func notModified(r *http.Request, etag string, lastModified time.Time) bool {
- if header := r.Header.Get("If-None-Match"); header != "" {
- return ifNoneMatchHits(header, etag)
- }
- if lastModified.IsZero() {
- return false
- }
- since, err := http.ParseTime(r.Header.Get("If-Modified-Since"))
- return err == nil && !lastModified.Truncate(time.Second).After(since)
-}
-
-// SetMetadataRewriteCacheSize enables the cache of rewritten metadata with
-// room for maxBytes of output. Zero or less disables it.
-func (p *Proxy) SetMetadataRewriteCacheSize(maxBytes int64) {
- p.rewrites = newRewriteCache(maxBytes)
- if p.rewrites != nil {
- p.rewrites.now = p.clock
- }
-}
diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go
deleted file mode 100644
index 3f4c062..0000000
--- a/internal/handler/rewrite_cache_test.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// countingRewrite upper-cases its input and counts calls. If entered is set it
-// is closed when the first call starts, and the call then waits on release.
-type countingRewrite struct {
- calls atomic.Int64
- entered chan struct{}
- release chan struct{}
- once sync.Once
-}
-
-func (r *countingRewrite) fn(in []byte) ([]byte, error) {
- r.calls.Add(1)
- if r.entered != nil {
- r.once.Do(func() { close(r.entered) })
- <-r.release
- }
- return []byte(strings.ToUpper(string(in))), nil
-}
-
-func (r *countingRewrite) keeping(in []byte) ([]byte, []string, error) {
- out, err := r.fn(in)
- return out, nil, err
-}
-
-func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) {
- c := newRewriteCache(1 << 20)
- var r countingRewrite
- key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte("doc"))
-
- for range 3 {
- m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
- if err != nil || string(m.content()) != "DOC" {
- t.Fatalf("rewrite = %q, %v", m.content(), err)
- }
- }
- if got := r.calls.Load(); got != 1 {
- t.Errorf("rewrites = %d, want 1", got)
- }
-}
-
-func TestRewriteCache_NewUpstreamBytesRewriteAgain(t *testing.T) {
- c := newRewriteCache(1 << 20)
- var r countingRewrite
-
- for _, doc := range []string{"v1", "v2"} {
- key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte(doc))
- m, err := c.rewrite(context.Background(), key, []byte(doc), nil, r.fn)
- if err != nil || string(m.content()) != strings.ToUpper(doc) {
- t.Fatalf("rewrite(%s) = %q, %v", doc, m.content(), err)
- }
- }
- if got := r.calls.Load(); got != 2 {
- t.Errorf("rewrites = %d, want 2", got)
- }
-}
-
-// TestRewriteCache_ConcurrentRequestsShareRewrite asserts that requests
-// arriving while a document is being rewritten wait for that rewrite instead
-// of running their own. Callers that arrive after it finishes hit the cache,
-// so the count holds however the goroutines are scheduled.
-func TestRewriteCache_ConcurrentRequestsShareRewrite(t *testing.T) {
- c := newRewriteCache(1 << 20)
- r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
- key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
- const n = 10
-
- outs := make(chan string, n)
- run := func() {
- m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
- if err != nil {
- t.Error(err)
- }
- outs <- string(m.content())
- }
- go run()
- <-r.entered
- for range n - 1 {
- go run()
- }
- close(r.release)
-
- for range n {
- if out := <-outs; out != "DOC" {
- t.Errorf("out = %q", out)
- }
- }
- if got := r.calls.Load(); got != 1 {
- t.Errorf("rewrites = %d, want 1", got)
- }
-}
-
-// TestRewriteCache_WaiterLeavingKeepsRewrite asserts that a caller waiting on
-// another's rewrite returns its own context error when its client leaves,
-// while the rewrite completes and is cached for the next request.
-func TestRewriteCache_WaiterLeavingKeepsRewrite(t *testing.T) {
- c := newRewriteCache(1 << 20)
- r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
- key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
-
- first := make(chan error, 1)
- go func() {
- _, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
- first <- err
- }()
- <-r.entered
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- if _, err := c.rewrite(ctx, key, []byte("doc"), nil, r.fn); !errors.Is(err, context.Canceled) {
- t.Errorf("waiter err = %v, want context.Canceled", err)
- }
- close(r.release)
- if err := <-first; err != nil {
- t.Fatal(err)
- }
- if m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn); err != nil || string(m.content()) != "DOC" {
- t.Errorf("cached rewrite = %q, %v", m.content(), err)
- }
- if got := r.calls.Load(); got != 1 {
- t.Errorf("rewrites = %d, want 1", got)
- }
-}
-
-func TestRewriteCache_EvictsLeastRecentlyUsed(t *testing.T) {
- c := newRewriteCache(6) // room for two three-byte outputs
- var r countingRewrite
- keyFor := func(doc string) string { return rewriteCacheKey("npm", "http://proxy", doc, []byte(doc)) }
-
- for _, doc := range []string{"aaa", "bbb"} {
- _, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), nil, r.fn)
- }
- _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn) // aaa is now the most recent
- _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn) // evicts bbb
-
- before := r.calls.Load()
- _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn)
- _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn)
- if got := r.calls.Load() - before; got != 0 {
- t.Errorf("aaa and ccc rewritten %d times, want both still cached", got)
- }
- _, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), nil, r.fn)
- if got := r.calls.Load() - before; got != 1 {
- t.Errorf("bbb should have been evicted and rewritten")
- }
-}
-
-func TestRewriteCache_OutputLargerThanCacheIsNotStored(t *testing.T) {
- c := newRewriteCache(2)
- var r countingRewrite
- key := rewriteCacheKey("npm", "http://proxy", "big", []byte("big"))
-
- _, _ = c.rewrite(context.Background(), key, []byte("big"), nil, r.fn)
- if c.len() != 0 {
- t.Errorf("cached %d entries, want 0", c.len())
- }
-}
-
-func TestRewriteCache_ErrorsAreNotCached(t *testing.T) {
- c := newRewriteCache(1 << 20)
- var calls int
- failing := func([]byte) ([]byte, error) { calls++; return nil, errors.New("bad document") }
- key := rewriteCacheKey("npm", "http://proxy", "broken", []byte("doc"))
-
- for range 2 {
- if _, err := c.rewrite(context.Background(), key, []byte("doc"), nil, failing); err == nil {
- t.Fatal("expected the rewrite error")
- }
- }
- if calls != 2 {
- t.Errorf("rewrites = %d, want 2", calls)
- }
-}
-
-func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.SetMetadataRewriteCacheSize(0)
- var r countingRewrite
-
- for range 2 {
- _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
- }
- if got := r.calls.Load(); got != 2 {
- t.Errorf("rewrites = %d, want 2", got)
- }
-}
-
-// TestCachedRewrite_CooldownExpires asserts that with cooldown on a rewrite
-// is cached for maxCooldownRewriteTTL only: cooldown filtering depends on the
-// current time, so a cached rewrite would otherwise keep hiding a version
-// after its cooldown ends.
-func TestCachedRewrite_CooldownExpires(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
- proxy.now = clock.now
- proxy.SetMetadataRewriteCacheSize(1 << 20)
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
- var r countingRewrite
-
- for range 2 {
- _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
- }
- if got := r.calls.Load(); got != 1 {
- t.Errorf("rewrites within the TTL = %d, want 1", got)
- }
-
- clock.advance(maxCooldownRewriteTTL)
- _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
- if got := r.calls.Load(); got != 2 {
- t.Errorf("rewrites after the TTL = %d, want 2", got)
- }
-}
-
-// TestRewriteCacheExpiredEntryFreesRoom asserts that an expired entry is
-// dropped when looked up, so its size no longer counts.
-func TestRewriteCacheExpiredEntryFreesRoom(t *testing.T) {
- c := newRewriteCache(1 << 20)
- clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
- c.now = clock.now
- upper := func(b []byte) ([]byte, error) { return bytes.ToUpper(b), nil }
- if _, err := c.rewrite(context.Background(), "k", []byte("doc"), func(start time.Time) time.Time { return start.Add(time.Minute) }, upper); err != nil {
- t.Fatal(err)
- }
- clock.advance(time.Minute)
- if _, ok := c.get("k"); ok {
- t.Fatal("expired entry was served")
- }
- if c.size != 0 || c.order.Len() != 0 {
- t.Errorf("size %d, entries %d after expiry; want 0", c.size, c.order.Len())
- }
-}
-
-// TestMetadataHandlers_ServeCachedRewrite asserts that repeated requests for
-// the same npm or Composer metadata reuse one rewrite and return the same
-// bytes, with the download URLs pointing at the proxy.
-func TestMetadataHandlers_ServeCachedRewrite(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/left-pad":
- _, _ = w.Write([]byte(`{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`))
- case "/p2/vendor/pkg.json":
- _, _ = w.Write([]byte(`{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- proxy.SetMetadataRewriteCacheSize(1 << 20)
-
- cases := []struct {
- name string
- handler http.Handler
- path string
- }{
- {"npm", NewNPMHandler(proxy, "http://proxy.example", upstream.URL).Routes(), "/left-pad"},
- {"composer", NewComposerHandlerWithUpstreams(proxy, "http://proxy.example", upstream.URL, upstream.URL).Routes(), "/p2/vendor/pkg.json"},
- }
- for i, c := range cases {
- var bodies []string
- for range 2 {
- rec := httptest.NewRecorder()
- c.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
- if rec.Code != http.StatusOK {
- t.Fatalf("%s: status %d", c.name, rec.Code)
- }
- bodies = append(bodies, rec.Body.String())
- }
- if bodies[0] != bodies[1] || !strings.Contains(bodies[0], "http://proxy.example") {
- t.Errorf("%s: bodies %q and %q", c.name, bodies[0], bodies[1])
- }
- if got := proxy.rewrites.len(); got != i+1 {
- t.Errorf("%s: cached rewrites = %d, want %d", c.name, got, i+1)
- }
- }
-}
-
-// len reports how many rewrites are cached.
-func (c *rewriteCache) len() int {
- if c == nil {
- return 0
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- return len(c.entries)
-}
-
-// TestCooldownRewriteTTL checks that the cap on a rewrite filtered by
-// cooldown is the shortest cooldown, between a minute and a day.
-func TestCooldownRewriteTTL(t *testing.T) {
- for _, tc := range []struct {
- shortest, want time.Duration
- }{
- {0, 24 * time.Hour},
- {72 * time.Hour, 24 * time.Hour},
- {2 * time.Hour, 2 * time.Hour},
- {10 * time.Second, time.Minute},
- } {
- p := &Proxy{ShortestCooldown: tc.shortest}
- if got := p.cooldownRewriteTTL(); got != tc.want {
- t.Errorf("shortest cooldown %v: TTL = %v, want %v", tc.shortest, got, tc.want)
- }
- }
-}
diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go
index 7363cca..cff5b00 100644
--- a/internal/handler/rpm.go
+++ b/internal/handler/rpm.go
@@ -2,39 +2,17 @@ package handler
import (
"fmt"
+ "io"
"net/http"
- pathpkg "path"
"regexp"
"strings"
-
- "github.com/git-pkgs/proxy/internal/retention"
- "github.com/git-pkgs/purl"
)
const (
// Default upstream for Fedora packages
defaultRPMUpstream = "https://dl.fedoraproject.org/pub/fedora/linux"
- rpmMatchCount = 5 // full match + name + version + release + arch
)
-// Packages are stored as pkg:rpm/{name}, without a vendor and with the name's
-// case kept. Package rules may name a vendor the way PURLs usually do,
-// pkg:rpm/fedora/curl; it is dropped.
-func init() {
- retention.Default.Register(retention.Spec{Key: "rpm", CanonicalPackage: rpmRetentionKey})
-}
-
-// rpmRetentionKey maps a package rule to the stored package PURL. A name with
-// a slash, or a namespace with more than one segment, can never match a stored
-// package and is refused.
-func rpmRetentionKey(p *purl.PURL) (string, bool) {
- if p == nil || p.Type != "rpm" || strings.Contains(p.Namespace, "/") || strings.Contains(p.Name, "/") {
- return "", false
- }
- canonical := purl.MakePURLString("rpm", p.Name, "")
- return canonical, canonical != ""
-}
-
// RPMHandler handles RPM/Yum repository protocol requests.
// It proxies requests to upstream RPM repositories and caches .rpm packages.
type RPMHandler struct {
@@ -52,17 +30,10 @@ func NewRPMHandler(proxy *Proxy, proxyURL string) *RPMHandler {
}
}
-// NewRPMHandlerWithUpstream creates an RPM handler with a custom upstream.
-func NewRPMHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *RPMHandler {
- h := NewRPMHandler(proxy, proxyURL)
- h.upstreamURL = configuredUpstreamURL(upstreamURL, defaultRPMUpstream)
- return h
-}
-
// Routes returns the HTTP handler for RPM requests.
// Mount this at /rpm on your router.
func (h *RPMHandler) Routes() http.Handler {
- return rejectQueryChars(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -70,40 +41,25 @@ func (h *RPMHandler) Routes() http.Handler {
path := strings.TrimPrefix(r.URL.Path, "/")
- if containsPathTraversal(path) {
- http.Error(w, "invalid path", http.StatusBadRequest)
- return
- }
-
// Route based on path type
switch {
case strings.HasSuffix(path, ".rpm"):
// Package downloads - cache these
- h.handlePackageDownload(w, r, cleanRPMPath(path))
+ h.handlePackageDownload(w, r, path)
case strings.Contains(path, "/repodata/"):
- // Repository metadata
+ // Repository metadata - proxy without caching (changes frequently)
h.handleMetadata(w, r, path)
default:
// Other files - proxy directly
h.proxyFile(w, r, path)
}
- }))
-}
-
-// cleanRPMPath drops empty and "." segments from a relative path, so each
-// package has one cache key. ".." segments are rejected before this.
-func cleanRPMPath(p string) string {
- return strings.TrimPrefix(pathpkg.Clean("/"+p), "/")
+ })
}
// handlePackageDownload fetches and caches .rpm packages.
// Path format varies by repo structure, e.g.:
// - releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm
// - updates/39/Everything/x86_64/Packages/n/nginx-1.24.0-2.fc39.x86_64.rpm
-//
-// The cache key is the full path, since repositories under one upstream
-// (Rocky and Alma on one mirror, say) can ship different bytes under one
-// filename.
func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Request, path string) {
// Parse the path to extract package info
name, version, arch := h.parseRPMPath(path)
@@ -113,69 +69,88 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques
return
}
+ filename := path[strings.LastIndex(path, "/")+1:]
downloadURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
h.proxy.Logger.Info("rpm package download",
- "name", name, "version", version, "arch", arch, "path", path)
+ "name", name, "version", version, "arch", arch, "filename", filename)
result, err := h.proxy.GetOrFetchArtifactFromURL(
- r.Context(), "rpm", name, version, path, downloadURL)
+ r.Context(), "rpm", name, version, filename, downloadURL)
if err != nil {
- h.proxy.serveArtifactError(w, err, "failed to fetch package")
+ h.proxy.Logger.Error("failed to get rpm package", "error", err)
+ http.Error(w, "failed to fetch package", http.StatusBadGateway)
return
}
- w.Header().Set(headerContentType, "application/x-rpm")
- ServeArtifactRequest(w, r, result)
+ w.Header().Set("Content-Type", "application/x-rpm")
+ ServeArtifact(w, result)
}
-// handleMetadata proxies repository metadata files (repomd.xml,
-// primary.xml.gz, etc.). repomd.xml changes with every repository update and
-// follows the metadata TTL. The files it points at are usually named after
-// their own checksum and so never change; those are kept for good. Plainly
-// named ones follow the cached repomd.xml (see proxyListedIndex).
+// handleMetadata proxies repository metadata files (repomd.xml, primary.xml.gz, etc.).
+// These change frequently so we don't cache them.
func (h *RPMHandler) handleMetadata(w http.ResponseWriter, r *http.Request, path string) {
upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
- if rpmContentAddressed(path) {
- h.proxy.proxyImmutable(w, r, upstreamURL, "rpm", h.metadataCacheKey(path), "*/*")
+
+ h.proxy.Logger.Debug("rpm metadata request", "path", path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
- i := strings.LastIndex(path, "/repodata/")
- if strings.HasPrefix(path[i:], "/repodata/repomd.xml") {
- h.proxy.ProxyCached(w, r, upstreamURL, "rpm", h.metadataCacheKey(path), "*/*")
+
+ // Forward relevant headers
+ for _, header := range []string{"Accept", "Accept-Encoding", "If-Modified-Since", "If-None-Match"} {
+ if v := r.Header.Get(header); v != "" {
+ req.Header.Set(header, v)
+ }
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("failed to fetch upstream metadata", "error", err)
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
- // Plainly named files (createrepo --simple-md-filenames) follow the
- // cached repomd.xml that lists them.
- repomd := h.proxy.readCachedRelease(r.Context(), "rpm", h.metadataCacheKey(path[:i]+"/repodata/repomd.xml"))
- var want string
- if repomd != nil {
- want = repomdSHA256(repomd.body, path[i+1:])
+ defer func() { _ = resp.Body.Close() }()
+
+ // Copy response headers
+ for _, header := range []string{"Content-Type", "Content-Length", "Last-Modified", "ETag"} {
+ if v := resp.Header.Get(header); v != "" {
+ w.Header().Set(header, v)
+ }
}
- h.proxy.proxyListedIndex(w, r, upstreamURL, "rpm", h.metadataCacheKey(path), repomd, want)
-}
-// rpmChecksumPrefix matches a repodata filename that createrepo prefixed
-// with the file's checksum: -primary.xml.gz. The hex lengths are those
-// of the checksum types createrepo_c offers: SHA-1, SHA-224, SHA-256,
-// SHA-384 and SHA-512. Repositories built with --simple-md-filenames use
-// plain names, which can be replaced in place and follow the TTL.
-var rpmChecksumPrefix = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{56}|[0-9a-f]{64}|[0-9a-f]{96}|[0-9a-f]{128})-[^/]+$`)
-
-// rpmContentAddressed reports whether a repodata path names a file after
-// its checksum, so its content never changes.
-func rpmContentAddressed(path string) bool {
- return rpmChecksumPrefix.MatchString(path[strings.LastIndex(path, "/")+1:])
-}
-
-// metadataCacheKey is the metadata cache key of a repository path.
-func (h *RPMHandler) metadataCacheKey(path string) string {
- return hashedCacheKey(h.upstreamURL, path)
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// proxyFile proxies any file directly without caching.
func (h *RPMHandler) proxyFile(w http.ResponseWriter, r *http.Request, path string) {
- h.proxy.ProxyFile(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path))
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for key, values := range resp.Header {
+ for _, v := range values {
+ w.Header().Add(key, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// rpmPackagePattern matches .rpm filenames to extract name, version, release, and arch.
@@ -196,7 +171,7 @@ func (h *RPMHandler) parseRPMPath(path string) (name, version, arch string) {
// Parse the filename
matches := rpmPackagePattern.FindStringSubmatch(filename)
- if len(matches) != rpmMatchCount {
+ if len(matches) != 5 {
return "", "", ""
}
diff --git a/internal/handler/rpm_test.go b/internal/handler/rpm_test.go
index d9561e3..b24d8fa 100644
--- a/internal/handler/rpm_test.go
+++ b/internal/handler/rpm_test.go
@@ -1,146 +1,89 @@
package handler
import (
- "io"
"net/http"
"net/http/httptest"
- "sync/atomic"
"testing"
-
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries/fetch"
)
func TestRPMHandler_parseRPMPath(t *testing.T) {
h := &RPMHandler{}
- assertPathParser(t, "parseRPMPath", h.parseRPMPath, []pathParseCase{
- {"releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm", "nginx", "1.24.0-1.fc39", "x86_64"},
- {"Packages/kernel-core-6.5.5-200.fc38.x86_64.rpm", "kernel-core", "6.5.5-200.fc38", "x86_64"},
- {"updates/39/Everything/aarch64/Packages/g/git-2.42.0-1.fc39.aarch64.rpm", "git", "2.42.0-1.fc39", "aarch64"},
- {"vim-enhanced-9.0.1000-1.fc38.noarch.rpm", "vim-enhanced", "9.0.1000-1.fc38", "noarch"},
- {"invalid.rpm", "", "", ""},
- {"not-an-rpm-file", "", "", ""},
- })
+ tests := []struct {
+ path string
+ wantName string
+ wantVersion string
+ wantArch string
+ }{
+ {
+ path: "releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm",
+ wantName: "nginx",
+ wantVersion: "1.24.0-1.fc39",
+ wantArch: "x86_64",
+ },
+ {
+ path: "Packages/kernel-core-6.5.5-200.fc38.x86_64.rpm",
+ wantName: "kernel-core",
+ wantVersion: "6.5.5-200.fc38",
+ wantArch: "x86_64",
+ },
+ {
+ path: "updates/39/Everything/aarch64/Packages/g/git-2.42.0-1.fc39.aarch64.rpm",
+ wantName: "git",
+ wantVersion: "2.42.0-1.fc39",
+ wantArch: "aarch64",
+ },
+ {
+ path: "vim-enhanced-9.0.1000-1.fc38.noarch.rpm",
+ wantName: "vim-enhanced",
+ wantVersion: "9.0.1000-1.fc38",
+ wantArch: "noarch",
+ },
+ {
+ path: "invalid.rpm",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ {
+ path: "not-an-rpm-file",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ name, version, arch := h.parseRPMPath(tt.path)
+ if name != tt.wantName {
+ t.Errorf("parseRPMPath() name = %q, want %q", name, tt.wantName)
+ }
+ if version != tt.wantVersion {
+ t.Errorf("parseRPMPath() version = %q, want %q", version, tt.wantVersion)
+ }
+ if arch != tt.wantArch {
+ t.Errorf("parseRPMPath() arch = %q, want %q", arch, tt.wantArch)
+ }
+ })
+ }
}
func TestRPMHandler_Routes(t *testing.T) {
h := NewRPMHandler(nil, "http://localhost:8080")
- assertRoutesBasics(t, h.Routes(), "/repodata/repomd.xml", "/releases/../../../etc/passwd")
-}
-func TestRPMRetentionKey(t *testing.T) {
- tests := []struct {
- key string
- want string
- ok bool
- }{
- {"pkg:rpm/curl", "pkg:rpm/curl", true},
- {"pkg:rpm/fedora/curl", "pkg:rpm/curl", true},
- {"pkg:rpm/Fedora/NetworkManager", "pkg:rpm/NetworkManager", true},
- {"pkg:rpm/fedora/libstdc++", "pkg:rpm/libstdc%2B%2B", true},
- {"pkg:rpm/fedora/40/curl", "", false},
- {"pkg:rpm/fedora/Packages%2Fcurl", "", false},
- {"pkg:deb/fedora/curl", "", false},
+ // Test that handler doesn't panic on initialization
+ handler := h.Routes()
+ if handler == nil {
+ t.Fatal("Routes() returned nil")
}
- for _, tt := range tests {
- p, err := purl.Parse(tt.key)
- if err != nil {
- t.Fatalf("Parse(%s): %v", tt.key, err)
- }
- got, ok := rpmRetentionKey(p)
- if ok != tt.ok || (ok && got != tt.want) {
- t.Errorf("rpmRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
- }
- }
- if _, ok := rpmRetentionKey(nil); ok {
- t.Error("rpmRetentionKey(nil) accepted")
- }
-}
-
-// TestRPMHandler_PackageDownloadCachesPerRepository checks that two
-// repositories under one upstream that ship the same filename with different
-// bytes are cached apart, as on a mirror root serving several distributions.
-func TestRPMHandler_PackageDownloadCachesPerRepository(t *testing.T) {
- packages := map[string]string{
- "/rocky/9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm": "rocky bash",
- "/almalinux/9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm": "alma bash",
- }
-
- var available atomic.Bool
- available.Store(true)
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- data, ok := packages[r.URL.Path]
- if !available.Load() || !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write([]byte(data))
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- srv := httptest.NewServer(NewRPMHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes())
- defer srv.Close()
-
- for round := range 2 {
- for path, want := range packages {
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatal(err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusOK || string(body) != want {
- t.Errorf("round %d %s: %d %q, want 200 %q", round, path, resp.StatusCode, body, want)
- }
- }
- // The second round must come from the cache.
- available.Store(false)
- }
-}
-
-// TestRPMHandler_PackageDownloadCleansPath checks that spellings of one
-// path with empty or "." segments share one cache entry.
-func TestRPMHandler_PackageDownloadCleansPath(t *testing.T) {
- const want = "bash"
- var hits atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/rocky/9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm" {
- http.NotFound(w, r)
- return
- }
- hits.Add(1)
- _, _ = w.Write([]byte(want))
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
- srv := httptest.NewServer(NewRPMHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes())
- defer srv.Close()
-
- for _, path := range []string{
- "/rocky/9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm",
- "/rocky//9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm",
- "/rocky/./9/BaseOS/x86_64/os/Packages/b/bash-5.1.8-9.el9.x86_64.rpm",
- } {
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatal(err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusOK || string(body) != want {
- t.Errorf("%s: %d %q, want 200 %q", path, resp.StatusCode, body, want)
- }
- }
- if n := hits.Load(); n != 1 {
- t.Errorf("upstream fetched %d times, want 1", n)
+
+ // Test method not allowed
+ req := httptest.NewRequest(http.MethodPost, "/repodata/repomd.xml", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST request: got status %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
diff --git a/internal/handler/scan_test.go b/internal/handler/scan_test.go
deleted file mode 100644
index 7480bad..0000000
--- a/internal/handler/scan_test.go
+++ /dev/null
@@ -1,310 +0,0 @@
-package handler
-
-import (
- "context"
- "encoding/json"
- "errors"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/scanner"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// newTestScanServer returns an httptest.Server implementing the HTTPScanner
-// notify contract, always replying with the given verdict.
-func newTestScanServer(t testing.TB, allowed bool, reason string) *httptest.Server {
- t.Helper()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- t.Errorf("decode scan notify body: %v", err)
- }
- if body["fetch_url"] == "" || body["fetch_url"] == nil {
- t.Error("scan notify body missing fetch_url")
- }
- _ = json.NewEncoder(w).Encode(map[string]any{"allowed": allowed, "reason": reason})
- }))
- t.Cleanup(srv.Close)
- return srv
-}
-
-func newTestScanGroup(t testing.TB, scanURL string, failOpen bool) *scanner.Group {
- t.Helper()
- g, err := scanner.NewGroup(config.ScanningConfig{
- Enabled: true,
- FailOpen: failOpen,
- Timeout: "15s",
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "test-scanner", URL: scanURL, Mode: "block"},
- },
- }, slog.New(slog.NewTextHandler(io.Discard, nil)))
- if err != nil {
- t.Fatalf("scanner.NewGroup() error: %v", err)
- }
- return g
-}
-
-func TestGetOrFetchArtifact_ScanAllowed(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, true, "").URL, false)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("clean content")),
- ContentType: "application/gzip",
- }
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- body, _ := io.ReadAll(result.Reader)
- if string(body) != "clean content" {
- t.Errorf("body = %q, want %q", body, "clean content")
- }
-
- cached, err := db.GetCachedArtifact(
- purl.MakePURLString("npm", "leftpad", ""), purl.MakePURLString("npm", "leftpad", "1.0.0"), "leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("GetCachedArtifact() error: %v", err)
- }
- if cached == nil {
- t.Error("expected allowed artifact to be committed to the cache database")
- }
- if len(store.files) == 0 {
- t.Error("expected allowed artifact bytes to remain in storage")
- }
-}
-
-func TestGetOrFetchArtifact_ScanBlocked(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, false, "malware detected").URL, false)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("evil content")),
- ContentType: "application/gzip",
- }
-
- _, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "evilpkg", "1.0.0", "evilpkg-1.0.0.tgz")
- if err == nil {
- t.Fatal("expected error for blocked artifact")
- }
- if !errors.Is(err, ErrArtifactBlocked) {
- t.Errorf("error = %v, want wrapped ErrArtifactBlocked", err)
- }
- if !strings.Contains(err.Error(), "malware detected") {
- t.Errorf("error %q does not include scanner reason", err.Error())
- }
-
- cached, err := db.GetCachedArtifact(
- purl.MakePURLString("npm", "evilpkg", ""), purl.MakePURLString("npm", "evilpkg", "1.0.0"), "evilpkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("GetCachedArtifact() error: %v", err)
- }
- if cached != nil {
- t.Error("blocked artifact must never be committed to the cache database")
- }
- if len(store.files) != 0 {
- t.Errorf("blocked artifact bytes must be deleted from storage, got %d files", len(store.files))
- }
-}
-
-func TestGetOrFetchArtifact_BlockedDeleteSurvivesClientDisconnect(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
-
- const scanDelay = 150 * time.Millisecond
- blockingSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(scanDelay)
- _ = json.NewEncoder(w).Encode(map[string]any{"allowed": false, "reason": "malware detected"})
- }))
- t.Cleanup(blockingSrv.Close)
- proxy.Scanners = newTestScanGroup(t, blockingSrv.URL, false)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("evil content")),
- ContentType: "application/gzip",
- }
-
- // The client disconnects long before the (genuinely malicious) verdict
- // comes back; cleanup of the blocked bytes must not be skipped just
- // because the client is gone.
- ctx, cancel := context.WithCancel(context.Background())
- time.AfterFunc(20*time.Millisecond, cancel)
-
- _, err := proxy.GetOrFetchArtifact(ctx, "npm", "evilpkg", "1.0.0", "evilpkg-1.0.0.tgz")
- if err == nil {
- t.Fatal("expected error for blocked artifact")
- }
- if !errors.Is(err, ErrArtifactBlocked) {
- t.Errorf("error = %v, want wrapped ErrArtifactBlocked", err)
- }
-
- cached, _ := db.GetCachedArtifact(
- purl.MakePURLString("npm", "evilpkg", ""), purl.MakePURLString("npm", "evilpkg", "1.0.0"), "evilpkg-1.0.0.tgz")
- if cached != nil {
- t.Error("blocked artifact must never be committed to the cache database")
- }
- if len(store.files) != 0 {
- t.Errorf("blocked artifact bytes must still be deleted even though the client disconnected mid-scan, got %d orphaned files", len(store.files))
- }
-}
-
-func TestGetOrFetchArtifact_ScanErrorFailClosed(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
-
- brokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- }))
- t.Cleanup(brokenSrv.Close)
- proxy.Scanners = newTestScanGroup(t, brokenSrv.URL, false)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("content")),
- ContentType: "application/gzip",
- }
-
- _, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "flaky", "1.0.0", "flaky-1.0.0.tgz")
- if err == nil {
- t.Fatal("expected error when scanner infrastructure fails")
- }
- if !errors.Is(err, ErrArtifactBlocked) {
- t.Errorf("error = %v, want wrapped ErrArtifactBlocked (fail-closed default)", err)
- }
- if strings.Contains(err.Error(), brokenSrv.URL) {
- t.Errorf("error %q leaks the internal scanner URL to the client-facing message", err.Error())
- }
- if !strings.Contains(err.Error(), "scan could not be completed") {
- t.Errorf("error %q does not use the generic infra-failure message", err.Error())
- }
-
- cached, _ := db.GetCachedArtifact(
- purl.MakePURLString("npm", "flaky", ""), purl.MakePURLString("npm", "flaky", "1.0.0"), "flaky-1.0.0.tgz")
- if cached != nil {
- t.Error("artifact must not be committed when scanning fails fail-closed")
- }
- if len(store.files) != 0 {
- t.Errorf("artifact bytes must be deleted on scan infra failure, got %d files", len(store.files))
- }
-}
-
-func TestGetOrFetchArtifact_ScanErrorFailOpen(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
-
- brokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- }))
- t.Cleanup(brokenSrv.Close)
- proxy.Scanners = newTestScanGroup(t, brokenSrv.URL, true)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("content")),
- ContentType: "application/gzip",
- }
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "flaky", "1.0.0", "flaky-1.0.0.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v (FailOpen must treat scanner infra failure as allowed)", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- cached, err := db.GetCachedArtifact(
- purl.MakePURLString("npm", "flaky", ""), purl.MakePURLString("npm", "flaky", "1.0.0"), "flaky-1.0.0.tgz")
- if err != nil {
- t.Fatalf("GetCachedArtifact() error: %v", err)
- }
- if cached == nil {
- t.Error("expected artifact to be committed to the cache when scanning fails fail-open")
- }
- if len(store.files) == 0 {
- t.Error("expected artifact bytes to remain in storage when scanning fails fail-open")
- }
-}
-
-func TestGetOrFetchArtifact_ScanSurvivesClientDisconnect(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
-
- const scanDelay = 150 * time.Millisecond
- slowSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(scanDelay)
- _ = json.NewEncoder(w).Encode(map[string]any{"allowed": true})
- }))
- t.Cleanup(slowSrv.Close)
- proxy.Scanners = newTestScanGroup(t, slowSrv.URL, false)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("clean content")),
- ContentType: "application/gzip",
- }
-
- // Simulate a client that disconnects shortly after issuing the request:
- // its context is cancelled well before the scanner replies, but the
- // scan itself must run to completion rather than being torn down with
- // it.
- ctx, cancel := context.WithCancel(context.Background())
- time.AfterFunc(20*time.Millisecond, cancel)
-
- start := time.Now()
- result, err := proxy.GetOrFetchArtifact(ctx, "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
- elapsed := time.Since(start)
- if err != nil {
- t.Fatalf("unexpected error: %v (a cancelled client context must not be mistaken for a scanner failure)", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if elapsed < scanDelay {
- t.Errorf("GetOrFetchArtifact returned after %v, want it to wait out the full scan (%v) despite client cancellation", elapsed, scanDelay)
- }
-
- cached, err := db.GetCachedArtifact(
- purl.MakePURLString("npm", "leftpad", ""), purl.MakePURLString("npm", "leftpad", "1.0.0"), "leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("GetCachedArtifact() error: %v", err)
- }
- if cached == nil {
- t.Error("expected artifact to be committed to the cache; a client disconnect must not cause a false block")
- }
- if len(store.files) == 0 {
- t.Error("expected artifact bytes to remain in storage; a client disconnect must not delete a legitimately allowed artifact")
- }
-}
-
-func TestGetOrFetchArtifact_ScanDisabledIsNoOp(t *testing.T) {
- proxy, db, _, fetcher := setupTestProxy(t)
- // proxy.Scanners left nil: scanning disabled.
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("content")),
- ContentType: "application/gzip",
- }
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "plainpkg", "1.0.0", "plainpkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- cached, err := db.GetCachedArtifact(
- purl.MakePURLString("npm", "plainpkg", ""), purl.MakePURLString("npm", "plainpkg", "1.0.0"), "plainpkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("GetCachedArtifact() error: %v", err)
- }
- if cached == nil {
- t.Error("expected artifact to be cached when scanning is disabled")
- }
-}
diff --git a/internal/handler/scanfetch.go b/internal/handler/scanfetch.go
deleted file mode 100644
index 832da9c..0000000
--- a/internal/handler/scanfetch.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package handler
-
-import (
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "time"
-)
-
-// scanFetchURL builds a short-lived, HMAC-signed URL for the internal
-// /_internal/scan-fetch route, so an external scanner can pull the exact
-// bytes staged at path without going through cooldown or the scan hook
-// itself. This is generated the same way for every storage backend: it
-// never depends on Storage.SignedURL, which not every backend implements.
-func (p *Proxy) scanFetchURL(path string, ttl time.Duration) string {
- exp := time.Now().Add(ttl).Unix()
- return fmt.Sprintf("%s/_internal/scan-fetch?path=%s&exp=%d&sig=%s",
- p.ScanFetchBaseURL, url.QueryEscape(path), exp, hmacHex(p.ScanSigningKey, path, exp))
-}
-
-func hmacHex(key []byte, path string, exp int64) string {
- mac := hmac.New(sha256.New, key)
- _, _ = fmt.Fprintf(mac, "%s|%d", path, exp)
- return hex.EncodeToString(mac.Sum(nil))
-}
-
-// ServeScanFetch streams a storage object to a caller presenting a valid
-// short-lived HMAC token, so external scanners can pull a staged artifact
-// without going through cooldown or the scan hook themselves. This handler
-// never calls GetOrFetchArtifact/fetchAndCache/storeArtifact — the
-// separation from the normal request path is structural, not a
-// conditional bypass flag.
-//
-// This route exists only for scanners configured under ScanningConfig; the
-// URL is minted by scanFetchURL and passed as fetch_url in the scan notify
-// request. It is not part of the public API and should be restricted to
-// internal-network access at the ingress/network-policy layer — the HMAC
-// scoping (one object, short TTL) limits what a leaked token can do, but
-// isn't a substitute for network restriction.
-//
-// @Summary Fetch a staged artifact for scanning
-// @Description Streams the exact bytes staged in storage for a pre-cache security scan.
-// @Description Requires a short-lived HMAC-signed token minted by the proxy itself and
-// @Description delivered via the fetch_url field of the scan notify request (see the
-// @Description Artifact Scanning section of docs/configuration.md). Not part of the
-// @Description public API; restrict access to the scanner network at the ingress layer.
-// @Tags scanning
-// @Produce application/octet-stream
-// @Param path query string true "Storage path of the staged artifact"
-// @Param exp query int true "Token expiry, Unix seconds"
-// @Param sig query string true "HMAC-SHA256 signature over the string path|exp"
-// @Success 200 {file} file
-// @Failure 403 {string} string "invalid, expired, or tampered token"
-// @Failure 404 {string} string "object not found in storage, or scanning is not configured"
-// @Router /_internal/scan-fetch [get]
-func (p *Proxy) ServeScanFetch(w http.ResponseWriter, r *http.Request) {
- if p.Scanners == nil || !p.Scanners.Enabled() || len(p.ScanSigningKey) == 0 {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
-
- path := r.URL.Query().Get("path")
- exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
- if err != nil || containsPathTraversal(path) || time.Now().Unix() > exp {
- http.Error(w, "invalid or expired token", http.StatusForbidden)
- return
- }
-
- want := hmacHex(p.ScanSigningKey, path, exp)
- if !hmac.Equal([]byte(r.URL.Query().Get("sig")), []byte(want)) {
- http.Error(w, "invalid signature", http.StatusForbidden)
- return
- }
-
- reader, err := p.Storage.Open(r.Context(), path)
- if err != nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- defer func() { _ = reader.Close() }()
-
- w.Header().Set("Content-Type", "application/octet-stream")
- _, _ = io.Copy(w, reader)
-}
diff --git a/internal/handler/scanfetch_test.go b/internal/handler/scanfetch_test.go
deleted file mode 100644
index 280f1a2..0000000
--- a/internal/handler/scanfetch_test.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package handler
-
-import (
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/scanner"
-)
-
-// newEnabledScanGroup returns a scanner.Group that reports Enabled() true,
-// so tests can exercise ServeScanFetch's normal signature-checking path
-// rather than tripping its "scanning not configured" guard.
-func newEnabledScanGroup(t testing.TB) *scanner.Group {
- t.Helper()
- g, err := scanner.NewGroup(config.ScanningConfig{
- Enabled: true,
- Timeout: "15s",
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "test-scanner", URL: "http://localhost/scan", Mode: "block"},
- },
- }, slog.Default())
- if err != nil {
- t.Fatalf("scanner.NewGroup() error: %v", err)
- }
- return g
-}
-
-func TestServeScanFetch_ValidToken(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newEnabledScanGroup(t)
- store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
-
- target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if w.Body.String() != "artifact bytes" {
- t.Errorf("body = %q, want %q", w.Body.String(), "artifact bytes")
- }
-}
-
-func TestServeScanFetch_Expired(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newEnabledScanGroup(t)
- store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
-
- target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", -time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status = %d, want 403", w.Code)
- }
-}
-
-func TestServeScanFetch_TamperedSignature(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newEnabledScanGroup(t)
- store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
-
- target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
- tampered := strings.Replace(target, "sig=", "sig=deadbeef", 1)
-
- req := httptest.NewRequest(http.MethodGet, tampered, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status = %d, want 403", w.Code)
- }
-}
-
-func TestServeScanFetch_PathTraversal(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newEnabledScanGroup(t)
-
- target := proxy.scanFetchURL("../../etc/passwd", time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status = %d, want 403", w.Code)
- }
-}
-
-func TestServeScanFetch_ScanningDisabled(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- // proxy.Scanners left nil: scanning disabled.
- store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
-
- target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want 404 when scanning is disabled", w.Code)
- }
-}
-
-func TestServeScanFetch_NoSigningKey(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- // proxy.ScanSigningKey left empty.
- proxy.Scanners = newEnabledScanGroup(t)
- store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
-
- target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want 404 when no signing key is configured", w.Code)
- }
-}
-
-func TestServeScanFetch_MissingObject(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.ScanSigningKey = []byte("test-signing-key")
- proxy.Scanners = newEnabledScanGroup(t)
-
- target := proxy.scanFetchURL("npm/missing/1.0.0/missing-1.0.0.tgz", time.Minute)
-
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- proxy.ServeScanFetch(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want 404", w.Code)
- }
-}
diff --git a/internal/handler/shared_holds_test.go b/internal/handler/shared_holds_test.go
deleted file mode 100644
index 5536c00..0000000
--- a/internal/handler/shared_holds_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
- "github.com/git-pkgs/proxy/internal/metrics"
- registryclient "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// limitedUpstream answers every request with 429 and a Retry-After of ten
-// minutes. Requests wait for release, which is open unless a test closes it
-// later.
-type limitedUpstream struct {
- server *httptest.Server
- calls atomic.Int32
- entered chan struct{}
- release chan struct{}
- released sync.Once
-}
-
-func newLimitedUpstream(t *testing.T, blocked bool) *limitedUpstream {
- u := &limitedUpstream{entered: make(chan struct{}, 8), release: make(chan struct{})}
- if !blocked {
- u.open()
- }
- t.Cleanup(u.open)
- u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- u.calls.Add(1)
- u.entered <- struct{}{}
- <-u.release
- w.Header().Set("Retry-After", "600")
- w.WriteHeader(http.StatusTooManyRequests)
- }))
- t.Cleanup(u.server.Close)
- return u
-}
-
-func (u *limitedUpstream) open() { u.released.Do(func() { close(u.release) }) }
-
-func (u *limitedUpstream) artifactURL() string { return u.server.URL + "/left-pad-1.3.0.tgz" }
-
-// useSharedGate routes p's upstream requests through its own rate limit gate,
-// sharing holds through db as a replica does.
-func useSharedGate(p *Proxy, db *database.DB, u *limitedUpstream) *upstreamhttp.SharedRateLimitGate {
- gate := upstreamhttp.NewSharedRateLimitGate(registryclient.NewRateLimitGate(u.server.Client().Transport), db, nil)
- client := &http.Client{Transport: gate}
- p.HTTPClient = client
- p.Fetcher = fetch.NewFetcher(fetch.WithHTTPClient(client), fetch.WithBaseDelay(time.Millisecond))
- p.RateLimits = gate
- return gate
-}
-
-func fetchLimited(ctx context.Context, p *Proxy, u *limitedUpstream) error {
- res, err := p.GetOrFetchArtifactFromURL(ctx, "npm", "left-pad", "1.3.0", "left-pad-1.3.0.tgz", u.artifactURL())
- if err == nil {
- _ = res.Reader.Close()
- }
- return err
-}
-
-// requireHeld checks that err is answered like any held upstream request,
-// with 503 and a Retry-After.
-func requireHeld(t *testing.T, name string, err error) {
- t.Helper()
- w := httptest.NewRecorder()
- if !writeUpstreamHeld(w, err) {
- t.Fatalf("%s: err = %v, want a held upstream", name, err)
- }
- if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
- t.Fatalf("%s: status %d, Retry-After %q; want 503 with Retry-After", name, w.Code, w.Header().Get("Retry-After"))
- }
-}
-
-// TestSharedHoldStopsOtherInstance checks that once one instance is rate
-// limited, another sharing its database refuses requests to that host
-// without sending them, until the hold ends.
-func TestSharedHoldStopsOtherInstance(t *testing.T) {
- path := filepath.Join(t.TempDir(), "shared.db")
- dbA, err := database.Create(path)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = dbA.Close() })
- dbB, err := database.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = dbB.Close() })
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- store := newMockStorage()
- a := NewProxy(dbA, store, &mockFetcher{}, fetch.NewResolver(), logger)
- b := NewProxy(dbB, store, &mockFetcher{}, fetch.NewResolver(), logger)
-
- u := newLimitedUpstream(t, false)
- useSharedGate(a, dbA, u)
- gateB := useSharedGate(b, dbB, u)
- gateB.Refresh = 0
-
- if err := fetchLimited(context.Background(), a, u); err == nil {
- t.Fatal("instance a: want the rate limit error")
- }
- if got := u.calls.Load(); got != 1 {
- t.Fatalf("upstream requests after a = %d, want 1", got)
- }
-
- requireHeld(t, "instance b artifact", fetchLimited(context.Background(), b, u))
- _, _, err = b.FetchOrCacheMetadata(context.Background(), "npm", "left-pad", u.server.URL+"/left-pad", "application/json")
- if !errors.Is(err, registryclient.ErrUpstreamHeld) {
- t.Errorf("instance b metadata: err = %v, want a held upstream", err)
- }
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests after b = %d, want 1", got)
- }
-
- // The hold ends when the upstream said, and is then deleted.
- holds, err := dbB.UpstreamHolds(time.Now().Add(11 * time.Minute))
- if err != nil || len(holds) != 0 {
- t.Fatalf("holds after expiry = %v, %v; want none", holds, err)
- }
- if err := b.upstreamHeld(u.artifactURL()); err != nil {
- t.Errorf("instance b after expiry: %v, want no hold", err)
- }
-}
-
-// TestFetchLeaseRateLimitedHolderStopsWaiters checks that instances waiting
-// on a fetch that was rate limited answer with the holder's Retry-After
-// instead of each sending one more request to the limited host.
-func TestFetchLeaseRateLimitedHolderStopsWaiters(t *testing.T) {
- a, b, db := sharedInstances(t)
- u := newLimitedUpstream(t, true)
- useSharedGate(a, db, u)
- gateB := useSharedGate(b, db, u)
- // b has read the shared holds just before, and would not read them
- // again for an hour on its own.
- gateB.Refresh = time.Hour
- if err := b.upstreamHeld(u.artifactURL()); err != nil {
- t.Fatal(err)
- }
-
- waits := testutil.ToFloat64(metrics.FetchLeaseWaits.WithLabelValues(metrics.CacheKindArtifact))
- resA := make(chan error, 1)
- go func() { resA <- fetchLimited(context.Background(), a, u) }()
- <-u.entered
- resB := make(chan error, 1)
- go func() { resB <- fetchLimited(context.Background(), b, u) }()
- waitForLeaseWaits(t, metrics.CacheKindArtifact, waits)
- u.open()
-
- if err := <-resA; err == nil {
- t.Fatal("instance a: want the rate limit error")
- }
- requireHeld(t, "instance b", <-resB)
- if got := u.calls.Load(); got != 1 {
- t.Errorf("upstream requests = %d, want 1", got)
- }
-}
diff --git a/internal/handler/stale_cache_test.go b/internal/handler/stale_cache_test.go
deleted file mode 100644
index cf2dd7b..0000000
--- a/internal/handler/stale_cache_test.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "strings"
- "testing"
-)
-
-// These tests cover an upstream re-publishing a version: the cache holds one
-// artifact and upstream now declares another digest for it.
-
-const (
- stalePkgPURL = "pkg:npm/pkg"
- staleVersionPURL = "pkg:npm/pkg@1.0.0"
- staleFilename = "pkg-1.0.0.tgz"
- staleStoragePath = "npm/pkg/1.0.0/pkg-1.0.0.tgz"
- staleURL = "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"
-)
-
-// seedCachedArtifact commits content the way a fetch does.
-func seedCachedArtifact(t *testing.T, proxy *Proxy, store *mockStorage, content string) {
- t.Helper()
- ctx := context.Background()
- if _, _, err := store.Store(ctx, staleStoragePath, strings.NewReader(content)); err != nil {
- t.Fatalf("seeding storage: %v", err)
- }
- artifact := testArtifact(content, staleVersionPURL, staleFilename, "application/gzip")
- if err := proxy.updateCacheDB("npm", "pkg", stalePkgPURL, staleURL, staleStoragePath, artifact, ""); err != nil {
- t.Fatalf("seeding cache record: %v", err)
- }
-}
-
-// cachedDigest reports the digest the cache record holds, or "" without one.
-func cachedDigest(t *testing.T, proxy *Proxy) string {
- t.Helper()
- record, err := proxy.DB.GetCachedArtifact(stalePkgPURL, staleVersionPURL, staleFilename)
- if err != nil {
- t.Fatalf("reading cache record: %v", err)
- }
- if record == nil {
- return ""
- }
- return record.Artifact.Digest.Encoded()
-}
-
-func bytesPresent(store *mockStorage) bool {
- r, err := store.Open(context.Background(), staleStoragePath)
- if err != nil {
- return false
- }
- _ = r.Close()
- return true
-}
-
-func TestStaleCacheCheckHasNoSideEffects(t *testing.T) {
- proxy, _, store, _ := setupTestProxy(t)
- seedCachedArtifact(t, proxy, store, "old bytes")
-
- res, err := proxy.getCachedArtifactWithUpstreamHash(context.Background(),
- stalePkgPURL, staleVersionPURL, staleFilename, sha256Hex("new bytes"))
- if err != nil {
- t.Fatalf("cache check failed: %v", err)
- }
- if res != nil {
- drain(res)
- t.Fatal("stale entry was served")
- }
- if got := cachedDigest(t, proxy); got != sha256Hex("old bytes") {
- t.Errorf("record digest = %q, want the stale one kept: the check must not discard", got)
- }
- if !bytesPresent(store) {
- t.Error("stale bytes were deleted by the check")
- }
-}
-
-func TestStaleCacheIsDiscardedBeforeTheFetch(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
- seedCachedArtifact(t, proxy, store, "old bytes")
- boom := errors.New("upstream unavailable")
- fetcher.fetchErr = boom
-
- _, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(),
- "npm", "pkg", "1.0.0", staleFilename, staleURL, "sha256:"+sha256Hex("new bytes"))
- if !errors.Is(err, boom) {
- t.Fatalf("got %v, want the fetch failure", err)
- }
- if got := cachedDigest(t, proxy); got != "" {
- t.Errorf("stale record survived a failed refresh, digest = %q", got)
- }
- // A request that read the stale record may still be opening its bytes,
- // so they are queued for deletion rather than deleted.
- if !bytesPresent(store) {
- t.Error("stale bytes were deleted while a reader may still open them")
- }
- assertQueuedForDeletion(t, proxy.DB, staleStoragePath)
-}
-
-func TestStaleCacheIsReplacedByTheFetch(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
- seedCachedArtifact(t, proxy, store, "old bytes")
- fetcher.artifact = artifactBody("new bytes")
- upstream := sha256Hex("new bytes")
-
- res, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(),
- "npm", "pkg", "1.0.0", staleFilename, staleURL, "sha256:"+upstream)
- if err != nil {
- t.Fatalf("refresh failed: %v", err)
- }
- got, err := io.ReadAll(res.Reader)
- _ = res.Reader.Close()
- if err != nil || string(got) != "new bytes" {
- t.Fatalf("got %q (err %v), want the refreshed bytes", got, err)
- }
- if !fetcher.fetchCalled {
- t.Error("stale entry was served without a fetch")
- }
- if d := cachedDigest(t, proxy); d != upstream {
- t.Errorf("record digest = %q, want %q", d, upstream)
- }
-
- fetcher.fetchCalled = false
- res, err = proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(),
- "npm", "pkg", "1.0.0", staleFilename, staleURL, "sha256:"+upstream)
- if err != nil {
- t.Fatalf("request after refresh failed: %v", err)
- }
- drain(res)
- if fetcher.fetchCalled || !res.Cached {
- t.Errorf("request after refresh: fetched=%v cached=%v, want served from cache", fetcher.fetchCalled, res.Cached)
- }
-}
-
-// TestLateLeaderKeepsRefreshedEntry is the race, at the point it would happen:
-// a caller whose cache check saw a stale entry reaches the coalescing step
-// after another caller's fetch replaced it. It must serve the replacement.
-func TestLateLeaderKeepsRefreshedEntry(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
- seedCachedArtifact(t, proxy, store, "new bytes")
- fetcher.fetchErr = errors.New("must not fetch")
- upstream := sha256Hex("new bytes")
-
- res, err := proxy.coalescedFetchFromURL(context.Background(),
- "npm", "pkg", "1.0.0", staleFilename, stalePkgPURL, staleVersionPURL, staleURL, nil, upstream, "")
- if err != nil {
- t.Fatalf("late leader failed: %v", err)
- }
- got, err := io.ReadAll(res.Reader)
- _ = res.Reader.Close()
- if err != nil || string(got) != "new bytes" {
- t.Fatalf("got %q (err %v), want the refreshed bytes", got, err)
- }
- if fetcher.fetchCalled {
- t.Error("refreshed entry was fetched again")
- }
- if d := cachedDigest(t, proxy); d != upstream {
- t.Errorf("refreshed record was discarded, digest = %q", d)
- }
- if !bytesPresent(store) {
- t.Error("refreshed bytes were deleted")
- }
-}
diff --git a/internal/handler/stale_metadata_test.go b/internal/handler/stale_metadata_test.go
deleted file mode 100644
index 9cd8e75..0000000
--- a/internal/handler/stale_metadata_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package handler
-
-import (
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// upstreamThen serves body once and answers every later request with status.
-func upstreamThen(t *testing.T, body string, status int) *httptest.Server {
- t.Helper()
- var requests atomic.Int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- if requests.Add(1) == 1 {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(body))
- return
- }
- w.WriteHeader(status)
- }))
- t.Cleanup(srv.Close)
- return srv
-}
-
-// staleProxy returns a proxy whose cached metadata expires right away.
-func staleProxy(t *testing.T, upstream *httptest.Server) *Proxy {
- t.Helper()
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Millisecond
- proxy.HTTPClient = upstream.Client()
- return proxy
-}
-
-// requestTwice requests path, lets the cached copy expire, and requests it
-// again.
-func requestTwice(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
- t.Helper()
- first := httptest.NewRecorder()
- h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, path, nil))
- if first.Code != http.StatusOK {
- t.Fatalf("first request: status = %d, body %s", first.Code, first.Body)
- }
- time.Sleep(5 * time.Millisecond)
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- return w
-}
-
-// staleStatuses are upstream answers the metadata cache falls back on.
-var staleStatuses = []int{http.StatusBadGateway, http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden}
-
-const staleComposerDocument = `{"packages":{"acme/widget":[{"name":"acme/widget","version":"1.0.0",` +
- `"dist":{"type":"zip","url":"https://repo.packagist.org/files/acme/widget/1.0.0/widget.zip"}}]},"minified":"composer/2.0"}`
-
-func TestComposerStaleMetadataIsMarked(t *testing.T) {
- for _, status := range staleStatuses {
- t.Run(http.StatusText(status), func(t *testing.T) {
- upstream := upstreamThen(t, staleComposerDocument, status)
- h := NewComposerHandlerWithUpstreams(staleProxy(t, upstream), "http://proxy.test", upstream.URL, upstream.URL).Routes()
-
- w := requestTwice(t, h, "/p2/acme/widget.json")
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200 from the cache, body %s", w.Code, w.Body)
- }
- if got := w.Header().Get("Warning"); got != staleWarning {
- t.Errorf("Warning = %q, want %q", got, staleWarning)
- }
- })
- }
-}
-
-const staleChefUniverse = `{"apt":{"7.5.0":{"location_type":"supermarket",` +
- `"location_path":"https://supermarket.chef.io/api/v1","download_url":` +
- `"https://supermarket.chef.io/api/v1/cookbooks/apt/versions/7.5.0/download","dependencies":{}}}}`
-
-func TestChefStaleMetadataIsMarked(t *testing.T) {
- for _, status := range staleStatuses {
- t.Run(http.StatusText(status), func(t *testing.T) {
- upstream := upstreamThen(t, staleChefUniverse, status)
- h := NewChefHandler(staleProxy(t, upstream), chefTestProxyURL, upstream.URL).Routes()
-
- w := requestTwice(t, h, "/universe")
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200 from the cache, body %s", w.Code, w.Body)
- }
- if got := w.Header().Get("Warning"); got != staleWarning {
- t.Errorf("Warning = %q, want %q", got, staleWarning)
- }
- })
- }
-}
diff --git a/internal/handler/stream_artifacts_test.go b/internal/handler/stream_artifacts_test.go
deleted file mode 100644
index af0e3f1..0000000
--- a/internal/handler/stream_artifacts_test.go
+++ /dev/null
@@ -1,411 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/artifacts"
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func newStreamingProxy(t *testing.T, body string) (*Proxy, *mockStorage, *mockFetcher) {
- t.Helper()
- proxy, _, store, fetcher := setupTestProxy(t)
- proxy.StreamArtifacts = true
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(body)),
- Size: int64(len(body)),
- ContentType: "application/gzip",
- }
- return proxy, store, fetcher
-}
-
-func TestStreamArtifactsFromURLStreamsWithoutStoring(t *testing.T) {
- proxy, store, fetcher := newStreamingProxy(t, "fetched content")
-
- result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0",
- "newpkg-1.0.0.tar.gz", "https://pypi.org/files/newpkg-1.0.0.tar.gz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- body, err := io.ReadAll(result.Reader)
- if err != nil {
- t.Fatalf("reading body: %v", err)
- }
- if string(body) != "fetched content" {
- t.Errorf("body = %q, want %q", body, "fetched content")
- }
- if fetcher.fetchedURL != "https://pypi.org/files/newpkg-1.0.0.tar.gz" {
- t.Errorf("fetched URL = %q", fetcher.fetchedURL)
- }
- if result.Cached {
- t.Error("streaming result reported as cached")
- }
- if result.Artifact.Size != int64(len("fetched content")) {
- t.Errorf("Size = %d, want the upstream size", result.Artifact.Size)
- }
- if result.Artifact.MediaType != "application/gzip" {
- t.Errorf("MediaType = %q", result.Artifact.MediaType)
- }
- if len(store.files) != 0 {
- t.Errorf("streaming stored %d files, want none", len(store.files))
- }
- cached, err := proxy.DB.GetCachedArtifact("pkg:pypi/newpkg", "pkg:pypi/newpkg@1.0.0", "newpkg-1.0.0.tar.gz")
- if err != nil {
- t.Fatalf("GetCachedArtifact: %v", err)
- }
- if cached != nil {
- t.Error("streaming recorded the artifact in the cache database")
- }
-}
-
-func TestStreamArtifactsGetOrFetchArtifactStreamsWithoutStoring(t *testing.T) {
- proxy, store, fetcher := newStreamingProxy(t, "tarball data")
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "leftpad", testVersion100, "leftpad-1.0.0.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- body, _ := io.ReadAll(result.Reader)
- if string(body) != "tarball data" {
- t.Errorf("body = %q", body)
- }
- if !fetcher.fetchCalled {
- t.Error("upstream was not fetched")
- }
- if len(store.files) != 0 {
- t.Errorf("streaming stored %d files, want none", len(store.files))
- }
-}
-
-func TestStreamArtifactsIgnoresCachedArtifacts(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("old bytes"))}
- url := "https://pypi.org/files/newpkg-1.0.0.tar.gz"
-
- // Populate the cache in normal mode first.
- result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", url)
- if err != nil {
- t.Fatalf("priming cache: %v", err)
- }
- _ = result.Reader.Close()
- if len(store.files) != 1 {
- t.Fatalf("expected the cache to hold the artifact, got %d files", len(store.files))
- }
-
- proxy.StreamArtifacts = true
- cached, err := proxy.GetCachedArtifact(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz")
- if err != nil || cached != nil {
- t.Fatalf("GetCachedArtifact = %v, %v; want nil, nil while streaming", cached, err)
- }
-
- fetcher.fetchCalled = false
- fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("new bytes"))}
- result, err = proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", url)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
- body, _ := io.ReadAll(result.Reader)
- if !fetcher.fetchCalled || string(body) != "new bytes" {
- t.Errorf("streaming served %q (fetched=%v), want a fresh upstream fetch", body, fetcher.fetchCalled)
- }
-}
-
-func TestStreamArtifactsVerifiesUpstreamDigest(t *testing.T) {
- const content = "blob bytes"
-
- t.Run("matching digest streams the body", func(t *testing.T) {
- proxy, _, _ := newStreamingProxy(t, content)
- result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
- "blob", "https://registry.test/blob", "sha256:"+sha256Hex(content))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- body, err := io.ReadAll(result.Reader)
- if err != nil {
- t.Fatalf("reading verified body: %v", err)
- }
- if string(body) != content {
- t.Errorf("body = %q", body)
- }
- if got := result.Artifact.Digest.Encoded(); got != sha256Hex(content) {
- t.Errorf("Digest = %q, want the upstream digest", got)
- }
- if result.Artifact.Size >= 0 {
- t.Errorf("Size = %d, want unknown so the response is chunked", result.Artifact.Size)
- }
- })
-
- t.Run("mismatched digest fails the read", func(t *testing.T) {
- proxy, _, _ := newStreamingProxy(t, content)
- result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
- "blob", "https://registry.test/blob", "sha256:"+sha256Hex("other bytes"))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if _, err := io.ReadAll(result.Reader); !errors.Is(err, ErrArtifactDigestMismatch) {
- t.Errorf("read error = %v, want ErrArtifactDigestMismatch", err)
- }
- })
-}
-
-func TestServeArtifactAbortsOnStreamedDigestMismatch(t *testing.T) {
- proxy, _, _ := newStreamingProxy(t, "blob bytes")
- result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
- "blob", "https://registry.test/blob", "sha256:"+sha256Hex("other bytes"))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- rec := httptest.NewRecorder()
- defer func() {
- if r := recover(); r != http.ErrAbortHandler {
- t.Fatalf("recovered %v, want http.ErrAbortHandler", r)
- }
- if rec.Header().Get(headerContentLength) != "" {
- t.Errorf("Content-Length = %q, want none so the client sees an unterminated response",
- rec.Header().Get(headerContentLength))
- }
- }()
- ServeArtifact(rec, result)
-}
-
-func TestNPMDownloadCooldownWhileStreaming(t *testing.T) {
- now := time.Now()
- packument := `{
- "name": "leftpad",
- "dist-tags": {"latest": "2.0.0"},
- "time": {
- "1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `",
- "2.0.0": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"
- },
- "versions": {"1.0.0": {}, "2.0.0": {}}
- }`
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", contentTypeJSON)
- _, _ = io.WriteString(w, packument)
- }))
- defer upstream.Close()
-
- tests := []struct {
- name string
- version string
- wantStatus int
- }{
- {"published before the window streams the tarball", testVersion100, http.StatusOK},
- {"published inside the window is withheld", "2.0.0", http.StatusNotFound},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, store, fetcher := newStreamingProxy(t, "tarball data")
- proxy.HTTPClient = upstream.Client()
- proxy.Cooldown = &cooldown.Config{Default: "7d"}
-
- srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != tt.wantStatus {
- t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
- }
- if tt.wantStatus == http.StatusOK && string(body) != "tarball data" {
- t.Errorf("body = %q", body)
- }
- if tt.wantStatus == http.StatusNotFound && fetcher.fetchCalled {
- t.Error("fetched a version that is still inside the cooldown window")
- }
- if len(store.files) != 0 {
- t.Errorf("streaming stored %d files, want none", len(store.files))
- }
- })
- }
-}
-
-// truncatingUpstream answers every request matching match with raw, then
-// closes the connection, so the response body ends early.
-func truncatingUpstream(t *testing.T, match func(*http.Request) bool, raw string, fallback http.HandlerFunc) *httptest.Server {
- t.Helper()
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !match(r) {
- fallback(w, r)
- return
- }
- conn, buf, err := w.(http.Hijacker).Hijack()
- if err != nil {
- t.Errorf("hijack: %v", err)
- return
- }
- _, _ = buf.WriteString(raw)
- _ = buf.Flush()
- _ = conn.Close()
- }))
- t.Cleanup(upstream.Close)
- return upstream
-}
-
-// requireIncompleteResponse fails unless reading the response surfaces an error,
-// i.e. the client cannot mistake the body for a complete download.
-func requireIncompleteResponse(t *testing.T, url string) {
- t.Helper()
- resp, err := http.Get(url)
- if err != nil {
- return
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err == nil {
- t.Fatalf("client read a complete %d response (Content-Length %q, body %q), want an incomplete one",
- resp.StatusCode, resp.Header.Get(headerContentLength), body)
- }
-}
-
-func useRealFetcher(t *testing.T, proxy *Proxy, upstream *httptest.Server) {
- t.Helper()
- proxy.HTTPClient = upstream.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- t.Cleanup(func() { _ = fetcher.Close() })
-}
-
-func TestStreamArtifactsOCIBlobShorterThanContentLengthIsIncomplete(t *testing.T) {
- digest := "sha256:" + sha256Hex(strings.Repeat("x", 100))
- upstream := truncatingUpstream(t,
- func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/blobs/") },
- "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 100\r\n\r\nshort",
- http.NotFound)
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.StreamArtifacts = true
- useRealFetcher(t, proxy, upstream)
- h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", "", map[string]string{"ghcr": upstream.URL})
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- requireIncompleteResponse(t, srv.URL+"/upstream/ghcr/owner/demo/blobs/"+digest)
- if len(store.files) != 0 {
- t.Errorf("streaming stored %d files, want none", len(store.files))
- }
-}
-
-func TestStreamArtifactsNPMTarballWithoutFinalChunkIsIncomplete(t *testing.T) {
- upstream := truncatingUpstream(t,
- func(r *http.Request) bool { return strings.HasSuffix(r.URL.Path, ".tgz") },
- "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nshort\r\n",
- func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"versions":{}}`) })
-
- proxy, _, store, _ := setupTestProxy(t)
- proxy.StreamArtifacts = true
- useRealFetcher(t, proxy, upstream)
- srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
- defer srv.Close()
-
- requireIncompleteResponse(t, srv.URL+"/leftpad/-/leftpad-1.0.0.tgz")
- if len(store.files) != 0 {
- t.Errorf("streaming stored %d files, want none", len(store.files))
- }
-}
-
-func TestServeArtifactAbortsOnShortRead(t *testing.T) {
- tests := []struct {
- name string
- reader io.Reader
- size int64
- }{
- {"read error", io.MultiReader(strings.NewReader("short"), iotestErrReader{io.ErrUnexpectedEOF}), -1},
- {"fewer bytes than the declared size", strings.NewReader("short"), 100},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := &CacheResult{Reader: io.NopCloser(tt.reader), Artifact: artifacts.Artifact{Size: tt.size}}
- defer func() {
- if r := recover(); r != http.ErrAbortHandler {
- t.Fatalf("recovered %v, want http.ErrAbortHandler", r)
- }
- }()
- ServeArtifact(httptest.NewRecorder(), result)
- })
- }
-}
-
-type iotestErrReader struct{ err error }
-
-func (r iotestErrReader) Read([]byte) (int, error) { return 0, r.err }
-
-func TestStreamArtifactsSwiftArchiveHeadIgnoresCachedEntry(t *testing.T) {
- archive := []byte("cached archive")
- checksum := sha256.Sum256(archive)
- var archiveRequests int
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, ".zip") {
- archiveRequests++
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(checksum[:]))
- }))
- defer upstream.Close()
-
- proxy, _, _, fetcher := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(string(archive))),
- Size: int64(len(archive)),
- ContentType: "application/zip",
- }
- packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
- cached, err := proxy.getOrFetchArtifactFromURLWithCachePURLs(
- context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
- packagePURL, versionPURL, upstream.URL+"/apple/example/1.2.3.zip", nil, hex.EncodeToString(checksum[:]), "",
- )
- if err != nil {
- t.Fatalf("seeding cache in normal mode: %v", err)
- }
- _ = cached.Reader.Close()
-
- proxy.StreamArtifacts = true
- fetcher.artifact = nil
- fetcher.fetchErr = fetch.ErrNotFound
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
-
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
- if w.Code == http.StatusOK {
- t.Fatalf("HEAD served the cached archive (Content-Length %q) while streaming artifacts", w.Header().Get(headerContentLength))
- }
- if archiveRequests == 0 {
- t.Error("HEAD did not ask the upstream archive")
- }
-
- w = httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
- if w.Code == http.StatusOK {
- t.Fatalf("GET served the cached archive while streaming artifacts")
- }
-}
diff --git a/internal/handler/stream_miss.go b/internal/handler/stream_miss.go
deleted file mode 100644
index fb8ae62..0000000
--- a/internal/handler/stream_miss.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "strings"
- "sync"
-
- "github.com/git-pkgs/artifacts"
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/registries/fetch"
- "github.com/opencontainers/go-digest"
-)
-
-// On a cache miss a marked request is sent the bytes as they arrive from
-// upstream while they are stored, rather than after the whole artifact is
-// stored. Clients with idle timeouts need this for large artifacts: git-lfs
-// gives up after 30 seconds without data, pacman after 10. The caller that
-// starts the shared fetch is handed the stream; callers joining it still wait
-// for the stored artifact.
-
-type streamCacheMissKey struct{}
-
-// StreamCacheMiss marks ctx so that on an artifact cache miss the bytes are
-// sent to the client while they are being stored. The server sets it on GET
-// requests without a Range header; a range of a stream that is still
-// arriving can't be served.
-func StreamCacheMiss(ctx context.Context) context.Context {
- return context.WithValue(ctx, streamCacheMissKey{}, true)
-}
-
-func streamsCacheMiss(ctx context.Context) bool {
- stream, _ := ctx.Value(streamCacheMissKey{}).(bool)
- return stream && !servingHead(ctx)
-}
-
-// missHandoff passes the client's stream from the shared fetch, which runs on
-// its own goroutine, to the caller that started it. The caller can leave
-// before a stream is offered, and the fetch must then not write to a stream
-// nobody reads.
-type missHandoff struct {
- mu sync.Mutex
- ready chan struct{}
- stream *CacheResult
- taken bool
- abandoned bool
- // feed is the stream's source. Set and read only on the fetch's
- // goroutine.
- feed *missFeed
-}
-
-type missHandoffKey struct{}
-
-func handoffFrom(ctx context.Context) *missHandoff {
- h, _ := ctx.Value(missHandoffKey{}).(*missHandoff)
- return h
-}
-
-// offer hands stream to the caller and reports whether it took it.
-func (h *missHandoff) offer(stream *CacheResult) bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- if h.abandoned {
- return false
- }
- h.stream, h.taken = stream, true
- close(h.ready)
- return true
-}
-
-// holdUnrecorded gives a client that fell behind, and reads the stored copy
-// of u, its own hold on u until it closes, so u isn't deleted under it.
-func (h *missHandoff) holdUnrecorded(u *unrecordedObject) {
- if h.feed != nil {
- h.feed.holdUntilClose(u)
- }
-}
-
-// handedOver reports whether the caller took a stream.
-func (h *missHandoff) handedOver() bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- return h.taken
-}
-
-// runSharedFetchStreaming runs the shared fetch on its own goroutine and
-// returns as soon as it offers a stream, or with its result if it finishes
-// without one: on a recheck hit, an artifact another instance stored, an
-// error, or an artifact a block-mode scanner has to see first. Waiters and
-// the fetch lease still see the fetch end only once the artifact is stored.
-func (p *Proxy) runSharedFetchStreaming(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
- h := &missHandoff{ready: make(chan struct{})}
- type outcome struct {
- res *CacheResult
- err error
- }
- done := make(chan outcome, 1)
- go func() {
- res, err := p.runSharedFetch(context.WithValue(ctx, missHandoffKey{}, h), key, f, recheck, commit)
- h.mu.Lock()
- defer h.mu.Unlock()
- if h.taken || h.abandoned {
- // The caller has the stream, or has gone; nobody reads this.
- if res != nil && res.Reader != nil {
- _ = res.Reader.Close()
- }
- return
- }
- done <- outcome{res, err}
- }()
-
- select {
- case <-h.ready:
- return h.stream, nil
- case o := <-done:
- return o.res, o.err
- case <-ctx.Done():
- h.mu.Lock()
- defer h.mu.Unlock()
- if h.taken {
- return h.stream, nil
- }
- h.abandoned = true
- select {
- case o := <-done:
- if o.res != nil && o.res.Reader != nil {
- _ = o.res.Reader.Close()
- }
- default:
- }
- return nil, ctx.Err()
- }
-}
-
-// storeFetched stores an artifact fetched from upstream. When the caller
-// that started the fetch is waiting for a stream, it is sent the bytes as
-// they are stored; see storeWhileServing.
-func (p *Proxy) storeFetched(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
- h := handoffFrom(ctx)
- if h == nil || p.Scanners.Blocks(ecosystem) {
- return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
- }
- return p.storeWhileServing(ctx, h, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
-}
-
-// errNoReaderLeft stops reading upstream once neither storage nor the
-// client takes the bytes any more.
-var errNoReaderLeft = errors.New("neither storage nor the client is reading the artifact")
-
-// storeWhileServing copies the upstream body to storage and to the client at
-// once, at the upstream's speed; see missFeed for a client that can't keep up.
-// The client's copy is checked before its last bytes, so a mismatch aborts
-// the response. A storage failure leaves the client's copy running.
-func (p *Proxy) storeWhileServing(ctx context.Context, h *missHandoff, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
- hash := strings.ToLower(upstreamHash)
- checks, err := newIntegrityChecks(hash, declared)
- if err != nil {
- _ = artifact.Body.Close()
- return artifacts.Artifact{}, "", err
- }
-
- feed := newMissFeed(clientFeedLimit)
- var client io.ReadCloser = feed
- if hash != "" || declared != "" {
- if client, err = checks.wrapFailOnMismatch(feed, func(reason string) {
- p.Logger.Warn("artifact sent on a cache miss failed its integrity check; aborting the response",
- "purl", versionPURL, "filename", filename, "reason", reason)
- }); err != nil {
- _ = artifact.Body.Close()
- return artifacts.Artifact{}, "", err
- }
- }
- stream := &CacheResult{
- Reader: client,
- Artifact: artifacts.Artifact{
- PURL: versionPURL,
- Filename: filename,
- MediaType: artifact.ContentType,
- // Unknown until stored, so the response goes out chunked and
- // can still be aborted.
- Size: -1,
- },
- }
- if hash != "" {
- stream.Artifact.Digest = digest.Digest("sha256:" + hash)
- }
- if !h.offer(stream) {
- _ = feed.Close()
- return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
- }
- h.feed = feed
-
- storeR, storeW := io.Pipe()
- type stored struct {
- artifact artifacts.Artifact
- path string
- err error
- }
- storeDone := make(chan stored, 1)
- go func() {
- // Not cancelled with ctx: the client's request ends once it has the
- // last byte, while storage may still be finishing the upload and the
- // database write. Reading the upstream still stops with ctx, which
- // ends the copy to storage too.
- a, path, err := p.storeArtifact(context.WithoutCancel(ctx), ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared,
- &fetch.Artifact{Body: storeR, Size: artifact.Size, ContentType: artifact.ContentType, ETag: artifact.ETag})
- // storeArtifact closes storeR; this covers a return before it read
- // anything, so the copy below never blocks on it.
- _ = storeR.Close()
- storeDone <- stored{a, path, err}
- }()
-
- readErr := copyToBoth(artifact.Body, storeW, feed)
- _ = artifact.Body.Close()
- _ = storeW.CloseWithError(readErr)
- feed.end(readErr)
-
- out := <-storeDone
- if feed.lagging() {
- // Opened before returning, so an unrecorded object is still there;
- // runSharedFetch then gives the client a hold on it.
- feed.resolve(p.openStoredForFeed(ctx, out.artifact, out.path, out.err))
- }
- if out.err != nil && readErr == nil && feed.fedFully() {
- p.Logger.Error("artifact sent to the client but not cached",
- "purl", versionPURL, "filename", filename, "url", upstreamURL, "error", out.err)
- metrics.RecordArtifactServedUncached(ecosystem, metrics.FailureDetail{
- PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: out.err})
- }
- return out.artifact, out.path, out.err
-}
-
-// openStoredForFeed opens the stored copy for a client that fell behind. An
-// unrecorded object is opened too: it is deleted only after this returns, and
-// the client holds it from then on (missHandoff.holdUnrecorded).
-func (p *Proxy) openStoredForFeed(ctx context.Context, a artifacts.Artifact, path string, storeErr error) (io.ReadCloser, error) {
- if storeErr != nil && !errors.Is(storeErr, errUnrecorded) {
- return nil, storeErr
- }
- res, err := p.openStoredArtifact(ctx, a, path)
- if err != nil {
- return nil, err
- }
- return res.Reader, nil
-}
-
-// copyToBoth copies src to store and to the client's feed until src ends,
-// dropping the store once a write to it fails. It returns the error that
-// ended the copy, nil at the end of src. Feeding the client never blocks.
-func copyToBoth(src io.Reader, store io.Writer, client *missFeed) error {
- buffer := artifactCopyBufferPool.Get().(*[]byte)
- defer artifactCopyBufferPool.Put(buffer)
- buf := *buffer
- storeOK := true
- for {
- n, err := src.Read(buf)
- if n > 0 {
- if storeOK {
- if _, werr := store.Write(buf[:n]); werr != nil {
- storeOK = false
- }
- }
- // A client that has gone, or that waits for the stored copy,
- // has no use for the rest once storing has failed.
- if !client.push(buf[:n]) && !storeOK {
- return errNoReaderLeft
- }
- }
- if errors.Is(err, io.EOF) {
- return nil
- }
- if err != nil {
- return err
- }
- }
-}
-
-// clientFeedLimit bounds the bytes held for a client that reads a streamed
-// miss slower than they arrive. A variable so tests can lower it.
-var clientFeedLimit = 8 << 20
-
-// missFeed is the client's side of a streamed miss. The upstream read pushes
-// into it without blocking, so a slow client can't hold back the copy to
-// storage, the fetch lease or the callers waiting on the fetch. Once the
-// client is more than the limit behind, it stops being fed: it reads what
-// is held, then continues from the stored copy where it left off.
-type missFeed struct {
- mu sync.Mutex
- cond *sync.Cond
- chunks [][]byte
- held int
- limit int
- // fed counts the bytes pushed before the client fell behind, where it
- // continues in the stored copy.
- fed int64
- ended bool
- endErr error
- behind bool
- closed bool
- early bool // closed before reading everything it was fed
-
- stored io.ReadCloser
- storedErr error
- resolved bool
- skipped bool
- // release drops the client's hold on an unrecorded stored copy.
- release func()
-}
-
-func newMissFeed(limit int) *missFeed {
- m := &missFeed{limit: limit}
- m.cond = sync.NewCond(&m.mu)
- return m
-}
-
-// push queues a copy of b and reports whether the client is still fed.
-func (m *missFeed) push(b []byte) bool {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.closed || m.behind {
- return false
- }
- if m.held+len(b) > m.limit {
- m.behind = true
- return false
- }
- m.chunks = append(m.chunks, append([]byte(nil), b...))
- m.held += len(b)
- m.fed += int64(len(b))
- m.cond.Broadcast()
- return true
-}
-
-// end records how the upstream read ended.
-func (m *missFeed) end(err error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.ended, m.endErr = true, err
- m.cond.Broadcast()
-}
-
-func (m *missFeed) lagging() bool {
- m.mu.Lock()
- defer m.mu.Unlock()
- return m.behind && !m.closed
-}
-
-// fedFully reports whether the client was sent every byte from upstream.
-func (m *missFeed) fedFully() bool {
- m.mu.Lock()
- defer m.mu.Unlock()
- return !m.behind && !m.early
-}
-
-// resolve gives a client that fell behind the stored copy, or the error
-// that left none.
-func (m *missFeed) resolve(r io.ReadCloser, err error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.closed {
- if r != nil {
- _ = r.Close()
- }
- return
- }
- m.stored, m.storedErr, m.resolved = r, err, true
- m.cond.Broadcast()
-}
-
-// holdUntilClose takes a hold on u for a client reading its stored copy and
-// releases it when the client closes.
-func (m *missFeed) holdUntilClose(u *unrecordedObject) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.closed || m.stored == nil {
- return
- }
- u.refs.Add(1)
- m.release = u.release
-}
-
-func (m *missFeed) Read(p []byte) (int, error) {
- m.mu.Lock()
- for {
- switch {
- case m.closed:
- m.mu.Unlock()
- return 0, io.ErrClosedPipe
- case len(m.chunks) > 0:
- n := copy(p, m.chunks[0])
- if m.chunks[0] = m.chunks[0][n:]; len(m.chunks[0]) == 0 {
- m.chunks[0] = nil
- m.chunks = m.chunks[1:]
- }
- m.held -= n
- m.mu.Unlock()
- return n, nil
- case m.behind && m.resolved:
- r, err, skipped := m.stored, m.storedErr, m.skipped
- m.skipped = true
- m.mu.Unlock()
- if err != nil {
- return 0, err
- }
- if !skipped {
- if err := skipTo(r, m.fed); err != nil {
- return 0, err
- }
- }
- return r.Read(p)
- case !m.behind && m.ended:
- err := m.endErr
- m.mu.Unlock()
- if err == nil {
- err = io.EOF
- }
- return 0, err
- }
- m.cond.Wait()
- }
-}
-
-func (m *missFeed) Close() error {
- m.mu.Lock()
- if m.closed {
- m.mu.Unlock()
- return nil
- }
- m.closed = true
- m.early = !m.ended || len(m.chunks) > 0
- m.chunks, m.held = nil, 0
- if m.stored != nil {
- _ = m.stored.Close()
- }
- release := m.release
- m.release = nil
- m.cond.Broadcast()
- m.mu.Unlock()
- if release != nil {
- // Outside the lock: the last release deletes the object.
- release()
- }
- return nil
-}
-
-// skipTo moves r past the first n bytes.
-func skipTo(r io.Reader, n int64) error {
- if s, ok := r.(io.Seeker); ok {
- _, err := s.Seek(n, io.SeekStart)
- return err
- }
- if _, err := io.CopyN(io.Discard, r, n); err != nil {
- return fmt.Errorf("skipping to byte %d of the stored artifact: %w", n, err)
- }
- return nil
-}
diff --git a/internal/handler/stream_miss_test.go b/internal/handler/stream_miss_test.go
deleted file mode 100644
index 2746588..0000000
--- a/internal/handler/stream_miss_test.go
+++ /dev/null
@@ -1,583 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/scanner"
- "github.com/git-pkgs/registries/fetch"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// lockedBuffer is a log destination safe for the goroutine that stores a
-// streamed artifact to write to while a test reads it.
-type lockedBuffer struct {
- mu sync.Mutex
- buf bytes.Buffer
-}
-
-func (b *lockedBuffer) Write(p []byte) (int, error) {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.buf.Write(p)
-}
-
-func (b *lockedBuffer) String() string {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.buf.String()
-}
-
-// streamMissSetup is a Git LFS upstream whose object store sends the first
-// half of an object, then waits for release before sending the rest, and a
-// proxy serving it to clients the way the server marks their requests.
-type streamMissSetup struct {
- proxy *Proxy
- store *mockStorage
- client *httptest.Server
- git *httptest.Server
- objects *httptest.Server
- release chan struct{}
- content string // what the object store sends
- oid string // the object ID clients ask for
- path string
- downloads atomic.Int32
- logs lockedBuffer
-}
-
-// streamMissContent spans many copy buffers: a stream checked against its
-// digest holds back its latest chunk until the next one arrives.
-var streamMissContent = strings.Repeat("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\n", 400<<10/63)
-
-// streamMissPrefix is how much a client must get while the upstream still
-// holds back the second half.
-const streamMissPrefix = 64 << 10
-
-func newStreamMiss(t *testing.T, served string) *streamMissSetup {
- t.Helper()
- return newStreamMissMarked(t, served, true)
-}
-
-// newStreamMissMarked is newStreamMiss, with requests marked to detach
-// their upstream fetch as the server does, or not, as when the server has
-// no detached fetch to spare.
-func newStreamMissMarked(t *testing.T, served string, detached bool) *streamMissSetup {
- t.Helper()
- sum := sha256.Sum256([]byte(streamMissContent))
- s := &streamMissSetup{content: served, oid: hex.EncodeToString(sum[:]), release: make(chan struct{})}
- s.objects = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- s.downloads.Add(1)
- half := len(s.content) / 2
- _, _ = io.WriteString(w, s.content[:half])
- w.(http.Flusher).Flush()
- select {
- case <-s.release:
- case <-r.Context().Done():
- return
- }
- _, _ = io.WriteString(w, s.content[half:])
- }))
- s.git = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var req lfsBatchRequest
- _ = json.NewDecoder(r.Body).Decode(&req)
- resp := lfsBatchResponse{Transfer: "basic"}
- for _, o := range req.Objects {
- resp.Objects = append(resp.Objects, lfsObjectResponse{OID: o.OID, Size: o.Size,
- Actions: map[string]lfsAction{"download": {Href: s.objects.URL + "/objects/" + o.OID}}})
- }
- w.Header().Set("Content-Type", gitLFSMediaType)
- _ = json.NewEncoder(w).Encode(resp)
- }))
-
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = true
- proxy.Logger = slog.New(slog.NewTextHandler(&s.logs, nil))
- proxy.HTTPClient = s.git.Client()
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(s.objects.Client()), fetch.WithMaxRetries(0))
- proxy.Fetcher = fetcher
- s.proxy, s.store = proxy, store
- h := NewGitHandler(proxy, nil, config.UpstreamConfig{Git: map[string]config.GitUpstream{
- "up": {URL: s.git.URL},
- }}).WithBaseURL("http://proxy.test/")
- routes := h.Routes()
- // As the server marks client requests.
- s.client = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx := StreamCacheMiss(r.Context())
- if detached {
- ctx = DetachUpstreamFetch(ctx)
- }
- routes.ServeHTTP(w, r.WithContext(ctx))
- }))
- s.path = fmt.Sprintf("/up/acme/widget.git/info/lfs/objects/%s/%d", s.oid, len(streamMissContent))
- t.Cleanup(func() {
- s.releaseUpstream()
- s.client.Close()
- s.git.Close()
- s.objects.Close()
- _ = fetcher.Close()
- })
- return s
-}
-
-func (s *streamMissSetup) releaseUpstream() {
- select {
- case <-s.release:
- default:
- close(s.release)
- }
-}
-
-// get requests the object. The response headers must arrive in time even
-// while the upstream holds back the second half.
-func (s *streamMissSetup) get(t *testing.T) *http.Response {
- t.Helper()
- type result struct {
- resp *http.Response
- err error
- }
- got := make(chan result, 1)
- go func() {
- resp, err := s.client.Client().Get(s.client.URL + s.path)
- got <- result{resp, err}
- }()
- select {
- case r := <-got:
- if r.err != nil {
- t.Fatal(r.err)
- }
- return r.resp
- case <-time.After(5 * time.Second):
- // Once the upstream is released the response arrives; close it.
- t.Cleanup(func() {
- s.releaseUpstream()
- if r := <-got; r.resp != nil {
- _ = r.resp.Body.Close()
- }
- })
- t.Fatal("no response while the upstream was still sending")
- return nil
- }
-}
-
-// cached waits for the stored copy, which is written after the client's
-// stream ends.
-func (s *streamMissSetup) cached(t *testing.T) bool {
- t.Helper()
- deadline := time.Now().Add(5 * time.Second)
- for {
- res, err := s.proxy.GetCachedArtifact(t.Context(), gitEcosystem, "up/acme/widget", s.oid, s.oid)
- if err == nil && res != nil {
- _ = res.Reader.Close()
- return true
- }
- if time.Now().After(deadline) {
- return false
- }
- time.Sleep(10 * time.Millisecond)
- }
-}
-
-// readFirst reads the first streamMissPrefix bytes of body, failing if they
-// don't arrive in time.
-func readFirst(t *testing.T, body io.Reader) string {
- t.Helper()
- got := make(chan string, 1)
- go func() {
- buf := make([]byte, streamMissPrefix)
- _, _ = io.ReadFull(body, buf)
- got <- string(buf)
- }()
- select {
- case s := <-got:
- return s
- case <-time.After(5 * time.Second):
- t.Fatal("no bytes reached the client while the upstream was still sending")
- return ""
- }
-}
-
-func TestCacheMissStreamsBeforeUpstreamFinishes(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- resp := s.get(t)
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK || resp.ContentLength != -1 {
- t.Fatalf("status %d, Content-Length %d; want 200, chunked", resp.StatusCode, resp.ContentLength)
- }
- if first := readFirst(t, resp.Body); first != streamMissContent[:streamMissPrefix] {
- t.Fatal("first bytes differ from the artifact's")
- }
-
- s.releaseUpstream()
- rest, err := io.ReadAll(resp.Body)
- if err != nil || string(rest) != streamMissContent[streamMissPrefix:] {
- t.Fatalf("rest: %d bytes, %v", len(rest), err)
- }
- if !s.cached(t) {
- t.Fatal("streamed artifact was not cached")
- }
-
- // Served from the cache now, with its size.
- s.objects.Close()
- again := s.get(t)
- body, _ := io.ReadAll(again.Body)
- _ = again.Body.Close()
- if again.StatusCode != http.StatusOK || string(body) != streamMissContent || again.ContentLength != int64(len(streamMissContent)) {
- t.Errorf("cached GET: status %d, length %d, %d bytes", again.StatusCode, again.ContentLength, len(body))
- }
- if s.downloads.Load() != 1 {
- t.Errorf("downloads = %d, want 1", s.downloads.Load())
- }
-}
-
-func TestCacheMissStreamAbortedOnMismatch(t *testing.T) {
- tampered := "X" + streamMissContent[1:]
- s := newStreamMiss(t, tampered)
- s.releaseUpstream()
- resp := s.get(t)
- body, err := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if err == nil {
- t.Fatalf("read %d bytes without error; want the response aborted", len(body))
- }
- if string(body) == tampered {
- t.Error("client got every byte of an artifact that doesn't match its object ID")
- }
- // Nothing to wait for: storeArtifact has refused it once the stream ends.
- time.Sleep(100 * time.Millisecond)
- if res, _ := s.proxy.GetCachedArtifact(t.Context(), gitEcosystem, "up/acme/widget", s.oid, s.oid); res != nil {
- _ = res.Reader.Close()
- t.Error("mismatching artifact was cached")
- }
-}
-
-func TestCacheMissStreamFinishesWhenStorageFails(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- s.store.mu.Lock()
- s.store.storeErr = errors.New("no space left on device")
- s.store.mu.Unlock()
- before := testutil.ToFloat64(metrics.ArtifactsServedUncached.WithLabelValues(gitEcosystem))
- s.releaseUpstream()
-
- resp := s.get(t)
- body, err := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if err != nil || resp.StatusCode != http.StatusOK || string(body) != streamMissContent {
- t.Fatalf("status %d, %d bytes, %v; want the whole artifact", resp.StatusCode, len(body), err)
- }
- deadline := time.Now().Add(5 * time.Second)
- for testutil.ToFloat64(metrics.ArtifactsServedUncached.WithLabelValues(gitEcosystem)) == before && time.Now().Before(deadline) {
- time.Sleep(10 * time.Millisecond)
- }
- if got := testutil.ToFloat64(metrics.ArtifactsServedUncached.WithLabelValues(gitEcosystem)) - before; got != 1 {
- t.Errorf("served-uncached count went up by %v, want 1", got)
- }
- if !strings.Contains(s.logs.String(), "level=ERROR") || !strings.Contains(s.logs.String(), "not cached") {
- t.Errorf("storage failure not logged at error level:\n%s", s.logs.String())
- }
-}
-
-func TestCacheMissStoredAfterClientLeaves(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- resp := s.get(t)
- readFirst(t, resp.Body)
- _ = resp.Body.Close()
- s.client.CloseClientConnections()
-
- s.releaseUpstream()
- if !s.cached(t) {
- t.Fatal("artifact not cached after the client that started the fetch left")
- }
-}
-
-func TestCacheMissWaiterGetsStoredArtifact(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- first := s.get(t)
- defer func() { _ = first.Body.Close() }()
- readFirst(t, first.Body)
-
- second := make(chan string, 1)
- go func() {
- resp, err := s.client.Client().Get(s.client.URL + s.path)
- if err != nil {
- second <- err.Error()
- return
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- second <- fmt.Sprintf("%d %d %s", resp.StatusCode, resp.ContentLength, body)
- }()
- select {
- case got := <-second:
- t.Fatalf("second request answered before the artifact was stored: %s", got)
- case <-time.After(200 * time.Millisecond):
- }
-
- s.releaseUpstream()
- if _, err := io.ReadAll(first.Body); err != nil {
- t.Fatal(err)
- }
- want := fmt.Sprintf("200 %d %s", len(streamMissContent), streamMissContent)
- if got := <-second; got != want {
- t.Errorf("second request = %.60q..., want the stored artifact with its length", got)
- }
- if s.downloads.Load() != 1 {
- t.Errorf("downloads = %d, want 1", s.downloads.Load())
- }
-}
-
-func newStreamMissScanGroup(t *testing.T, mode string) *scanner.Group {
- t.Helper()
- g, err := scanner.NewGroup(config.ScanningConfig{
- Enabled: true,
- Timeout: "15s",
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "test-scanner", URL: newTestScanServer(t, true, "").URL, Mode: mode},
- },
- }, slog.New(slog.NewTextHandler(io.Discard, nil)))
- if err != nil {
- t.Fatal(err)
- }
- return g
-}
-
-// A block-mode scanner must see the whole artifact before any of it is
-// served, so a miss isn't streamed.
-func TestCacheMissNotStreamedWithBlockingScanner(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- s.proxy.ScanSigningKey = []byte("test-signing-key")
- s.proxy.Scanners = newStreamMissScanGroup(t, "block")
-
- got := make(chan *http.Response, 1)
- go func() {
- resp, err := s.client.Client().Get(s.client.URL + s.path)
- if err != nil {
- t.Error(err)
- }
- got <- resp
- }()
- select {
- case resp := <-got:
- _ = resp.Body.Close()
- t.Fatal("response started before the upstream finished, with a block-mode scanner")
- case <-time.After(300 * time.Millisecond):
- }
- s.releaseUpstream()
- resp := <-got
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusOK || string(body) != streamMissContent || resp.ContentLength != int64(len(streamMissContent)) {
- t.Errorf("status %d, length %d, %d bytes; want the scanned, stored artifact", resp.StatusCode, resp.ContentLength, len(body))
- }
-}
-
-// A monitor-mode scanner doesn't gate the artifact, so the miss is streamed
-// and the scan runs after the client's stream ends.
-func TestCacheMissStreamedWithMonitoringScanner(t *testing.T) {
- s := newStreamMiss(t, streamMissContent)
- s.proxy.ScanSigningKey = []byte("test-signing-key")
- s.proxy.Scanners = newStreamMissScanGroup(t, "monitor")
-
- resp := s.get(t)
- defer func() { _ = resp.Body.Close() }()
- readFirst(t, resp.Body)
- s.releaseUpstream()
- if _, err := io.ReadAll(resp.Body); err != nil {
- t.Fatal(err)
- }
- if !s.cached(t) {
- t.Error("artifact not cached")
- }
-}
-
-// slowStorage stores like mockStorage, but takes a while to finish once it
-// has read the last byte, as an upload to S3 does.
-type slowStorage struct {
- *mockStorage
- delay time.Duration
-}
-
-func (s *slowStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- data, err := io.ReadAll(r)
- if err != nil {
- return 0, "", err
- }
- select {
- case <-time.After(s.delay):
- case <-ctx.Done():
- return 0, "", ctx.Err()
- }
- return s.mockStorage.Store(ctx, path, bytes.NewReader(data))
-}
-
-// Without a detached fetch, storing still finishes after the client's
-// request has ended with the last byte.
-func TestCacheMissStoredAfterResponseWithoutDetach(t *testing.T) {
- s := newStreamMissMarked(t, streamMissContent, false)
- s.proxy.Storage = &slowStorage{mockStorage: s.store, delay: 300 * time.Millisecond}
- s.releaseUpstream()
- resp := s.get(t)
- body, err := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if err != nil || string(body) != streamMissContent {
- t.Fatalf("%d bytes, %v", len(body), err)
- }
- if !s.cached(t) {
- t.Fatal("artifact not cached once the response ended")
- }
-}
-
-// lowerFeedLimit makes a client fall behind after n bytes.
-func lowerFeedLimit(t *testing.T, n int) {
- t.Helper()
- old := clientFeedLimit
- clientFeedLimit = n
- t.Cleanup(func() { clientFeedLimit = old })
-}
-
-// TestCacheMissSlowClientDoesNotHoldBackFetch has the caller that starts a
-// streamed miss read nothing until a second caller, waiting on the same
-// fetch, has the stored artifact. The slow client then gets every byte, the
-// ones past the limit from the stored copy.
-func TestCacheMissSlowClientDoesNotHoldBackFetch(t *testing.T) {
- for _, seekable := range []bool{false, true} {
- t.Run(fmt.Sprintf("seekable=%v", seekable), func(t *testing.T) { testSlowClient(t, seekable) })
- }
-}
-
-func testSlowClient(t *testing.T, seekable bool) {
- t.Helper()
- lowerFeedLimit(t, 64<<10)
- proxy, _, store, _ := setupTestProxy(t)
- store.seekable = seekable
- fetcher := newGatedFetcher(streamMissContent)
- proxy.Fetcher = fetcher
- get := func(ctx context.Context) (*CacheResult, error) {
- return proxy.GetOrFetchArtifactFromURL(ctx, "npm", "big", "1.0.0", "big-1.0.0.tgz", "https://example.com/big.tgz")
- }
-
- slow := make(chan *CacheResult, 1)
- go func() {
- res, err := get(StreamCacheMiss(context.Background()))
- if err != nil {
- t.Errorf("slow client: %v", err)
- }
- slow <- res
- }()
- <-fetcher.entered
- waiter := make(chan *CacheResult, 1)
- go func() {
- res, err := get(context.Background())
- if err != nil {
- t.Errorf("waiter: %v", err)
- }
- waiter <- res
- }()
- waitForWaiters(t, proxy, 1)
- close(fetcher.release)
-
- lead := <-slow
- if lead == nil {
- t.FailNow()
- }
- defer func() { _ = lead.Reader.Close() }()
- if lead.Artifact.Size != -1 {
- t.Fatalf("slow client got size %d, want a stream", lead.Artifact.Size)
- }
- select {
- case res := <-waiter:
- if res == nil {
- t.FailNow()
- }
- body, err := io.ReadAll(res.Reader)
- _ = res.Reader.Close()
- if err != nil || string(body) != streamMissContent {
- t.Fatalf("waiter: %d bytes, %v", len(body), err)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("waiter held back by a client that reads nothing")
- }
-
- body, err := io.ReadAll(lead.Reader)
- if err != nil || string(body) != streamMissContent {
- t.Fatalf("slow client: %d bytes, %v; want the whole artifact", len(body), err)
- }
- if fetcher.calls.Load() != 1 {
- t.Errorf("upstream fetches = %d, want 1", fetcher.calls.Load())
- }
-}
-
-// TestCacheMissSlowClientFailsWithoutStoredCopy: a client that fell behind
-// has nothing to continue from when storing fails, so its response fails
-// rather than ending short.
-func TestCacheMissSlowClientFailsWithoutStoredCopy(t *testing.T) {
- lowerFeedLimit(t, 64<<10)
- proxy, _, store, _ := setupTestProxy(t)
- store.storeErr = errors.New("no space left on device")
- proxy.Fetcher = &countingFetcher{content: streamMissContent}
-
- res, err := proxy.GetOrFetchArtifactFromURL(StreamCacheMiss(context.Background()), "npm", "big", "1.0.0", "big-1.0.0.tgz", "https://example.com/big.tgz")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = res.Reader.Close() }()
-
- // Leave the stream unread until the fetch ends so the feed must overflow.
- proxy.fetchMu.Lock()
- var done <-chan struct{}
- for _, f := range proxy.inFlight {
- done = f.done
- }
- proxy.fetchMu.Unlock()
- if done != nil {
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("fetch did not finish while the client was idle")
- }
- }
-
- body, err := io.ReadAll(res.Reader)
- if !errors.Is(err, store.storeErr) {
- t.Fatalf("read %d bytes with error %v; want %v", len(body), err, store.storeErr)
- }
-}
-
-func TestMissFeedCloseWhileWaiting(t *testing.T) {
- m := newMissFeed(4)
- done := make(chan error, 1)
- go func() {
- _, err := m.Read(make([]byte, 8))
- done <- err
- }()
- time.Sleep(10 * time.Millisecond)
- _ = m.Close()
- select {
- case err := <-done:
- if !errors.Is(err, io.ErrClosedPipe) {
- t.Errorf("Read after Close = %v", err)
- }
- case <-time.After(time.Second):
- t.Fatal("Read still blocked after Close")
- }
- if m.push([]byte("ab")) {
- t.Error("push to a closed feed reported the client fed")
- }
- m.resolve(io.NopCloser(strings.NewReader("x")), nil)
-}
diff --git a/internal/handler/swift.go b/internal/handler/swift.go
deleted file mode 100644
index 37f666e..0000000
--- a/internal/handler/swift.go
+++ /dev/null
@@ -1,714 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/proxy/internal/retention"
- "github.com/git-pkgs/purl"
-)
-
-const (
- swiftAcceptJSON = "application/vnd.swift.registry.v1+json"
- swiftAcceptManifest = "application/vnd.swift.registry.v1+swift"
- swiftAcceptArchive = "application/vnd.swift.registry.v1+zip"
- swiftContentVersion = "1"
- swiftMaxScopeLength = 39
- swiftMaxNameLength = 100
-)
-
-// Source archives are stored under the registry identity,
-// pkg:generic/swift-registry/{scope}.{name}, lowercased. Package rules write
-// the scope as the namespace, pkg:swift/apple/swift-nio, or use the stored
-// form. A source repository such as pkg:swift/github.com/apple/swift-nio
-// names no registry identity; it is resolved through the upstream's
-// /identifiers before it applies.
-func init() {
- retention.Default.Register(retention.Spec{
- Key: "swift",
- CanonicalPackage: swiftRetentionKey,
- SourcePackage: func(p *purl.PURL) bool { return SwiftSourcePURL(p) != "" },
- })
-}
-
-// SwiftSourceURL returns the repository URL of a pkg:swift name whose
-// namespace starts with a host, as in github.com/apple/swift-nio, or "" for
-// a name whose namespace is a registry scope.
-func SwiftSourceURL(name string) string {
- if host, _, ok := strings.Cut(name, "/"); ok && strings.Contains(host, ".") {
- return "https://" + name
- }
- return ""
-}
-
-// SwiftSourcePURL returns the repository URL of a pkg:swift PURL that names
-// a package by its source repository, as SBOM tools and GHSA do, or "".
-func SwiftSourcePURL(p *purl.PURL) string {
- if p == nil || p.Type != "swift" || p.Namespace == "" {
- return ""
- }
- return SwiftSourceURL(p.Namespace + "/" + p.Name)
-}
-
-// ResolveSourcePURL returns p with a source repository namespace replaced by
-// each registry scope and name the upstream lists for it, and p itself when
-// it already names a registry identity. A policy for the repository applies
-// to all of them, since a client may use any.
-func (h *SwiftHandler) ResolveSourcePURL(ctx context.Context, p *purl.PURL) ([]*purl.PURL, error) {
- source := SwiftSourcePURL(p)
- if source == "" {
- return []*purl.PURL{p}, nil
- }
- ids, err := h.ResolveIdentities(ctx, source)
- if err != nil {
- return nil, err
- }
- resolved := make([]*purl.PURL, 0, len(ids))
- for _, id := range ids {
- resolved = append(resolved, purl.New("swift", id.Scope, id.Name, p.Version, nil))
- }
- return resolved, nil
-}
-
-// swiftRetentionKey maps a package rule to the stored package PURL, refusing
-// keys that name no valid registry scope and package. MakeCacheStrings
-// lowercases the identity the way the handler stores it.
-func swiftRetentionKey(p *purl.PURL) (string, bool) {
- if p == nil {
- return "", false
- }
- var scope, name string
- switch {
- case p.Type == "swift":
- scope, name = p.Namespace, p.Name
- case p.Type == "generic" && strings.EqualFold(p.Namespace, "swift-registry"):
- var found bool
- if scope, name, found = strings.Cut(p.Name, "."); !found {
- return "", false
- }
- default:
- return "", false
- }
- if !validSwiftScope(scope) || !validSwiftPackageName(name) {
- return "", false
- }
- packagePURL, _ := packageurl.MakeCacheStrings("swift", scope+"/"+name, "")
- return packagePURL, packagePURL != ""
-}
-
-// SwiftHandler handles the read-only Swift Package Registry v1 protocol.
-type SwiftHandler struct {
- proxy *Proxy
- upstreamURL string
- proxyURL string
-}
-
-// NewSwiftHandler creates a Swift Package Registry protocol handler.
-func NewSwiftHandler(proxy *Proxy, proxyURL, upstreamURL string) *SwiftHandler {
- if strings.TrimSpace(upstreamURL) == "" {
- upstreamURL = config.DefaultSwiftUpstream
- }
-
- return &SwiftHandler{
- proxy: proxy,
- upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
- proxyURL: strings.TrimSuffix(proxyURL, "/"),
- }
-}
-
-// Routes returns the HTTP handler for Swift registry requests.
-func (h *SwiftHandler) Routes() http.Handler {
- mux := http.NewServeMux()
- mux.HandleFunc("GET /identifiers", h.handleIdentifiers)
- mux.HandleFunc("GET /{scope}/{name}/{version}/Package.swift", h.handleManifest)
- mux.HandleFunc("GET /{scope}/{name}/{version}", h.handleRelease)
- mux.HandleFunc("PUT /{scope}/{name}/{version}", h.handlePublishingUnsupported)
- mux.HandleFunc("GET /{scope}/{name}", h.handlePackageReleases)
- return mux
-}
-
-func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Request) {
- scope := r.PathValue("scope")
- name := strings.TrimSuffix(r.PathValue("name"), ".json")
- if !validSwiftScope(scope) || !validSwiftPackageName(name) {
- writeSwiftProblem(w, http.StatusBadRequest, "invalid package identifier")
- return
- }
- scope, name = canonicalSwiftPackage(scope, name)
-
- upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery)
- doc, err := h.fetchDocument(
- r.Context(), swiftReleasesCacheKey(scope, name, r.URL.RawQuery), upstreamURL, requestAccept(r, swiftAcceptJSON),
- )
- if err != nil {
- h.writeMetadataError(w, err)
- return
- }
-
- rewritten, err := h.rewriteReleaseURLs(scope, name, doc.body)
- if err != nil {
- h.proxy.Logger.Warn("failed to rewrite Swift release URLs", "error", err)
- rewritten = doc.body
- }
- if doc.link != "" {
- w.Header().Set("Link", h.rewriteLinkHeader(doc.link, upstreamURL))
- }
- writeSwiftMetadata(w, r, rewritten, doc.contentType)
-}
-
-func (h *SwiftHandler) handleRelease(w http.ResponseWriter, r *http.Request) {
- scope := r.PathValue("scope")
- name := r.PathValue("name")
- version := r.PathValue("version")
- if strings.HasSuffix(version, ".zip") {
- h.handleSourceArchive(w, r, scope, name, strings.TrimSuffix(version, ".zip"))
- return
- }
-
- version = strings.TrimSuffix(version, ".json")
- if !validSwiftPackageReference(scope, name, version) {
- writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
- return
- }
- scope, name = canonicalSwiftPackage(scope, name)
-
- upstreamURL := h.buildUpstreamURL(scope, name, version, "", r.URL.RawQuery)
- body, contentType, err := h.proxy.FetchOrCacheMetadata(
- r.Context(), "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, requestAccept(r, swiftAcceptJSON),
- )
- if err != nil {
- h.writeMetadataError(w, err)
- return
- }
- writeSwiftMetadata(w, r, body, contentType)
-}
-
-func (h *SwiftHandler) handleManifest(w http.ResponseWriter, r *http.Request) {
- scope := r.PathValue("scope")
- name := r.PathValue("name")
- version := r.PathValue("version")
- if !validSwiftPackageReference(scope, name, version) {
- writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
- return
- }
- scope, name = canonicalSwiftPackage(scope, name)
-
- upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", r.URL.RawQuery)
- doc, err := h.fetchDocument(
- r.Context(), swiftManifestCacheKey(scope, name, version, r.URL.RawQuery), upstreamURL, requestAccept(r, swiftAcceptManifest),
- )
- if err != nil {
- h.writeMetadataError(w, err)
- return
- }
- if doc.link != "" {
- w.Header().Set("Link", h.rewriteLinkHeader(doc.link, upstreamURL))
- }
- writeSwiftMetadata(w, r, doc.body, doc.contentType)
-}
-
-func (h *SwiftHandler) handleIdentifiers(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("url") == "" {
- writeSwiftProblem(w, http.StatusBadRequest, "url query parameter is required")
- return
- }
-
- upstreamURL := h.upstreamURL + "/identifiers?" + r.URL.RawQuery
- cacheKey := swiftMetadataCacheKey("identifiers", r.URL.RawQuery)
- body, contentType, err := h.proxy.FetchOrCacheMetadata(
- r.Context(), "swift", cacheKey, upstreamURL, requestAccept(r, swiftAcceptJSON),
- )
- if err != nil {
- h.writeMetadataError(w, err)
- return
- }
- writeSwiftMetadata(w, r, body, contentType)
-}
-
-func (h *SwiftHandler) handlePublishingUnsupported(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Allow", "GET, HEAD")
- writeSwiftProblem(w, http.StatusMethodNotAllowed, "publishing isn't supported")
-}
-
-func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Request, scope, name, version string) {
- if !validSwiftPackageReference(scope, name, version) {
- writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
- return
- }
- scope, name = canonicalSwiftPackage(scope, name)
-
- key, err := swiftSourceArchiveKey(scope, name, version)
- if err != nil {
- h.writeArtifactError(w, err)
- return
- }
- filename, packagePURL, versionPURL := key.filename, key.packagePURL, key.versionPURL
- upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery)
- archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version)
- if infoErr != nil {
- h.writeArtifactError(w, fmt.Errorf("fetching release metadata: %w", infoErr))
- return
- }
-
- if r.Method == http.MethodHead {
- h.handleSourceArchiveHead(w, r, name, version, filename, packagePURL, versionPURL, upstreamURL, archiveInfo)
- return
- }
-
- headers := make(http.Header)
- headers.Set("Accept", requestAccept(r, swiftAcceptArchive))
- result, err := h.fetchSourceArchive(r.Context(), key, version, upstreamURL, headers, archiveInfo)
- if err != nil {
- h.writeArtifactError(w, err)
- return
- }
-
- result.Artifact.MediaType = "application/zip"
- setSwiftArchiveHeaders(w.Header(), name, version, result.Artifact.Digest.Encoded(), archiveInfo)
- ServeArtifactRequest(w, r, result)
-}
-
-// swiftArchiveKey is the cache key a release's source archive is stored
-// under.
-type swiftArchiveKey struct {
- packageName, filename, packagePURL, versionPURL string
-}
-
-// swiftSourceArchiveKey returns the cache key of the source archive of the
-// canonical (lowercased) scope and name at version.
-func swiftSourceArchiveKey(scope, name, version string) (swiftArchiveKey, error) {
- key := swiftArchiveKey{packageName: scope + "/" + name, filename: fmt.Sprintf("%s-%s.zip", name, version)}
- key.packagePURL, key.versionPURL = packageurl.MakeCacheStrings("swift", key.packageName, version)
- if key.packagePURL == "" || key.versionPURL == "" {
- return swiftArchiveKey{}, fmt.Errorf("%w: swift %q", errUnsupportedPackageIdentity, key.packageName)
- }
- return key, nil
-}
-
-// fetchSourceArchive returns the source archive under key from the cache, or
-// fetches it from upstreamURL, verified against the checksum in info.
-func (h *SwiftHandler) fetchSourceArchive(ctx context.Context, key swiftArchiveKey, version, upstreamURL string, headers http.Header, info swiftArchiveInfo) (*CacheResult, error) {
- return h.proxy.getOrFetchArtifactFromURLWithCachePURLs(
- ctx, "swift", key.packageName, version, key.filename, key.packagePURL, key.versionPURL,
- upstreamURL, headers, info.checksum, "",
- )
-}
-
-func (h *SwiftHandler) handleSourceArchiveHead(
- w http.ResponseWriter,
- r *http.Request,
- name, version, filename, packagePURL, versionPURL, upstreamURL string,
- archiveInfo swiftArchiveInfo,
-) {
- result, err := h.proxy.getCachedArtifactWithUpstreamHash(
- r.Context(), packagePURL, versionPURL, filename, archiveInfo.checksum,
- )
- if err != nil {
- h.writeArtifactError(w, err)
- return
- }
- if result != nil {
- result.Artifact.MediaType = "application/zip"
- setSwiftArchiveHeaders(w.Header(), name, version, result.Artifact.Digest.Encoded(), archiveInfo)
- ServeArtifactRequest(w, r, result)
- return
- }
-
- size, err := h.probeSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive))
- if err != nil {
- h.writeArtifactError(w, err)
- return
- }
- setSwiftArchiveHeaders(w.Header(), name, version, "", archiveInfo)
- w.Header().Set(headerContentType, "application/zip")
- if size >= 0 {
- w.Header().Set(headerContentLength, strconv.FormatInt(size, 10))
- }
- w.WriteHeader(http.StatusOK)
-}
-
-func (h *SwiftHandler) probeSourceArchive(ctx context.Context, upstreamURL, accept string) (int64, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
- if err != nil {
- return 0, fmt.Errorf("creating upstream archive request: %w", err)
- }
- req.Header.Set("Accept", accept)
- req.Header.Set("Range", "bytes=0-0")
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return 0, fmt.Errorf("requesting upstream archive: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode == http.StatusNotFound {
- return 0, ErrUpstreamNotFound
- }
- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
- return 0, fmt.Errorf("upstream archive returned %d", resp.StatusCode)
- }
-
- if resp.StatusCode == http.StatusPartialContent {
- _, total, found := strings.Cut(resp.Header.Get("Content-Range"), "/")
- if !found || total == "*" {
- return -1, nil
- }
- if parsed, parseErr := strconv.ParseInt(total, 10, 64); parseErr == nil {
- return parsed, nil
- }
- return -1, nil
- }
-
- size := int64(-1)
- if contentLength := resp.Header.Get(headerContentLength); contentLength != "" {
- if parsed, parseErr := strconv.ParseInt(contentLength, 10, 64); parseErr == nil {
- size = parsed
- }
- }
- return size, nil
-}
-
-func (h *SwiftHandler) fetchMetadataWithHeaders(
- ctx context.Context,
- upstreamURL, accept string,
-) ([]byte, string, http.Header, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
- if err != nil {
- return nil, "", nil, fmt.Errorf("creating upstream metadata request: %w", err)
- }
- req.Header.Set("Accept", accept)
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil, "", nil, fmt.Errorf("requesting upstream metadata: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode == http.StatusNotFound {
- return nil, "", nil, ErrUpstreamNotFound
- }
- if resp.StatusCode != http.StatusOK {
- return nil, "", nil, fmt.Errorf("upstream metadata returned %d", resp.StatusCode)
- }
-
- body, err := h.proxy.ReadMetadata(resp.Body)
- if err != nil {
- return nil, "", nil, fmt.Errorf("reading upstream metadata: %w", err)
- }
- contentType := resp.Header.Get(headerContentType)
- if contentType == "" {
- contentType = contentTypeJSON
- }
- return body, contentType, resp.Header.Clone(), nil
-}
-
-type swiftReleaseMetadata struct {
- Resources []struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Checksum string `json:"checksum"`
- Signing *struct {
- Signature string `json:"signatureBase64Encoded"`
- Format string `json:"signatureFormat"`
- } `json:"signing"`
- } `json:"resources"`
-}
-
-type swiftArchiveInfo struct {
- checksum string
- signature string
- signatureFormat string
-}
-
-func (h *SwiftHandler) fetchArchiveInfo(ctx context.Context, scope, name, version string) (swiftArchiveInfo, error) {
- upstreamURL := h.buildUpstreamURL(scope, name, version, "", "")
- body, _, err := h.proxy.FetchOrCacheMetadata(
- ctx, "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, swiftAcceptJSON,
- )
- if err != nil {
- return swiftArchiveInfo{}, err
- }
-
- var metadata swiftReleaseMetadata
- if err := json.Unmarshal(body, &metadata); err != nil {
- return swiftArchiveInfo{}, fmt.Errorf("parsing release metadata: %w", err)
- }
- for _, resource := range metadata.Resources {
- if resource.Name != "source-archive" || resource.Type != "application/zip" {
- continue
- }
- checksum, err := normalizeSwiftChecksum(resource.Checksum)
- if err != nil {
- return swiftArchiveInfo{}, err
- }
- info := swiftArchiveInfo{checksum: checksum}
- if resource.Signing != nil {
- if resource.Signing.Signature == "" || resource.Signing.Format == "" {
- return swiftArchiveInfo{}, errors.New("source archive signing metadata is incomplete")
- }
- info.signature = resource.Signing.Signature
- info.signatureFormat = resource.Signing.Format
- }
- return info, nil
- }
-
- return swiftArchiveInfo{}, errors.New("source archive is missing from release metadata")
-}
-
-func normalizeSwiftChecksum(checksum string) (string, error) {
- digest, err := hex.DecodeString(checksum)
- if err != nil || len(digest) != sha256.Size {
- return "", errors.New("source archive checksum is not a SHA-256 digest")
- }
- return hex.EncodeToString(digest), nil
-}
-
-func setSwiftArchiveHeaders(header http.Header, name, version, contentHash string, info swiftArchiveInfo) {
- header.Set("Cache-Control", "public, immutable")
- header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.zip"`, name, version))
- header.Set("Content-Version", swiftContentVersion)
-
- checksum := info.checksum
- if checksum == "" {
- checksum = contentHash
- }
- if digest := swiftDigestHeader(checksum); digest != "" {
- header.Set("Digest", digest)
- }
- if info.signature != "" && info.signatureFormat != "" {
- header.Set("X-Swift-Package-Signature", info.signature)
- header.Set("X-Swift-Package-Signature-Format", info.signatureFormat)
- }
-}
-
-func swiftDigestHeader(checksum string) string {
- digest, err := hex.DecodeString(checksum)
- if err != nil || len(digest) != sha256.Size {
- return ""
- }
- return "sha-256=" + base64.StdEncoding.EncodeToString(digest)
-}
-
-func (h *SwiftHandler) rewriteLinkHeader(value, upstreamRequestURL string) string {
- var result strings.Builder
- for len(value) > 0 {
- start := strings.IndexByte(value, '<')
- if start < 0 {
- result.WriteString(value)
- break
- }
- endOffset := strings.IndexByte(value[start+1:], '>')
- if endOffset < 0 {
- result.WriteString(value)
- break
- }
- end := start + 1 + endOffset
- result.WriteString(value[:start+1])
- result.WriteString(h.rewriteRegistryURL(value[start+1:end], upstreamRequestURL))
- result.WriteByte('>')
- value = value[end+1:]
- }
- return result.String()
-}
-
-func (h *SwiftHandler) rewriteRegistryURL(rawURL, upstreamRequestURL string) string {
- base, err := url.Parse(h.upstreamURL)
- if err != nil {
- return rawURL
- }
- requestURL, err := url.Parse(upstreamRequestURL)
- if err != nil {
- return rawURL
- }
- reference, err := url.Parse(rawURL)
- if err != nil {
- return rawURL
- }
- absolute := requestURL.ResolveReference(reference)
- if !strings.EqualFold(absolute.Scheme, base.Scheme) || !strings.EqualFold(absolute.Host, base.Host) {
- return rawURL
- }
-
- basePath := strings.TrimSuffix(base.EscapedPath(), "/")
- absolutePath := absolute.EscapedPath()
- if absolutePath != basePath && !strings.HasPrefix(absolutePath, basePath+"/") {
- return rawURL
- }
- suffix := strings.TrimPrefix(absolutePath, basePath)
- rewritten := h.proxyURL + "/swift" + suffix
- if absolute.RawQuery != "" {
- rewritten += "?" + absolute.RawQuery
- }
- if absolute.Fragment != "" {
- rewritten += "#" + absolute.Fragment
- }
- return rewritten
-}
-
-func (h *SwiftHandler) rewriteReleaseURLs(scope, name string, body []byte) ([]byte, error) {
- var metadata map[string]any
- if err := json.Unmarshal(body, &metadata); err != nil {
- return nil, err
- }
- releases, ok := metadata["releases"].(map[string]any)
- if !ok {
- return body, nil
- }
-
- for version, value := range releases {
- release, ok := value.(map[string]any)
- if !ok {
- continue
- }
- if _, hasURL := release["url"]; !hasURL {
- continue
- }
- release["url"] = fmt.Sprintf(
- "%s/swift/%s/%s/%s",
- h.proxyURL,
- url.PathEscape(scope),
- url.PathEscape(name),
- url.PathEscape(version),
- )
- }
- return json.Marshal(metadata)
-}
-
-func (h *SwiftHandler) buildUpstreamURL(scope, name, version, resource, rawQuery string) string {
- parts := []string{h.upstreamURL, url.PathEscape(scope), url.PathEscape(name)}
- if version != "" {
- parts = append(parts, url.PathEscape(version))
- }
- if resource != "" {
- parts = append(parts, resource)
- }
- result := strings.Join(parts, "/")
- if rawQuery != "" {
- result += "?" + rawQuery
- }
- return result
-}
-
-func swiftMetadataCacheKey(parts ...string) string {
- joined := strings.Join(parts, "\x00")
- digest := sha256.Sum256([]byte(joined))
- return hex.EncodeToString(digest[:])
-}
-
-func swiftReleaseCacheKey(scope, name, version string) string {
- return swiftMetadataCacheKey("release", scope, name, version)
-}
-
-func requestAccept(r *http.Request, fallback string) string {
- if accept := r.Header.Get("Accept"); accept != "" {
- return accept
- }
- return fallback
-}
-
-func writeSwiftMetadata(w http.ResponseWriter, r *http.Request, body []byte, contentType string) {
- if contentType == "" {
- contentType = "application/json"
- }
- digest := sha256.Sum256(body)
- etag := fmt.Sprintf(`"%x"`, digest)
- w.Header().Set(headerContentType, contentType)
- w.Header().Set("Content-Version", swiftContentVersion)
- w.Header().Set(headerETag, etag)
- if ifNoneMatchHits(r.Header.Get("If-None-Match"), etag) {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- w.Header().Set(headerContentLength, strconv.Itoa(len(body)))
- w.WriteHeader(http.StatusOK)
- if r.Method != http.MethodHead {
- _, _ = w.Write(body)
- }
-}
-
-func (h *SwiftHandler) writeMetadataError(w http.ResponseWriter, err error) {
- if errors.Is(err, ErrUpstreamNotFound) {
- writeSwiftProblem(w, http.StatusNotFound, "not found")
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("Swift metadata request failed", "error", err)
- writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed")
-}
-
-func (h *SwiftHandler) writeArtifactError(w http.ResponseWriter, err error) {
- if errors.Is(err, ErrUpstreamNotFound) {
- writeSwiftProblem(w, http.StatusNotFound, "release not found")
- return
- }
- if errors.Is(err, ErrArtifactBlocked) || errors.Is(err, ErrVersionDenied) {
- writeSwiftProblem(w, http.StatusForbidden, err.Error())
- return
- }
- if writeUpstreamHeld(w, err) {
- return
- }
- h.proxy.Logger.Error("Swift archive request failed", "error", err)
- writeSwiftProblem(w, http.StatusBadGateway, "failed to fetch package")
-}
-
-func writeSwiftProblem(w http.ResponseWriter, status int, detail string) {
- w.Header().Set(headerContentType, "application/problem+json")
- w.Header().Set("Content-Version", swiftContentVersion)
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(map[string]string{"detail": detail})
-}
-
-func validSwiftPackageReference(scope, name, version string) bool {
- return validSwiftScope(scope) && validSwiftPackageName(name) && version != "" && version != "." && version != ".." && !strings.ContainsAny(version, "/\\")
-}
-
-func canonicalSwiftPackage(scope, name string) (string, string) {
- return strings.ToLower(scope), strings.ToLower(name)
-}
-
-func validSwiftScope(scope string) bool {
- return validSwiftIdentifier(scope, swiftMaxScopeLength, "-")
-}
-
-func validSwiftPackageName(name string) bool {
- return validSwiftIdentifier(name, swiftMaxNameLength, "-_")
-}
-
-func validSwiftIdentifier(value string, maxLength int, separators string) bool {
- if value == "" || len(value) > maxLength {
- return false
- }
- previousSeparator := false
- for i := 0; i < len(value); i++ {
- character := value[i]
- separator := strings.ContainsRune(separators, rune(character))
- if separator {
- if i == 0 || i == len(value)-1 || previousSeparator {
- return false
- }
- previousSeparator = true
- continue
- }
- if (character < 'a' || character > 'z') &&
- (character < 'A' || character > 'Z') &&
- (character < '0' || character > '9') {
- return false
- }
- previousSeparator = false
- }
- return true
-}
diff --git a/internal/handler/swift_cache.go b/internal/handler/swift_cache.go
deleted file mode 100644
index d778179..0000000
--- a/internal/handler/swift_cache.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "database/sql"
- "errors"
- "net/url"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// swiftDocument is a release list or manifest as the upstream sent it.
-type swiftDocument struct {
- body []byte
- contentType string
- // link is the upstream Link header, its values joined, with the
- // upstream's URLs.
- link string
- fetchedAt time.Time
-}
-
-// fetchDocument returns the document at upstreamURL, from the metadata cache
-// under cacheKey while it is fresh or when the upstream fails, and otherwise
-// from upstream, storing it. The upstream Link header is cached with it,
-// since it carries the pagination of release lists and the version-specific
-// manifests a client picks from.
-func (h *SwiftHandler) fetchDocument(ctx context.Context, cacheKey, upstreamURL, accept string) (*swiftDocument, error) {
- cached := h.loadDocument(ctx, cacheKey)
- if cached != nil && h.proxy.MetadataTTL > 0 && time.Since(cached.fetchedAt) < h.proxy.MetadataTTL {
- return cached, nil
- }
- body, contentType, headers, err := h.fetchMetadataWithHeaders(ctx, upstreamURL, accept)
- if err != nil {
- if cached == nil || errors.Is(err, ErrUpstreamNotFound) {
- return nil, err
- }
- h.proxy.Logger.Warn("upstream Swift metadata fetch failed, serving cached copy",
- "url", upstreamURL, "error", err)
- return cached, nil
- }
- doc := &swiftDocument{
- body: body,
- contentType: contentType,
- link: strings.Join(headers.Values("Link"), ", "),
- fetchedAt: time.Now(),
- }
- h.storeDocument(ctx, cacheKey, doc)
- return doc, nil
-}
-
-func (h *SwiftHandler) documentsCached() bool {
- return h.proxy.CacheMetadata && h.proxy.DB != nil && h.proxy.Storage != nil
-}
-
-// loadDocument returns the cached document under cacheKey, or nil.
-func (h *SwiftHandler) loadDocument(ctx context.Context, cacheKey string) *swiftDocument {
- if !h.documentsCached() {
- return nil
- }
- entry := h.proxy.storedMetadataEntry("swift", cacheKey, true)
- if entry == nil {
- return nil
- }
- reader, err := h.proxy.openMetadataEntry(ctx, "swift", cacheKey, entry)
- if err != nil {
- return nil
- }
- defer func() { _ = reader.Close() }()
- body, err := h.proxy.ReadMetadata(reader)
- if err != nil {
- return nil
- }
- h.proxy.recordMetadataRead(entry)
- return &swiftDocument{
- body: body,
- contentType: entry.ContentType.String,
- link: entry.Link.String,
- fetchedAt: entry.FetchedAt.Time,
- }
-}
-
-// storeDocument caches doc under cacheKey. A failure is logged; the
-// document is still served.
-func (h *SwiftHandler) storeDocument(ctx context.Context, cacheKey string, doc *swiftDocument) {
- if !h.documentsCached() {
- return
- }
- storagePath := metadataStoragePath("swift", cacheKey)
- size, hash, err := h.proxy.Storage.Store(ctx, storagePath, bytes.NewReader(doc.body))
- if err == nil {
- err = h.proxy.DB.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "swift",
- Name: cacheKey,
- StoragePath: storagePath,
- Link: sql.NullString{String: doc.link, Valid: doc.link != ""},
- ContentType: sql.NullString{String: doc.contentType, Valid: doc.contentType != ""},
- ContentDigest: sql.NullString{String: "sha256:" + hash, Valid: hash != ""},
- Size: sql.NullInt64{Int64: size, Valid: true},
- FetchedAt: sql.NullTime{Time: doc.fetchedAt, Valid: true},
- })
- }
- if err != nil {
- h.proxy.Logger.Warn("failed to cache Swift metadata", "key", cacheKey, "error", err)
- }
-}
-
-func swiftReleasesCacheKey(scope, name, rawQuery string) string {
- return swiftMetadataCacheKey("releases", scope, name, rawQuery)
-}
-
-// swiftManifestCacheKey keys a manifest by its swift-version as a tools
-// version, so the 5.8 of a manifest's alternate link and the 5.8.0 a client
-// requests for it share a key.
-func swiftManifestCacheKey(scope, name, version, rawQuery string) string {
- if query, err := url.ParseQuery(rawQuery); err == nil {
- if tools := query.Get("swift-version"); tools != "" {
- for strings.Count(tools, ".") > 0 && strings.HasSuffix(tools, ".0") {
- tools = strings.TrimSuffix(tools, ".0")
- }
- query.Set("swift-version", tools)
- }
- rawQuery = query.Encode()
- }
- return swiftMetadataCacheKey("manifest", scope, name, version, rawQuery)
-}
diff --git a/internal/handler/swift_cache_test.go b/internal/handler/swift_cache_test.go
deleted file mode 100644
index 2b194a5..0000000
--- a/internal/handler/swift_cache_test.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package handler
-
-import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-)
-
-func TestSwiftReleaseListAndManifestServedFromCacheWhenUpstreamFails(t *testing.T) {
- var down atomic.Bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if down.Load() {
- http.Error(w, "down", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set("Link", `; rel="next"`)
- if strings.HasSuffix(r.URL.Path, "/Package.swift") {
- w.Header().Set("Content-Type", "text/x-swift")
- _, _ = io.WriteString(w, "// swift-tools-version:5.9")
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, `{"releases":{"1.0.0":{}}}`)
- }))
- defer upstream.Close()
-
- for _, cacheMetadata := range []bool{true, false} {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = cacheMetadata
- routes := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
- get := func(path string) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- return w
- }
- down.Store(false)
- for _, path := range []string{"/apple/example", "/apple/example/1.0.0/Package.swift"} {
- if w := get(path); w.Code != http.StatusOK {
- t.Fatalf("%s: status = %d with the upstream up", path, w.Code)
- }
- }
-
- down.Store(true)
- for path, body := range map[string]string{"/apple/example": `"1.0.0"`, "/apple/example/1.0.0/Package.swift": "swift-tools-version:5.9"} {
- w := get(path)
- if !cacheMetadata {
- if w.Code == http.StatusOK {
- t.Errorf("%s: served without cache_metadata while the upstream is down", path)
- }
- continue
- }
- if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), body) {
- t.Errorf("%s: status = %d, body %q; want the cached copy", path, w.Code, w.Body.String())
- }
- if link := w.Header().Get("Link"); link != `; rel="next"` {
- t.Errorf("%s: Link = %q, want the cached Link rewritten", path, link)
- }
- }
- }
-}
-
-// TestSwiftCorruptManifestRefetched checks that a cached manifest whose
-// stored bytes no longer match the recorded digest is fetched again, even
-// within metadata_ttl.
-func TestSwiftCorruptManifestRefetched(t *testing.T) {
- var hits atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- hits.Add(1)
- w.Header().Set("Content-Type", "text/x-swift")
- _, _ = io.WriteString(w, "// swift-tools-version:5.9")
- }))
- defer upstream.Close()
-
- proxy, db, store, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = time.Hour
- routes := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
- get := func() *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.0.0/Package.swift", nil))
- return w
- }
-
- get()
- entry, err := db.GetMetadataCache("swift", swiftManifestCacheKey("apple", "example", "1.0.0", ""))
- if err != nil || entry == nil {
- t.Fatalf("manifest not cached: %v", err)
- }
- if !strings.HasPrefix(entry.ContentDigest.String, "sha256:") {
- t.Fatalf("ContentDigest = %q, want a sha256 digest", entry.ContentDigest.String)
- }
- if w := get(); hits.Load() != 1 || w.Code != http.StatusOK {
- t.Fatalf("fresh cached manifest: status %d, %d upstream fetches; want it served from the cache", w.Code, hits.Load())
- }
-
- if _, _, err := store.Store(context.Background(), entry.StoragePath, strings.NewReader("// tampered")); err != nil {
- t.Fatal(err)
- }
- w := get()
- if hits.Load() != 2 {
- t.Errorf("upstream fetches = %d, want the corrupt manifest fetched again", hits.Load())
- }
- if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swift-tools-version:5.9") {
- t.Errorf("status = %d, body %q; want the upstream manifest", w.Code, w.Body.String())
- }
-}
-
-func TestSwiftManifestCacheKeyIgnoresToolsVersionZeros(t *testing.T) {
- key := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.8")
- if got := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.8.0"); got != key {
- t.Error("5.8.0 and 5.8 have different keys")
- }
- if got := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.9"); got == key {
- t.Error("5.9 and 5.8 share a key")
- }
- if got := swiftManifestCacheKey("apple", "example", "1.0.0", ""); got == key {
- t.Error("Package.swift and the 5.8 manifest share a key")
- }
-}
diff --git a/internal/handler/swift_mirror.go b/internal/handler/swift_mirror.go
deleted file mode 100644
index 5326c1d..0000000
--- a/internal/handler/swift_mirror.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package handler
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strings"
-)
-
-// MirrorRelease caches release version of scope.name so that a client can
-// resolve and fetch the release without the upstream.
-func (h *SwiftHandler) MirrorRelease(ctx context.Context, scope, name, version string) (*CacheResult, error) {
- if !validSwiftPackageReference(scope, name, version) {
- return nil, fmt.Errorf("invalid Swift registry release %s.%s@%s", scope, name, version)
- }
- scope, name = canonicalSwiftPackage(scope, name)
- key, err := swiftSourceArchiveKey(scope, name, version)
- if err != nil {
- return nil, err
- }
- if h.documentsCached() {
- if err := h.mirrorReleaseList(ctx, scope, name); err != nil {
- return nil, fmt.Errorf("fetching release list: %w", err)
- }
- if err := h.mirrorManifests(ctx, scope, name, version); err != nil {
- return nil, fmt.Errorf("fetching manifest: %w", err)
- }
- }
- info, err := h.fetchArchiveInfo(ctx, scope, name, version)
- if err != nil {
- return nil, fmt.Errorf("fetching release metadata: %w", err)
- }
- headers := http.Header{"Accept": {swiftAcceptArchive}}
- return h.fetchSourceArchive(ctx, key, version, h.buildUpstreamURL(scope, name, version+".zip", "", ""), headers, info)
-}
-
-// maxSwiftReleasePages bounds the release list pages a mirror follows.
-const maxSwiftReleasePages = 100
-
-// mirrorReleaseList caches every page of the release list, under the keys
-// the release list route reads for the page links it rewrites.
-func (h *SwiftHandler) mirrorReleaseList(ctx context.Context, scope, name string) error {
- query := ""
- seen := map[string]bool{}
- for page := 0; page < maxSwiftReleasePages && !seen[query]; page++ {
- seen[query] = true
- upstreamURL := h.buildUpstreamURL(scope, name, "", "", query)
- doc, err := h.fetchDocument(ctx, swiftReleasesCacheKey(scope, name, query), upstreamURL, swiftAcceptJSON)
- if err != nil {
- return err
- }
- next := swiftLinkQueries(doc.link, "next", upstreamURL)
- if len(next) == 0 {
- return nil
- }
- query = next[0]
- }
- return nil
-}
-
-// mirrorManifests caches Package.swift of a release and the version-specific
-// manifests its Link header lists, under the keys the manifest route reads
-// for the alternate links it rewrites.
-func (h *SwiftHandler) mirrorManifests(ctx context.Context, scope, name, version string) error {
- upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", "")
- doc, err := h.fetchDocument(ctx, swiftManifestCacheKey(scope, name, version, ""), upstreamURL, swiftAcceptManifest)
- if err != nil {
- return err
- }
- for _, query := range swiftLinkQueries(doc.link, "alternate", upstreamURL) {
- alternateURL := h.buildUpstreamURL(scope, name, version, "Package.swift", query)
- if _, err := h.fetchDocument(ctx, swiftManifestCacheKey(scope, name, version, query), alternateURL, swiftAcceptManifest); err != nil {
- return fmt.Errorf("%s: %w", query, err)
- }
- }
- return nil
-}
-
-// swiftLinkQueries returns the queries of the links with relation rel in
-// link, a Link header, that point at requestURL's own path with a query,
-// such as the next page of a release list or a version-specific manifest.
-func swiftLinkQueries(link, rel, requestURL string) []string {
- base, err := url.Parse(requestURL)
- if err != nil {
- return nil
- }
- var queries []string
- for _, value := range strings.Split(link, ",") {
- target, params, ok := strings.Cut(strings.TrimSpace(value), ";")
- if !ok || !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") || !swiftLinkHasRel(params, rel) {
- continue
- }
- ref, err := url.Parse(strings.TrimSpace(target[1 : len(target)-1]))
- if err != nil {
- continue
- }
- resolved := base.ResolveReference(ref)
- if resolved.Host != base.Host || resolved.EscapedPath() != base.EscapedPath() || resolved.RawQuery == "" {
- continue
- }
- queries = append(queries, resolved.RawQuery)
- }
- return queries
-}
-
-func swiftLinkHasRel(params, rel string) bool {
- for _, param := range strings.Split(params, ";") {
- key, value, ok := strings.Cut(strings.TrimSpace(param), "=")
- if !ok || !strings.EqualFold(strings.TrimSpace(key), "rel") {
- continue
- }
- for _, r := range strings.Fields(strings.Trim(strings.TrimSpace(value), `"`)) {
- if strings.EqualFold(r, rel) {
- return true
- }
- }
- }
- return false
-}
-
-// SwiftIdentity is a package's identity in a Swift registry, scope.name.
-type SwiftIdentity struct {
- Scope, Name string
-}
-
-// ResolveIdentity returns the first registry identity the upstream lists for
-// the source repository at sourceURL, the one a download uses.
-func (h *SwiftHandler) ResolveIdentity(ctx context.Context, sourceURL string) (scope, name string, _ error) {
- ids, err := h.ResolveIdentities(ctx, sourceURL)
- if err != nil {
- return "", "", err
- }
- return ids[0].Scope, ids[0].Name, nil
-}
-
-// ResolveIdentities returns every registry identity the upstream lists for
-// the source repository at sourceURL; the registry protocol allows several.
-// The answer is cached as the identifiers route caches it.
-func (h *SwiftHandler) ResolveIdentities(ctx context.Context, sourceURL string) ([]SwiftIdentity, error) {
- query := url.Values{"url": {sourceURL}}.Encode()
- body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "swift", swiftMetadataCacheKey("identifiers", query),
- h.upstreamURL+"/identifiers?"+query, swiftAcceptJSON)
- if errors.Is(err, ErrUpstreamNotFound) {
- return nil, fmt.Errorf("the Swift registry has no package for %s", sourceURL)
- }
- if err != nil {
- return nil, err
- }
- return SwiftIdentitiesOf(body, sourceURL)
-}
-
-// SwiftIdentityOf returns the scope and name of the first identity in body,
-// a registry's answer to an identifiers lookup for sourceURL.
-func SwiftIdentityOf(body []byte, sourceURL string) (scope, name string, _ error) {
- ids, err := SwiftIdentitiesOf(body, sourceURL)
- if err != nil {
- return "", "", err
- }
- return ids[0].Scope, ids[0].Name, nil
-}
-
-// SwiftIdentitiesOf returns every identity in body, a registry's answer to
-// an identifiers lookup for sourceURL. One invalid identity fails the whole
-// answer, so a policy never covers only some of them.
-func SwiftIdentitiesOf(body []byte, sourceURL string) ([]SwiftIdentity, error) {
- var answer struct {
- Identifiers []string `json:"identifiers"`
- }
- if err := json.Unmarshal(body, &answer); err != nil {
- return nil, fmt.Errorf("parsing the Swift registry's identifiers for %s: %w", sourceURL, err)
- }
- if len(answer.Identifiers) == 0 {
- return nil, fmt.Errorf("the Swift registry has no package for %s", sourceURL)
- }
- ids := make([]SwiftIdentity, 0, len(answer.Identifiers))
- for _, identifier := range answer.Identifiers {
- scope, name, ok := strings.Cut(identifier, ".")
- if !ok || scope == "" || name == "" {
- return nil, fmt.Errorf("the Swift registry answered %s with the invalid identity %q", sourceURL, identifier)
- }
- ids = append(ids, SwiftIdentity{Scope: scope, Name: name})
- }
- return ids, nil
-}
diff --git a/internal/handler/swift_mirror_test.go b/internal/handler/swift_mirror_test.go
deleted file mode 100644
index 8359a5e..0000000
--- a/internal/handler/swift_mirror_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package handler
-
-import (
- "reflect"
- "testing"
-)
-
-func TestSwiftLinkQueries(t *testing.T) {
- const manifest = "https://registry.example/swift/apple/example/1.0.0/Package.swift"
- link := `; rel="alternate"; filename="Package@swift-5.8.swift", ` +
- `; rel=alternate, ` +
- `; rel="alternate", ` +
- `; rel="alternate", ` +
- `; rel="canonical"`
-
- got := swiftLinkQueries(link, "alternate", manifest)
-
- want := []string{"swift-version=5.8", "swift-version=5.7"}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("swiftLinkQueries() = %q, want %q", got, want)
- }
-}
diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go
deleted file mode 100644
index c53be77..0000000
--- a/internal/handler/swift_test.go
+++ /dev/null
@@ -1,585 +0,0 @@
-package handler
-
-import (
- "context"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries/fetch"
-)
-
-func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) {
- var gotAccept string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/registry/apple/swift-argument-parser" {
- t.Errorf("upstream path = %q", r.URL.Path)
- }
- gotAccept = r.Header.Get("Accept")
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.Header().Set("Content-Version", "1")
- w.Header().Add("Link", `; rel="next"`)
- _, _ = io.WriteString(w, `{"releases":{"1.2.0":{"url":"/registry/apple/swift-argument-parser/1.2.0"},"1.1.0":{}}}`)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
- req := httptest.NewRequest(http.MethodGet, "/APPLE/SWIFT-ARGUMENT-PARSER", nil)
- req.Header.Set("Accept", swiftAcceptJSON)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if gotAccept != swiftAcceptJSON {
- t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptJSON)
- }
- if got := w.Header().Get("Content-Version"); got != "1" {
- t.Errorf("Content-Version = %q, want 1", got)
- }
- if got := w.Header().Get("Link"); got != `; rel="next"` {
- t.Errorf("Link = %q", got)
- }
-
- var body struct {
- Releases map[string]struct {
- URL string `json:"url"`
- } `json:"releases"`
- }
- if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if got := body.Releases["1.2.0"].URL; got != "https://proxy.example/swift/apple/swift-argument-parser/1.2.0" {
- t.Errorf("release URL = %q", got)
- }
- if got := body.Releases["1.1.0"].URL; got != "" {
- t.Errorf("release without upstream URL gained URL %q", got)
- }
-}
-
-func TestSwiftReleaseMetadataSupportsJSONExtensionAndHead(t *testing.T) {
- var requestMethods []string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestMethods = append(requestMethods, r.Method)
- if r.URL.Path != "/registry/apple/example/1.2.3" {
- t.Errorf("upstream path = %q", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, `{"id":"apple.example","version":"1.2.3","resources":[]}`)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
-
- for _, method := range []string{http.MethodGet, http.MethodHead} {
- req := httptest.NewRequest(method, "/APPLE/EXAMPLE/1.2.3.json", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("%s status = %d, want 200", method, w.Code)
- }
- if method == http.MethodHead && w.Body.Len() != 0 {
- t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
- }
- }
- if len(requestMethods) != 2 || requestMethods[0] != http.MethodGet || requestMethods[1] != http.MethodGet {
- t.Errorf("upstream methods = %v, want metadata GETs", requestMethods)
- }
-}
-
-func TestSwiftManifestProxiesQueryAndRewritesLinks(t *testing.T) {
- var upstream *httptest.Server
- var gotAccept string
- upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- t.Errorf("upstream method = %s, want GET", r.Method)
- }
- if r.URL.Path != "/registry/apple/example/1.2.3/Package.swift" {
- t.Errorf("upstream path = %q", r.URL.Path)
- }
- if got := r.URL.Query().Get("swift-version"); got != "5.9" {
- t.Errorf("swift-version = %q, want 5.9", got)
- }
- gotAccept = r.Header.Get("Accept")
- w.Header().Set("Content-Type", "text/x-swift")
- w.Header().Add("Link", fmt.Sprintf(`<%s/registry/apple/example/1.2.3/Package.swift?swift-version=5.8>; rel="alternate"; filename="Package@swift-5.8.swift"`, upstream.URL))
- w.Header().Add("Link", `; rel="canonical"`)
- _, _ = io.WriteString(w, "// swift-tools-version: 5.9\n")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
- req := httptest.NewRequest(http.MethodGet, "/APPLE/EXAMPLE/1.2.3/Package.swift?swift-version=5.9", nil)
- req.Header.Set("Accept", swiftAcceptManifest)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- if gotAccept != swiftAcceptManifest {
- t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptManifest)
- }
- links := strings.Join(w.Header().Values("Link"), ",")
- if !strings.Contains(links, "https://proxy.example/swift/apple/example/1.2.3/Package.swift?swift-version=5.8") {
- t.Errorf("internal manifest Link was not rewritten: %q", links)
- }
- if !strings.Contains(links, "https://github.com/apple/example") {
- t.Errorf("external canonical Link was changed: %q", links)
- }
- if got := w.Header().Get("Content-Version"); got != "1" {
- t.Errorf("Content-Version = %q, want 1", got)
- }
-}
-
-func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) {
- archive := []byte("swift source archive")
- checksumBytes := sha256.Sum256(archive)
- checksum := hex.EncodeToString(checksumBytes[:])
- signature := base64.StdEncoding.EncodeToString([]byte("signature"))
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/registry/apple/example/1.2.3" {
- t.Errorf("metadata path = %q", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q,"signing":{"signatureBase64Encoded":%q,"signatureFormat":"cms-1.0.0"}}]}`, checksum, signature)
- }))
- defer upstream.Close()
-
- proxy, db, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(string(archive))),
- Size: int64(len(archive)),
- ContentType: "application/zip",
- }
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
-
- requestArchive := func(method string) *httptest.ResponseRecorder {
- req := httptest.NewRequest(method, "/apple/example/1.2.3.zip", nil)
- req.Header.Set("Accept", swiftAcceptArchive)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
- return w
- }
-
- w := requestArchive(http.MethodGet)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if got := w.Body.Bytes(); string(got) != string(archive) {
- t.Errorf("archive body = %q", got)
- }
- if !fetcher.fetchCalled {
- t.Fatal("archive fetcher was not called")
- }
- if got := fetcher.fetchedURL; got != upstream.URL+"/registry/apple/example/1.2.3.zip" {
- t.Errorf("fetched URL = %q", got)
- }
- if got := fetcher.fetchedHeader.Get("Accept"); got != swiftAcceptArchive {
- t.Errorf("archive Accept = %q, want %q", got, swiftAcceptArchive)
- }
- if got := w.Header().Get("Digest"); got != "sha-256="+base64.StdEncoding.EncodeToString(checksumBytes[:]) {
- t.Errorf("Digest = %q", got)
- }
- if got := w.Header().Get("X-Swift-Package-Signature"); got != signature {
- t.Errorf("signature = %q", got)
- }
- if got := w.Header().Get("X-Swift-Package-Signature-Format"); got != "cms-1.0.0" {
- t.Errorf("signature format = %q", got)
- }
- if got := w.Header().Get("Content-Disposition"); got != `attachment; filename="example-1.2.3.zip"` {
- t.Errorf("Content-Disposition = %q", got)
- }
-
- packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
- if strings.HasPrefix(packagePURL, "pkg:swift/") {
- t.Fatalf("registry identity produced source PURL %q", packagePURL)
- }
- versionRecord, err := db.GetVersionByPURL(versionPURL)
- if err != nil {
- t.Fatalf("cached Swift version %q not found: %v", versionPURL, err)
- }
- if versionRecord == nil {
- t.Fatalf("cached Swift version %q not found", versionPURL)
- }
- if versionRecord.PackagePURL != packagePURL {
- t.Errorf("cached package PURL = %q, want %q", versionRecord.PackagePURL, packagePURL)
- }
-
- fetcher.fetchCalled = false
- w = requestArchive(http.MethodHead)
- if w.Code != http.StatusOK {
- t.Fatalf("HEAD status = %d, want 200", w.Code)
- }
- if w.Body.Len() != 0 {
- t.Errorf("HEAD body length = %d, want 0", w.Body.Len())
- }
- if got := w.Header().Get("Content-Length"); got != fmt.Sprint(len(archive)) {
- t.Errorf("HEAD Content-Length = %q", got)
- }
-
- w = requestArchive(http.MethodGet)
- if w.Code != http.StatusOK || w.Body.String() != string(archive) {
- t.Fatalf("cached response = %d %q", w.Code, w.Body.Bytes())
- }
- if fetcher.fetchCalled {
- t.Error("cached archive contacted artifact upstream")
- }
-}
-
-func TestSwiftSourceArchiveRejectsChecksumMismatch(t *testing.T) {
- archive := []byte("unexpected archive")
- expectedChecksum := sha256.Sum256([]byte("expected archive"))
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(expectedChecksum[:]))
- }))
- defer upstream.Close()
-
- proxy, db, store, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(string(archive))),
- Size: int64(len(archive)),
- ContentType: "application/zip",
- }
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
-
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
-
- if w.Code != http.StatusBadGateway {
- t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
- }
- if len(store.files) != 0 {
- t.Errorf("mismatched archive remained in storage: %v", store.files)
- }
- packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
- cached, err := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip")
- if err != nil {
- t.Fatalf("checking cache: %v", err)
- }
- if cached != nil {
- t.Error("mismatched archive gained a cache record")
- }
-}
-
-func TestSwiftSourceArchiveCanonicalizesPackageIdentity(t *testing.T) {
- archive := []byte("swift source archive")
- checksumBytes := sha256.Sum256(archive)
- checksum := hex.EncodeToString(checksumBytes[:])
- var metadataPaths []string
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- metadataPaths = append(metadataPaths, r.URL.Path)
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
- }))
- defer upstream.Close()
-
- proxy, db, store, fetcher := setupTestProxy(t)
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
- requestArchive := func(path string) {
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(string(archive))),
- Size: int64(len(archive)),
- ContentType: "application/zip",
- }
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
- if w.Code != http.StatusOK {
- t.Fatalf("GET %s status = %d, want 200; body: %s", path, w.Code, w.Body.String())
- }
- }
-
- requestArchive("/apple/example/1.2.3.zip")
- requestArchive("/APPLE/EXAMPLE/1.2.3.zip")
-
- if len(store.files) != 1 {
- t.Errorf("cached files = %d, want 1", len(store.files))
- }
- for _, path := range metadataPaths {
- if path != "/apple/example/1.2.3" {
- t.Errorf("metadata path = %q, want canonical lowercase path", path)
- }
- }
-
- canonicalPURL, _ := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
- canonical, err := db.GetPackageByPURL(canonicalPURL)
- if err != nil {
- t.Fatalf("getting canonical package: %v", err)
- }
- if canonical == nil {
- t.Fatalf("canonical package %q not found", canonicalPURL)
- }
-
- nonCanonicalPURL, _ := packageurl.MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
- if nonCanonicalPURL != canonicalPURL {
- t.Errorf("uppercase cache PURL = %q, want %q", nonCanonicalPURL, canonicalPURL)
- }
-}
-
-func TestSwiftSourceArchiveHeadLeavesStaleCacheForTheFetch(t *testing.T) {
- archive := []byte("cached archive")
- upstreamChecksum := sha256.Sum256([]byte("upstream archive"))
-
- var probed bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, ".zip") {
- probed = true
- w.Header().Set("Content-Range", "bytes 0-0/456")
- w.WriteHeader(http.StatusPartialContent)
- _, _ = w.Write([]byte("x"))
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(upstreamChecksum[:]))
- }))
- defer upstream.Close()
-
- proxy, db, store, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader(string(archive))),
- Size: int64(len(archive)),
- ContentType: "application/zip",
- }
- packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
- cached, err := proxy.getOrFetchArtifactFromURLWithCachePURLs(
- context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
- packagePURL, versionPURL, upstream.URL+"/apple/example/1.2.3.zip", nil, "", "",
- )
- if err != nil {
- t.Fatalf("seeding cache: %v", err)
- }
- _ = cached.Reader.Close()
-
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if !probed {
- t.Error("stale cache entry was not replaced by an upstream probe")
- }
- if got := w.Header().Get("Content-Length"); got != "456" {
- t.Errorf("Content-Length = %q, want 456 from upstream probe", got)
- }
- // HEAD leaves the stale entry alone; the next GET replaces it under the
- // coalescing key.
- if len(store.files) != 1 {
- t.Errorf("HEAD must leave the stale archive in storage, got %v", store.files)
- }
- if rec, _ := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip"); rec == nil {
- t.Error("HEAD must leave the stale cache record in place")
- }
-
- fetcher.artifact = artifactBody("upstream archive")
- w = httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
- if w.Code != http.StatusOK {
- t.Fatalf("GET status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if w.Body.String() != "upstream archive" {
- t.Errorf("GET body = %q, want the refreshed archive", w.Body.String())
- }
- rec, _ := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip")
- if rec == nil || rec.Artifact.Digest.Encoded() != hex.EncodeToString(upstreamChecksum[:]) {
- t.Errorf("cache record after GET = %+v, want the upstream checksum", rec)
- }
-}
-
-func TestSwiftSourceArchiveRequiresReleaseMetadata(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- }))
- defer upstream.Close()
-
- proxy, _, store, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("signed archive")),
- ContentType: "application/zip",
- }
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
-
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
-
- if w.Code != http.StatusBadGateway {
- t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
- }
- if fetcher.fetchCalled {
- t.Error("archive was fetched without release security metadata")
- }
- if len(store.files) != 0 {
- t.Errorf("archive was cached without release security metadata: %v", store.files)
- }
-}
-
-func TestSwiftSourceArchiveColdHeadUsesRangeGetAcrossRedirect(t *testing.T) {
- checksum := strings.Repeat("a", sha256.Size*2)
- var archiveAccept string
- var archiveMethod string
- var archiveRange string
- download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- archiveMethod = r.Method
- archiveRange = r.Header.Get("Range")
- w.Header().Set("Content-Range", "bytes 0-0/123")
- w.WriteHeader(http.StatusPartialContent)
- _, _ = w.Write([]byte("x"))
- }))
- defer download.Close()
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/apple/example/1.2.3":
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
- case "/apple/example/1.2.3.zip":
- archiveAccept = r.Header.Get("Accept")
- http.Redirect(w, r, download.URL, http.StatusSeeOther)
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
-
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if archiveMethod != http.MethodGet {
- t.Errorf("download method = %q, want GET", archiveMethod)
- }
- if archiveRange != "bytes=0-0" {
- t.Errorf("download Range = %q, want bytes=0-0", archiveRange)
- }
- if archiveAccept != swiftAcceptArchive {
- t.Errorf("upstream Accept = %q, want %q", archiveAccept, swiftAcceptArchive)
- }
- if got := w.Header().Get("Content-Length"); got != "123" {
- t.Errorf("Content-Length = %q, want 123", got)
- }
-}
-
-func TestSwiftIdentifiersAndPublishingUnsupported(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/registry/identifiers" {
- t.Errorf("upstream path = %q", r.URL.Path)
- }
- if got := r.URL.Query().Get("url"); got != "https://github.com/apple/example" {
- t.Errorf("lookup URL = %q", got)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, `{"identifiers":["apple.example"]}`)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
-
- req := httptest.NewRequest(http.MethodGet, "/identifiers?url=https%3A%2F%2Fgithub.com%2Fapple%2Fexample", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
- if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "apple.example") {
- t.Fatalf("identifier response = %d %q", w.Code, w.Body.String())
- }
-
- req = httptest.NewRequest(http.MethodGet, "/identifiers", nil)
- w = httptest.NewRecorder()
- handler.ServeHTTP(w, req)
- if w.Code != http.StatusBadRequest {
- t.Errorf("missing URL status = %d, want 400", w.Code)
- }
-
- req = httptest.NewRequest(http.MethodPut, "/apple/example/1.2.3", strings.NewReader("ignored"))
- w = httptest.NewRecorder()
- handler.ServeHTTP(w, req)
- if w.Code != http.StatusMethodNotAllowed {
- t.Errorf("publish status = %d, want 405", w.Code)
- }
- if got := w.Header().Get("Allow"); got != "GET, HEAD" {
- t.Errorf("Allow = %q", got)
- }
-}
-
-func TestSwiftIdentifierValidation(t *testing.T) {
- tests := []struct {
- name string
- value string
- valid func(string) bool
- want bool
- }{
- {"scope", "apple", validSwiftScope, true},
- {"scope hyphen", "swift-server", validSwiftScope, true},
- {"scope underscore", "swift_server", validSwiftScope, false},
- {"scope repeated separator", "swift--server", validSwiftScope, false},
- {"package", "swift-argument_parser", validSwiftPackageName, true},
- {"package repeated separators", "swift-_argument", validSwiftPackageName, false},
- {"package trailing separator", "example-", validSwiftPackageName, false},
- {"package non-ASCII", "café", validSwiftPackageName, false},
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- if got := test.valid(test.value); got != test.want {
- t.Errorf("validation of %q = %v, want %v", test.value, got, test.want)
- }
- })
- }
-}
-
-func TestSwiftRetentionKey(t *testing.T) {
- tests := []struct {
- key string
- want string
- ok bool
- }{
- {"pkg:swift/apple/swift-nio", "pkg:generic/swift-registry/apple.swift-nio", true},
- {"pkg:swift/Apple/Swift-NIO", "pkg:generic/swift-registry/apple.swift-nio", true},
- {"pkg:generic/swift-registry/apple.swift-nio", "pkg:generic/swift-registry/apple.swift-nio", true},
- {"pkg:generic/swift-registry/Apple.Swift-NIO", "pkg:generic/swift-registry/apple.swift-nio", true},
- {"pkg:generic/Swift-Registry/apple.swift-nio", "pkg:generic/swift-registry/apple.swift-nio", true},
- {"pkg:swift/github.com/apple/swift-nio", "", false},
- {"pkg:generic/swift-registry/swift-nio", "", false},
- {"pkg:generic/swift-registry/apple.swift.nio", "", false},
- {"pkg:swift/apple/-nio", "", false},
- {"pkg:generic/apple.swift-nio", "", false},
- {"pkg:generic/other/apple.swift-nio", "", false},
- {"pkg:npm/apple/swift-nio", "", false},
- }
- for _, tt := range tests {
- p, err := purl.Parse(tt.key)
- if err != nil {
- t.Fatalf("Parse(%s): %v", tt.key, err)
- }
- got, ok := swiftRetentionKey(p)
- if ok != tt.ok || (ok && got != tt.want) {
- t.Errorf("swiftRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
- }
- }
- if _, ok := swiftRetentionKey(nil); ok {
- t.Error("swiftRetentionKey(nil) accepted")
- }
-}
diff --git a/internal/handler/unrecorded_test.go b/internal/handler/unrecorded_test.go
deleted file mode 100644
index d87ddc9..0000000
--- a/internal/handler/unrecorded_test.go
+++ /dev/null
@@ -1,415 +0,0 @@
-package handler
-
-import (
- "context"
- "errors"
- "io"
- "maps"
- "net/http"
- "net/http/httptest"
- "slices"
- "sync"
- "testing"
- "time"
-)
-
-// These tests refetch an artifact whose stored file is gone while
-// pending_deletes refuses writes. The record can't move to the new file and
-// the file can't be queued, so the fetch must serve it to every caller sharing
-// it and delete it once the last of them is done.
-
-const (
- unrecordedContent = "refetched content"
- unrecordedURL = "https://example.com/gone.tgz"
-)
-
-func setupUnrecorded(t *testing.T) (*Proxy, *mockStorage) {
- t.Helper()
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "gone", "1.0.0", "gone-1.0.0.tgz", "old content")
- oldPath := recordedStoragePath(t, db, "pkg:npm/gone@1.0.0", "gone-1.0.0.tgz")
- delete(store.files, oldPath)
- if _, err := db.Exec(`CREATE TRIGGER refuse_queue BEFORE INSERT ON pending_deletes
- BEGIN SELECT RAISE(ABORT, 'queue refused'); END`); err != nil {
- t.Fatalf("creating trigger: %v", err)
- }
- t.Cleanup(func() {
- if got := recordedStoragePath(t, db, "pkg:npm/gone@1.0.0", "gone-1.0.0.tgz"); got != oldPath {
- t.Errorf("record points at %q, want it kept at %q", got, oldPath)
- }
- })
- return proxy, store
-}
-
-func fetchGone(ctx context.Context, proxy *Proxy) (*CacheResult, error) {
- return proxy.GetOrFetchArtifactFromURL(ctx, "npm", "gone", "1.0.0", "gone-1.0.0.tgz", unrecordedURL)
-}
-
-func storedFiles(store *mockStorage) []string {
- store.mu.Lock()
- defer store.mu.Unlock()
- return slices.Collect(maps.Keys(store.files))
-}
-
-func readAndClose(t *testing.T, res *CacheResult) {
- t.Helper()
- body, err := io.ReadAll(res.Reader)
- _ = res.Reader.Close()
- if err != nil || string(body) != unrecordedContent {
- t.Errorf("body = %q, %v, want %q", body, err, unrecordedContent)
- }
-}
-
-// waitForWaiters blocks until n callers wait on the one fetch in flight.
-func waitForWaiters(t *testing.T, proxy *Proxy, n int) {
- t.Helper()
- deadline := time.Now().Add(5 * time.Second)
- for time.Now().Before(deadline) {
- proxy.fetchMu.Lock()
- got := -1
- for _, f := range proxy.inFlight {
- got = f.waiters
- }
- proxy.fetchMu.Unlock()
- if got == n {
- return
- }
- time.Sleep(time.Millisecond)
- }
- t.Fatalf("never saw %d waiters on the fetch", n)
-}
-
-func TestUnrecordedRefetchIsDeletedOnceServed(t *testing.T) {
- proxy, store := setupUnrecorded(t)
- fetcher := &countingFetcher{content: unrecordedContent}
- proxy.Fetcher = fetcher
-
- for range 2 {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Fatalf("fetch: %v", err)
- }
- if got := storedFiles(store); len(got) != 1 {
- t.Errorf("stored files while serving = %v, want the refetched one", got)
- }
- readAndClose(t, res)
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after serving = %v, want none left untracked", got)
- }
- }
-}
-
-func TestUnrecordedRefetchServesRangeRequests(t *testing.T) {
- proxy, store := setupUnrecorded(t)
- store.seekable = true
- proxy.Fetcher = &countingFetcher{content: unrecordedContent}
-
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Fatalf("fetch: %v", err)
- }
- req := httptest.NewRequest(http.MethodGet, "/gone-1.0.0.tgz", nil)
- req.Header.Set("Range", "bytes=2-5")
- rec := httptest.NewRecorder()
- ServeArtifactRequest(rec, req, res)
-
- if rec.Code != http.StatusPartialContent || rec.Body.String() != unrecordedContent[2:6] {
- t.Errorf("range response = %d %q, want 206 %q", rec.Code, rec.Body.String(), unrecordedContent[2:6])
- }
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after serving = %v, want none left untracked", got)
- }
-}
-
-// TestUnrecordedRefetchWaitsForEveryCaller keeps the object until the caller
-// that ran the fetch and every caller that waited on it have closed.
-func TestUnrecordedRefetchWaitsForEveryCaller(t *testing.T) {
- const waiters = 4
- proxy, store := setupUnrecorded(t)
- fetcher := newGatedFetcher(unrecordedContent)
- proxy.Fetcher = fetcher
-
- leader := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("leader: %v", err)
- }
- leader <- res
- }()
- <-fetcher.entered
-
- results := make(chan *CacheResult, waiters)
- var wg sync.WaitGroup
- for range waiters {
- wg.Go(func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("waiter: %v", err)
- return
- }
- results <- res
- })
- }
- waitForWaiters(t, proxy, waiters)
- close(fetcher.release)
-
- // The leader holds its reader open while every waiter reads and closes.
- lead := <-leader
- wg.Wait()
- close(results)
- for res := range results {
- readAndClose(t, res)
- }
- if got := storedFiles(store); len(got) != 1 {
- t.Fatalf("stored files with the leader still reading = %v, want the refetched one", got)
- }
- readAndClose(t, lead)
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after every caller closed = %v, want none", got)
- }
-}
-
-func TestUnrecordedRefetchSurvivesDoubleClose(t *testing.T) {
- proxy, store := setupUnrecorded(t)
- fetcher := newGatedFetcher(unrecordedContent)
- proxy.Fetcher = fetcher
-
- leader := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("leader: %v", err)
- }
- leader <- res
- }()
- <-fetcher.entered
-
- waiter := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("waiter: %v", err)
- }
- waiter <- res
- }()
- waitForWaiters(t, proxy, 1)
- close(fetcher.release)
-
- lead, wait := <-leader, <-waiter
- readAndClose(t, lead)
- _ = lead.Reader.Close()
- if got := storedFiles(store); len(got) != 1 {
- t.Fatalf("stored files with the waiter still reading = %v, want the refetched one", got)
- }
- readAndClose(t, wait)
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after every caller closed = %v, want none", got)
- }
-}
-
-// ctxStorage fails to open under a cancelled context, as cloud backends do.
-type ctxStorage struct{ *mockStorage }
-
-func (s ctxStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- return s.mockStorage.Open(ctx, path)
-}
-
-// TestUnrecordedRefetchOutlivesDetachedCaller is the retry a detached fetch
-// exists for: the client that started it gave up, and its retry waits on the
-// same fetch. The retry must get the bytes rather than a deleted object.
-func TestUnrecordedRefetchOutlivesDetachedCaller(t *testing.T) {
- proxy, store := setupUnrecorded(t)
- proxy.Storage = ctxStorage{store}
- fetcher := newGatedFetcher(unrecordedContent)
- proxy.Fetcher = fetcher
-
- ctx, cancel := context.WithCancel(DetachUpstreamFetch(context.Background()))
- leaderDone := make(chan struct{})
- go func() {
- defer close(leaderDone)
- res, _ := fetchGone(ctx, proxy)
- drain(res)
- }()
- <-fetcher.entered
- cancel()
-
- retry := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("retry: %v", err)
- }
- retry <- res
- }()
- waitForWaiters(t, proxy, 1)
- close(fetcher.release)
- <-leaderDone
-
- if res := <-retry; res != nil {
- readAndClose(t, res)
- }
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after the retry closed = %v, want none", got)
- }
-}
-
-// TestUnrecordedRefetchIgnoresWaiterThatLeft checks that a waiter giving up
-// before the fetch ends does not keep the object forever.
-func TestUnrecordedRefetchIgnoresWaiterThatLeft(t *testing.T) {
- proxy, store := setupUnrecorded(t)
- fetcher := newGatedFetcher(unrecordedContent)
- proxy.Fetcher = fetcher
-
- leader := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("leader: %v", err)
- }
- leader <- res
- }()
- <-fetcher.entered
-
- ctx, cancel := context.WithCancel(context.Background())
- left := make(chan error, 1)
- go func() {
- _, err := fetchGone(ctx, proxy)
- left <- err
- }()
- waitForWaiters(t, proxy, 1)
- cancel()
- if err := <-left; !errors.Is(err, context.Canceled) {
- t.Fatalf("waiter that left: err = %v, want context.Canceled", err)
- }
- close(fetcher.release)
-
- readAndClose(t, <-leader)
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after the leader closed = %v, want none", got)
- }
-}
-
-// TestLeaveFetch covers both ways a waiter can give up: before the fetch
-// finished, when it was not handed a hold yet, and after, when it was.
-func TestLeaveFetch(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- deleted := false
- newFetch := func() *inflightFetch {
- f := &inflightFetch{done: make(chan struct{}), waiters: 1}
- f.unrecorded = &unrecordedObject{delete: func() { deleted = true }}
- f.unrecorded.refs.Add(1)
- return f
- }
-
- f := newFetch()
- proxy.leaveFetch(f)
- if f.waiters != 0 || deleted {
- t.Errorf("before done: waiters = %d, deleted = %v, want 0 and false", f.waiters, deleted)
- }
-
- f = newFetch()
- close(f.done)
- proxy.leaveFetch(f)
- if f.waiters != 1 || !deleted {
- t.Errorf("after done: waiters = %d, deleted = %v, want 1 and true", f.waiters, deleted)
- }
-}
-
-// TestRecordedAt is the check that keeps an object whose record update
-// committed despite reporting an error.
-func TestRecordedAt(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "kept", "1.0.0", "kept-1.0.0.tgz", "content")
- path := recordedStoragePath(t, db, "pkg:npm/kept@1.0.0", "kept-1.0.0.tgz")
-
- if !proxy.recordedAt("pkg:npm/kept@1.0.0", "kept-1.0.0.tgz", path) {
- t.Error("record pointing at the path not seen")
- }
- if proxy.recordedAt("pkg:npm/kept@1.0.0", "kept-1.0.0.tgz", "other/path.tgz") {
- t.Error("record pointing elsewhere taken as recorded")
- }
- if proxy.recordedAt("pkg:npm/none@1.0.0", "none-1.0.0.tgz", path) {
- t.Error("missing record taken as recorded")
- }
- _ = db.Close()
- if proxy.recordedAt("pkg:npm/kept@1.0.0", "kept-1.0.0.tgz", path) {
- t.Error("failed lookup taken as recorded")
- }
-}
-
-// TestUnrecordedStreamedMiss streams an unrecorded refetch to the caller
-// that started it, and the waiters still get the stored object. A caller fed
-// from upstream holds no reader on it, so it is deleted once the waiters are
-// done; one that fell behind reads it, so it is deleted once that caller is
-// done too.
-func TestUnrecordedStreamedMiss(t *testing.T) {
- for _, behind := range []bool{false, true} {
- t.Run(map[bool]string{false: "fed", true: "behind"}[behind], func(t *testing.T) {
- testUnrecordedStreamedMiss(t, behind)
- })
- }
-}
-
-func testUnrecordedStreamedMiss(t *testing.T, behind bool) {
- t.Helper()
- if behind {
- old := clientFeedLimit
- clientFeedLimit = 4
- t.Cleanup(func() { clientFeedLimit = old })
- }
- proxy, store := setupUnrecorded(t)
- fetcher := newGatedFetcher(unrecordedContent)
- proxy.Fetcher = fetcher
-
- leader := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(StreamCacheMiss(context.Background()), proxy)
- if err != nil {
- t.Errorf("leader: %v", err)
- }
- leader <- res
- }()
- <-fetcher.entered
- waiter := make(chan *CacheResult, 1)
- go func() {
- res, err := fetchGone(context.Background(), proxy)
- if err != nil {
- t.Errorf("waiter: %v", err)
- }
- waiter <- res
- }()
- waitForWaiters(t, proxy, 1)
- close(fetcher.release)
-
- lead := <-leader
- if lead == nil || lead.Artifact.Size != -1 {
- t.Fatalf("leader = %+v, want a stream", lead)
- }
- w := <-waiter
- if w == nil {
- t.FailNow()
- }
- readAndClose(t, w)
- if behind {
- // The leader reads the stored copy, so it must outlive the
- // waiter.
- if len(storedFiles(store)) == 0 {
- t.Fatal("stored copy deleted while the leader still reads it")
- }
- readAndClose(t, lead)
- }
- deadline := time.Now().Add(5 * time.Second)
- for len(storedFiles(store)) != 0 && time.Now().Before(deadline) {
- time.Sleep(time.Millisecond)
- }
- if got := storedFiles(store); len(got) != 0 {
- t.Errorf("stored files after every reader closed = %v, want none", got)
- }
- if !behind {
- readAndClose(t, lead)
- }
-}
diff --git a/internal/handler/upstream_auth_helper_test.go b/internal/handler/upstream_auth_helper_test.go
deleted file mode 100644
index 261636d..0000000
--- a/internal/handler/upstream_auth_helper_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package handler
-
-import (
- "net/http"
-
- upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
-)
-
-// authorizeUpstream sends authorization on every upstream request the way the
-// server does: through the auth transport, which scopes it to each hop.
-func authorizeUpstream(proxy *Proxy, authorization string) {
- base := proxy.HTTPClient.Transport
- if base == nil {
- base = http.DefaultTransport
- }
- client := *proxy.HTTPClient
- client.Transport = upstreamhttp.NewTransport(base, func(string) (string, string) {
- return "Authorization", authorization
- })
- proxy.HTTPClient = &client
-}
diff --git a/internal/handler/upstream_body.go b/internal/handler/upstream_body.go
deleted file mode 100644
index 7db9076..0000000
--- a/internal/handler/upstream_body.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package handler
-
-import (
- "errors"
- "io"
-)
-
-// upstreamBodyReader marks a failure to read a document from upstream, so a
-// body upstream cuts off is told apart from one that ends, and from a
-// failure to store it.
-type upstreamBodyReader struct{ r io.Reader }
-
-func (u upstreamBodyReader) Read(b []byte) (int, error) {
- n, err := u.r.Read(b)
- if err != nil && !errors.Is(err, io.EOF) {
- err = &upstreamBodyError{err}
- }
- return n, err
-}
-
-// upstreamBodyError is a failure to read a document from upstream. It is
-// handled as an upstream failure: the stored copy, if any, is served.
-type upstreamBodyError struct{ err error }
-
-func (e *upstreamBodyError) Error() string { return "reading upstream metadata: " + e.err.Error() }
-func (e *upstreamBodyError) Unwrap() error { return e.err }
diff --git a/internal/handler/upstream_held.go b/internal/handler/upstream_held.go
deleted file mode 100644
index 57ac84d..0000000
--- a/internal/handler/upstream_held.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package handler
-
-import (
- "errors"
- "math"
- "net/http"
- "strconv"
-
- registryclient "github.com/git-pkgs/registries/client"
-)
-
-// writeUpstreamHeld answers a request the rate limit gate refused to send
-// with 503 and the Retry-After the upstream asked for, and reports whether
-// err was such a refusal.
-func writeUpstreamHeld(w http.ResponseWriter, err error) bool {
- var held *registryclient.HeldError
- if !errors.As(err, &held) {
- return false
- }
- secs := int(math.Ceil(held.RetryAfter.Seconds()))
- if secs < 1 {
- secs = 1
- }
- w.Header().Set("Retry-After", strconv.Itoa(secs))
- http.Error(w, held.Error(), http.StatusServiceUnavailable)
- return true
-}
-
-// UpstreamHolds reports whether requests to a URL's host are held by rate
-// limiting. *registryclient.RateLimitGate and the shared gate in
-// internal/httpclient implement it.
-type UpstreamHolds interface {
- // Held returns a *registryclient.HeldError while rawURL's host is held.
- Held(rawURL string) error
-}
-
-// upstreamHeld returns the hold on rawURL's host, if any.
-func (p *Proxy) upstreamHeld(rawURL string) error {
- if p.RateLimits == nil {
- return nil
- }
- return p.RateLimits.Held(rawURL)
-}
-
-// reloadUpstreamHolds rereads holds shared with other instances, when the
-// gate shares them. It is called after another instance's fetch failed,
-// since a 429 there will have placed a hold this instance may not have read
-// yet.
-func (p *Proxy) reloadUpstreamHolds() {
- if r, ok := p.RateLimits.(interface{ Reload() }); ok {
- r.Reload()
- }
-}
diff --git a/internal/handler/upstream_test.go b/internal/handler/upstream_test.go
deleted file mode 100644
index 9d99214..0000000
--- a/internal/handler/upstream_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package handler
-
-import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestHandlerUpstreamConfiguration(t *testing.T) {
- const (
- proxyURL = "https://proxy.example.com/"
- baseURL = "https://upstream.example.com"
- )
- hex := NewHexHandlerWithUpstreams(nil, proxyURL, baseURL+"/hex/", baseURL+"/hex-api/")
- pypi := NewPyPIHandlerWithUpstreams(nil, proxyURL, baseURL+"/pypi/", baseURL+"/pypi-download/")
- nuget := NewNuGetHandlerWithUpstreams(nil, proxyURL, baseURL+"/nuget/", baseURL+"/nuget-search/")
- composer := NewComposerHandlerWithUpstreams(
- nil, proxyURL, baseURL+"/composer/", baseURL+"/composer-repository/",
- )
-
- got := map[string]string{
- "gem": NewGemHandlerWithUpstream(nil, proxyURL, baseURL+"/gem/").upstreamURL,
- "go": NewGoHandlerWithUpstream(nil, proxyURL, baseURL+"/go/").upstreamURL,
- "hex": hex.upstreamURL,
- "hex_api": hex.apiURL,
- "pub": NewPubHandlerWithUpstream(nil, proxyURL, baseURL+"/pub/").upstreamURL,
- "pypi": pypi.upstreamURL,
- "pypi_download": pypi.downloadURL,
- "nuget": nuget.upstreamURL,
- "nuget_search": nuget.searchURL,
- "composer": composer.upstreamURL,
- "composer_repository": composer.repoURL,
- "conan": NewConanHandlerWithUpstream(nil, proxyURL, baseURL+"/conan/").upstreamURL,
- "conda": NewCondaHandlerWithUpstream(nil, proxyURL, baseURL+"/conda/").upstreamURL,
- "cran": NewCRANHandlerWithUpstream(nil, proxyURL, baseURL+"/cran/").upstreamURL,
- "julia": NewJuliaHandlerWithUpstream(nil, baseURL+"/julia/").upstreamURL,
- "oci_default": NewContainerHandlerWithRegistry(nil, proxyURL, baseURL+"/oci/").registryURL,
- "rpm": NewRPMHandlerWithUpstream(nil, proxyURL, baseURL+"/rpm/").upstreamURL,
- }
-
- want := map[string]string{
- "gem": baseURL + "/gem",
- "go": baseURL + "/go",
- "hex": baseURL + "/hex",
- "hex_api": baseURL + "/hex-api",
- "pub": baseURL + "/pub",
- "pypi": baseURL + "/pypi",
- "pypi_download": baseURL + "/pypi-download",
- "nuget": baseURL + "/nuget",
- "nuget_search": baseURL + "/nuget-search",
- "composer": baseURL + "/composer",
- "composer_repository": baseURL + "/composer-repository",
- "conan": baseURL + "/conan",
- "conda": baseURL + "/conda",
- "cran": baseURL + "/cran",
- "julia": baseURL + "/julia",
- "oci_default": baseURL + "/oci",
- "rpm": baseURL + "/rpm",
- }
-
- for name, wantURL := range want {
- if gotURL := got[name]; gotURL != wantURL {
- t.Errorf("%s upstream = %q, want %q", name, gotURL, wantURL)
- }
- }
-}
-
-func TestConfiguredUpstreamURL(t *testing.T) {
- if got := configuredUpstreamURL("", "https://default.example.com/"); got != "https://default.example.com" {
- t.Errorf("empty configured URL = %q, want default", got)
- }
- if got := configuredUpstreamURL("https://custom.example.com/", "https://default.example.com"); got != "https://custom.example.com" {
- t.Errorf("configured URL = %q, want trimmed custom URL", got)
- }
- if got := configuredUpstreamURL("https://custom.example.com///", "https://default.example.com"); got != "https://custom.example.com" {
- t.Errorf("configured URL with trailing slashes = %q, want trimmed custom URL", got)
- }
-}
-
-func TestHexHandlerUsesConfiguredAPIUpstream(t *testing.T) {
- var requestedPath string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestedPath = r.URL.Path
- _, _ = io.WriteString(w, `{"releases":[]}`)
- }))
- defer upstream.Close()
-
- h := NewHexHandlerWithUpstreams(
- &Proxy{HTTPClient: upstream.Client()},
- "https://proxy.example.com",
- upstream.URL+"/hex",
- upstream.URL+"/hex-api",
- )
- timed := h.timedPackage("demo")
- res := h.proxy.lookupMetadata(context.Background(), "hex", timed.timesKey, timed.timesURL, timed.timesAccept, "", nil)
- if res.err != nil {
- t.Fatalf("fetching publish times failed: %v", res.err)
- }
- if requestedPath != "/hex-api/api/packages/demo" {
- t.Errorf("API path = %q, want %q", requestedPath, "/hex-api/api/packages/demo")
- }
-}
-
-func TestPyPIHandlerRewritesConfiguredDownloadUpstream(t *testing.T) {
- h := NewPyPIHandlerWithUpstreams(
- nil,
- "https://proxy.example.com",
- "https://upstream.example.com/pypi",
- "https://upstream.example.com/pypi",
- )
- body := []byte(`demo`)
- want := `demo`
- if got := string(h.rewriteSimpleHTML(body, nil)); got != want {
- t.Errorf("rewritten HTML = %q, want %q", got, want)
- }
-}
-
-func TestNuGetHandlerUsesConfiguredSearchUpstream(t *testing.T) {
- h := NewNuGetHandlerWithUpstreams(
- nil,
- "https://proxy.example.com",
- "https://upstream.example.com/nuget",
- "https://upstream.example.com/nuget-search",
- )
- req := httptest.NewRequest(http.MethodGet, "/query?q=demo", nil)
- want := "https://upstream.example.com/nuget-search/query?q=demo"
- if got := h.buildUpstreamURL(req); got != want {
- t.Errorf("search URL = %q, want %q", got, want)
- }
-}
diff --git a/internal/httpclient/access_log.go b/internal/httpclient/access_log.go
deleted file mode 100644
index 8e13f23..0000000
--- a/internal/httpclient/access_log.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package httpclient
-
-import (
- "log/slog"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/accesslog"
-)
-
-type accessLogTransport struct {
- base http.RoundTripper
- accessLog *accesslog.Logger
- logger *slog.Logger
-}
-
-// NewAccessLogTransport records each upstream HTTP exchange around base.
-func NewAccessLogTransport(base http.RoundTripper, log *accesslog.Logger, logger *slog.Logger) http.RoundTripper {
- if base == nil {
- base = http.DefaultTransport
- }
- if logger == nil {
- logger = slog.Default()
- }
- if log == nil {
- return base
- }
- return &accessLogTransport{
- base: base,
- accessLog: log,
- logger: logger,
- }
-}
-
-func (t *accessLogTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- start := time.Now()
- resp, err := t.base.RoundTrip(req)
-
- entry := accesslog.Entry{
- Event: accesslog.EventUpstream,
- RequestID: accesslog.RequestID(req.Context()),
- Method: req.Method,
- URL: accesslog.URLWithoutSecrets(req.URL),
- DurationMS: time.Since(start).Milliseconds(),
- }
- if resp != nil {
- entry.StatusCode = resp.StatusCode
- }
- if err != nil {
- entry.Error = errorWithoutSecrets(err, req.URL)
- }
- if writeErr := t.accessLog.Write(entry); writeErr != nil {
- t.logger.Error("failed to write access log", "error", writeErr)
- }
-
- return resp, err
-}
-
-func errorWithoutSecrets(err error, requestURL *url.URL) string {
- message := err.Error()
- if requestURL == nil {
- return message
- }
-
- cleanURL := accesslog.URLWithoutSecrets(requestURL)
- for _, value := range []string{requestURL.String(), requestURL.Redacted()} {
- if value != "" {
- message = strings.ReplaceAll(message, value, cleanURL)
- }
- }
- return message
-}
diff --git a/internal/httpclient/access_log_test.go b/internal/httpclient/access_log_test.go
deleted file mode 100644
index aa3a43c..0000000
--- a/internal/httpclient/access_log_test.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package httpclient
-
-import (
- "bufio"
- "encoding/json"
- "errors"
- "io"
- "log/slog"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/accesslog"
-)
-
-type roundTripFunc func(*http.Request) (*http.Response, error)
-
-func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return f(req)
-}
-
-func TestAccessLogTransportRecordsUpstreamStatus(t *testing.T) {
- path := filepath.Join(t.TempDir(), "access.jsonl")
- accessLogger, err := accesslog.Open(path)
- if err != nil {
- t.Fatal(err)
- }
-
- base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Body: io.NopCloser(strings.NewReader("rate limited")),
- Request: req,
- }, nil
- })
- client := &http.Client{Transport: NewAccessLogTransport(base, accessLogger, slog.Default())}
- req, err := http.NewRequest(http.MethodGet, "https://user:password@registry.example/package.tgz?token=secret", nil)
- if err != nil {
- t.Fatal(err)
- }
- req = req.WithContext(accesslog.WithRequestID(req.Context(), "request-123"))
-
- resp, err := client.Do(req)
- if err != nil {
- t.Fatal(err)
- }
- _ = resp.Body.Close()
- if err := accessLogger.Close(); err != nil {
- t.Fatal(err)
- }
-
- entry := readAccessLogEntry(t, path)
- if entry.Event != accesslog.EventUpstream {
- t.Errorf("event = %q, want %q", entry.Event, accesslog.EventUpstream)
- }
- if entry.RequestID != "request-123" {
- t.Errorf("request_id = %q, want %q", entry.RequestID, "request-123")
- }
- if entry.StatusCode != http.StatusTooManyRequests {
- t.Errorf("status_code = %d, want %d", entry.StatusCode, http.StatusTooManyRequests)
- }
- if entry.URL != "https://registry.example/package.tgz" {
- t.Errorf("url = %q, want URL without credentials or query", entry.URL)
- }
-}
-
-func TestAccessLogTransportRecordsUpstreamError(t *testing.T) {
- path := filepath.Join(t.TempDir(), "access.jsonl")
- accessLogger, err := accesslog.Open(path)
- if err != nil {
- t.Fatal(err)
- }
-
- wantErr := errors.New("GET https://user:password@registry.example/package.tgz?token=secret: connection refused")
- base := roundTripFunc(func(*http.Request) (*http.Response, error) {
- return nil, wantErr
- })
- client := &http.Client{Transport: NewAccessLogTransport(base, accessLogger, slog.Default())}
-
- _, err = client.Get("https://user:password@registry.example/package.tgz?token=secret")
- if !errors.Is(err, wantErr) {
- t.Fatalf("GET error = %v, want %v", err, wantErr)
- }
- if err := accessLogger.Close(); err != nil {
- t.Fatal(err)
- }
-
- entry := readAccessLogEntry(t, path)
- if entry.StatusCode != 0 {
- t.Errorf("status_code = %d, want 0", entry.StatusCode)
- }
- if strings.Contains(entry.Error, "password") || strings.Contains(entry.Error, "secret") {
- t.Errorf("error contains URL credentials or query: %q", entry.Error)
- }
- if !strings.Contains(entry.Error, "connection refused") {
- t.Errorf("error = %q, want connection failure", entry.Error)
- }
-}
-
-func readAccessLogEntry(t *testing.T, path string) accesslog.Entry {
- t.Helper()
-
- file, err := os.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = file.Close() }()
-
- scanner := bufio.NewScanner(file)
- if !scanner.Scan() {
- t.Fatalf("access log is empty: %v", scanner.Err())
- }
-
- var entry accesslog.Entry
- if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
- t.Fatalf("decoding access log: %v", err)
- }
- return entry
-}
diff --git a/internal/httpclient/shared_holds.go b/internal/httpclient/shared_holds.go
deleted file mode 100644
index 175931b..0000000
--- a/internal/httpclient/shared_holds.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package httpclient
-
-import (
- "log/slog"
- "net/http"
- "net/url"
- "strings"
- "sync"
- "time"
-
- registryclient "github.com/git-pkgs/registries/client"
-)
-
-// DefaultHoldRefresh is how long a SharedRateLimitGate trusts its copy of
-// the shared holds before reading them again. It bounds both the database
-// reads, at one per interval per instance, and how long another instance
-// may keep sending after a hold is placed.
-const DefaultHoldRefresh = 2 * time.Second
-
-// HoldStore keeps rate-limit holds where every instance sharing it sees them.
-type HoldStore interface {
- PutUpstreamHold(host string, until time.Time) error
- UpstreamHolds(now time.Time) (map[string]time.Time, error)
-}
-
-// SharedRateLimitGate adds holds shared with other instances to a
-// RateLimitGate. Holds the gate places are published to the store, and
-// requests to a host another instance holds are refused like the gate's own,
-// with a HeldError. Without a store it behaves as the gate alone.
-type SharedRateLimitGate struct {
- gate *registryclient.RateLimitGate
- store HoldStore
- logger *slog.Logger
- now func() time.Time
-
- // Refresh is how long the shared holds read from the store are used
- // before reading them again. Set it before use.
- Refresh time.Duration
-
- mu sync.Mutex
- shared map[string]time.Time
- // published holds the holds this instance wrote, so a read that started
- // before a write does not drop it.
- published map[string]time.Time
- loaded time.Time
- loading bool
-}
-
-// NewSharedRateLimitGate wraps gate, publishing its holds to store and
-// honouring those in store. It takes over gate.OnHold, calling the callback
-// set before. A nil store shares nothing.
-func NewSharedRateLimitGate(gate *registryclient.RateLimitGate, store HoldStore, logger *slog.Logger) *SharedRateLimitGate {
- s := &SharedRateLimitGate{
- gate: gate,
- store: store,
- logger: logger,
- now: time.Now,
- Refresh: DefaultHoldRefresh,
- shared: make(map[string]time.Time),
- published: make(map[string]time.Time),
- }
- if store != nil {
- onHold := gate.OnHold
- gate.OnHold = func(event registryclient.HoldEvent) {
- s.publish(event.Host, event.Until)
- if onHold != nil {
- onHold(event)
- }
- }
- }
- return s
-}
-
-// RoundTrip implements http.RoundTripper.
-func (s *SharedRateLimitGate) RoundTrip(req *http.Request) (*http.Response, error) {
- if err := s.sharedHeld(hostKey(req.URL)); err != nil {
- return nil, err
- }
- return s.gate.RoundTrip(req)
-}
-
-// Held returns a *registryclient.HeldError when requests to rawURL's host are
-// held by this instance or another, and nil otherwise. It is safe to call on
-// a nil gate.
-func (s *SharedRateLimitGate) Held(rawURL string) error {
- if s == nil {
- return nil
- }
- if err := s.gate.Held(rawURL); err != nil {
- return err
- }
- u, err := url.Parse(rawURL)
- if err != nil {
- return nil
- }
- return s.sharedHeld(hostKey(u))
-}
-
-// Holds returns the hosts held by this instance or another, and when each
-// hold ends. It is safe to call on a nil gate.
-func (s *SharedRateLimitGate) Holds() map[string]time.Time {
- if s == nil {
- return nil
- }
- holds := s.gate.Holds()
- if s.store == nil {
- return holds
- }
- s.load(false)
- now := s.now()
- s.mu.Lock()
- defer s.mu.Unlock()
- for host, until := range s.shared {
- if now.Before(until) && until.After(holds[host]) {
- if holds == nil {
- holds = make(map[string]time.Time)
- }
- holds[host] = until
- }
- }
- return holds
-}
-
-// Reload reads the shared holds now rather than when the copy runs out. A
-// caller that knows another instance has just failed, say on a 429, uses it
-// to see that instance's hold at once.
-func (s *SharedRateLimitGate) Reload() {
- if s == nil || s.store == nil {
- return
- }
- s.load(true)
-}
-
-func (s *SharedRateLimitGate) sharedHeld(host string) error {
- if s.store == nil {
- return nil
- }
- s.load(false)
- now := s.now()
- s.mu.Lock()
- until, ok := s.shared[host]
- s.mu.Unlock()
- if !ok || !now.Before(until) {
- return nil
- }
- return ®istryclient.HeldError{Host: host, Until: until, RetryAfter: until.Sub(now)}
-}
-
-// load refreshes the copy of the shared holds once it is older than Refresh,
-// or at once when force is set. One caller reads the store while concurrent
-// callers go on with the copy they have; a forced read does not wait for
-// one already running, which may have started before the hold it is after.
-func (s *SharedRateLimitGate) load(force bool) {
- now := s.now()
- s.mu.Lock()
- if !force && (s.loading || now.Sub(s.loaded) < s.Refresh) {
- s.mu.Unlock()
- return
- }
- s.loading = true
- s.mu.Unlock()
-
- holds, err := s.store.UpstreamHolds(now)
-
- s.mu.Lock()
- defer s.mu.Unlock()
- s.loading = false
- // A failed read keeps the last copy and waits a full interval before
- // trying again, so a database outage costs no more queries than usual.
- s.loaded = now
- if err != nil {
- if s.logger != nil {
- s.logger.Warn("reading shared upstream holds failed", "error", err)
- }
- if holds == nil {
- return
- }
- }
- for host, until := range s.published {
- if !now.Before(until) {
- delete(s.published, host)
- } else if until.After(holds[host]) {
- holds[host] = until
- }
- }
- s.shared = holds
-}
-
-// publish records a hold this instance placed, locally and in the store. The
-// responses already in flight when a hold starts each report it again; only a
-// later end is written.
-func (s *SharedRateLimitGate) publish(host string, until time.Time) {
- s.mu.Lock()
- if !until.After(s.published[host]) {
- s.mu.Unlock()
- return
- }
- s.published[host] = until
- if until.After(s.shared[host]) {
- s.shared[host] = until
- }
- s.mu.Unlock()
- if err := s.store.PutUpstreamHold(host, until); err != nil && s.logger != nil {
- s.logger.Warn("publishing upstream hold failed", "host", host, "error", err)
- }
-}
-
-func hostKey(u *url.URL) string {
- if u == nil {
- return ""
- }
- return strings.ToLower(u.Host)
-}
diff --git a/internal/httpclient/shared_holds_test.go b/internal/httpclient/shared_holds_test.go
deleted file mode 100644
index 31b9d79..0000000
--- a/internal/httpclient/shared_holds_test.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package httpclient
-
-import (
- "errors"
- "net/http"
- "net/http/httptest"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- registryclient "github.com/git-pkgs/registries/client"
-)
-
-// memoryHoldStore is a HoldStore shared by gates in one test, counting reads.
-type memoryHoldStore struct {
- mu sync.Mutex
- holds map[string]time.Time
- reads atomic.Int32
-}
-
-func (m *memoryHoldStore) PutUpstreamHold(host string, until time.Time) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if until.After(m.holds[host]) {
- m.holds[host] = until
- }
- return nil
-}
-
-func (m *memoryHoldStore) UpstreamHolds(now time.Time) (map[string]time.Time, error) {
- m.reads.Add(1)
- m.mu.Lock()
- defer m.mu.Unlock()
- out := make(map[string]time.Time)
- for host, until := range m.holds {
- if now.Before(until) {
- out[host] = until
- }
- }
- return out, nil
-}
-
-func TestSharedRateLimitGateSharesHolds(t *testing.T) {
- var calls atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- calls.Add(1)
- w.Header().Set("Retry-After", "120")
- w.WriteHeader(http.StatusTooManyRequests)
- }))
- defer upstream.Close()
-
- store := &memoryHoldStore{holds: make(map[string]time.Time)}
- a := NewSharedRateLimitGate(registryclient.NewRateLimitGate(upstream.Client().Transport), store, nil)
- b := NewSharedRateLimitGate(registryclient.NewRateLimitGate(upstream.Client().Transport), store, nil)
- clock := time.Now()
- b.now = func() time.Time { return clock }
-
- // b reads the store before a is rate limited, and keeps that copy for
- // Refresh.
- if err := b.Held(upstream.URL); err != nil {
- t.Fatalf("b held before any 429: %v", err)
- }
- resp, err := a.RoundTrip(httptest.NewRequest(http.MethodGet, upstream.URL, nil))
- if err != nil || resp.StatusCode != http.StatusTooManyRequests {
- t.Fatalf("a: %v, %v; want the 429", resp, err)
- }
- _ = resp.Body.Close()
- if err := b.Held(upstream.URL); err != nil {
- t.Errorf("b refreshed before Refresh ran out: %v", err)
- }
-
- clock = clock.Add(DefaultHoldRefresh)
- _, err = b.RoundTrip(httptest.NewRequest(http.MethodGet, upstream.URL, nil))
- var held *registryclient.HeldError
- if !errors.As(err, &held) || held.RetryAfter <= 100*time.Second {
- t.Fatalf("b after refresh: %v, want a hold of about two minutes", err)
- }
- if calls.Load() != 1 {
- t.Errorf("upstream requests = %d, want 1", calls.Load())
- }
- if holds := b.Holds(); len(holds) != 1 {
- t.Errorf("b.Holds() = %v, want a's hold", holds)
- }
- // One read by a before its request, two by b: the first and the one
- // after Refresh.
- if n := store.reads.Load(); n != 3 {
- t.Errorf("store reads = %d, want 3", n)
- }
-
- // Once the hold ends, b sends again.
- clock = clock.Add(2 * time.Minute)
- if err := b.Held(upstream.URL); err != nil {
- t.Errorf("b after the hold ended: %v", err)
- }
-}
-
-func TestSharedRateLimitGateReload(t *testing.T) {
- store := &memoryHoldStore{holds: make(map[string]time.Time)}
- g := NewSharedRateLimitGate(registryclient.NewRateLimitGate(nil), store, nil)
- g.Refresh = time.Hour
- if err := g.Held("https://registry.example/x"); err != nil {
- t.Fatal(err)
- }
- _ = store.PutUpstreamHold("registry.example", time.Now().Add(time.Minute))
- if err := g.Held("https://registry.example/x"); err != nil {
- t.Fatalf("held before reload: %v", err)
- }
- g.Reload()
- if !errors.Is(g.Held("https://registry.example/x"), registryclient.ErrUpstreamHeld) {
- t.Error("not held after reload")
- }
-}
-
-func TestSharedRateLimitGateWithoutStore(t *testing.T) {
- g := NewSharedRateLimitGate(registryclient.NewRateLimitGate(nil), nil, nil)
- if err := g.Held("https://registry.example/x"); err != nil {
- t.Fatal(err)
- }
- g.Reload()
- var nilGate *SharedRateLimitGate
- if nilGate.Held("https://registry.example/x") != nil || nilGate.Holds() != nil {
- t.Error("nil gate holds")
- }
-}
diff --git a/internal/httpclient/token_scope_test.go b/internal/httpclient/token_scope_test.go
deleted file mode 100644
index 563d939..0000000
--- a/internal/httpclient/token_scope_test.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package httpclient
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync/atomic"
- "testing"
-)
-
-// tokenService issues "secret-token" only to requests carrying the configured
-// credentials, and "anonymous-token" otherwise.
-func tokenService(t *testing.T, issued *atomic.Int32) *httptest.Server {
- t.Helper()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- issued.Add(1)
- token := "anonymous-token"
- if r.Header.Get("Authorization") == "Basic Y29ycDpwYXNz" {
- token = "secret-token"
- }
- _, _ = io.WriteString(w, `{"token":"`+token+`","expires_in":3600}`)
- }))
- t.Cleanup(srv.Close)
- return srv
-}
-
-// challengingRegistry answers every request without a bearer token with a
-// challenge naming realm, and records the Authorization headers it receives.
-func challengingRegistry(t *testing.T, realm string, received *[]string) *httptest.Server {
- t.Helper()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := r.Header.Get("Authorization")
- *received = append(*received, auth)
- if !strings.HasPrefix(auth, "Bearer ") {
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`/token",service="registry.corp",scope="repository:corp/private:pull"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- return
- }
- _, _ = io.WriteString(w, "blob")
- }))
- t.Cleanup(srv.Close)
- return srv
-}
-
-func credentialsFor(prefix string) AuthFunc {
- return func(rawURL string) (string, string) {
- if strings.HasPrefix(rawURL, prefix) {
- return "Authorization", "Basic Y29ycDpwYXNz"
- }
- return "", ""
- }
-}
-
-func get(t *testing.T, client *http.Client, rawURL string) int {
- t.Helper()
- resp, err := client.Get(rawURL)
- if err != nil {
- t.Fatalf("GET %s: %v", rawURL, err)
- }
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- return resp.StatusCode
-}
-
-// TestTransportDoesNotSendCredentialedTokenToAnotherSite is a regression test
-// for a credential leak. Any host the proxy fetches from, such as one named in
-// a Composer dist URL or a Helm index, could answer with a challenge naming a
-// token service the proxy has credentials for. The proxy fetched a token with
-// those credentials and sent it to that host.
-func TestTransportDoesNotSendCredentialedTokenToAnotherSite(t *testing.T) {
- var issued atomic.Int32
- realm := tokenService(t, &issued)
- var received []string
- evil := challengingRegistry(t, realm.URL, &received)
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, credentialsFor(realm.URL))}
- if status := get(t, client, evil.URL+"/v2/corp/private/blobs/sha256:abc"); status != http.StatusUnauthorized {
- t.Errorf("status = %d, want the challenge passed back as 401", status)
- }
- for _, auth := range received {
- if auth != "" {
- t.Errorf("the challenging host received Authorization %q", auth)
- }
- }
- if n := issued.Load(); n != 0 {
- t.Errorf("token service was asked %d times on the challenging host's behalf, want 0", n)
- }
-}
-
-// TestTransportDoesNotSendCachedCredentialedTokenToAnotherSite checks the
-// same for a token already cached from a legitimate pull: a challenge that
-// names the same realm, service and scope must not be handed it.
-func TestTransportDoesNotSendCachedCredentialedTokenToAnotherSite(t *testing.T) {
- var issued atomic.Int32
- realm := tokenService(t, &issued)
- var legitimate, leaked []string
- registry := challengingRegistry(t, realm.URL, &legitimate)
- evil := challengingRegistry(t, realm.URL, &leaked)
-
- // The registry itself has configured credentials, so it is trusted with
- // tokens from its realm.
- auth := func(rawURL string) (string, string) {
- if strings.HasPrefix(rawURL, realm.URL) || strings.HasPrefix(rawURL, registry.URL) {
- return "Authorization", "Basic Y29ycDpwYXNz"
- }
- return "", ""
- }
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, auth)}
- if status := get(t, client, registry.URL+"/v2/corp/private/blobs/sha256:abc"); status != http.StatusOK {
- t.Fatalf("legitimate pull: status = %d, want 200", status)
- }
- if got := legitimate[len(legitimate)-1]; got != "Bearer secret-token" {
- t.Fatalf("legitimate pull sent %q, want the credentialed token", got)
- }
-
- get(t, client, evil.URL+"/v2/corp/private/blobs/sha256:abc")
- for _, got := range leaked {
- if strings.HasPrefix(got, "Bearer ") {
- t.Errorf("the challenging host received %q", got)
- }
- }
-}
-
-// TestTransportFollowsAnonymousTokenServiceOnAnotherHost keeps public pulls
-// working where the token service is on another host and no credentials are
-// configured for it: the token is anonymous, so any registry may have it.
-func TestTransportFollowsAnonymousTokenServiceOnAnotherHost(t *testing.T) {
- var issued atomic.Int32
- realm := tokenService(t, &issued)
- var received []string
- registry := challengingRegistry(t, realm.URL, &received)
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, credentialsFor("https://elsewhere.example"))}
- if status := get(t, client, registry.URL+"/v2/library/alpine/blobs/sha256:abc"); status != http.StatusOK {
- t.Errorf("status = %d, want 200", status)
- }
- if got := received[len(received)-1]; got != "Bearer anonymous-token" {
- t.Errorf("registry received %q, want the anonymous token", got)
- }
-}
-
-// TestTransportDoesNotSendCredentialedTokenToHostWithOtherCredentials checks
-// that a registry with credentials of its own, but not the realm's, is not
-// handed a token minted from the realm's credentials.
-func TestTransportDoesNotSendCredentialedTokenToHostWithOtherCredentials(t *testing.T) {
- var issued atomic.Int32
- realm := tokenService(t, &issued)
- var received []string
- vendor := challengingRegistry(t, realm.URL, &received)
-
- auth := func(rawURL string) (string, string) {
- switch {
- case strings.HasPrefix(rawURL, realm.URL):
- return "Authorization", "Basic Y29ycDpwYXNz"
- case strings.HasPrefix(rawURL, vendor.URL):
- return "Authorization", "Basic dmVuZG9yOmtleQ=="
- }
- return "", ""
- }
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, auth)}
- if status := get(t, client, vendor.URL+"/v2/corp/private/blobs/sha256:abc"); status != http.StatusUnauthorized {
- t.Errorf("status = %d, want the challenge passed back as 401", status)
- }
- for _, got := range received {
- if got != "Basic dmVuZG9yOmtleQ==" {
- t.Errorf("the vendor registry received Authorization %q, want only its own credentials", got)
- }
- }
- if n := issued.Load(); n != 0 {
- t.Errorf("token service was asked %d times on the vendor registry's behalf, want 0", n)
- }
-}
-
-// TestTransportRequestsPullScopeForRequestedRepository checks that the token
-// is requested for pull access to the repository in the request path, not
-// whatever scope the challenge names.
-func TestTransportRequestsPullScopeForRequestedRepository(t *testing.T) {
- var scopes [][]string
- var srv *httptest.Server
- srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/token" {
- scopes = append(scopes, r.URL.Query()["scope"])
- _, _ = io.WriteString(w, `{"token":"t","expires_in":3600}`)
- return
- }
- if r.Header.Get("Authorization") != "Bearer t" {
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+srv.URL+`/token",service="registry.corp",scope="repository:corp/other:pull,push"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- return
- }
- _, _ = io.WriteString(w, "blob")
- }))
- t.Cleanup(srv.Close)
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, credentialsFor(srv.URL))}
- if status := get(t, client, srv.URL+"/v2/corp/app/manifests/latest"); status != http.StatusOK {
- t.Fatalf("status = %d, want 200", status)
- }
- if len(scopes) != 1 || len(scopes[0]) != 1 || scopes[0][0] != "repository:corp/app:pull" {
- t.Errorf("token requested with scopes %q, want [[repository:corp/app:pull]]", scopes)
- }
-}
-
-func TestSameTokenService(t *testing.T) {
- for _, tt := range []struct {
- registry, realm string
- want bool
- }{
- {"https://registry-1.docker.io", "https://auth.docker.io/token", true},
- {"https://index.docker.io", "https://auth.docker.io/token", true},
- {"https://registry.gitlab.com", "https://gitlab.com/jwt/auth", true},
- {"https://ghcr.io", "https://ghcr.io/token", true},
- {"https://harbor.corp.example", "https://harbor.corp.example/service/token", true},
- {"https://registry.corp.example", "https://auth.corp.example", false},
- {"https://evil.example", "https://auth.docker.io/token", false},
- {"https://docker.io.evil.example", "https://auth.docker.io/token", false},
- {"https://evil.jfrog.io", "https://myco.jfrog.io/artifactory/api/docker/token", false},
- {"https://evil.azurecr.io", "https://myco.azurecr.io/oauth2/token", false},
- {"https://gitlab.com", "https://registry.gitlab.com", false},
- {"http://registry-1.docker.io", "https://auth.docker.io/token", false},
- {"https://registry-1.docker.io", "https://auth.docker.io:8443/token", false},
- {"http://127.0.0.1:5000", "http://127.0.0.1:5000/token", true},
- {"http://127.0.0.1:5000", "http://127.0.0.1:5001/token", false},
- {"http://localhost:5000", "https://localhost:5000/token", false},
- } {
- if got := sameTokenService(mustParse(t, tt.registry), mustParse(t, tt.realm)); got != tt.want {
- t.Errorf("sameTokenService(%s, %s) = %v, want %v", tt.registry, tt.realm, got, tt.want)
- }
- }
-}
-
-func mustParse(t *testing.T, rawURL string) *url.URL {
- t.Helper()
- u, err := url.Parse(rawURL)
- if err != nil {
- t.Fatal(err)
- }
- return u
-}
diff --git a/internal/httpclient/transport.go b/internal/httpclient/transport.go
deleted file mode 100644
index a007c8d..0000000
--- a/internal/httpclient/transport.go
+++ /dev/null
@@ -1,597 +0,0 @@
-// Package httpclient provides authentication-aware HTTP transports for upstream requests.
-package httpclient
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "strings"
- "sync"
- "time"
-)
-
-const (
- defaultTokenLifetime = 60 * time.Second
- tokenExpirySkew = 5 * time.Second
- maxTokenResponseSize = 1 << 20
- shortTokenSkewDivisor = 10
- tokenMaxRetries = 3
- tokenRetryBaseDelay = 500 * time.Millisecond
-
- // maxTokenLifetime caps how long a token is reused, whatever lifetime
- // the token service claims.
- maxTokenLifetime = time.Hour
- // maxCachedTokens and maxCachedChallenges bound the token and
- // challenge caches, which grow with every repository and registry
- // host the proxy pulls from.
- maxCachedTokens = 1024
- maxCachedChallenges = 256
-)
-
-// AuthFunc returns a configured authentication header for a URL.
-type AuthFunc func(url string) (headerName, headerValue string)
-
-// Transport adds configured authentication and follows OCI Bearer challenges.
-type Transport struct {
- base http.RoundTripper
- authForURL AuthFunc
- retryWait func(context.Context, time.Duration) error
-
- mu sync.Mutex
- tokens map[string]cachedToken
- challenges map[string]bearerChallenge // by registry origin, without scopes
-}
-
-type cachedToken struct {
- value string
- expiresAt time.Time
-}
-
-type bearerChallenge struct {
- realm string
- service string
- scopes []string
-}
-
-type tokenResponse struct {
- Token string `json:"token"`
- AccessToken string `json:"access_token"`
- ExpiresIn int64 `json:"expires_in"`
- IssuedAt string `json:"issued_at"`
-}
-
-// NewTransport creates an authentication-aware transport around base.
-func NewTransport(base http.RoundTripper, authForURL AuthFunc) *Transport {
- if base == nil {
- base = http.DefaultTransport
- }
- return &Transport{
- base: base,
- authForURL: authForURL,
- retryWait: waitForRetry,
- tokens: make(map[string]cachedToken),
- challenges: make(map[string]bearerChallenge),
- }
-}
-
-// RoundTrip implements http.RoundTripper.
-func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
- hasExplicitAuthorization := req.Header.Get("Authorization") != ""
- outbound := cloneRequest(req)
- t.applyAuthentication(outbound, hasExplicitAuthorization)
-
- resp, err := t.base.RoundTrip(outbound)
- if err != nil || resp.StatusCode != http.StatusUnauthorized {
- return resp, err
- }
- if hasExplicitAuthorization {
- return resp, nil
- }
- repository := registryRepository(req.URL)
- if repository == "" {
- return resp, nil
- }
-
- challenge, ok := parseBearerChallenge(resp.Header.Values("WWW-Authenticate"))
- // Ask only for pull access to the repository being fetched, whatever
- // scope the challenge names: the proxy only reads.
- challenge.scopes = []string{pullScope(repository)}
- if !ok || !canReplay(req) || !t.trustedWithToken(req.URL, challenge) {
- return resp, nil
- }
-
- drainAndClose(resp.Body)
- token, err := t.token(req.Context(), challenge)
- if err != nil {
- return nil, fmt.Errorf("registry authentication: %w", err)
- }
- t.rememberChallenge(req.URL, challenge)
-
- retry, err := cloneRequestForRetry(req)
- if err != nil {
- return nil, err
- }
- t.applyConfiguredAuthentication(retry)
- retry.Header.Set("Authorization", "Bearer "+token)
- return t.base.RoundTrip(retry)
-}
-
-// trustedWithToken reports whether a token from challenge's realm may be
-// fetched for, and sent to, the registry at requestURL. The challenge comes
-// from that registry, which can be any host the proxy fetches from, such as
-// one named in a Composer dist URL or a Helm index. Credentials configured for
-// the realm would otherwise be turned into a token handed to that host. A
-// token from a realm with no configured credentials is anonymous, and any
-// registry may have it. A credentialed token only goes to the realm's own
-// host, to a registry configured with the same credentials as the realm, or
-// to a registry in knownTokenServices.
-func (t *Transport) trustedWithToken(requestURL *url.URL, challenge bearerChallenge) bool {
- if t.authForURL == nil {
- return true
- }
- realmName, realmValue := t.authForURL(challenge.realm)
- if realmName == "" || realmValue == "" {
- return true
- }
- if name, value := t.authForURL(requestURL.String()); name == realmName && value == realmValue {
- return true
- }
- realm, err := url.Parse(challenge.realm)
- return err == nil && sameTokenService(requestURL, realm)
-}
-
-// knownTokenServices maps registry hosts to the token service host they
-// use where the two differ and the registry cannot be configured with the
-// token service's credentials under the same upstream.auth key.
-var knownTokenServices = map[string]string{
- "registry-1.docker.io": "auth.docker.io",
- "index.docker.io": "auth.docker.io",
- "registry.gitlab.com": "gitlab.com",
-}
-
-// sameTokenService reports whether a token service at realm belongs to the
-// registry at registry: both on the same scheme, host and port, or a known
-// pair of registry and token service hosts.
-func sameTokenService(registry, realm *url.URL) bool {
- if strings.EqualFold(registry.Scheme, realm.Scheme) && strings.EqualFold(registry.Host, realm.Host) {
- return true
- }
- if registry.Scheme != "https" || realm.Scheme != "https" || registry.Port() != "" || realm.Port() != "" {
- return false
- }
- want, ok := knownTokenServices[strings.ToLower(registry.Hostname())]
- return ok && strings.EqualFold(realm.Hostname(), want)
-}
-
-func (t *Transport) applyAuthentication(req *http.Request, hasExplicitAuthorization bool) {
- t.applyConfiguredAuthentication(req)
- if hasExplicitAuthorization {
- return
- }
- if token := t.cachedTokenForRequest(req.URL); token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
-}
-
-func (t *Transport) applyConfiguredAuthentication(req *http.Request) {
- if t.authForURL == nil {
- return
- }
- name, value := t.authForURL(req.URL.String())
- if name != "" && value != "" && req.Header.Get(name) == "" {
- req.Header.Set(name, value)
- }
-}
-
-func (t *Transport) token(ctx context.Context, challenge bearerChallenge) (string, error) {
- key := challenge.key()
- if token := t.cachedToken(key); token != "" {
- return token, nil
- }
-
- token, expiresAt, err := t.fetchToken(ctx, challenge)
- if err != nil {
- return "", err
- }
-
- t.cacheToken(key, cachedToken{value: token, expiresAt: expiresAt})
- return token, nil
-}
-
-func (t *Transport) cacheToken(key string, token cachedToken) {
- now := time.Now()
- t.mu.Lock()
- defer t.mu.Unlock()
-
- for cachedKey, cached := range t.tokens {
- if !now.Before(cached.expiresAt) {
- delete(t.tokens, cachedKey)
- }
- }
- if _, ok := t.tokens[key]; !ok && len(t.tokens) >= maxCachedTokens {
- // Drop the token that expires soonest.
- var oldestKey string
- var oldest time.Time
- for cachedKey, cached := range t.tokens {
- if oldestKey == "" || cached.expiresAt.Before(oldest) {
- oldestKey, oldest = cachedKey, cached.expiresAt
- }
- }
- delete(t.tokens, oldestKey)
- }
- t.tokens[key] = token
-}
-
-func (t *Transport) fetchToken(ctx context.Context, challenge bearerChallenge) (string, time.Time, error) {
- tokenURL, err := url.Parse(challenge.realm)
- if err != nil || !tokenURL.IsAbs() || (tokenURL.Scheme != "https" && tokenURL.Scheme != "http") {
- return "", time.Time{}, fmt.Errorf("invalid token realm %q", challenge.realm)
- }
-
- query := tokenURL.Query()
- if challenge.service != "" {
- query.Set("service", challenge.service)
- }
- for _, scope := range challenge.scopes {
- query.Add("scope", scope)
- }
- query.Set("client_id", "git-pkgs-proxy")
- tokenURL.RawQuery = query.Encode()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil)
- if err != nil {
- return "", time.Time{}, err
- }
-
- client := &http.Client{Transport: configuredTransport{parent: t}}
- for attempt := 0; attempt <= tokenMaxRetries; attempt++ {
- resp, err := client.Do(req.Clone(ctx))
- if err != nil {
- requestErr := fmt.Errorf("requesting token: %w", err)
- if !shouldRetryTokenRequest(ctx, err) || attempt == tokenMaxRetries {
- return "", time.Time{}, requestErr
- }
- if err := t.waitForTokenRetry(ctx, attempt); err != nil {
- return "", time.Time{}, err
- }
- continue
- }
-
- if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
- return decodeTokenResponse(resp)
- }
-
- responseErr := tokenResponseError(resp)
- if !shouldRetryTokenStatus(resp.StatusCode) || attempt == tokenMaxRetries {
- return "", time.Time{}, responseErr
- }
- if err := t.waitForTokenRetry(ctx, attempt); err != nil {
- return "", time.Time{}, err
- }
- }
-
- return "", time.Time{}, errors.New("token request retries exhausted")
-}
-
-func decodeTokenResponse(resp *http.Response) (string, time.Time, error) {
- defer func() { _ = resp.Body.Close() }()
-
- var payload tokenResponse
- if err := json.NewDecoder(io.LimitReader(resp.Body, maxTokenResponseSize)).Decode(&payload); err != nil {
- return "", time.Time{}, fmt.Errorf("decoding token response: %w", err)
- }
- token := payload.Token
- if token == "" {
- token = payload.AccessToken
- }
- if token == "" {
- return "", time.Time{}, fmt.Errorf("token response did not contain a token")
- }
-
- issuedAt := time.Now()
- if payload.IssuedAt != "" {
- if parsed, parseErr := time.Parse(time.RFC3339, payload.IssuedAt); parseErr == nil {
- issuedAt = parsed
- }
- }
- lifetime := time.Duration(payload.ExpiresIn) * time.Second
- if lifetime <= 0 {
- lifetime = defaultTokenLifetime
- }
- lifetime = min(lifetime, maxTokenLifetime)
- expiresAt := issuedAt.Add(lifetime).Add(-expirySkew(lifetime))
- return token, expiresAt, nil
-}
-
-func tokenResponseError(resp *http.Response) error {
- defer func() { _ = resp.Body.Close() }()
- body, _ := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseSize))
- return fmt.Errorf("token service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
-}
-
-func shouldRetryTokenRequest(ctx context.Context, err error) bool {
- if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return false
- }
-
- var networkErr net.Error
- if !errors.As(err, &networkErr) {
- return false
- }
-
- var dnsErr *net.DNSError
- if errors.As(err, &dnsErr) {
- return dnsErr.IsTemporary || dnsErr.IsTimeout
- }
- return networkErr.Timeout()
-}
-
-func shouldRetryTokenStatus(status int) bool {
- return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
-}
-
-func (t *Transport) waitForTokenRetry(ctx context.Context, attempt int) error {
- delay := tokenRetryBaseDelay << attempt
- if t.retryWait != nil {
- return t.retryWait(ctx, delay)
- }
- return waitForRetry(ctx, delay)
-}
-
-func waitForRetry(ctx context.Context, delay time.Duration) error {
- timer := time.NewTimer(delay)
- defer timer.Stop()
-
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- return nil
- }
-}
-
-type configuredTransport struct {
- parent *Transport
-}
-
-func (t configuredTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- outbound := cloneRequest(req)
- t.parent.applyConfiguredAuthentication(outbound)
- return t.parent.base.RoundTrip(outbound)
-}
-
-func (t *Transport) cachedTokenForRequest(requestURL *url.URL) string {
- repository := registryRepository(requestURL)
- if repository == "" {
- return ""
- }
-
- t.mu.Lock()
- challenge, ok := t.challenges[registryOrigin(requestURL)]
- t.mu.Unlock()
- if !ok {
- return ""
- }
- challenge.scopes = []string{pullScope(repository)}
- return t.cachedToken(challenge.key())
-}
-
-func (t *Transport) cachedToken(key string) string {
- now := time.Now()
- t.mu.Lock()
- defer t.mu.Unlock()
-
- token, ok := t.tokens[key]
- if !ok {
- return ""
- }
- if !now.Before(token.expiresAt) {
- delete(t.tokens, key)
- return ""
- }
- return token.value
-}
-
-// rememberChallenge records the token service a registry named, so later
-// requests to any of its repositories can send a cached token up front.
-func (t *Transport) rememberChallenge(requestURL *url.URL, challenge bearerChallenge) {
- origin := registryOrigin(requestURL)
- challenge.scopes = nil
- t.mu.Lock()
- defer t.mu.Unlock()
- if _, ok := t.challenges[origin]; !ok && len(t.challenges) >= maxCachedChallenges {
- for evict := range t.challenges {
- delete(t.challenges, evict)
- break
- }
- }
- t.challenges[origin] = challenge
-}
-
-func (c bearerChallenge) key() string {
- return c.realm + "\x00" + c.service + "\x00" + strings.Join(c.scopes, "\x00")
-}
-
-func registryOrigin(u *url.URL) string {
- return strings.ToLower(u.Scheme + "://" + u.Host)
-}
-
-func pullScope(repository string) string {
- return "repository:" + repository + ":pull"
-}
-
-// registryRepository returns the repository name from an OCI distribution
-// API path, such as library/alpine from /v2/library/alpine/manifests/latest,
-// or "" if the path is not one.
-func registryRepository(u *url.URL) string {
- const registryPrefix = "/v2/"
- if u == nil || !strings.HasPrefix(u.Path, registryPrefix) {
- return ""
- }
- rest := strings.TrimPrefix(u.Path, registryPrefix)
- end := len(rest)
- for _, marker := range []string{"/blobs/", "/manifests/", "/tags/", "/referrers/"} {
- if index := strings.Index(rest, marker); index >= 0 && index < end {
- end = index
- }
- }
- if end == len(rest) || end == 0 {
- return ""
- }
- return rest[:end]
-}
-
-func parseBearerChallenge(values []string) (bearerChallenge, bool) {
- for _, value := range values {
- params, ok := bearerParameters(value)
- if !ok || params["realm"] == "" {
- continue
- }
- challenge := bearerChallenge{
- realm: params["realm"],
- service: params["service"],
- }
- if scope := params["scope"]; scope != "" {
- challenge.scopes = append(challenge.scopes, scope)
- }
- return challenge, true
- }
- return bearerChallenge{}, false
-}
-
-func bearerParameters(value string) (map[string]string, bool) {
- start := findAuthScheme(value, "Bearer")
- if start < 0 {
- return nil, false
- }
- rest := value[start+len("Bearer"):]
- params := make(map[string]string)
- for {
- rest = strings.TrimLeft(rest, " \t,")
- if rest == "" {
- break
- }
-
- keyEnd := strings.IndexAny(rest, "= \t,")
- if keyEnd <= 0 {
- break
- }
- key := strings.ToLower(rest[:keyEnd])
- rest = strings.TrimLeft(rest[keyEnd:], " \t")
- if rest == "" || rest[0] != '=' {
- break
- }
- rest = strings.TrimLeft(rest[1:], " \t")
-
- parsed, remaining, ok := parseAuthValue(rest)
- if !ok {
- return nil, false
- }
- params[key] = parsed
- rest = remaining
- }
- return params, true
-}
-
-func findAuthScheme(value, scheme string) int {
- inQuote := false
- escaped := false
- for index := 0; index+len(scheme) <= len(value); index++ {
- char := value[index]
- if escaped {
- escaped = false
- continue
- }
- if char == '\\' && inQuote {
- escaped = true
- continue
- }
- if char == '"' {
- inQuote = !inQuote
- continue
- }
- if inQuote || !strings.EqualFold(value[index:index+len(scheme)], scheme) {
- continue
- }
- beforeOK := index == 0 || value[index-1] == ',' || value[index-1] == ' ' || value[index-1] == '\t'
- after := index + len(scheme)
- afterOK := after < len(value) && (value[after] == ' ' || value[after] == '\t')
- if beforeOK && afterOK {
- return index
- }
- }
- return -1
-}
-
-func parseAuthValue(value string) (parsed, remaining string, ok bool) {
- if value == "" {
- return "", "", false
- }
- if value[0] != '"' {
- end := strings.IndexAny(value, " \t,")
- if end < 0 {
- return value, "", true
- }
- return value[:end], value[end:], end > 0
- }
-
- var builder strings.Builder
- escaped := false
- for index := 1; index < len(value); index++ {
- char := value[index]
- if escaped {
- builder.WriteByte(char)
- escaped = false
- continue
- }
- if char == '\\' {
- escaped = true
- continue
- }
- if char == '"' {
- return builder.String(), value[index+1:], true
- }
- builder.WriteByte(char)
- }
- return "", "", false
-}
-
-func cloneRequest(req *http.Request) *http.Request {
- clone := req.Clone(req.Context())
- clone.Header = req.Header.Clone()
- return clone
-}
-
-func canReplay(req *http.Request) bool {
- return req.Body == nil || req.GetBody != nil
-}
-
-func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
- clone := cloneRequest(req)
- if req.Body == nil {
- return clone, nil
- }
- body, err := req.GetBody()
- if err != nil {
- return nil, fmt.Errorf("replaying authenticated request: %w", err)
- }
- clone.Body = body
- return clone, nil
-}
-
-func expirySkew(lifetime time.Duration) time.Duration {
- if lifetime < tokenExpirySkew*2 {
- return lifetime / shortTokenSkewDivisor
- }
- return tokenExpirySkew
-}
-
-func drainAndClose(body io.ReadCloser) {
- _, _ = io.Copy(io.Discard, io.LimitReader(body, maxTokenResponseSize))
- _ = body.Close()
-}
diff --git a/internal/httpclient/transport_test.go b/internal/httpclient/transport_test.go
deleted file mode 100644
index 46a31b6..0000000
--- a/internal/httpclient/transport_test.go
+++ /dev/null
@@ -1,463 +0,0 @@
-package httpclient
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-)
-
-type roundTripperFunc func(*http.Request) (*http.Response, error)
-
-func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return fn(req)
-}
-
-func TestTransportFollowsBearerChallengeAndCachesToken(t *testing.T) {
- var registryRequests int
- var tokenRequests int
- var server *httptest.Server
-
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/token":
- tokenRequests++
- if got := r.URL.Query().Get("service"); got != "registry.test" {
- t.Errorf("service = %q, want %q", got, "registry.test")
- }
- if got := r.URL.Query().Get("scope"); got != "repository:library/test:pull" {
- t.Errorf("scope = %q, want %q", got, "repository:library/test:pull")
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, `{"token":"registry-token","expires_in":3600}`)
- case "/v2/library/test/blobs/sha256:first", "/v2/library/test/blobs/sha256:second":
- registryRequests++
- if r.Header.Get("Authorization") != "Bearer registry-token" {
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token",service="registry.test",scope="repository:library/test:pull"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- return
- }
- _, _ = io.WriteString(w, "blob")
- default:
- http.NotFound(w, r)
- }
- }))
- defer server.Close()
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, nil)}
- for _, digest := range []string{"sha256:first", "sha256:second"} {
- resp, err := client.Get(server.URL + "/v2/library/test/blobs/" + digest)
- if err != nil {
- t.Fatalf("GET %s: %v", digest, err)
- }
- body, readErr := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if readErr != nil {
- t.Fatalf("read %s response: %v", digest, readErr)
- }
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s status = %d, want %d", digest, resp.StatusCode, http.StatusOK)
- }
- if string(body) != "blob" {
- t.Errorf("GET %s body = %q, want %q", digest, body, "blob")
- }
- }
-
- if tokenRequests != 1 {
- t.Errorf("token requests = %d, want 1", tokenRequests)
- }
- if registryRequests != 3 {
- t.Errorf("registry requests = %d, want 3", registryRequests)
- }
-}
-
-func TestTransportRetriesTemporaryTokenLookupFailures(t *testing.T) {
- var registryRequests int
- var tokenRequests int
- var tokenLookupFailures int
- var server *httptest.Server
-
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/token":
- tokenRequests++
- _, _ = io.WriteString(w, `{"token":"registry-token"}`)
- case "/v2/library/test/blobs/sha256:test":
- registryRequests++
- if r.Header.Get("Authorization") != "Bearer registry-token" {
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token",service="registry.test",scope="repository:library/test:pull"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- return
- }
- _, _ = io.WriteString(w, "blob")
- default:
- http.NotFound(w, r)
- }
- }))
- defer server.Close()
-
- base := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
- if req.URL.Path == "/token" && tokenLookupFailures < 2 {
- tokenLookupFailures++
- return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true}
- }
- return http.DefaultTransport.RoundTrip(req)
- })
- transport := NewTransport(base, nil)
- transport.retryWait = func(context.Context, time.Duration) error { return nil }
- client := &http.Client{Transport: transport}
-
- resp, err := client.Get(server.URL + "/v2/library/test/blobs/sha256:test")
- if err != nil {
- t.Fatalf("GET blob: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if tokenLookupFailures != 2 {
- t.Errorf("token lookup failures = %d, want 2", tokenLookupFailures)
- }
- if tokenRequests != 1 {
- t.Errorf("token requests = %d, want 1", tokenRequests)
- }
- if registryRequests != 2 {
- t.Errorf("registry requests = %d, want 2", registryRequests)
- }
-}
-
-func TestTransportDoesNotRetryPermanentTokenLookupFailures(t *testing.T) {
- var tokenRequests int
- base := roundTripperFunc(func(*http.Request) (*http.Response, error) {
- tokenRequests++
- return nil, &net.DNSError{Err: "no such host"}
- })
- transport := NewTransport(base, nil)
- transport.retryWait = func(context.Context, time.Duration) error { return nil }
-
- _, _, err := transport.fetchToken(context.Background(), bearerChallenge{realm: "https://auth.example.test/token"})
- if err == nil {
- t.Fatal("fetchToken succeeded, want error")
- }
- if tokenRequests != 1 {
- t.Errorf("token requests = %d, want 1", tokenRequests)
- }
-}
-
-func TestTransportDoesNotRetryPermanentTokenFailures(t *testing.T) {
- var tokenRequests int
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- tokenRequests++
- http.Error(w, "invalid credentials", http.StatusUnauthorized)
- }))
- defer server.Close()
-
- transport := NewTransport(http.DefaultTransport, nil)
- _, _, err := transport.fetchToken(context.Background(), bearerChallenge{realm: server.URL + "/token"})
- if err == nil {
- t.Fatal("fetchToken succeeded, want error")
- }
- if tokenRequests != 1 {
- t.Errorf("token requests = %d, want 1", tokenRequests)
- }
-}
-
-func TestTransportRetriesTokenServiceFailures(t *testing.T) {
- for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
- t.Run(http.StatusText(status), func(t *testing.T) {
- var tokenRequests int
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- tokenRequests++
- http.Error(w, "temporary token service failure", status)
- }))
- defer server.Close()
-
- var delays []time.Duration
- transport := NewTransport(http.DefaultTransport, nil)
- transport.retryWait = func(_ context.Context, delay time.Duration) error {
- delays = append(delays, delay)
- return nil
- }
-
- _, _, err := transport.fetchToken(context.Background(), bearerChallenge{realm: server.URL + "/token"})
- if err == nil {
- t.Fatal("fetchToken succeeded, want error")
- }
- if tokenRequests != tokenMaxRetries+1 {
- t.Errorf("token requests = %d, want %d", tokenRequests, tokenMaxRetries+1)
- }
- wantDelays := []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
- if len(delays) != len(wantDelays) {
- t.Fatalf("retry delays = %v, want %v", delays, wantDelays)
- }
- for index, want := range wantDelays {
- if delays[index] != want {
- t.Errorf("retry delay %d = %s, want %s", index, delays[index], want)
- }
- }
- })
- }
-}
-
-func TestTransportStopsTokenRetriesWhenWaitingIsCancelled(t *testing.T) {
- var tokenRequests int
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- tokenRequests++
- http.Error(w, "temporary token service failure", http.StatusServiceUnavailable)
- }))
- defer server.Close()
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- transport := NewTransport(http.DefaultTransport, nil)
- transport.retryWait = func(ctx context.Context, _ time.Duration) error {
- cancel()
- <-ctx.Done()
- return ctx.Err()
- }
-
- _, _, err := transport.fetchToken(ctx, bearerChallenge{realm: server.URL + "/token"})
- if !errors.Is(err, context.Canceled) {
- t.Errorf("fetchToken error = %v, want context canceled", err)
- }
- if tokenRequests != 1 {
- t.Errorf("token requests = %d, want 1", tokenRequests)
- }
-}
-
-func TestTransportAddsConfiguredAuthentication(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("X-Registry-Token"); got != "configured-token" {
- t.Errorf("X-Registry-Token = %q, want %q", got, "configured-token")
- }
- w.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
-
- authForURL := func(url string) (string, string) {
- if strings.HasPrefix(url, server.URL) {
- return "X-Registry-Token", "configured-token"
- }
- return "", ""
- }
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, authForURL)}
-
- resp, err := client.Get(server.URL + "/metadata")
- if err != nil {
- t.Fatalf("GET metadata: %v", err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusNoContent {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
- }
-}
-
-func TestTransportPreservesExplicitAuthentication(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("Authorization"); got != "Bearer explicit-token" {
- t.Errorf("Authorization = %q, want %q", got, "Bearer explicit-token")
- }
- w.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
-
- authForURL := func(string) (string, string) {
- return "Authorization", "Bearer configured-token"
- }
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, authForURL)}
- req, err := http.NewRequest(http.MethodGet, server.URL+"/artifact", nil)
- if err != nil {
- t.Fatal(err)
- }
- req.Header.Set("Authorization", "Bearer explicit-token")
-
- resp, err := client.Do(req)
- if err != nil {
- t.Fatalf("GET artifact: %v", err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusNoContent {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
- }
-}
-
-func TestTransportDoesNotReplaceExplicitAuthenticationAfterBearerChallenge(t *testing.T) {
- var registryRequests int
- var tokenRequests int
- var server *httptest.Server
-
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/token":
- tokenRequests++
- _, _ = io.WriteString(w, `{"token":"registry-token"}`)
- case "/v2/library/test/blobs/sha256:test":
- registryRequests++
- if got := r.Header.Get("Authorization"); got != "Bearer explicit-token" {
- t.Errorf("Authorization = %q, want %q", got, "Bearer explicit-token")
- }
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- default:
- http.NotFound(w, r)
- }
- }))
- defer server.Close()
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, nil)}
- req, err := http.NewRequest(http.MethodGet, server.URL+"/v2/library/test/blobs/sha256:test", nil)
- if err != nil {
- t.Fatal(err)
- }
- req.Header.Set("Authorization", "Bearer explicit-token")
-
- resp, err := client.Do(req)
- if err != nil {
- t.Fatalf("GET blob: %v", err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusUnauthorized {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
- }
- if registryRequests != 1 {
- t.Errorf("registry requests = %d, want 1", registryRequests)
- }
- if tokenRequests != 0 {
- t.Errorf("token requests = %d, want 0", tokenRequests)
- }
-}
-
-func TestTransportDoesNotForwardConfiguredAuthenticationOnTokenRedirect(t *testing.T) {
- destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("X-Registry-Token"); got != "" {
- t.Errorf("redirected X-Registry-Token = %q, want empty", got)
- }
- _, _ = io.WriteString(w, `{"token":"registry-token"}`)
- }))
- defer destination.Close()
-
- source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("X-Registry-Token"); got != "configured-token" {
- t.Errorf("source X-Registry-Token = %q, want %q", got, "configured-token")
- }
- http.Redirect(w, r, destination.URL+"/token", http.StatusFound)
- }))
- defer source.Close()
-
- authForURL := func(rawURL string) (string, string) {
- if strings.HasPrefix(rawURL, source.URL) {
- return "X-Registry-Token", "configured-token"
- }
- return "", ""
- }
- transport := NewTransport(http.DefaultTransport, authForURL)
- token, _, err := transport.fetchToken(context.Background(), bearerChallenge{realm: source.URL + "/token"})
- if err != nil {
- t.Fatalf("fetchToken: %v", err)
- }
- if token != "registry-token" {
- t.Errorf("token = %q, want %q", token, "registry-token")
- }
-}
-
-func TestTransportPrunesExpiredTokens(t *testing.T) {
- transport := NewTransport(http.DefaultTransport, nil)
- transport.tokens["expired-unused"] = cachedToken{
- value: "expired-token",
- expiresAt: time.Now().Add(-time.Minute),
- }
- transport.cacheToken("current", cachedToken{
- value: "current-token",
- expiresAt: time.Now().Add(time.Minute),
- })
-
- if got := transport.cachedToken("current"); got != "current-token" {
- t.Errorf("cachedToken(current) = %q, want %q", got, "current-token")
- }
- if _, ok := transport.tokens["expired-unused"]; ok {
- t.Error("expired unused token was not pruned")
- }
-}
-
-func TestTransportDoesNotFollowBearerChallengeOutsideOCIRegistry(t *testing.T) {
- tokenRequests := 0
- var server *httptest.Server
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/token" {
- tokenRequests++
- _, _ = io.WriteString(w, `{"token":"unexpected"}`)
- return
- }
- w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token"`)
- http.Error(w, "authentication required", http.StatusUnauthorized)
- }))
- defer server.Close()
-
- client := &http.Client{Transport: NewTransport(http.DefaultTransport, nil)}
- resp, err := client.Get(server.URL + "/api/packages")
- if err != nil {
- t.Fatalf("GET API: %v", err)
- }
- _ = resp.Body.Close()
- if resp.StatusCode != http.StatusUnauthorized {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
- }
- if tokenRequests != 0 {
- t.Errorf("token requests = %d, want 0", tokenRequests)
- }
-}
-
-func TestTransportBoundsTokenCache(t *testing.T) {
- transport := NewTransport(http.DefaultTransport, nil)
- expiry := time.Now().Add(time.Hour)
- for i := range maxCachedTokens + 10 {
- transport.cacheToken(fmt.Sprintf("key-%d", i), cachedToken{
- value: "token",
- expiresAt: expiry.Add(time.Duration(i) * time.Second),
- })
- }
- if got := len(transport.tokens); got != maxCachedTokens {
- t.Errorf("cached tokens = %d, want %d", got, maxCachedTokens)
- }
- if got := transport.cachedToken(fmt.Sprintf("key-%d", maxCachedTokens+9)); got != "token" {
- t.Error("the newest token was evicted")
- }
- if got := transport.cachedToken("key-0"); got != "" {
- t.Error("the token expiring soonest was kept")
- }
-}
-
-func TestTransportBoundsChallengeCache(t *testing.T) {
- transport := NewTransport(http.DefaultTransport, nil)
- challenge := bearerChallenge{realm: "https://auth.example.test/token", service: "registry"}
- for i := range 50 {
- transport.rememberChallenge(mustParse(t, fmt.Sprintf("https://registry.example.test/v2/repo-%d/blobs/sha256:abc", i)), challenge)
- }
- if got := len(transport.challenges); got != 1 {
- t.Errorf("challenges for one registry host = %d, want 1", got)
- }
- for i := range maxCachedChallenges + 10 {
- transport.rememberChallenge(mustParse(t, fmt.Sprintf("https://registry-%d.example.test/v2/repo/blobs/sha256:abc", i)), challenge)
- }
- if got := len(transport.challenges); got != maxCachedChallenges {
- t.Errorf("challenges = %d, want %d", got, maxCachedChallenges)
- }
-}
-
-func TestTransportCapsTokenLifetime(t *testing.T) {
- resp := &http.Response{Body: io.NopCloser(strings.NewReader(`{"token":"t","expires_in":31536000}`))}
- _, expiresAt, err := decodeTokenResponse(resp)
- if err != nil {
- t.Fatal(err)
- }
- if limit := time.Now().Add(maxTokenLifetime); expiresAt.After(limit) {
- t.Errorf("token expires at %v, want no later than %v", expiresAt, limit)
- }
-}
diff --git a/internal/inventory/fuzz_test.go b/internal/inventory/fuzz_test.go
deleted file mode 100644
index f7fcadd..0000000
--- a/internal/inventory/fuzz_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package inventory
-
-import (
- "os"
- "testing"
-)
-
-// FuzzParse checks that no uploaded body makes the SBOM parsing panic.
-func FuzzParse(f *testing.F) {
- if data, err := os.ReadFile("testdata/syft-cyclonedx.json"); err == nil {
- f.Add(data)
- }
- f.Add([]byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"type":"library","name":"x","version":"1","purl":"pkg:npm/x@1"}]}`))
- f.Add([]byte(`{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","packages":[{"SPDXID":"SPDXRef-a","name":"a","versionInfo":"1","externalRefs":[{"referenceCategory":"PACKAGE-MANAGER","referenceType":"purl","referenceLocator":"pkg:conda/a@1?channel=c"}]}]}`))
- f.Add([]byte("SPDXVersion: SPDX-2.3\nPackageName: a\nSPDXID: SPDXRef-a\nExternalRef: PACKAGE-MANAGER purl pkg:deb/debian/a@1\n"))
- f.Add([]byte(`xpkg:pypi/X_Y@1`))
- f.Fuzz(func(t *testing.T, data []byte) {
- components, _, err := Parse(data)
- if err != nil {
- return
- }
- for _, c := range components {
- if c.PackagePURL == "" || c.Version == "" || c.Ecosystem == "" {
- t.Errorf("component with an empty field: %+v", c)
- }
- }
- })
-}
diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go
deleted file mode 100644
index c48f387..0000000
--- a/internal/inventory/inventory.go
+++ /dev/null
@@ -1,190 +0,0 @@
-// Package inventory turns SBOMs uploaded for a project into the package
-// versions the proxy records for it, named the way the proxy's handlers name
-// the packages they cache, so the two can be joined.
-package inventory
-
-import (
- "errors"
- "fmt"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/sbom"
- "github.com/git-pkgs/vers"
-)
-
-// MaxProjectNameLength bounds a project name, such as a GitLab project path.
-const MaxProjectNameLength = 255
-
-// Component is one package version a project uses.
-type Component struct {
- // Ecosystem is the name the proxy's handlers store the package under,
- // e.g. "npm", "composer", "golang" or "alpine".
- Ecosystem string
- // Name is the ecosystem-native package name, e.g. "@babel/core",
- // "symfony/console" or "org.slf4j:slf4j-api".
- Name string
- // Version is the version as the SBOM gives it; it may be empty.
- Version string
- // PackagePURL is the unversioned package URL the proxy records the
- // package under, so it matches packages.purl.
- PackagePURL string
-}
-
-// servedTypes are the PURL types the proxy serves packages of and the
-// inventory records.
-var servedTypes = map[string]bool{
- "alpm": true, "apk": true, "bsd": true, "cargo": true, "chef": true,
- "composer": true, "conan": true, "conda": true, "cran": true, "deb": true,
- "gem": true, "golang": true, "hex": true, "julia": true, "maven": true,
- "npm": true, "nuget": true, "pub": true, "pypi": true, "rpm": true,
- "swift": true,
-}
-
-// ErrUnsupportedPURL is returned for a package URL whose type the proxy
-// doesn't serve.
-var ErrUnsupportedPURL = errors.New("package type is not served by the proxy")
-
-// ErrNoCondaChannel is returned for a conda package URL without a channel
-// qualifier: the proxy records conda packages per channel.
-var ErrNoCondaChannel = errors.New("conda package URL has no channel qualifier")
-
-// FromPURL maps a package URL to a component, with the package named the way
-// the handler for its type records it. Qualifiers and subpath are dropped;
-// the version is kept as given.
-func FromPURL(s string) (Component, error) {
- p, err := purl.Parse(s)
- if err != nil {
- return Component{}, err
- }
- if !servedTypes[p.Type] {
- return Component{}, fmt.Errorf("%w: %s", ErrUnsupportedPURL, p.Type)
- }
- ecosystem, name := packageurl.HandlerPackage(p)
- if ecosystem == "conda" && !strings.Contains(name, "/") {
- return Component{}, ErrNoCondaChannel
- }
- packagePURL, _ := packageurl.MakeCacheStrings(ecosystem, name, "")
- if packagePURL == "" {
- return Component{}, fmt.Errorf("package URL %q has no valid package name", s)
- }
- return Component{Ecosystem: ecosystem, Name: name, Version: p.Version, PackagePURL: packagePURL}, nil
-}
-
-// syftMainModule is the property syft sets on modules found in a Go binary,
-// naming the module the binary was built from.
-const syftMainModule = "syft:metadata:mainModule"
-
-// unknownVersion is what syft gives a package without a resolved version,
-// such as the project's own module in go.mod or the root of a lock file.
-const unknownVersion = "UNKNOWN"
-
-// Parse reads a CycloneDX (JSON or XML) or SPDX SBOM and returns the package
-// versions it lists, without duplicates. Packages without a package URL, of a
-// type the proxy doesn't serve, without a version, or conda packages without
-// a channel are counted in skipped.
-// So are the project's own packages: Go modules its binaries were built from,
-// and the roots sbom finds for the dependency graph.
-func Parse(data []byte) (components []Component, skipped int, err error) {
- doc, err := sbom.Parse(data)
- if err != nil {
- return nil, 0, err
- }
- ownModules := mainModules(doc.Packages)
- roots := map[string]bool{}
- for _, id := range doc.ProjectRoots() {
- roots[id] = true
- }
- seen := make(map[[2]string]bool, len(doc.Packages))
- for _, pkg := range doc.Packages {
- c, ok := componentOf(pkg)
- if !ok || roots[pkg.ID] || (c.Ecosystem == "golang" && ownModules[c.Name]) {
- skipped++
- continue
- }
- key := [2]string{c.PackagePURL, c.Version}
- if seen[key] {
- continue
- }
- seen[key] = true
- components = append(components, c)
- }
- return components, skipped, nil
-}
-
-func componentOf(pkg sbom.Package) (Component, bool) {
- locator := pkg.PURL()
- if locator == "" {
- return Component{}, false
- }
- c, err := FromPURL(locator)
- if err != nil {
- return Component{}, false
- }
- if c.Version == "" {
- c.Version = pkg.Version
- }
- // A package without a version is the project itself (the module in
- // go.mod, the root of package-lock.json), not something it depends on.
- if c.Version == "" || c.Version == unknownVersion {
- return Component{}, false
- }
- return c, true
-}
-
-// mainModules returns the Go modules the SBOM's binaries were built from,
-// which are the project's own. Module paths are case-sensitive and package
-// URLs keep their case, so they are compared as they are.
-func mainModules(pkgs []sbom.Package) map[string]bool {
- own := map[string]bool{}
- for _, pkg := range pkgs {
- for _, prop := range pkg.Properties {
- if prop.Name == syftMainModule && prop.Value != "" {
- own[prop.Value] = true
- }
- }
- }
- return own
-}
-
-// ValidateProjectName checks a project name such as "group/sub/repo": one or
-// more segments of letters, digits, ".", "_" and "-", none of them "." or
-// "..".
-func ValidateProjectName(name string) error {
- if name == "" {
- return errors.New("project name is empty")
- }
- if len(name) > MaxProjectNameLength {
- return fmt.Errorf("project name is longer than %d characters", MaxProjectNameLength)
- }
- for segment := range strings.SplitSeq(name, "/") {
- if segment == "" || segment == "." || segment == ".." {
- return fmt.Errorf("project name %q has an empty, \".\" or \"..\" segment", name)
- }
- for _, r := range segment {
- if !validNameRune(r) {
- return fmt.Errorf("project name %q may only contain letters, digits, '.', '_', '-' and '/'", name)
- }
- }
- }
- return nil
-}
-
-func validNameRune(r rune) bool {
- return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-'
-}
-
-// CompareVersions compares two versions of a package in ecosystem with the
-// ecosystem's version rules, returning -1, 0 or 1.
-func CompareVersions(ecosystem, a, b string) int {
- return vers.CompareWithScheme(a, b, purl.EcosystemToPURLType(ecosystem))
-}
-
-// Outdated reports whether version is lower than latest.
-func Outdated(ecosystem, version, latest string) bool {
- if version == "" || latest == "" || version == latest {
- return false
- }
- return CompareVersions(ecosystem, version, latest) < 0
-}
diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go
deleted file mode 100644
index 89b070b..0000000
--- a/internal/inventory/inventory_test.go
+++ /dev/null
@@ -1,337 +0,0 @@
-package inventory
-
-import (
- "errors"
- "os"
- "slices"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/packageurl"
-)
-
-func TestParseSyftCycloneDX(t *testing.T) {
- data, err := os.ReadFile("testdata/syft-cyclonedx.json")
- if err != nil {
- t.Fatal(err)
- }
- components, skipped, err := Parse(data)
- if err != nil {
- t.Fatalf("Parse: %v", err)
- }
- want := []Component{
- {Ecosystem: "npm", Name: "@babel/core", Version: "7.23.0", PackagePURL: "pkg:npm/%40babel/core"},
- {Ecosystem: "alpine", Name: "busybox", Version: "1.36.1-r15", PackagePURL: "pkg:apk/alpine/busybox"},
- {Ecosystem: "golang", Name: "github.com/inconshreveable/mousetrap", Version: "v1.1.0", PackagePURL: "pkg:golang/github.com/inconshreveable/mousetrap"},
- {Ecosystem: "golang", Name: "github.com/spf13/cobra", Version: "v1.8.0", PackagePURL: "pkg:golang/github.com/spf13/cobra"},
- {Ecosystem: "golang", Name: "github.com/spf13/pflag", Version: "v1.0.5", PackagePURL: "pkg:golang/github.com/spf13/pflag"},
- {Ecosystem: "npm", Name: "lodash", Version: "4.17.20", PackagePURL: "pkg:npm/lodash"},
- {Ecosystem: "composer", Name: "monolog/monolog", Version: "3.5.0", PackagePURL: "pkg:composer/monolog/monolog"},
- {Ecosystem: "alpine", Name: "musl", Version: "1.2.4-r2", PackagePURL: "pkg:apk/alpine/musl"},
- {Ecosystem: "composer", Name: "symfony/console", Version: "v6.4.1", PackagePURL: "pkg:composer/symfony/console"},
- }
- if !slices.Equal(components, want) {
- t.Errorf("components =\n%v\nwant\n%v", components, want)
- }
- // The operating system and the four lock files syft lists carry no PURL,
- // and webapp is the root of package-lock.json, the project itself.
- if skipped != 6 {
- t.Errorf("skipped = %d, want 6", skipped)
- }
-}
-
-func TestParseDeduplicatesAndSkipsUnknownTypes(t *testing.T) {
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
- {"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"},
- {"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"},
- {"type":"library","name":"lodash","version":"4.17.20","purl":"pkg:npm/lodash@4.17.20"},
- {"type":"library","name":"checkout","version":"v4","purl":"pkg:github/actions/checkout@v4"},
- {"type":"library","name":"Django","version":"4.2.0","purl":"pkg:pypi/Django"},
- {"type":"library","name":"guava","version":"32.1.3-jre","purl":"pkg:maven/com.google.guava/guava@32.1.3-jre?type=jar"},
- {"type":"library","name":"libc6","version":"2.36-9","purl":"pkg:deb/debian/libc6@2.36-9?arch=amd64&distro=debian-12"},
- {"type":"library","name":"broken","purl":"not a purl"}
- ]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- want := []Component{
- {Ecosystem: "npm", Name: "lodash", Version: "4.17.21", PackagePURL: "pkg:npm/lodash"},
- {Ecosystem: "npm", Name: "lodash", Version: "4.17.20", PackagePURL: "pkg:npm/lodash"},
- {Ecosystem: "pypi", Name: "django", Version: "4.2.0", PackagePURL: "pkg:pypi/django"},
- {Ecosystem: "maven", Name: "com.google.guava:guava", Version: "32.1.3-jre", PackagePURL: "pkg:maven/com.google.guava/guava"},
- {Ecosystem: "deb", Name: "libc6", Version: "2.36-9", PackagePURL: "pkg:deb/libc6"},
- }
- if !slices.Equal(components, want) {
- t.Errorf("components =\n%v\nwant\n%v", components, want)
- }
- if skipped != 2 {
- t.Errorf("skipped = %d, want 2", skipped)
- }
-}
-
-func TestParseRejectsNonSBOM(t *testing.T) {
- if _, _, err := Parse([]byte(`{"hello":"world"}`)); err == nil {
- t.Error("Parse accepted a document that is not an SBOM")
- }
-}
-
-func TestFromPURLUnsupported(t *testing.T) {
- _, err := FromPURL("pkg:github/actions/checkout")
- if !errors.Is(err, ErrUnsupportedPURL) {
- t.Errorf("err = %v, want ErrUnsupportedPURL", err)
- }
-}
-
-// TestFromPURLMatchesHandlerNames checks, for every PURL type the inventory
-// maps, that an SBOM's package URL ends up as the package URL the handler
-// records when it caches that package. handlerName is the name the handler
-// passes to GetOrFetchArtifactFromURL, taken from the handler's code.
-func TestFromPURLMatchesHandlerNames(t *testing.T) {
- tests := []struct {
- purlType string
- sbomPURL string
- handlerName string
- }{
- {"alpm", "pkg:alpm/arch/glibc@2.39-1?arch=x86_64", "glibc"},
- {"apk", "pkg:apk/alpine/busybox@1.36.1-r15?arch=x86_64", "busybox"},
- {"bsd", "pkg:bsd/freebsd/curl@8.7.1", "curl"},
- {"cargo", "pkg:cargo/serde@1.0.200", "serde"},
- {"chef", "pkg:chef/nginx@12.0.0", "nginx"},
- {"composer", "pkg:composer/symfony/console@v6.4.1", "symfony/console"},
- {"conan", "pkg:conan/zlib@1.3.1", "zlib"},
- {"conda", "pkg:conda/numpy@1.26.4?channel=conda-forge&subdir=linux-64", "conda-forge/numpy"},
- {"cran", "pkg:cran/ggplot2@3.5.1", "ggplot2"},
- {"deb", "pkg:deb/debian/libc6@2.36-9?arch=amd64&distro=debian-12", "libc6"},
- {"gem", "pkg:gem/rails@7.1.3", "rails"},
- // The go handler stores the module path with its case; PURLs of
- // golang modules are lowercased on both sides.
- {"golang", "pkg:golang/github.com/Azure/azure-sdk-for-go@v68.0.0", "github.com/Azure/azure-sdk-for-go"},
- {"hex", "pkg:hex/phoenix@1.7.12", "phoenix"},
- {"julia", "pkg:julia/Example@0.5.3?uuid=7876af07-990d-54b4-ab0e-23690620f79a", "Example"},
- {"maven", "pkg:maven/org.slf4j/slf4j-api@2.0.13?type=jar", "org.slf4j:slf4j-api"},
- {"npm", "pkg:npm/%40babel/core@7.23.0", "@babel/core"},
- // The NuGet handler lowercases the package ID.
- {"nuget", "pkg:nuget/Newtonsoft.Json@13.0.3", "newtonsoft.json"},
- {"pub", "pkg:pub/http@1.2.1", "http"},
- // The PyPI handler takes the name from the wheel or sdist file name,
- // which spells "." and "-" as "_".
- {"pypi", "pkg:pypi/zope.interface@6.4", "zope_interface"},
- {"rpm", "pkg:rpm/fedora/curl@8.6.0-7.fc40?arch=x86_64", "curl"},
- {"swift", "pkg:swift/github.com/apple/swift-argument-parser@1.3.1", "github.com/apple/swift-argument-parser"},
- }
- covered := map[string]bool{}
- for _, tt := range tests {
- t.Run(tt.purlType, func(t *testing.T) {
- covered[tt.purlType] = true
- c, err := FromPURL(tt.sbomPURL)
- if err != nil {
- t.Fatalf("FromPURL(%q): %v", tt.sbomPURL, err)
- }
- want, _ := packageurl.MakeCacheStrings(packageurl.HandlerEcosystem(tt.purlType), tt.handlerName, "")
- if want == "" {
- t.Fatalf("handler name %q has no package URL", tt.handlerName)
- }
- if c.PackagePURL != want {
- t.Errorf("PackagePURL = %q, want %q as the handler records it", c.PackagePURL, want)
- }
- })
- }
- for purlType := range servedTypes {
- if !covered[purlType] {
- t.Errorf("no case for PURL type %q", purlType)
- }
- }
-}
-
-func TestFromPURLNormalizesPyPINames(t *testing.T) {
- for _, s := range []string{"pkg:pypi/zope.interface", "pkg:pypi/Zope_Interface", "pkg:pypi/zope--interface", "pkg:pypi/zope._-interface"} {
- c, err := FromPURL(s)
- if err != nil {
- t.Fatal(err)
- }
- if c.Name != "zope-interface" || c.PackagePURL != "pkg:pypi/zope-interface" {
- t.Errorf("FromPURL(%q) = %+v, want zope-interface", s, c)
- }
- }
-}
-
-func TestFromPURLCondaNeedsChannel(t *testing.T) {
- if _, err := FromPURL("pkg:conda/numpy@1.26.4"); !errors.Is(err, ErrNoCondaChannel) {
- t.Errorf("err = %v, want ErrNoCondaChannel", err)
- }
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
- {"type":"library","name":"numpy","version":"1.26.4","purl":"pkg:conda/numpy@1.26.4"},
- {"type":"library","name":"numpy","version":"1.26.4","purl":"pkg:conda/numpy@1.26.4?channel=conda-forge"}
- ]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 1 || components[0].Name != "conda-forge/numpy" || skipped != 1 {
- t.Errorf("components = %+v, skipped = %d; want conda-forge/numpy and 1 skipped", components, skipped)
- }
-}
-
-func TestValidateProjectName(t *testing.T) {
- valid := []string{"repo", "group/repo", "group/sub/my_repo-2.0", strings.Repeat("a", MaxProjectNameLength)}
- for _, name := range valid {
- if err := ValidateProjectName(name); err != nil {
- t.Errorf("ValidateProjectName(%q) = %v, want nil", name, err)
- }
- }
- invalid := []string{
- "", "/repo", "repo/", "group//repo", ".", "..", "group/../repo", "group/./repo",
- "group/re po", "group/repo?x", "group\\repo", "grüppe/repo", "group/repo%2F",
- strings.Repeat("a", MaxProjectNameLength+1),
- }
- for _, name := range invalid {
- if err := ValidateProjectName(name); err == nil {
- t.Errorf("ValidateProjectName(%q) = nil, want an error", name)
- }
- }
-}
-
-func TestOutdated(t *testing.T) {
- tests := []struct {
- name string
- ecosystem string
- version string
- other string
- outdated bool
- }{
- {"npm older", "npm", "4.17.20", "4.17.21", true},
- {"npm same", "npm", "4.17.21", "4.17.21", false},
- {"npm newer", "npm", "4.18.0", "4.17.21", false},
- {"composer v prefix", "composer", "v6.4.1", "v6.4.2", true},
- {"golang", "golang", "v1.8.0", "v1.10.0", true},
- {"alpine revisions", "alpine", "1.36.1-r15", "1.36.1-r2", false},
- {"empty version", "npm", "", "1.0.0", false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := Outdated(tt.ecosystem, tt.version, tt.other); got != tt.outdated {
- t.Errorf("Outdated = %v, want %v", got, tt.outdated)
- }
- })
- }
-}
-
-func TestParseSkipsTheProjectsOwnPackages(t *testing.T) {
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
- {"bom-ref":"mod","type":"library","name":"github.com/acme/app","version":"UNKNOWN","purl":"pkg:golang/github.com/acme/app"},
- {"bom-ref":"bin","type":"library","name":"github.com/acme/app","version":"v0.0.0-20261027162038-a3e9dd9fd3f4+dirty","purl":"pkg:golang/github.com/acme/app@v0.0.0-20261027162038-a3e9dd9fd3f4%2Bdirty",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/acme/app"}]},
- {"bom-ref":"cobra","type":"library","name":"github.com/spf13/cobra","version":"v1.8.0","purl":"pkg:golang/github.com/spf13/cobra@v1.8.0",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/acme/app"}]},
- {"bom-ref":"root","type":"library","name":"app-frontend","version":"2.1.0","purl":"pkg:npm/app-frontend@2.1.0",
- "properties":[{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]},
- {"bom-ref":"nover","type":"library","name":"app-tools","purl":"pkg:npm/app-tools"},
- {"bom-ref":"jsdom","type":"library","name":"jsdom","version":"30.1.3","purl":"pkg:npm/jsdom@30.1.3",
- "properties":[{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]}
- ],"dependencies":[{"ref":"root","dependsOn":["jsdom"]}]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- want := []Component{
- {Ecosystem: "golang", Name: "github.com/spf13/cobra", Version: "v1.8.0", PackagePURL: "pkg:golang/github.com/spf13/cobra"},
- {Ecosystem: "npm", Name: "jsdom", Version: "30.1.3", PackagePURL: "pkg:npm/jsdom"},
- }
- if !slices.Equal(components, want) {
- t.Errorf("components =\n%v\nwant\n%v", components, want)
- }
- if skipped != 4 {
- t.Errorf("skipped = %d, want 4", skipped)
- }
-}
-
-func TestParseKeepsTopLevelDependenciesOfLockFiles(t *testing.T) {
- // A poetry.lock doesn't list the project, so requests is a direct
- // dependency, not the project's own entry.
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
- {"bom-ref":"requests","type":"library","name":"requests","version":"2.32.3","purl":"pkg:pypi/requests@2.32.3",
- "properties":[{"name":"syft:package:metadataType","value":"python-poetry-lock-entry"},{"name":"syft:location:0:path","value":"/poetry.lock"}]},
- {"bom-ref":"urllib3","type":"library","name":"urllib3","version":"2.2.3","purl":"pkg:pypi/urllib3@2.2.3",
- "properties":[{"name":"syft:package:metadataType","value":"python-poetry-lock-entry"},{"name":"syft:location:0:path","value":"/poetry.lock"}]}
- ],"dependencies":[{"ref":"requests","dependsOn":["urllib3"]}]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 2 || skipped != 0 {
- t.Errorf("components = %+v, skipped = %d; want requests and urllib3, 0 skipped", components, skipped)
- }
-}
-
-func TestParseKeepsOSPackagesNothingDependsOn(t *testing.T) {
- // syft's CycloneDX of an image: metadata.component is the image, with
- // no edges, and busybox is something nothing else depends on.
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6",
- "metadata":{"component":{"bom-ref":"image","type":"container","name":"alpine:3.20"}},
- "components":[
- {"bom-ref":"busybox","type":"library","name":"busybox","version":"1.36.1-r29","purl":"pkg:apk/alpine/busybox@1.36.1-r29?distro=alpine-3.20.3",
- "properties":[{"name":"syft:package:metadataType","value":"apk-db-entry"}]},
- {"bom-ref":"musl","type":"library","name":"musl","version":"1.2.5-r0","purl":"pkg:apk/alpine/musl@1.2.5-r0?distro=alpine-3.20.3",
- "properties":[{"name":"syft:package:metadataType","value":"apk-db-entry"}]}
- ],"dependencies":[{"ref":"busybox","dependsOn":["musl"]}]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 2 || skipped != 0 {
- t.Errorf("components = %+v, skipped = %d; want busybox and musl, 0 skipped", components, skipped)
- }
-}
-
-func TestParseSkipsTheDeclaredRoot(t *testing.T) {
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6",
- "metadata":{"component":{"bom-ref":"app","type":"application","name":"app","version":"1.0.0","purl":"pkg:npm/app@1.0.0"}},
- "components":[
- {"bom-ref":"lodash","type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"}
- ],"dependencies":[{"ref":"app","dependsOn":["lodash"]}]}`
- components, _, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 1 || components[0].Name != "lodash" {
- t.Errorf("components = %+v, want only lodash", components)
- }
-}
-
-func TestParseSkipsMixedCaseMainModule(t *testing.T) {
- // syft keeps the module path's case in mainModule, and so does the
- // package URL.
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
- {"bom-ref":"bin","type":"library","name":"github.com/Azure/foo","version":"v1.2.0","purl":"pkg:golang/github.com/Azure/foo@v1.2.0",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]},
- {"bom-ref":"cobra","type":"library","name":"github.com/spf13/cobra","version":"v1.8.0","purl":"pkg:golang/github.com/spf13/cobra@v1.8.0",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]}
- ]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 1 || components[0].Name != "github.com/spf13/cobra" || skipped != 1 {
- t.Errorf("components = %+v, skipped = %d; want only cobra, 1 skipped", components, skipped)
- }
-}
-
-func TestParseMainModuleKeepsCase(t *testing.T) {
- // Go module paths are case-sensitive, and package URLs keep their case,
- // so github.com/azure/foo is another module than the main module
- // github.com/Azure/foo, and a dependency.
- doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
- {"bom-ref":"bin","type":"library","name":"github.com/Azure/foo","version":"v1.2.0","purl":"pkg:golang/github.com/Azure/foo@v1.2.0",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]},
- {"bom-ref":"other","type":"library","name":"github.com/azure/foo","version":"v0.1.0","purl":"pkg:golang/github.com/azure/foo@v0.1.0",
- "properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]}
- ]}`
- components, skipped, err := Parse([]byte(doc))
- if err != nil {
- t.Fatal(err)
- }
- if len(components) != 1 || components[0].Name != "github.com/azure/foo" || skipped != 1 {
- t.Errorf("components = %+v, skipped = %d; want only github.com/azure/foo, 1 skipped", components, skipped)
- }
-}
diff --git a/internal/inventory/testdata/syft-cyclonedx.json b/internal/inventory/testdata/syft-cyclonedx.json
deleted file mode 100644
index 7647d68..0000000
--- a/internal/inventory/testdata/syft-cyclonedx.json
+++ /dev/null
@@ -1 +0,0 @@
-{"$schema":"http://cyclonedx.org/schema/bom-1.7.schema.json","bomFormat":"CycloneDX","specVersion":"1.7","serialNumber":"urn:uuid:d69cf664-a43e-4d65-93e3-e43e24d33c0e","version":1,"metadata":{"timestamp":"2026-10-27T19:17:53+01:00","tools":{"components":[{"type":"application","author":"anchore","name":"syft","version":"1.55.0"}]},"component":{"bom-ref":"af63bd4c8601b7f1","type":"file","name":"."}},"components":[{"bom-ref":"pkg:npm/%40babel/core@7.23.0?package-id=7b01fd7a9ce63128","type":"library","name":"@babel/core","version":"7.23.0","licenses":[{"license":{"id":"MIT"}}],"cpe":"cpe:2.3:a:\\@babel\\/core:\\@babel\\/core:7.23.0:*:*:*:*:*:*:*","purl":"pkg:npm/%40babel/core@7.23.0","properties":[{"name":"syft:package:foundBy","value":"javascript-lock-cataloger"},{"name":"syft:package:language","value":"javascript"},{"name":"syft:package:type","value":"npm"},{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]},{"bom-ref":"pkg:apk/alpine/busybox@1.36.1-r15?arch=x86_64&distro=alpine-3.19.1&package-id=464f552f6c89c192","type":"library","publisher":"Sören Tempel ","name":"busybox","version":"1.36.1-r15","description":"Size optimized toolbox of many common UNIX utilities","licenses":[{"license":{"id":"GPL-2.0-only"}}],"cpe":"cpe:2.3:a:busybox:busybox:1.36.1-r15:*:*:*:*:*:*:*","purl":"pkg:apk/alpine/busybox@1.36.1-r15?arch=x86_64&distro=alpine-3.19.1","externalReferences":[{"url":"https://busybox.net/","type":"distribution"}],"properties":[{"name":"syft:package:foundBy","value":"apk-db-cataloger"},{"name":"syft:package:type","value":"apk"},{"name":"syft:package:metadataType","value":"apk-db-entry"},{"name":"syft:location:0:path","value":"/lib/apk/db/installed"},{"name":"syft:metadata:gitCommitOfApkPort","value":"1b0b2f6e7d0e6a5b4c3d2e1f0a9b8c7d6e5f4a3b"},{"name":"syft:metadata:installedSize","value":"950272"},{"name":"syft:metadata:originPackage","value":"busybox"},{"name":"syft:metadata:pullChecksum","value":"Q1Xy0qB0j7Y7uQ2kGmWbN8vT6Ff0A4E="},{"name":"syft:metadata:pullDependencies:0","value":"so:libc.musl-x86_64.so.1"},{"name":"syft:metadata:size","value":"509268"}]},{"bom-ref":"pkg:golang/github.com/inconshreveable/mousetrap@v1.1.0?package-id=f6300927a9f6b106","type":"library","name":"github.com/inconshreveable/mousetrap","version":"v1.1.0","cpe":"cpe:2.3:a:inconshreveable:mousetrap:v1.1.0:*:*:*:*:*:*:*","purl":"pkg:golang/github.com/inconshreveable/mousetrap@v1.1.0","properties":[{"name":"syft:package:foundBy","value":"go-module-file-cataloger"},{"name":"syft:package:language","value":"go"},{"name":"syft:package:type","value":"go-module"},{"name":"syft:package:metadataType","value":"go-module-entry"},{"name":"syft:location:0:path","value":"/go.mod"},{"name":"syft:metadata:h1Digest","value":"h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8="}]},{"bom-ref":"pkg:golang/github.com/spf13/cobra@v1.8.0?package-id=81b06fbcbe87f414","type":"library","name":"github.com/spf13/cobra","version":"v1.8.0","cpe":"cpe:2.3:a:spf13:cobra:v1.8.0:*:*:*:*:*:*:*","purl":"pkg:golang/github.com/spf13/cobra@v1.8.0","properties":[{"name":"syft:package:foundBy","value":"go-module-file-cataloger"},{"name":"syft:package:language","value":"go"},{"name":"syft:package:type","value":"go-module"},{"name":"syft:package:metadataType","value":"go-module-entry"},{"name":"syft:location:0:path","value":"/go.mod"},{"name":"syft:metadata:h1Digest","value":"h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0="}]},{"bom-ref":"pkg:golang/github.com/spf13/pflag@v1.0.5?package-id=163f67459b76e534","type":"library","name":"github.com/spf13/pflag","version":"v1.0.5","cpe":"cpe:2.3:a:spf13:pflag:v1.0.5:*:*:*:*:*:*:*","purl":"pkg:golang/github.com/spf13/pflag@v1.0.5","properties":[{"name":"syft:package:foundBy","value":"go-module-file-cataloger"},{"name":"syft:package:language","value":"go"},{"name":"syft:package:type","value":"go-module"},{"name":"syft:package:metadataType","value":"go-module-entry"},{"name":"syft:location:0:path","value":"/go.mod"},{"name":"syft:metadata:h1Digest","value":"h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRPuqXzS0rkr6qZw="}]},{"bom-ref":"pkg:npm/lodash@4.17.20?package-id=ece3658907628a6b","type":"library","name":"lodash","version":"4.17.20","licenses":[{"license":{"id":"MIT"}}],"cpe":"cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*","purl":"pkg:npm/lodash@4.17.20","properties":[{"name":"syft:package:foundBy","value":"javascript-lock-cataloger"},{"name":"syft:package:language","value":"javascript"},{"name":"syft:package:type","value":"npm"},{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]},{"bom-ref":"pkg:composer/monolog/monolog@3.5.0?package-id=3abfdcdd00fd8d2f","type":"library","name":"monolog/monolog","version":"3.5.0","licenses":[{"license":{"id":"MIT"}}],"cpe":"cpe:2.3:a:monolog\\/monolog:monolog\\/monolog:3.5.0:*:*:*:*:*:*:*","purl":"pkg:composer/monolog/monolog@3.5.0","properties":[{"name":"syft:package:foundBy","value":"php-composer-lock-cataloger"},{"name":"syft:package:language","value":"php"},{"name":"syft:package:type","value":"php-composer"},{"name":"syft:package:metadataType","value":"php-composer-lock-entry"},{"name":"syft:location:0:path","value":"/composer.lock"}]},{"bom-ref":"pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64&distro=alpine-3.19.1&package-id=9f0e3d80c35c10bc","type":"library","publisher":"Timo Teräs ","name":"musl","version":"1.2.4-r2","description":"the musl c library (libc) implementation","licenses":[{"license":{"id":"MIT"}}],"cpe":"cpe:2.3:a:musl-libc:musl:1.2.4-r2:*:*:*:*:*:*:*","purl":"pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64&distro=alpine-3.19.1","externalReferences":[{"url":"https://musl.libc.org/","type":"distribution"}],"properties":[{"name":"syft:package:foundBy","value":"apk-db-cataloger"},{"name":"syft:package:type","value":"apk"},{"name":"syft:package:metadataType","value":"apk-db-entry"},{"name":"syft:cpe23","value":"cpe:2.3:a:musl_libc:musl:1.2.4-r2:*:*:*:*:*:*:*"},{"name":"syft:cpe23","value":"cpe:2.3:a:musl:musl:1.2.4-r2:*:*:*:*:*:*:*"},{"name":"syft:location:0:path","value":"/lib/apk/db/installed"},{"name":"syft:metadata:gitCommitOfApkPort","value":"4b2f0d7b4e0e8a4b0e1b1b4d2b8a7a3d1a2f8e0c"},{"name":"syft:metadata:installedSize","value":"647168"},{"name":"syft:metadata:originPackage","value":"musl"},{"name":"syft:metadata:pullChecksum","value":"Q1N9CgYbNRm8SgkqbXqJhCAc6iqXo="},{"name":"syft:metadata:size","value":"407383"}]},{"bom-ref":"pkg:composer/symfony/console@v6.4.1?package-id=767524bf09da7da7","type":"library","name":"symfony/console","version":"v6.4.1","licenses":[{"license":{"id":"MIT"}}],"cpe":"cpe:2.3:a:symfony\\/console:symfony\\/console:v6.4.1:*:*:*:*:*:*:*","purl":"pkg:composer/symfony/console@v6.4.1","properties":[{"name":"syft:package:foundBy","value":"php-composer-lock-cataloger"},{"name":"syft:package:language","value":"php"},{"name":"syft:package:type","value":"php-composer"},{"name":"syft:package:metadataType","value":"php-composer-lock-entry"},{"name":"syft:location:0:path","value":"/composer.lock"}]},{"bom-ref":"pkg:npm/webapp@1.0.0?package-id=982a05090388c92d","type":"library","name":"webapp","version":"1.0.0","cpe":"cpe:2.3:a:webapp:webapp:1.0.0:*:*:*:*:*:*:*","purl":"pkg:npm/webapp@1.0.0","properties":[{"name":"syft:package:foundBy","value":"javascript-lock-cataloger"},{"name":"syft:package:language","value":"javascript"},{"name":"syft:package:type","value":"npm"},{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]},{"bom-ref":"os:alpine@3.19.1","type":"operating-system","name":"alpine","version":"3.19.1","description":"Alpine Linux v3.19","swid":{"tagId":"alpine","name":"alpine","version":"3.19.1"},"properties":[{"name":"syft:distro:extendedSupport","value":"false"},{"name":"syft:distro:id","value":"alpine"},{"name":"syft:distro:prettyName","value":"Alpine Linux v3.19"},{"name":"syft:distro:versionID","value":"3.19.1"}]},{"bom-ref":"c8bb62e6bded633e","type":"file","name":"/builds/acme/webapp/composer.lock","hashes":[{"alg":"SHA-1","content":"53c59ca459a8ff42918b5ed4fdcea1d8ba98432f"},{"alg":"SHA-256","content":"63738301cc7186a6645f00ae0a63ae6347e04552489f3cc70e92200463f5b4ac"}]},{"bom-ref":"3fc5a8d3d86e9790","type":"file","name":"/builds/acme/webapp/go.mod","hashes":[{"alg":"SHA-1","content":"c67c41265c415c41049b31f850b4497898eff1fd"},{"alg":"SHA-256","content":"f2303d4139e491238d8cb9a6c448e4c366b82bae3a646164465387df76e44f32"}]},{"bom-ref":"9f5aca292136191a","type":"file","name":"/builds/acme/webapp/lib/apk/db/installed","hashes":[{"alg":"SHA-1","content":"b2ebf78ea604d3a76359a262afd401ab966a36a1"},{"alg":"SHA-256","content":"d363cb96812dc6eb1d6f4c00879c2564cdbff238b6c1d0a37701d2adedd326e0"}]},{"bom-ref":"fd71c2238fc07657","type":"file","name":"/builds/acme/webapp/package-lock.json","hashes":[{"alg":"SHA-1","content":"fc26eadd11405354163eb0f53ffd2bb8cb56c5e7"},{"alg":"SHA-256","content":"69094786dd997053d1c7a81d4349bb5b608c08b576e67360f07cdc2726a041ea"}]}],"dependencies":[{"ref":"pkg:npm/webapp@1.0.0?package-id=982a05090388c92d","dependsOn":["pkg:npm/%40babel/core@7.23.0?package-id=7b01fd7a9ce63128","pkg:npm/lodash@4.17.20?package-id=ece3658907628a6b"]}]}
diff --git a/internal/jsonscan/jsonscan.go b/internal/jsonscan/jsonscan.go
deleted file mode 100644
index 7fcade7..0000000
--- a/internal/jsonscan/jsonscan.go
+++ /dev/null
@@ -1,309 +0,0 @@
-// Package jsonscan walks a JSON document in place, reporting where
-// object members sit as offsets into the original bytes. It lets a caller
-// read or replace one value in a large metadata document without decoding the
-// rest of it into Go values, which for an npm packument costs many times the
-// document's size.
-//
-// It checks structure only as far as it needs to find its way through
-// the document. Callers that copy unvisited bytes into a response validate the
-// whole document first with json.Valid.
-package jsonscan
-
-import (
- "bytes"
- "encoding/json"
- "errors"
-)
-
-// ErrMalformed is returned when the document is not well-formed JSON.
-var ErrMalformed = errors.New("malformed JSON")
-
-// ErrNotObject is returned when a value expected to be an object is not.
-var ErrNotObject = errors.New("JSON value is not an object")
-
-// ErrStop is returned by a callback to end a walk early once it has
-// found what it was looking for.
-var ErrStop = errors.New("stop scanning")
-
-// Member is one member of a JSON object as offsets into the bytes that
-// were scanned. The key span includes its quotes.
-type Member struct {
- KeyStart, KeyEnd int
- ValStart, ValEnd int
-}
-
-func (m Member) Key(data []byte) []byte { return data[m.KeyStart:m.KeyEnd] }
-func (m Member) Value(data []byte) []byte { return data[m.ValStart:m.ValEnd] }
-
-func skipJSONSpace(data []byte, i int) int {
- for i < len(data) {
- switch data[i] {
- case ' ', '\t', '\n', '\r':
- i++
- default:
- return i
- }
- }
- return i
-}
-
-// skipJSONString returns the index just past the string starting at data[i].
-func skipJSONString(data []byte, i int) (int, error) {
- for j := i + 1; j < len(data); j++ {
- switch data[j] {
- case '\\':
- j++
- case '"':
- return j + 1, nil
- }
- }
- return 0, ErrMalformed
-}
-
-// skipJSONValue returns the index just past the value starting at data[i].
-func skipJSONValue(data []byte, i int) (int, error) {
- if i >= len(data) {
- return 0, ErrMalformed
- }
- switch data[i] {
- case '"':
- return skipJSONString(data, i)
- case '{', '[':
- depth := 0
- for j := i; j < len(data); j++ {
- switch data[j] {
- case '"':
- end, err := skipJSONString(data, j)
- if err != nil {
- return 0, err
- }
- j = end - 1
- case '{', '[':
- depth++
- case '}', ']':
- depth--
- if depth == 0 {
- return j + 1, nil
- }
- }
- }
- return 0, ErrMalformed
- case '}', ']', ',', ':':
- return 0, ErrMalformed
- default:
- // A number, true, false or null runs to the next delimiter.
- j := i
- for j < len(data) {
- switch data[j] {
- case ',', '}', ']', ' ', '\t', '\n', '\r':
- return j, nil
- }
- j++
- }
- return j, nil
- }
-}
-
-// EachMember calls fn for each member of the object obj holds, in
-// document order. It returns ErrNotObject if obj is not an object, and
-// stops early with fn's error if fn returns one.
-func EachMember(obj []byte, fn func(Member) error) error {
- i := skipJSONSpace(obj, 0)
- if i >= len(obj) || obj[i] != '{' {
- return ErrNotObject
- }
- i = skipJSONSpace(obj, i+1)
- if i < len(obj) && obj[i] == '}' {
- return nil
- }
- for {
- if i >= len(obj) || obj[i] != '"' {
- return ErrMalformed
- }
- keyEnd, err := skipJSONString(obj, i)
- if err != nil {
- return err
- }
- colon := skipJSONSpace(obj, keyEnd)
- if colon >= len(obj) || obj[colon] != ':' {
- return ErrMalformed
- }
- valStart := skipJSONSpace(obj, colon+1)
- valEnd, err := skipJSONValue(obj, valStart)
- if err != nil {
- return err
- }
- if err := fn(Member{KeyStart: i, KeyEnd: keyEnd, ValStart: valStart, ValEnd: valEnd}); err != nil {
- return err
- }
- next := skipJSONSpace(obj, valEnd)
- if next >= len(obj) {
- return ErrMalformed
- }
- switch obj[next] {
- case ',':
- i = skipJSONSpace(obj, next+1)
- case '}':
- return nil
- default:
- return ErrMalformed
- }
- }
-}
-
-// EachElement calls fn with each element of the array arr holds, in
-// order, and stops early with fn's error if fn returns one.
-func EachElement(arr []byte, fn func(element []byte) error) error {
- i := skipJSONSpace(arr, 0)
- if i >= len(arr) || arr[i] != '[' {
- return ErrMalformed
- }
- i = skipJSONSpace(arr, i+1)
- if i < len(arr) && arr[i] == ']' {
- return nil
- }
- for {
- end, err := skipJSONValue(arr, i)
- if err != nil {
- return err
- }
- if err := fn(arr[i:end]); err != nil {
- return err
- }
- next := skipJSONSpace(arr, end)
- if next >= len(arr) {
- return ErrMalformed
- }
- switch arr[next] {
- case ',':
- i = skipJSONSpace(arr, next+1)
- case ']':
- return nil
- default:
- return ErrMalformed
- }
- }
-}
-
-// RewriteMembers copies the object obj holds to out byte for byte,
-// except that rewrite may write a member's value itself. It returns true
-// when it did; otherwise the original value is copied.
-func RewriteMembers(out *bytes.Buffer, obj []byte, rewrite func(out *bytes.Buffer, m Member) (bool, error)) error {
- copied := 0
- err := EachMember(obj, func(m Member) error {
- out.Write(obj[copied:m.ValStart])
- copied = m.ValStart
- rewritten, err := rewrite(out, m)
- if rewritten {
- copied = m.ValEnd
- }
- return err
- })
- out.Write(obj[copied:])
- return err
-}
-
-// Key decodes a quoted key as EachMember reports it.
-func Key(raw []byte) (string, error) {
- if bytes.IndexByte(raw, '\\') < 0 {
- return string(raw[1 : len(raw)-1]), nil
- }
- var key string
- err := json.Unmarshal(raw, &key)
- return key, err
-}
-
-// KeyIs reports whether a quoted key decodes to name.
-func KeyIs(raw []byte, name string) bool {
- if bytes.IndexByte(raw, '\\') < 0 {
- return string(raw[1:len(raw)-1]) == name
- }
- key, err := Key(raw)
- return err == nil && key == name
-}
-
-// StringIs reports whether raw is a JSON string that decodes to s.
-func StringIs(raw []byte, s string) bool {
- return len(raw) >= 2 && raw[0] == '"' && KeyIs(raw, s)
-}
-
-// FindMember returns the member of obj named name. When the key repeats,
-// the last one wins, as it does for JSON.parse and encoding/json.
-func FindMember(obj []byte, name string) (Member, bool, error) {
- var found Member
- ok := false
- err := EachMember(obj, func(m Member) error {
- if KeyIs(m.Key(obj), name) {
- found, ok = m, true
- }
- return nil
- })
- return found, ok, err
-}
-
-// Lookup follows path through nested objects in doc and returns the
-// value at its end. It returns nil and no error when a key along the path is
-// missing or names something other than an object.
-func Lookup(doc []byte, path ...string) ([]byte, error) {
- value := doc
- for _, name := range path {
- m, ok, err := FindMember(value, name)
- if errors.Is(err, ErrNotObject) {
- return nil, nil
- }
- if err != nil || !ok {
- return nil, err
- }
- value = m.Value(value)
- }
- return value, nil
-}
-
-// LookupString is Lookup for a string value. ok is false when the
-// value is missing or is not a string.
-func LookupString(doc []byte, path ...string) (string, bool, error) {
- raw, err := Lookup(doc, path...)
- if err != nil || len(raw) == 0 || raw[0] != '"' {
- return "", false, err
- }
- var s string
- if err := json.Unmarshal(raw, &s); err != nil {
- return "", false, err
- }
- return s, true, nil
-}
-
-// WriteFilteredObject writes the object obj holds, keeping the members
-// whose decoded key keep accepts, followed by extra string members.
-func WriteFilteredObject(out *bytes.Buffer, obj []byte, keep func(string) bool, extra [][2]string) error {
- out.WriteByte('{')
- first := true
- err := EachMember(obj, func(m Member) error {
- key, err := Key(m.Key(obj))
- if err != nil {
- return err
- }
- if !keep(key) {
- return nil
- }
- if !first {
- out.WriteByte(',')
- }
- first = false
- out.Write(obj[m.KeyStart:m.ValEnd])
- return nil
- })
- for _, member := range extra {
- if !first {
- out.WriteByte(',')
- }
- first = false
- key, _ := json.Marshal(member[0])
- value, _ := json.Marshal(member[1])
- out.Write(key)
- out.WriteByte(':')
- out.Write(value)
- }
- out.WriteByte('}')
- return err
-}
diff --git a/internal/jsonscan/jsonscan_test.go b/internal/jsonscan/jsonscan_test.go
deleted file mode 100644
index a14ac85..0000000
--- a/internal/jsonscan/jsonscan_test.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package jsonscan
-
-import (
- "bytes"
- "errors"
- "testing"
-)
-
-func TestLookupJSONString(t *testing.T) {
- doc := []byte(`{
- "name": "demo",
- "versions": {
- "1.0.0": {"dist": {"tarball": "https://example.com/a.tgz", "shasum": "x"}},
- "2.0.0": {"readme": "a } tricky \" string ] with {brackets}", "dist": {"tarball": "https://example.com/b.tgz"}},
- "3.0.0": "not an object",
- "4.0.0": {"dist": {"tarball": 42}}
- },
- "time": {"1.0.0": "2020-01-01T00:00:00Z", "1.0.0": "2021-01-01T00:00:00Z"},
- "n": -1.5e3, "t": true, "z": null, "list": [1, {"a": []}, "]"]
- }`)
-
- cases := []struct {
- path []string
- want string
- wantOK bool
- }{
- {[]string{"versions", "1.0.0", "dist", "tarball"}, "https://example.com/a.tgz", true},
- {[]string{"versions", "2.0.0", "dist", "tarball"}, "https://example.com/b.tgz", true},
- {[]string{"versions", "3.0.0", "dist", "tarball"}, "", false},
- {[]string{"versions", "4.0.0", "dist", "tarball"}, "", false},
- {[]string{"versions", "9.9.9", "dist", "tarball"}, "", false},
- {[]string{"time", "1.0.0"}, "2021-01-01T00:00:00Z", true}, // last duplicate wins
- {[]string{"name"}, "demo", true},
- {[]string{"name", "x"}, "", false},
- }
- for _, c := range cases {
- got, ok, err := LookupString(doc, c.path...)
- if err != nil || ok != c.wantOK || got != c.want {
- t.Errorf("lookup %v = %q, %v, %v; want %q, %v", c.path, got, ok, err, c.want, c.wantOK)
- }
- }
-}
-
-func TestLookupJSONEscapedKey(t *testing.T) {
- doc := []byte(`{"versions": {"1.0.0": {"dist": {"tarball": "u"}}}}`)
- got, ok, err := LookupString(doc, "versions", "1.0.0", "dist", "tarball")
- if err != nil || !ok || got != "u" {
- t.Errorf("lookup = %q, %v, %v", got, ok, err)
- }
-}
-
-func TestLookupJSONMalformed(t *testing.T) {
- for _, doc := range []string{
- `{"versions": {"1.0.0": {"dist": `,
- `{"versions" {}}`,
- `{"versions": {"a": 1 "b": 2}}`,
- `{"a": "unterminated}`,
- `{"a": [1, 2}`,
- } {
- _, _, err := LookupString([]byte(doc), "versions", "1.0.0", "dist", "tarball")
- if !errors.Is(err, ErrMalformed) {
- t.Errorf("%s: err = %v, want ErrMalformed", doc, err)
- }
- }
-}
-
-func TestForEachJSONMemberOffsets(t *testing.T) {
- doc := []byte(` { "a" : 1 , "b":{"c":[true,null]} ,"d":"x\"y" } `)
- var got [][2]string
- err := EachMember(doc, func(m Member) error {
- got = append(got, [2]string{string(m.Key(doc)), string(m.Value(doc))})
- return nil
- })
- want := [][2]string{{`"a"`, `1`}, {`"b"`, `{"c":[true,null]}`}, {`"d"`, `"x\"y"`}}
- if err != nil || len(got) != len(want) {
- t.Fatalf("members = %v, %v", got, err)
- }
- for i := range want {
- if got[i] != want[i] {
- t.Errorf("member %d = %v, want %v", i, got[i], want[i])
- }
- }
-
- if err := EachMember([]byte(`[1]`), func(Member) error { return nil }); !errors.Is(err, ErrNotObject) {
- t.Errorf("array: err = %v, want ErrNotObject", err)
- }
- if err := EachMember([]byte(`{}`), func(Member) error { t.Error("called for empty object"); return nil }); err != nil {
- t.Errorf("empty object: err = %v", err)
- }
-}
-
-func TestWriteFilteredJSONObject(t *testing.T) {
- var out bytes.Buffer
- err := WriteFilteredObject(&out, []byte(`{"a": 1, "b": {"x": 2}, "c": 3}`), func(k string) bool { return k != "b" }, nil)
- if err != nil || out.String() != `{"a": 1,"c": 3}` {
- t.Errorf("filtered = %s, %v", out.String(), err)
- }
-}
-
-func TestForEachJSONElement(t *testing.T) {
- var got []string
- err := EachElement([]byte(` [ 1, "a,]", {"b": [2]} ,[] ] `), func(e []byte) error {
- got = append(got, string(e))
- return nil
- })
- want := []string{`1`, `"a,]"`, `{"b": [2]}`, `[]`}
- if err != nil || len(got) != len(want) {
- t.Fatalf("elements = %q, %v; want %q", got, err, want)
- }
- for i := range want {
- if got[i] != want[i] {
- t.Errorf("element %d = %s, want %s", i, got[i], want[i])
- }
- }
-
- if err := EachElement([]byte(`[]`), func([]byte) error { t.Error("called for empty array"); return nil }); err != nil {
- t.Errorf("empty array: err = %v", err)
- }
- for _, bad := range []string{`{}`, `[1,`, `[1 2]`, `[1`} {
- if err := EachElement([]byte(bad), func([]byte) error { return nil }); err == nil {
- t.Errorf("%s: expected an error", bad)
- }
- }
-}
-
-func TestRewriteJSONMembers(t *testing.T) {
- obj := []byte(`{ "a": 1, "b": [2], "c" : 3 }`)
- var out bytes.Buffer
- err := RewriteMembers(&out, obj, func(out *bytes.Buffer, m Member) (bool, error) {
- if !KeyIs(m.Key(obj), "b") {
- return false, nil
- }
- out.WriteString(`"B"`)
- return true, nil
- })
- if want := `{ "a": 1, "b": "B", "c" : 3 }`; err != nil || out.String() != want {
- t.Errorf("rewritten = %s, %v; want %s", out.String(), err, want)
- }
-}
-
-func TestJSONStringIs(t *testing.T) {
- for raw, want := range map[string]bool{
- `"~dev"`: true,
- `"~\u0064ev"`: true,
- `"~dev2"`: false,
- `{"~dev":1}`: false,
- `"`: false,
- ``: false,
- `["~dev"]`: false,
- } {
- if got := StringIs([]byte(raw), "~dev"); got != want {
- t.Errorf("StringIs(%s) = %v, want %v", raw, got, want)
- }
- }
-}
diff --git a/internal/jsonstream/jsonstream.go b/internal/jsonstream/jsonstream.go
deleted file mode 100644
index cf213ce..0000000
--- a/internal/jsonstream/jsonstream.go
+++ /dev/null
@@ -1,172 +0,0 @@
-// Package jsonstream copies JSON objects from a reader to a writer one member
-// at a time, so a document can be filtered without holding it in memory.
-// Values that are kept are written with their original bytes.
-package jsonstream
-
-import (
- "bytes"
- "encoding/json/jsontext"
- "errors"
- "fmt"
- "io"
-)
-
-// ErrNotObject is returned when a value expected to be an object is not.
-var ErrNotObject = errors.New("JSON value is not an object")
-
-var (
- errConsumed = errors.New("jsonstream: member value already consumed")
- errUnconsumed = errors.New("jsonstream: Write before the member value was read")
-)
-
-// Member is one member of an object being copied. Its value must be consumed
-// exactly once, with Raw, Skip, Copy or Object.
-type Member struct {
- Key string
- rawKey []byte
- dec *jsontext.Decoder
- out *objectWriter
- consumed bool
-}
-
-// Raw reads the member's value without writing it. Follow it with Write to
-// keep the member. The bytes are only valid until the next member is read.
-func (m *Member) Raw() ([]byte, error) {
- if m.consumed {
- return nil, errConsumed
- }
- m.consumed = true
- return m.dec.ReadValue()
-}
-
-// Skip reads past the member's value, leaving the member out.
-func (m *Member) Skip() error {
- if m.consumed {
- return errConsumed
- }
- m.consumed = true
- return m.dec.SkipValue()
-}
-
-// Write writes the member with value as its raw JSON value.
-func (m *Member) Write(value []byte) error {
- if !m.consumed {
- return errUnconsumed
- }
- if err := m.out.key(m.rawKey); err != nil {
- return err
- }
- _, err := m.out.w.Write(value)
- return err
-}
-
-// Copy writes the member with its value unchanged.
-func (m *Member) Copy() error {
- raw, err := m.Raw()
- if err != nil {
- return err
- }
- return m.Write(raw)
-}
-
-// Object writes the member and copies its value, which must be an object,
-// calling fn for each of its members. A null value is written as null.
-func (m *Member) Object(fn func(*Member) error) error {
- if m.consumed {
- return errConsumed
- }
- m.consumed = true
- if err := m.out.key(m.rawKey); err != nil {
- return err
- }
- return copyObject(m.dec, m.out.w, fn, true)
-}
-
-// CopyObject copies the object read from r to w, calling fn for each member.
-// fn decides what is written by how it consumes the member's value; a member
-// whose value fn leaves unconsumed is copied unchanged. As with
-// encoding/json, of repeated names each is passed to fn, and invalid UTF-8
-// is accepted.
-func CopyObject(r io.Reader, w io.Writer, fn func(*Member) error) error {
- dec := jsontext.NewDecoder(r, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true))
- if err := copyObject(dec, w, fn, false); err != nil {
- return err
- }
- _, err := dec.ReadToken()
- switch err {
- case io.EOF:
- return nil
- case nil:
- return errors.New("unexpected data after the top-level object")
- }
- return err
-}
-
-func copyObject(dec *jsontext.Decoder, w io.Writer, fn func(*Member) error, allowNull bool) error {
- tok, err := dec.ReadToken()
- if err != nil {
- return err
- }
- if tok.Kind() == 'n' && allowNull {
- _, err := io.WriteString(w, "null")
- return err
- }
- if tok.Kind() != '{' {
- return ErrNotObject
- }
- if _, err := io.WriteString(w, "{"); err != nil {
- return err
- }
- out := &objectWriter{w: w}
- m := &Member{dec: dec, out: out}
- // keyBuf holds the current key; ReadValue's bytes don't outlast fn.
- var keyBuf []byte
- for dec.PeekKind() != '}' {
- // The key is read raw, so it is written with its own bytes.
- rawKey, err := dec.ReadValue()
- if err != nil {
- return err
- }
- if rawKey.Kind() != '"' {
- return fmt.Errorf("object key is %v, not a string", rawKey.Kind())
- }
- keyBuf = append(keyBuf[:0], rawKey...)
- // The decoder has checked the key, so the only error left is invalid
- // UTF-8, which AppendUnquote reports after replacing it with U+FFFD,
- // as encoding/json does.
- key, _ := jsontext.AppendUnquote(nil, keyBuf)
- *m = Member{Key: string(key), rawKey: keyBuf, dec: dec, out: out}
- if err := fn(m); err != nil {
- return err
- }
- if !m.consumed {
- if err := m.Copy(); err != nil {
- return err
- }
- }
- }
- if _, err := dec.ReadToken(); err != nil {
- return err
- }
- _, err = io.WriteString(w, "}")
- return err
-}
-
-type objectWriter struct {
- w io.Writer
- wrote bool
- buf bytes.Buffer
-}
-
-// key writes the separator and the key's bytes for the next member.
-func (o *objectWriter) key(rawKey []byte) error {
- o.buf.Reset()
- if o.wrote {
- o.buf.WriteByte(',')
- }
- o.buf.Write(rawKey)
- o.buf.WriteByte(':')
- o.wrote = true
- _, err := o.w.Write(o.buf.Bytes())
- return err
-}
diff --git a/internal/jsonstream/jsonstream_test.go b/internal/jsonstream/jsonstream_test.go
deleted file mode 100644
index cd201ae..0000000
--- a/internal/jsonstream/jsonstream_test.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package jsonstream
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "slices"
- "strings"
- "testing"
-)
-
-func copyString(t *testing.T, in string, fn func(*Member) error) (string, error) {
- t.Helper()
- var out bytes.Buffer
- err := CopyObject(strings.NewReader(in), &out, fn)
- return out.String(), err
-}
-
-func TestCopyObjectKeepsValueBytes(t *testing.T) {
- in := `{"a": 1.50, "b" : {"x": "<&>", "y":[1, 2]}, "c":null, "d":"é"}`
- got, err := copyString(t, in, func(m *Member) error { return nil })
- if err != nil {
- t.Fatal(err)
- }
- if want := `{"a":1.50,"b":{"x": "<&>", "y":[1, 2]},"c":null,"d":"é"}`; got != want {
- t.Errorf("got %s\nwant %s", got, want)
- }
-}
-
-func TestCopyObjectFiltersNested(t *testing.T) {
- in := `{"info":{"subdir":"noarch"},"packages":{"keep":{"n":1},"drop":{"n":2},"also":{"n":3}},"tail":[]}`
- got, err := copyString(t, in, func(m *Member) error {
- if m.Key != "packages" {
- return m.Copy()
- }
- return m.Object(func(entry *Member) error {
- raw, err := entry.Raw()
- if err != nil {
- return err
- }
- if entry.Key == "drop" {
- return nil
- }
- return entry.Write(raw)
- })
- })
- if err != nil {
- t.Fatal(err)
- }
- if want := `{"info":{"subdir":"noarch"},"packages":{"keep":{"n":1},"also":{"n":3}},"tail":[]}`; got != want {
- t.Errorf("got %s\nwant %s", got, want)
- }
- if !json.Valid([]byte(got)) {
- t.Error("output is not valid JSON")
- }
-}
-
-func TestCopyObjectSkipReplaceAndEmpty(t *testing.T) {
- got, err := copyString(t, `{"first":1,"secret":"x","n":2,"empty":{}}`, func(m *Member) error {
- switch m.Key {
- case "first", "secret":
- _, err := m.Raw()
- return err
- case "n":
- if _, err := m.Raw(); err != nil {
- return err
- }
- return m.Write([]byte(`3`))
- case "empty":
- return m.Object(func(*Member) error { return nil })
- }
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
- if want := `{"n":3,"empty":{}}`; got != want {
- t.Errorf("got %s, want %s", got, want)
- }
-}
-
-func TestCopyObjectKeys(t *testing.T) {
- got, err := copyString(t, `{"a 0 && limit < noLimit {
- return limit, true
- }
- }
- return 0, false
-}
-
-// ownCgroups returns the process's cgroup v2 path and its cgroup v1 memory
-// controller path from /proc/self/cgroup, each "" when not listed.
-func ownCgroups(fsys fs.FS) (v2, v1 string) {
- f, err := fsys.Open("proc/self/cgroup")
- if err != nil {
- return "", ""
- }
- defer func() { _ = f.Close() }()
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- // Lines are "hierarchy-ID:controller-list:cgroup-path".
- id, rest, ok := strings.Cut(scanner.Text(), ":")
- if !ok {
- continue
- }
- controllers, cgroup, ok := strings.Cut(rest, ":")
- if !ok {
- continue
- }
- switch {
- case id == "0" && controllers == "":
- v2 = cgroup
- case hasController(controllers, "memory"):
- v1 = cgroup
- }
- }
- return v2, v1
-}
-
-func hasController(list, name string) bool {
- for controller := range strings.SplitSeq(list, ",") {
- if controller == name {
- return true
- }
- }
- return false
-}
diff --git a/internal/memlimit/memlimit_test.go b/internal/memlimit/memlimit_test.go
deleted file mode 100644
index bf8474b..0000000
--- a/internal/memlimit/memlimit_test.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package memlimit
-
-import (
- "testing"
- "testing/fstest"
-)
-
-func TestCgroupLimit(t *testing.T) {
- const gib = 1 << 30
- for _, tt := range []struct {
- name string
- files map[string]string
- want int64
- }{
- {
- name: "cgroup v2 container",
- files: map[string]string{"proc/self/cgroup": "0::/\n", "sys/fs/cgroup/memory.max": "2147483648\n"},
- want: 2 * gib,
- },
- {
- name: "cgroup v2 own cgroup",
- files: map[string]string{
- "proc/self/cgroup": "0::/system.slice/proxy.service\n",
- "sys/fs/cgroup/system.slice/proxy.service/memory.max": "1073741824\n",
- },
- want: gib,
- },
- {
- name: "cgroup v2 unlimited",
- files: map[string]string{"proc/self/cgroup": "0::/\n", "sys/fs/cgroup/memory.max": "max\n"},
- },
- {
- name: "cgroup v1",
- files: map[string]string{
- "proc/self/cgroup": "12:cpu,cpuacct:/\n4:memory:/\n",
- "sys/fs/cgroup/memory/memory.limit_in_bytes": "536870912\n",
- },
- want: gib / 2,
- },
- {
- name: "cgroup v1 unlimited",
- files: map[string]string{
- "proc/self/cgroup": "4:memory:/\n",
- "sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712\n",
- },
- },
- {
- name: "malformed",
- files: map[string]string{"sys/fs/cgroup/memory.max": "lots\n"},
- },
- {
- name: "no cgroup files",
- },
- } {
- t.Run(tt.name, func(t *testing.T) {
- fsys := fstest.MapFS{}
- for name, data := range tt.files {
- fsys[name] = &fstest.MapFile{Data: []byte(data)}
- }
- got, ok := cgroupLimit(fsys)
- if got != tt.want || ok != (tt.want != 0) {
- t.Errorf("cgroupLimit = %d, %v; want %d", got, ok, tt.want)
- }
- })
- }
-}
diff --git a/internal/metadata/npm/denylist.go b/internal/metadata/npm/denylist.go
deleted file mode 100644
index d34d4d3..0000000
--- a/internal/metadata/npm/denylist.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package npm
-
-import "github.com/Masterminds/semver/v3"
-
-func (h *transformer) applyDenylistFiltering(metadata, versions map[string]any) {
- denied := h.DeniedVersions
- if len(denied) == 0 {
- return
- }
- times, _ := metadata["time"].(map[string]any)
- for version := range denied {
- delete(versions, version)
- delete(times, version)
- }
- tags, _ := metadata["dist-tags"].(map[string]any)
- for tag, value := range tags {
- version, _ := value.(string)
- if !denied[version] {
- continue
- }
- delete(tags, tag)
- // Custom tags describe publisher intent; do not retarget them. For
- // latest, use the highest remaining stable version, even in abbreviated
- // packuments without publication timestamps.
- if tag == "latest" {
- if latest := newestStableNPMVersion(versions); latest != "" {
- tags[tag] = latest
- }
- }
- }
-}
-
-func newestStableNPMVersion(versions map[string]any) string {
- var best *semver.Version
- result := ""
- for version := range versions {
- parsed, err := semver.StrictNewVersion(version)
- if err != nil || parsed.Prerelease() != "" {
- continue
- }
- if best == nil || parsed.GreaterThan(best) || (parsed.Equal(best) && version > result) {
- best, result = parsed, version
- }
- }
- return result
-}
diff --git a/internal/metadata/npm/imports_test.go b/internal/metadata/npm/imports_test.go
deleted file mode 100644
index 19f828c..0000000
--- a/internal/metadata/npm/imports_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package npm
-
-import (
- "os/exec"
- "slices"
- "strings"
- "testing"
-)
-
-// TestPortableImports keeps the package free of the proxy's infrastructure:
-// no handler, database, storage, metrics, HTTP or logging, so it builds for
-// targets such as WASI where those are not available.
-func TestPortableImports(t *testing.T) {
- goTool, err := exec.LookPath("go")
- if err != nil {
- t.Skip("go tool not found")
- }
- out, err := exec.Command(goTool, "list", "-deps", "-f", "{{.Standard}} {{.ImportPath}}", ".").Output()
- if err != nil {
- t.Fatalf("go list: %v", err)
- }
- allowed := []string{
- "github.com/Masterminds/semver/v3",
- "github.com/git-pkgs/cooldown",
- "github.com/git-pkgs/proxy/internal/jsonscan",
- "github.com/git-pkgs/proxy/internal/jsonstream",
- "github.com/git-pkgs/proxy/internal/metadata/npm",
- "github.com/git-pkgs/proxy/internal/packageurl",
- "github.com/git-pkgs/purl",
- "github.com/git-pkgs/vers",
- "github.com/package-url/packageurl-go",
- }
- // database/sql/driver comes in through semver, which implements its
- // Valuer interface; it is only interfaces, no driver.
- forbiddenStd := []string{"database/sql", "log/slog", "net", "net/http"}
- for line := range strings.Lines(string(out)) {
- standard, path, _ := strings.Cut(strings.TrimSpace(line), " ")
- if standard == "true" {
- if slices.Contains(forbiddenStd, path) {
- t.Errorf("imports %s", path)
- }
- continue
- }
- if !slices.Contains(allowed, path) {
- t.Errorf("imports %s, which is not on the portable list", path)
- }
- }
-}
diff --git a/internal/metadata/npm/metadata.go b/internal/metadata/npm/metadata.go
deleted file mode 100644
index 7fc4a40..0000000
--- a/internal/metadata/npm/metadata.go
+++ /dev/null
@@ -1,551 +0,0 @@
-// Package npm transforms registry metadata without storage or network access.
-package npm
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "maps"
- "net/url"
- "reflect"
- "slices"
- "sort"
- "strings"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/proxy/internal/jsonscan"
- "github.com/git-pkgs/proxy/internal/packageurl"
-)
-
-var ErrVersionDenied = errors.New("package version blocked by denylist")
-var ErrVersionNotFound = errors.New("version not found")
-
-// CooldownPolicy evaluates version age at the caller's supplied time.
-type CooldownPolicy interface {
- Enabled() bool
- Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision
-}
-
-// LocalVersion is an already approved hosted version and its publication time.
-type LocalVersion struct {
- Version string
- Entry json.RawMessage
- PublishedAt string
-}
-
-// Options supplies the public URL, policy and hosted versions for a rewrite.
-// EvaluatedAt is used as given; the transformation never reads the clock.
-type Options struct {
- ProxyURL string
- Cooldown CooldownPolicy
- DeniedVersions map[string]bool
- EvaluatedAt time.Time
- LocalVersions []LocalVersion
- // Optional callbacks report changes synchronously without retaining events.
- // OnTarballsRewritten is called at most once per document, with how many
- // tarball URLs it pointed at the proxy, and not at all when none.
- OnTarballsRewritten func(count int)
- OnCooldownFilter func(version, publishedAt string)
-}
-
-type transformer struct{ Options }
-
-// Rewrite filters upstream versions and points their tarball and attestation
-// URLs at the proxy, preserving untouched JSON values verbatim. Hosted entries
-// override upstream versions. The returned version set is unordered. The
-// document, denylist and hosted entries are not modified.
-func Rewrite(packageName string, body []byte, options Options) ([]byte, []string, error) {
- return (&transformer{options}).rewriteMetadataWithLocal(packageName, body, options.LocalVersions)
-}
-
-// Version resolves a version or dist-tag through the filtered packument.
-func Version(packageName, specifier string, packument []byte, options Options) ([]byte, error) {
- filtered, _, err := Rewrite(packageName, packument, options)
- if err != nil {
- return nil, err
- }
-
- target := specifier
- if resolved, ok, err := jsonscan.LookupString(filtered, "dist-tags", specifier); err != nil {
- return nil, err
- } else if ok {
- target = resolved
- }
-
- if options.DeniedVersions[target] {
- return nil, ErrVersionDenied
- }
-
- versionRaw, err := jsonscan.Lookup(filtered, "versions", target)
- if err != nil {
- return nil, err
- }
- if len(versionRaw) == 0 {
- return nil, ErrVersionNotFound
- }
-
- var out bytes.Buffer
- (&transformer{options}).writeNPMVersion(&out, packageName, target, versionRaw)
- return out.Bytes(), nil
-}
-
-// Abbreviated reports whether body is an abbreviated packument: one with the
-// top-level "modified" field the abbreviated format carries and no "time"
-// map, which cooldown needs. A full document without a time map, as some
-// registries serve, is not abbreviated, and neither is anything that is not
-// a JSON object.
-func Abbreviated(body []byte) bool {
- var hasTime, hasModified bool
- err := jsonscan.EachMember(body, func(m jsonscan.Member) error {
- switch key := m.Key(body); {
- case jsonscan.KeyIs(key, "time"):
- hasTime = true
- case jsonscan.KeyIs(key, "modified"):
- hasModified = true
- }
- return nil
- })
- return err == nil && hasModified && !hasTime
-}
-
-func (h *transformer) rewriteMetadataWithLocal(packageName string, body []byte, local []LocalVersion) ([]byte, []string, error) {
- doc, err := indexNPMPackument(body)
- if err != nil {
- return nil, nil, err
- }
-
- versionsRaw := doc.value(doc.versionsAt)
- if len(versionsRaw) == 0 || versionsRaw[0] != '{' {
- version, ok, err := jsonscan.LookupString(body, "version")
- if err != nil {
- return nil, nil, err
- }
- if ok {
- if h.DeniedVersions[version] {
- return nil, nil, ErrVersionDenied
- }
- var out bytes.Buffer
- if h.writeNPMVersion(&out, packageName, version, body) {
- h.tarballsRewritten(1)
- }
- return out.Bytes(), []string{version}, nil
- }
- if len(h.DeniedVersions) != 0 {
- return nil, nil, errors.New("npm metadata has no versions object")
- }
- return body, nil, nil // No versions to rewrite
- }
-
- // The filters only look at which versions exist, so they run on a map of
- // version names alongside the decoded time map and dist-tags.
- versions := map[string]any{}
- if err := jsonscan.EachMember(versionsRaw, func(m jsonscan.Member) error {
- version, err := jsonscan.Key(m.Key(versionsRaw))
- versions[version] = nil
- return err
- }); err != nil {
- return nil, nil, err
- }
- metadata := map[string]any{}
- timeMap := doc.decodeObject(doc.timeAt, metadata, "time")
- distTags := doc.decodeObject(doc.tagsAt, metadata, "dist-tags")
- timeEntries := len(timeMap)
- originalTags := maps.Clone(distTags)
-
- h.applyCooldownFiltering(metadata, versions, packageName)
- h.applyDenylistFiltering(metadata, versions)
- for _, e := range local {
- delete(versions, e.Version)
- }
-
- // Untouched members are copied as they are; the three the filters
- // handled are written from their filtered state, and only when it changed.
- out, err := doc.write(func(out *bytes.Buffer, i int) error {
- switch {
- case i == doc.versionsAt:
- return h.writeNPMVersions(out, packageName, versionsRaw, versions, local)
- case i == doc.timeAt && timeMap != nil && (len(timeMap) != timeEntries || len(local) > 0):
- pushedAt := make([][2]string, 0, len(local))
- for _, e := range local {
- pushedAt = append(pushedAt, [2]string{e.Version, e.PublishedAt})
- }
- return jsonscan.WriteFilteredObject(out, doc.value(i), func(key string) bool {
- _, ok := timeMap[key]
- pushed := slices.ContainsFunc(local, func(e LocalVersion) bool { return e.Version == key })
- return ok && !pushed
- }, pushedAt)
- case i == doc.tagsAt && distTags != nil && !reflect.DeepEqual(distTags, originalTags):
- encoded, err := json.Marshal(distTags)
- out.Write(encoded)
- return err
- default:
- out.Write(doc.value(i))
- return nil
- }
- })
- kept := slices.Collect(maps.Keys(versions))
- for _, e := range local {
- kept = append(kept, e.Version)
- }
- return out, kept, err
-}
-
-// npmPackument is a packument's top-level members as offsets into its bytes,
-// with the positions of the members rewriteMetadata filters, or -1 for those
-// it lacks. A key that repeats is recorded at its last occurrence, the one
-// JSON.parse keeps.
-type npmPackument struct {
- body []byte
- members []jsonscan.Member
- versionsAt, timeAt, tagsAt int
-}
-
-func indexNPMPackument(body []byte) (*npmPackument, error) {
- if !json.Valid(body) {
- return nil, errors.New("npm metadata is not valid JSON")
- }
- doc := &npmPackument{body: body, versionsAt: -1, timeAt: -1, tagsAt: -1}
- err := jsonscan.EachMember(body, func(m jsonscan.Member) error {
- switch key := m.Key(body); {
- case jsonscan.KeyIs(key, "versions"):
- doc.versionsAt = len(doc.members)
- case jsonscan.KeyIs(key, "time"):
- doc.timeAt = len(doc.members)
- case jsonscan.KeyIs(key, "dist-tags"):
- doc.tagsAt = len(doc.members)
- }
- doc.members = append(doc.members, m)
- return nil
- })
- return doc, err
-}
-
-// value returns the raw value of member i, or nil when i is -1.
-func (d *npmPackument) value(i int) []byte {
- if i < 0 {
- return nil
- }
- return d.members[i].Value(d.body)
-}
-
-// decodeObject decodes member i into metadata[name] and returns it when it is
-// an object. The filters edit the returned map in place.
-func (d *npmPackument) decodeObject(i int, metadata map[string]any, name string) map[string]any {
- raw := d.value(i)
- if raw == nil {
- return nil
- }
- var value any
- if err := json.Unmarshal(raw, &value); err != nil {
- return nil
- }
- metadata[name] = value
- object, _ := value.(map[string]any)
- return object
-}
-
-// write assembles the document in member order, with writeValue writing each
-// member's value. Earlier duplicates of the filtered keys are dropped: they
-// would carry unfiltered data, and JSON.parse ignores them anyway.
-func (d *npmPackument) write(writeValue func(out *bytes.Buffer, i int) error) ([]byte, error) {
- var out bytes.Buffer
- out.Grow(len(d.body) + len(d.body)/8)
- out.WriteByte('{')
- first := true
- for i, m := range d.members {
- if i != d.versionsAt && i != d.timeAt && i != d.tagsAt {
- key := m.Key(d.body)
- if jsonscan.KeyIs(key, "versions") || jsonscan.KeyIs(key, "time") || jsonscan.KeyIs(key, "dist-tags") {
- continue
- }
- }
- if !first {
- out.WriteByte(',')
- }
- first = false
- out.Write(m.Key(d.body))
- out.WriteByte(':')
- if err := writeValue(&out, i); err != nil {
- return nil, err
- }
- }
- out.WriteByte('}')
- return out.Bytes(), nil
-}
-
-// writeNPMVersions writes the versions object, keeping the versions still in
-// keep and pointing each kept version's tarball at this proxy, followed by the
-// pushed versions in local.
-func (h *transformer) writeNPMVersions(out *bytes.Buffer, packageName string, versionsRaw []byte, keep map[string]any, local []LocalVersion) error {
- out.WriteByte('{')
- first := true
- rewritten := 0
- err := jsonscan.EachMember(versionsRaw, func(m jsonscan.Member) error {
- version, err := jsonscan.Key(m.Key(versionsRaw))
- if err != nil {
- return err
- }
- if _, ok := keep[version]; !ok {
- return nil
- }
- if !first {
- out.WriteByte(',')
- }
- first = false
- out.Write(m.Key(versionsRaw))
- out.WriteByte(':')
- if h.writeNPMVersion(out, packageName, version, m.Value(versionsRaw)) {
- rewritten++
- }
- return nil
- })
- h.tarballsRewritten(rewritten)
- for _, e := range local {
- if !first {
- out.WriteByte(',')
- }
- first = false
- key, _ := json.Marshal(e.Version)
- out.Write(key)
- out.WriteByte(':')
- out.Write(e.Entry)
- }
- out.WriteByte('}')
- return err
-}
-
-// writeNPMVersion writes one version entry with its dist.tarball and
-// dist.attestations.url pointing at this proxy, and reports whether it
-// rewrote the tarball. Members that are missing or not strings are written
-// unchanged.
-func (h *transformer) writeNPMVersion(out *bytes.Buffer, packageName, version string, raw []byte) bool {
- dist, ok, err := jsonscan.FindMember(raw, "dist")
- if err != nil || !ok {
- out.Write(raw)
- return false
- }
- distRaw := dist.Value(raw)
-
- var buf [2]urlEdit
- edits := buf[:0]
- tarball, ok := stringMember(distRaw, "tarball")
- if ok {
- tarball.replacement = TarballURL(h.ProxyURL, packageName, version, tarball.old)
- edits = append(edits, tarball.shift(dist.ValStart))
- }
- if attestations, found, err := jsonscan.FindMember(distRaw, "attestations"); err == nil && found {
- if att, ok := stringMember(attestations.Value(distRaw), "url"); ok {
- att.replacement = AttestationsURL(h.ProxyURL, packageName, version)
- edits = append(edits, att.shift(dist.ValStart+attestations.ValStart))
- }
- }
- writeEdits(out, raw, edits)
- return tarball.replacement != ""
-}
-
-// urlEdit replaces the string value at raw[start:end], which decodes to old,
-// with replacement.
-type urlEdit struct {
- start, end int
- old string
- replacement string
-}
-
-func (e urlEdit) shift(offset int) urlEdit {
- e.start += offset
- e.end += offset
- return e
-}
-
-// stringMember finds the string member name in obj.
-func stringMember(obj []byte, name string) (urlEdit, bool) {
- m, ok, err := jsonscan.FindMember(obj, name)
- if err != nil || !ok || obj[m.ValStart] != '"' {
- return urlEdit{}, false
- }
- var old string
- if err := json.Unmarshal(m.Value(obj), &old); err != nil {
- return urlEdit{}, false
- }
- return urlEdit{start: m.ValStart, end: m.ValEnd, old: old}, true
-}
-
-// writeEdits writes raw with the edits applied. There are at most two, which
-// may come in either order.
-func writeEdits(out *bytes.Buffer, raw []byte, edits []urlEdit) {
- if len(edits) == 2 && edits[1].start < edits[0].start {
- edits[0], edits[1] = edits[1], edits[0]
- }
- pos := 0
- for _, e := range edits {
- encoded, err := json.Marshal(e.replacement)
- if err != nil {
- continue
- }
- out.Write(raw[pos:e.start])
- out.Write(encoded)
- pos = e.end
- }
- out.Write(raw[pos:])
-}
-
-// tarballsRewritten reports once per document how many tarball URLs it
-// rewrote. A report per version flooded debug logs: packages such as eslint
-// list hundreds of versions, and with cooldown on every request rewrites the
-// document again.
-func (h *transformer) tarballsRewritten(count int) {
- if count > 0 && h.OnTarballsRewritten != nil {
- h.OnTarballsRewritten(count)
- }
-}
-
-// applyCooldownFiltering removes versions that are too recently published,
-// and updates the dist-tags that named them.
-func (h *transformer) applyCooldownFiltering(metadata map[string]any, versions map[string]any, packageName string) {
- if h.Cooldown == nil || !h.Cooldown.Enabled() {
- return
- }
-
- timeMap, _ := metadata["time"].(map[string]any)
- if timeMap == nil {
- return
- }
-
- packagePURL := packageurl.MakeString("npm", packageName, "")
-
- withheld := map[string]bool{}
- for version := range versions {
- publishedStr, ok := timeMap[version].(string)
- if !ok {
- continue
- }
- publishedAt, err := time.Parse(time.RFC3339, publishedStr)
- if err != nil {
- continue
- }
- if !h.Cooldown.Evaluate("npm", packagePURL, publishedAt, h.EvaluatedAt).Allowed {
- if h.OnCooldownFilter != nil {
- h.OnCooldownFilter(version, publishedStr)
- }
- delete(versions, version)
- delete(timeMap, version)
- withheld[version] = true
- }
- }
-
- h.updateDistTags(metadata, versions, timeMap, withheld)
-}
-
-// updateDistTags moves dist-tags.latest to the newest remaining version if
-// the current latest was removed by cooldown filtering, and, as the denylist
-// does, drops other tags that name a withheld version.
-func (h *transformer) updateDistTags(metadata, versions, timeMap map[string]any, withheld map[string]bool) {
- distTags, ok := metadata["dist-tags"].(map[string]any)
- if !ok {
- return
- }
-
- for tag, value := range distTags {
- version, _ := value.(string)
- if tag != "latest" && withheld[version] {
- delete(distTags, tag)
- }
- }
-
- latest, ok := distTags["latest"].(string)
- if !ok {
- return
- }
-
- if _, exists := versions[latest]; exists {
- return
- }
-
- if newLatest := h.findNewestVersion(versions, timeMap); newLatest != "" {
- distTags["latest"] = newLatest
- } else if withheld[latest] {
- delete(distTags, "latest")
- }
-}
-
-// TarballURL returns the proxy URL that serves the tarball upstream
-// lists at tarball for this version.
-func TarballURL(proxyURL, packageName, version, tarball string) string {
- filename := tarball
- if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
- filename = tarball[idx+1:]
- }
- if VersionFromFilename(packageName, filename) != version {
- _, shortName, scoped := strings.Cut(packageName, "/")
- if !scoped {
- shortName = packageName
- }
- filename = shortName + "-" + version + ".tgz"
- }
-
- return fmt.Sprintf("%s/npm/%s/-/%s", proxyURL, url.PathEscape(packageName), filename)
-}
-
-// AttestationsURL returns the proxy URL that serves the attestations of a
-// version, in the form the npm registry uses: the package name with its scope
-// slash encoded, "@", and the version.
-func AttestationsURL(proxyURL, packageName, version string) string {
- return fmt.Sprintf("%s/npm/-/npm/v1/attestations/%s@%s", proxyURL, url.PathEscape(packageName), url.PathEscape(version))
-}
-
-// findNewestVersion returns the version string with the most recent timestamp
-// from the remaining versions, using the time map.
-func (h *transformer) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
- if timeMap == nil {
- return ""
- }
-
- type versionTime struct {
- version string
- t time.Time
- }
-
- var vts []versionTime
- for v := range versions {
- if ts, ok := timeMap[v].(string); ok {
- if t, err := time.Parse(time.RFC3339, ts); err == nil {
- vts = append(vts, versionTime{v, t})
- }
- }
- }
-
- if len(vts) == 0 {
- return ""
- }
-
- sort.Slice(vts, func(i, j int) bool {
- return vts[i].t.After(vts[j].t)
- })
-
- return vts[0].version
-}
-
-// VersionFromFilename extracts a version from an npm tarball filename.
-func VersionFromFilename(packageName, filename string) string {
- // Remove .tgz extension
- if !strings.HasSuffix(filename, ".tgz") {
- return ""
- }
- base := strings.TrimSuffix(filename, ".tgz")
-
- // For scoped packages, the filename uses the short name
- shortName := packageName
- if _, name, scoped := strings.Cut(packageName, "/"); scoped {
- shortName = name
- }
-
- // Expected format: {shortName}-{version}
- prefix := shortName + "-"
- if !strings.HasPrefix(base, prefix) {
- return ""
- }
-
- return strings.TrimPrefix(base, prefix)
-}
diff --git a/internal/metadata/npm/metadata_test.go b/internal/metadata/npm/metadata_test.go
deleted file mode 100644
index 2748ed3..0000000
--- a/internal/metadata/npm/metadata_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package npm
-
-import (
- "encoding/json"
- "fmt"
- "github.com/git-pkgs/cooldown"
- "strings"
- "testing"
- "time"
-)
-
-// TestNPMRewriteMetadataKeepsUpstreamBytes checks that only tarball URLs
-// change: everything else, including numbers encoding/json would round
-// through float64 and characters it would HTML-escape, is copied verbatim.
-func TestNPMRewriteMetadataKeepsUpstreamBytes(t *testing.T) {
- options := Options{ProxyURL: "http://proxy"}
- input := `{"name":"demo","_big":12345678901234567890,"readme":"&",` +
- `"versions":{"1.0.0":{"version":"1.0.0","dist":{"shasum":"s","tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz","integrity":"i"}}}}`
-
- out, _, err := Rewrite("demo", []byte(input), options)
- if err != nil {
- t.Fatal(err)
- }
- want := strings.Replace(input, "https://registry.npmjs.org/demo/-/demo-1.0.0.tgz", "http://proxy/npm/demo/-/demo-1.0.0.tgz", 1)
- if string(out) != want {
- t.Errorf("rewrite =\n%s\nwant\n%s", out, want)
- }
-}
-
-func TestNPMRewriteMetadataUnusualEntries(t *testing.T) {
- options := Options{ProxyURL: "http://proxy"}
- input := `{
- "versions": {
- "1.0.0": "not an object",
- "2.0.0": {"dist": "no object either"},
- "3.0.0": {"dist": {"tarball": 7}},
- "4.0.0": {"dist": {"tarball": "https://registry.npmjs.org/demo/-/renamed.tgz"}},
- "5.0.0": {"dist": {}}
- }
- }`
- out, _, err := Rewrite("demo", []byte(input), options)
- if err != nil {
- t.Fatal(err)
- }
- var doc struct {
- Versions map[string]json.RawMessage `json:"versions"`
- }
- if err := json.Unmarshal(out, &doc); err != nil {
- t.Fatalf("output is not JSON: %v\n%s", err, out)
- }
- for v, want := range map[string]string{
- "1.0.0": `"not an object"`,
- "2.0.0": `{"dist": "no object either"}`,
- "3.0.0": `{"dist": {"tarball": 7}}`,
- "4.0.0": `{"dist": {"tarball": "http://proxy/npm/demo/-/demo-4.0.0.tgz"}}`,
- "5.0.0": `{"dist": {}}`,
- } {
- if got := string(doc.Versions[v]); got != want {
- t.Errorf("%s = %s, want %s", v, got, want)
- }
- }
-}
-
-func TestNPMRewriteMetadataInvalidJSON(t *testing.T) {
- options := Options{ProxyURL: "http://proxy"}
- for _, input := range []string{`{"versions": {}`, `[1]`, `{"versions": {}} trailing`} {
- if _, _, err := Rewrite("demo", []byte(input), options); err == nil {
- t.Errorf("%s: expected an error", input)
- }
- }
-}
-
-func TestNPMRewriteMetadataNoVersions(t *testing.T) {
- options := Options{ProxyURL: "http://proxy"}
- input := `{"name":"demo","versions":[]}`
- out, _, err := Rewrite("demo", []byte(input), options)
- if err != nil || string(out) != input {
- t.Errorf("rewrite = %s, %v; want input unchanged", out, err)
- }
-
- options.DeniedVersions = map[string]bool{"1.0.0": true}
- if _, _, err := Rewrite("demo", []byte(input), options); err == nil {
- t.Error("expected an error when a denylisted package has no versions object")
- }
-}
-
-// TestNPMRewriteMetadataCooldownAndDenylist runs both filters together and
-// checks that versions, their time entries and the tags pointing at them are
-// all removed, with latest moved to the newest version left.
-func TestNPMRewriteMetadataCooldownAndDenylist(t *testing.T) {
- now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
- ts := func(age time.Duration) string { return now.Add(-age).UTC().Format(time.RFC3339) }
-
- options := Options{ProxyURL: "http://proxy", EvaluatedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}
- options.Cooldown = &cooldown.Config{Default: "3d"}
- options.DeniedVersions = map[string]bool{"2.0.0": true}
-
- version := func(v string) string {
- return fmt.Sprintf(`%q:{"version":%q,"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-%s.tgz"}}`, v, v, v)
- }
- input := `{"name":"demo",` +
- `"dist-tags":{"latest":"3.0.0","beta":"2.0.0","next":"1.0.0"},` +
- `"versions":{` + version("1.0.0") + `,` + version("2.0.0") + `,` + version("3.0.0") + `},` +
- `"time":{"created":"` + ts(1000*time.Hour) + `","1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}}`
-
- out, _, err := Rewrite("demo", []byte(input), options)
- if err != nil {
- t.Fatal(err)
- }
- var doc struct {
- DistTags map[string]string `json:"dist-tags"`
- Versions map[string]json.RawMessage `json:"versions"`
- Time map[string]string `json:"time"`
- }
- if err := json.Unmarshal(out, &doc); err != nil {
- t.Fatalf("output is not JSON: %v\n%s", err, out)
- }
- if len(doc.Versions) != 1 || doc.Versions["1.0.0"] == nil {
- t.Errorf("versions = %v, want only 1.0.0", doc.Versions)
- }
- if _, ok := doc.Time["2.0.0"]; ok {
- t.Error("time still lists denylisted 2.0.0")
- }
- if _, ok := doc.Time["3.0.0"]; ok {
- t.Error("time still lists 3.0.0, which is inside cooldown")
- }
- if _, ok := doc.Time["created"]; !ok {
- t.Error("time lost its created entry")
- }
- want := map[string]string{"latest": "1.0.0", "next": "1.0.0"}
- if fmt.Sprint(doc.DistTags) != fmt.Sprint(want) {
- t.Errorf("dist-tags = %v, want %v", doc.DistTags, want)
- }
- if !strings.Contains(string(doc.Versions["1.0.0"]), "http://proxy/npm/demo/-/demo-1.0.0.tgz") {
- t.Errorf("1.0.0 tarball not rewritten: %s", doc.Versions["1.0.0"])
- }
-}
-
-// TestNPMRewriteMetadataDuplicateKeys checks that a repeated top-level key
-// cannot smuggle an unfiltered copy of the versions past the filters.
-func TestNPMRewriteMetadataDuplicateKeys(t *testing.T) {
- options := Options{ProxyURL: "http://proxy", EvaluatedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}
- options.DeniedVersions = map[string]bool{"2.0.0": true}
- input := `{"versions":{"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}},` +
- `"versions":{"1.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}},"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}}}`
- out, _, err := Rewrite("demo", []byte(input), options)
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(string(out), "2.0.0") || strings.Count(string(out), `"versions"`) != 1 {
- t.Errorf("rewrite = %s", out)
- }
-}
-
-func TestAbbreviated(t *testing.T) {
- for body, want := range map[string]bool{
- `{"name":"demo","modified":"2024-01-01T00:00:00Z","versions":{}}`: true,
- `{"name":"demo","modified":"2024-01-01T00:00:00Z","time":{},"versions":{}}`: false,
- `{"name":"demo","versions":{}}`: false,
- `["modified"]`: false,
- `{"modified":`: false,
- } {
- if got := Abbreviated([]byte(body)); got != want {
- t.Errorf("Abbreviated(%s) = %v, want %v", body, got, want)
- }
- }
-}
-
-// TestNPMRewriteAttestationsURL checks that dist.attestations.url points at
-// the proxy whichever order dist lists its members in, and that nothing else
-// in dist changes.
-func TestNPMRewriteAttestationsURL(t *testing.T) {
- options := Options{ProxyURL: "http://proxy"}
- tests := []struct{ name, version, dist, want string }{
- {"demo", "1.0.0",
- `{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz","attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/demo@1.0.0","provenance":{"predicateType":"p"}}}`,
- `{"tarball":"http://proxy/npm/demo/-/demo-1.0.0.tgz","attestations":{"url":"http://proxy/npm/-/npm/v1/attestations/demo@1.0.0","provenance":{"predicateType":"p"}}}`},
- {"@scope/demo", "2.0.0-rc.1",
- `{"attestations":{"provenance":{},"url":"https://registry.npmjs.org/-/npm/v1/attestations/@scope%2fdemo@2.0.0-rc.1"},"tarball":"https://registry.npmjs.org/@scope/demo/-/demo-2.0.0-rc.1.tgz"}`,
- `{"attestations":{"provenance":{},"url":"http://proxy/npm/-/npm/v1/attestations/@scope%2Fdemo@2.0.0-rc.1"},"tarball":"http://proxy/npm/@scope%2Fdemo/-/demo-2.0.0-rc.1.tgz"}`},
- {"demo", "3.0.0",
- `{"attestations":{"url":7}}`,
- `{"attestations":{"url":7}}`},
- }
- for _, tt := range tests {
- input := `{"versions":{"` + tt.version + `":{"dist":` + tt.dist + `}}}`
- out, _, err := Rewrite(tt.name, []byte(input), options)
- if err != nil {
- t.Fatal(err)
- }
- want := `{"versions":{"` + tt.version + `":{"dist":` + tt.want + `}}}`
- if string(out) != want {
- t.Errorf("%s@%s: rewrite =\n%s\nwant\n%s", tt.name, tt.version, out, want)
- }
- }
-}
-
-// TestNPMRewriteCooldownDropsTags checks that cooldown, like the denylist,
-// drops a tag other than latest that names a version it withholds, in both
-// the in-memory and the streamed rewrite.
-func TestNPMRewriteCooldownDropsTags(t *testing.T) {
- now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
- ts := func(age time.Duration) string { return now.Add(-age).UTC().Format(time.RFC3339) }
- options := Options{ProxyURL: "http://proxy", EvaluatedAt: now, Cooldown: &cooldown.Config{Default: "3d"}}
- input := `{"name":"demo",` +
- `"dist-tags":{"latest":"3.0.0","next":"3.0.0","legacy":"1.0.0"},` +
- `"versions":{"1.0.0":{},"2.0.0":{},"3.0.0":{}},` +
- `"time":{"1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}}`
-
- rewrite := map[string]func() ([]byte, []string, error){
- "memory": func() ([]byte, []string, error) { return Rewrite("demo", []byte(input), options) },
- "stream": func() ([]byte, []string, error) { return rewriteStream("demo", []byte(input), options) },
- }
- for name, run := range rewrite {
- t.Run(name, func(t *testing.T) {
- out, _, err := run()
- if err != nil {
- t.Fatal(err)
- }
- var doc struct {
- DistTags map[string]string `json:"dist-tags"`
- }
- if err := json.Unmarshal(out, &doc); err != nil {
- t.Fatalf("output is not JSON: %v\n%s", err, out)
- }
- want := map[string]string{"latest": "2.0.0", "legacy": "1.0.0"}
- if fmt.Sprint(doc.DistTags) != fmt.Sprint(want) {
- t.Errorf("dist-tags = %v, want %v", doc.DistTags, want)
- }
- })
- }
-}
diff --git a/internal/metadata/npm/npmbench/npmbench.go b/internal/metadata/npm/npmbench/npmbench.go
deleted file mode 100644
index cc4ad57..0000000
--- a/internal/metadata/npm/npmbench/npmbench.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Package npmbench builds npm packuments for benchmarks.
-package npmbench
-
-import (
- "fmt"
- "strings"
-)
-
-// readmeSentences makes each version's readme about 4KB.
-const readmeSentences = 160
-
-// Packument builds a full packument shaped like a large real one: versions
-// 1.0.0 to 1.0.{versions-1}, each with dependencies, scripts and a readme,
-// and a time entry for each.
-func Packument(versions int) []byte {
- var b strings.Builder
- readme := strings.Repeat("Some documentation text. ", readmeSentences)
- b.WriteString(`{"_id":"big","name":"big","dist-tags":{"latest":"1.0.` + fmt.Sprint(versions-1) + `"},"versions":{`)
- for i := range versions {
- if i > 0 {
- b.WriteByte(',')
- }
- v := fmt.Sprintf("1.0.%d", i)
- fmt.Fprintf(&b, `%q:{"name":"big","version":%q,"description":"A big package","main":"index.js",`+
- `"scripts":{"test":"node test.js","build":"tsc -p ."},"dependencies":{`, v, v)
- for d := range 25 {
- if d > 0 {
- b.WriteByte(',')
- }
- fmt.Fprintf(&b, `"dep-%d":"^%d.0.0"`, d, d)
- }
- fmt.Fprintf(&b, `},"readme":%q,"dist":{"shasum":"0123456789abcdef0123456789abcdef01234567",`+
- `"integrity":"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",`+
- `"tarball":"https://registry.npmjs.org/big/-/big-%s.tgz"}}`, readme, v)
- }
- b.WriteString(`},"time":{"created":"2015-01-01T00:00:00.000Z"`)
- for i := range versions {
- fmt.Fprintf(&b, `,"1.0.%d":"2016-01-01T00:00:00.000Z"`, i)
- }
- b.WriteString(`}}`)
- return []byte(b.String())
-}
diff --git a/internal/metadata/npm/policy_test.go b/internal/metadata/npm/policy_test.go
deleted file mode 100644
index 7726d29..0000000
--- a/internal/metadata/npm/policy_test.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package npm
-
-import (
- "bytes"
- _ "embed"
- "encoding/json"
- "errors"
- "slices"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-//go:embed testdata/packument.json
-var packument []byte
-
-func TestRewriteCallbacks(t *testing.T) {
- options := Options{ProxyURL: "https://proxy.example", Cooldown: &cooldown.Config{Default: "7d"},
- DeniedVersions: map[string]bool{"2.0.0": true}, EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)}
- want, kept, err := Rewrite("@example/widgets", packument, options)
- if err != nil {
- t.Fatal(err)
- }
- var rewrites, filtered int
- options.OnTarballsRewritten = func(count int) {
- rewrites++
- if count != 1 {
- t.Errorf("rewrite count = %d, want 1", count)
- }
- }
- options.OnCooldownFilter = func(version, publishedAt string) {
- filtered++
- if version != "3.0.0" || publishedAt != "2099-01-01T00:00:00Z" {
- t.Errorf("filter event: %s, %s", version, publishedAt)
- }
- }
- out, gotKept, err := Rewrite("@example/widgets", packument, options)
- if err != nil || !bytes.Equal(out, want) || !slices.Equal(gotKept, kept) || rewrites != 1 || filtered != 1 {
- t.Fatalf("callbacks: rewrites=%d, filtered=%d, kept=%v, error=%v", rewrites, filtered, gotKept, err)
- }
-}
-
-func TestRewriteHostedOverride(t *testing.T) {
- options := Options{
- ProxyURL: "https://proxy.example",
- Cooldown: &cooldown.Config{Default: "7d"},
- DeniedVersions: map[string]bool{"2.0.0": true},
- EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
- LocalVersions: []LocalVersion{{Version: "3.0.0", PublishedAt: "2030-01-01T00:00:00Z",
- Entry: json.RawMessage(`{"version":"3.0.0","dist":{"tarball":"https://proxy.example/npm/@example%2Fwidgets/-/widgets-3.0.0.tgz","integrity":"sha512-local"}}`)}},
- }
- before := bytes.Clone(packument)
- localBefore := bytes.Clone(options.LocalVersions[0].Entry)
- out, kept, err := Rewrite("@example/widgets", packument, options)
- if err != nil {
- t.Fatal(err)
- }
- slices.Sort(kept)
- if !slices.Equal(kept, []string{"1.0.0", "3.0.0"}) {
- t.Fatalf("kept = %v", kept)
- }
- var doc struct {
- Versions map[string]json.RawMessage `json:"versions"`
- Tags map[string]string `json:"dist-tags"`
- Time map[string]string `json:"time"`
- }
- if err := json.Unmarshal(out, &doc); err != nil {
- t.Fatal(err)
- }
- if len(doc.Versions) != 2 || !bytes.Equal(doc.Versions["3.0.0"], localBefore) || doc.Time["3.0.0"] != options.LocalVersions[0].PublishedAt {
- t.Fatalf("hosted override lost: %s", out)
- }
- if len(doc.Tags) != 1 || doc.Tags["latest"] != "1.0.0" || doc.Time["2.0.0"] != "" || doc.Time["created"] == "" {
- t.Fatalf("policy output: %s", out)
- }
- for _, want := range []string{"12345678901234567890", "Widgets & tools", "https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"} {
- if !strings.Contains(string(out), want) {
- t.Errorf("output lost %q", want)
- }
- }
- if !bytes.Equal(packument, before) || !bytes.Equal(options.LocalVersions[0].Entry, localBefore) || len(options.DeniedVersions) != 1 || !options.DeniedVersions["2.0.0"] {
- t.Fatal("rewrite mutated its input")
- }
- again, _, err := Rewrite("@example/widgets", packument, options)
- if err != nil || !bytes.Equal(out, again) {
- t.Fatalf("repeat changed output: %v", err)
- }
-}
-
-func TestVersionPolicyErrors(t *testing.T) {
- options := Options{ProxyURL: "https://proxy.example", Cooldown: &cooldown.Config{Default: "7d"},
- DeniedVersions: map[string]bool{"2.0.0": true}, EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)}
- for _, tc := range []struct {
- specifier string
- want error
- }{
- {"1.0.0", nil}, {"latest", nil}, {"2.0.0", ErrVersionDenied}, {"3.0.0", ErrVersionNotFound}, {"beta", ErrVersionNotFound}, {"missing", ErrVersionNotFound},
- } {
- t.Run(tc.specifier, func(t *testing.T) {
- var counts []int
- options := options
- options.OnTarballsRewritten = func(count int) { counts = append(counts, count) }
- out, err := Version("@example/widgets", tc.specifier, packument, options)
- if !errors.Is(err, tc.want) {
- t.Fatalf("error = %v, want %v", err, tc.want)
- }
- // The packument is reported once; picking one version out of it
- // is not a second report.
- if !slices.Equal(counts, []int{1}) {
- t.Errorf("rewrite reports = %v, want [1]", counts)
- }
- if tc.want == nil && (!bytes.Contains(out, []byte(`"version": "1.0.0"`)) || !bytes.Contains(out, []byte("https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"))) {
- t.Fatalf("resolved version: %s", out)
- }
- })
- }
-}
-
-func TestRewriteCooldownEvaluationTime(t *testing.T) {
- body := []byte(`{"versions":{"1.0.0":{}},"time":{"1.0.0":"2030-01-01T00:00:00Z"}}`)
- boundary := time.Date(2030, 1, 8, 0, 0, 0, 0, time.UTC)
- options := Options{Cooldown: &cooldown.Config{Default: "7d"}}
- for _, tc := range []struct {
- at time.Time
- count int
- }{
- {boundary.Add(-time.Nanosecond), 0}, {boundary, 1}, {boundary.Add(time.Second), 1},
- } {
- options.EvaluatedAt = tc.at
- _, kept, err := Rewrite("@example/widgets", body, options)
- if err != nil || len(kept) != tc.count {
- t.Errorf("at %s: kept %v, error %v", tc.at, kept, err)
- }
- }
- options.Cooldown = &cooldown.Config{Default: "7d", Packages: map[string]string{"pkg:npm/%40example/widgets": "0"}}
- options.EvaluatedAt = boundary.Add(-time.Hour)
- _, kept, err := Rewrite("@example/widgets", body, options)
- if err != nil || len(kept) != 1 {
- t.Fatalf("scoped exemption: kept %v, error %v", kept, err)
- }
-}
-
-func TestRewriteVersionDocument(t *testing.T) {
- body := []byte(`{"name":"@example/widgets","version":"1.0.0","dist":{"tarball":"https://cdn.example/abcdef.tgz"}}`)
- options := Options{ProxyURL: "https://proxy.example"}
- out, kept, err := Rewrite("@example/widgets", body, options)
- if err != nil || !slices.Equal(kept, []string{"1.0.0"}) || !bytes.Contains(out, []byte("https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz")) {
- t.Fatalf("rewrite: %s, %v, %v", out, kept, err)
- }
- var counts []int
- options.OnTarballsRewritten = func(count int) { counts = append(counts, count) }
- if _, _, err := Rewrite("@example/widgets", body, options); err != nil || !slices.Equal(counts, []int{1}) {
- t.Fatalf("version document reported %v, %v", counts, err)
- }
- options.DeniedVersions = map[string]bool{"1.0.0": true}
- if _, _, err := Rewrite("@example/widgets", body, options); !errors.Is(err, ErrVersionDenied) {
- t.Fatalf("denied version: %v", err)
- }
-}
diff --git a/internal/metadata/npm/stream.go b/internal/metadata/npm/stream.go
deleted file mode 100644
index ba847df..0000000
--- a/internal/metadata/npm/stream.go
+++ /dev/null
@@ -1,426 +0,0 @@
-package npm
-
-import (
- "bytes"
- "encoding/json"
- "encoding/json/jsontext"
- "errors"
- "fmt"
- "io"
- "maps"
- "reflect"
- "slices"
-
- "github.com/git-pkgs/proxy/internal/jsonstream"
-)
-
-// ErrNotStreamable is returned by RewriteStream for a document it leaves to
-// Rewrite: one without a versions object, such as a single version's
-// metadata, or a rewrite that lists pushed versions.
-var ErrNotStreamable = errors.New("npm metadata can't be rewritten as a stream")
-
-// Scan is what a first pass over a packument learns: the version names, and
-// the time map and dist-tags the filters work on. It holds no version
-// entries, so it stays small whatever the size of the packument.
-type Scan struct {
- versions []string
- versionsObject bool
- time any
- distTags any
- // occurrences counts each filtered key, so the second pass can drop the
- // earlier duplicates, as Rewrite does.
- occurrences map[string]int
- hasModified bool
-}
-
-// Abbreviated reports whether the scanned document is an abbreviated
-// packument, as the package function Abbreviated does for a document in
-// memory.
-func (s *Scan) Abbreviated() bool {
- return s.hasModified && s.occurrences["time"] == 0
-}
-
-// ScanPackument reads a packument once, keeping only what RewriteStream
-// needs to decide which versions remain. Version entries are read one at a
-// time and dropped.
-func ScanPackument(r io.Reader) (*Scan, error) {
- s := &Scan{occurrences: map[string]int{}}
- dec := newDecoder(r)
- if err := expectDelim(dec, '{'); err != nil {
- return nil, err
- }
- for dec.PeekKind() != '}' {
- key, err := objectKey(dec)
- if err != nil {
- return nil, err
- }
- switch key {
- case "versions":
- s.occurrences[key]++
- s.versions, s.versionsObject, err = objectKeys(dec)
- case "time":
- s.occurrences[key]++
- s.time = nil
- err = decodeValue(dec, &s.time)
- case "dist-tags":
- s.occurrences[key]++
- s.distTags = nil
- err = decodeValue(dec, &s.distTags)
- default:
- if key == "modified" {
- s.hasModified = true
- }
- err = dec.SkipValue()
- }
- if err != nil {
- return nil, err
- }
- }
- if err := expectDelim(dec, '}'); err != nil {
- return nil, err
- }
- return s, expectEnd(dec)
-}
-
-// RewriteStream is Rewrite for a packument too large to hold in memory. scan
-// is the result of ScanPackument over the same bytes r reads again; the
-// caller checks they are the same. The rewritten document is written to w
-// with the bytes of untouched values copied as they are, and its output is
-// the same as Rewrite's for the same document and options. It returns the
-// versions it kept. For a document without a versions object, or options
-// listing pushed versions, it returns ErrNotStreamable before writing.
-func RewriteStream(packageName string, scan *Scan, r io.Reader, w io.Writer, options Options) ([]string, error) {
- if !scan.versionsObject || len(options.LocalVersions) > 0 {
- return nil, ErrNotStreamable
- }
- h := &transformer{options}
- f := scan.filter(h, packageName)
- sw := &streamWriter{
- transformer: h,
- packageName: packageName,
- scan: scan,
- filtered: f,
- seen: map[string]int{},
- }
- if err := jsonstream.CopyObject(r, w, sw.member); err != nil {
- return nil, err
- }
- h.tarballsRewritten(sw.rewritten)
- return slices.Collect(maps.Keys(f.versions)), nil
-}
-
-// filtered is what cooldown and the denylist leave of a scanned packument.
-type filtered struct {
- versions map[string]any
- timeMap, distTags map[string]any
- timeChanged, tagsChanged bool
-}
-
-// filter runs the filters Rewrite runs over what scan collected. The scan is
-// left as it is, so it can be filtered again.
-func (s *Scan) filter(h *transformer, packageName string) filtered {
- versions := make(map[string]any, len(s.versions))
- for _, v := range s.versions {
- versions[v] = nil
- }
- metadata := map[string]any{}
- timeMap := s.objectValue(metadata, "time", s.time)
- distTags := s.objectValue(metadata, "dist-tags", s.distTags)
- timeEntries := len(timeMap)
- originalTags := maps.Clone(distTags)
-
- h.applyCooldownFiltering(metadata, versions, packageName)
- h.applyDenylistFiltering(metadata, versions)
-
- return filtered{
- versions: versions,
- timeMap: timeMap,
- distTags: distTags,
- timeChanged: timeMap != nil && len(timeMap) != timeEntries,
- tagsChanged: distTags != nil && !reflect.DeepEqual(distTags, originalTags),
- }
-}
-
-// streamWriter writes the second pass of RewriteStream from the filtered
-// state the first pass left.
-type streamWriter struct {
- *transformer
- filtered
- packageName string
- scan *Scan
- seen map[string]int
- entry bytes.Buffer
- rewritten int
-}
-
-// member writes one top-level member. Members the filters don't touch are
-// left unconsumed, so CopyObject copies them as they are.
-func (s *streamWriter) member(m *jsonstream.Member) error {
- total, filtered := s.scan.occurrences[m.Key]
- if !filtered {
- return nil
- }
- s.seen[m.Key]++
- if s.seen[m.Key] < total {
- return m.Skip() // an earlier duplicate, dropped
- }
- switch {
- case m.Key == "versions":
- return m.Object(s.version)
- case m.Key == "time" && s.timeChanged:
- return m.Object(s.time)
- case m.Key == "dist-tags" && s.tagsChanged:
- if err := m.Skip(); err != nil {
- return err
- }
- encoded, err := json.Marshal(s.distTags)
- if err != nil {
- return err
- }
- return m.Write(encoded)
- }
- return nil
-}
-
-// version writes a kept version with its tarball pointing at the proxy.
-func (s *streamWriter) version(v *jsonstream.Member) error {
- if _, keep := s.versions[v.Key]; !keep {
- return v.Skip()
- }
- raw, err := v.Raw()
- if err != nil {
- return err
- }
- s.entry.Reset()
- if s.writeNPMVersion(&s.entry, s.packageName, v.Key, raw) {
- s.rewritten++
- }
- return v.Write(s.entry.Bytes())
-}
-
-// time writes a time entry the filters kept.
-func (s *streamWriter) time(t *jsonstream.Member) error {
- if _, keep := s.timeMap[t.Key]; keep {
- return t.Copy()
- }
- return t.Skip()
-}
-
-// objectValue records value under name in metadata, as Rewrite's
-// decodeObject does, and returns it when it is an object. An object is
-// copied, since the filters edit it.
-func (s *Scan) objectValue(metadata map[string]any, name string, value any) map[string]any {
- if s.occurrences[name] == 0 {
- return nil
- }
- object, ok := value.(map[string]any)
- if !ok {
- metadata[name] = value
- return nil
- }
- object = maps.Clone(object)
- metadata[name] = object
- return object
-}
-
-// newDecoder reads JSON as encoding/json does: of repeated names the last
-// counts, and invalid UTF-8 in strings is replaced rather than refused.
-func newDecoder(r io.Reader) *jsontext.Decoder {
- return jsontext.NewDecoder(r, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true))
-}
-
-func expectDelim(dec *jsontext.Decoder, want jsontext.Kind) error {
- tok, err := dec.ReadToken()
- if err != nil {
- return err
- }
- if tok.Kind() != want {
- return fmt.Errorf("npm metadata: expected %v, got %v", want, tok.Kind())
- }
- return nil
-}
-
-// expectEnd checks nothing follows the top-level object.
-func expectEnd(dec *jsontext.Decoder) error {
- _, err := dec.ReadToken()
- switch err {
- case io.EOF:
- return nil
- case nil:
- return errors.New("unexpected data after the top-level object")
- }
- return err
-}
-
-func objectKey(dec *jsontext.Decoder) (string, error) {
- tok, err := dec.ReadToken()
- if err != nil {
- return "", err
- }
- if tok.Kind() != '"' {
- return "", fmt.Errorf("npm metadata: object key is %v, not a string", tok.Kind())
- }
- return tok.String(), nil
-}
-
-// decodeValue reads the next value into v, as encoding/json decodes it.
-func decodeValue(dec *jsontext.Decoder, v any) error {
- raw, err := dec.ReadValue()
- if err != nil {
- return err
- }
- return json.Unmarshal(raw, v)
-}
-
-// objectKeys reads a value and returns its keys when it is an object.
-func objectKeys(dec *jsontext.Decoder) ([]string, bool, error) {
- kind := dec.PeekKind()
- if kind != '{' && kind != '[' {
- return nil, false, dec.SkipValue()
- }
- if _, err := dec.ReadToken(); err != nil {
- return nil, false, err
- }
- var keys []string
- for next := dec.PeekKind(); next != '}' && next != ']'; next = dec.PeekKind() {
- if kind == '{' {
- key, err := objectKey(dec)
- if err != nil {
- return nil, false, err
- }
- keys = append(keys, key)
- }
- if err := dec.SkipValue(); err != nil {
- return nil, false, err
- }
- }
- if _, err := dec.ReadToken(); err != nil {
- return nil, false, err
- }
- return keys, kind == '{', nil
-}
-
-// VersionStream is Version for a packument too large to hold in memory. scan
-// is the result of ScanPackument over the bytes r reads again; the second
-// pass only looks for the one version entry it serves. For a document
-// without a versions object it returns ErrNotStreamable.
-func VersionStream(packageName, specifier string, scan *Scan, r io.Reader, options Options) ([]byte, error) {
- if !scan.versionsObject || len(options.LocalVersions) > 0 {
- return nil, ErrNotStreamable
- }
- h := &transformer{options}
- f := scan.filter(h, packageName)
-
- target := specifier
- if tag, ok := f.distTags[specifier].(string); ok {
- target = tag
- }
- if options.DeniedVersions[target] {
- return nil, ErrVersionDenied
- }
- if _, kept := f.versions[target]; !kept {
- return nil, ErrVersionNotFound
- }
- entry, err := LookupVersion(r, target)
- if err != nil {
- return nil, err
- }
- if entry.Entry == nil {
- return nil, ErrVersionNotFound
- }
- var out bytes.Buffer
- h.writeNPMVersion(&out, packageName, target, entry.Entry)
- return out.Bytes(), nil
-}
-
-// VersionEntry is what a packument holds for one version.
-type VersionEntry struct {
- // Entry is versions[version], or nil when there is none.
- Entry json.RawMessage
- // Published is time[version], or "" when it is missing or not a string.
- Published string
- // Abbreviated is set for an abbreviated packument (see Abbreviated).
- Abbreviated bool
-}
-
-// LookupVersion reads a packument once and returns what it holds for
-// version, keeping no other version's entry. Of repeated keys the last one
-// counts, as with JSON.parse.
-func LookupVersion(r io.Reader, version string) (VersionEntry, error) {
- var e VersionEntry
- var hasTime, hasModified bool
- dec := newDecoder(r)
- if err := expectDelim(dec, '{'); err != nil {
- return e, err
- }
- for dec.PeekKind() != '}' {
- key, err := objectKey(dec)
- if err != nil {
- return e, err
- }
- switch key {
- case "versions":
- e.Entry, err = objectMember(dec, version)
- case "time":
- hasTime = true
- var raw json.RawMessage
- raw, err = objectMember(dec, version)
- e.Published = ""
- if err == nil && raw != nil {
- _ = json.Unmarshal(raw, &e.Published)
- }
- default:
- hasModified = hasModified || key == "modified"
- err = dec.SkipValue()
- }
- if err != nil {
- return e, err
- }
- }
- if err := expectDelim(dec, '}'); err != nil {
- return e, err
- }
- if err := expectEnd(dec); err != nil {
- return e, err
- }
- e.Abbreviated = hasModified && !hasTime
- return e, nil
-}
-
-// objectMember reads a value and returns its member key when it is an object
-// that has one, the last if repeated, or nil.
-func objectMember(dec *jsontext.Decoder, key string) (json.RawMessage, error) {
- kind := dec.PeekKind()
- if kind != '{' && kind != '[' {
- return nil, dec.SkipValue()
- }
- if _, err := dec.ReadToken(); err != nil {
- return nil, err
- }
- var found json.RawMessage
- for next := dec.PeekKind(); next != '}' && next != ']'; next = dec.PeekKind() {
- k := ""
- if kind == '{' {
- var err error
- if k, err = objectKey(dec); err != nil {
- return nil, err
- }
- }
- if kind == '{' && k == key {
- raw, err := dec.ReadValue()
- if err != nil {
- return nil, err
- }
- // ReadValue's bytes are only valid until the next read.
- found = bytes.Clone(raw)
- continue
- }
- if err := dec.SkipValue(); err != nil {
- return nil, err
- }
- }
- if _, err := dec.ReadToken(); err != nil {
- return nil, err
- }
- return found, nil
-}
diff --git a/internal/metadata/npm/stream_bench_test.go b/internal/metadata/npm/stream_bench_test.go
deleted file mode 100644
index 12d04d8..0000000
--- a/internal/metadata/npm/stream_bench_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package npm
-
-import (
- "bytes"
- "io"
- "os"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metadata/npm/npmbench"
-)
-
-func BenchmarkScanPackument(b *testing.B) {
- body := npmbench.Packument(5000)
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- if _, err := ScanPackument(bytes.NewReader(body)); err != nil {
- b.Fatal(err)
- }
- }
-}
-
-func BenchmarkLookupVersion(b *testing.B) {
- body := npmbench.Packument(5000)
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- entry, err := LookupVersion(bytes.NewReader(body), "1.0.2500")
- if err != nil || entry.Entry == nil || entry.Published == "" {
- b.Fatalf("LookupVersion = %+v, %v", entry, err)
- }
- }
-}
-
-// BenchmarkRewriteStream times RewriteStream's second pass, the copy, over
-// the 5,000-version packument, or the one NPM_PACKUMENT_BENCH names.
-func BenchmarkRewriteStream(b *testing.B) {
- body := npmbench.Packument(5000)
- name := "big"
- if path := os.Getenv("NPM_PACKUMENT_BENCH"); path != "" {
- var err error
- if body, err = os.ReadFile(path); err != nil {
- b.Fatal(err)
- }
- name = os.Getenv("NPM_PACKUMENT_BENCH_NAME")
- }
- scan, err := ScanPackument(bytes.NewReader(body))
- if err != nil {
- b.Fatal(err)
- }
- options := Options{ProxyURL: "http://proxy.example"}
- b.SetBytes(int64(len(body)))
- b.ReportAllocs()
- for b.Loop() {
- if _, err := RewriteStream(name, scan, bytes.NewReader(body), io.Discard, options); err != nil {
- b.Fatal(err)
- }
- }
-}
diff --git a/internal/metadata/npm/stream_test.go b/internal/metadata/npm/stream_test.go
deleted file mode 100644
index 620882d..0000000
--- a/internal/metadata/npm/stream_test.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package npm
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "reflect"
- "slices"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-// rewriteStream runs both passes of RewriteStream over body.
-func rewriteStream(packageName string, body []byte, options Options) ([]byte, []string, error) {
- scan, err := ScanPackument(bytes.NewReader(body))
- if err != nil {
- return nil, nil, err
- }
- var out bytes.Buffer
- kept, err := RewriteStream(packageName, scan, bytes.NewReader(body), &out, options)
- return out.Bytes(), kept, err
-}
-
-// streamCases returns packuments with the filtered members in either
-// order, duplicates and values Rewrite copies verbatim, and the options to
-// rewrite them with.
-func streamCases() (map[string]string, map[string]Options) {
- now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
- ts := func(age time.Duration) string { return now.Add(-age).Format(time.RFC3339) }
- version := func(v string) string {
- return fmt.Sprintf(`%q:{"version":%q,"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-%s.tgz"}}`, v, v, v)
- }
- versions := `"versions":{` + version("1.0.0") + `,` + version("2.0.0") + `,` + version("3.0.0") + `}`
- times := `"time":{"created":"` + ts(1000*time.Hour) + `","1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}`
- tags := `"dist-tags":{"latest":"3.0.0","beta":"2.0.0","next":"1.0.0"}`
-
- docs := map[string]string{
- "testdata": string(packument),
- "tags, versions, time": `{"name":"demo",` + tags + `,` + versions + `,` + times + `}`,
- "time first": `{` + times + `,"name":"demo",` + versions + `,` + tags + `}`,
- "no time": `{"name":"demo",` + tags + `,` + versions + `}`,
- "time null": `{"time":null,` + tags + `,` + versions + `}`,
- "tags not an object": `{"dist-tags":"x",` + versions + `,` + times + `}`,
- "duplicates": `{"versions":{` + version("9.0.0") + `},` + tags + `,"time":{"9.0.0":"` + ts(time.Hour) + `"},` +
- versions + `,` + times + `,"dist-tags":{"latest":"3.0.0"}}`,
- "verbatim values": `{"_big":12345678901234567890,"readme":"&","nested":{"a":[1, 2 ,{"b":null}]},` + versions + `}`,
- "unusual entries": `{"versions":{"1.0.0":"not an object","2.0.0":{"dist":"x"},"3.0.0":{"dist":{"tarball":7}},` +
- `"4.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/renamed.tgz"}},"5.0.0":{"dist":{}}}}`,
- "whitespace": "{ \"name\" : \"demo\" ,\n\t\"versions\" : {\n \"1.0.0\" : { \"dist\" : { \"tarball\" : \"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz\" } } } }",
- "empty versions": `{"name":"demo","versions":{}}`,
- }
- options := map[string]Options{
- "plain": {ProxyURL: "http://proxy"},
- "cooldown": {ProxyURL: "http://proxy", Cooldown: &cooldown.Config{Default: "3d"}, EvaluatedAt: now},
- "denylist": {ProxyURL: "http://proxy", DeniedVersions: map[string]bool{"2.0.0": true}},
- "both": {ProxyURL: "http://proxy", Cooldown: &cooldown.Config{Default: "3d"}, EvaluatedAt: now,
- DeniedVersions: map[string]bool{"1.0.0": true}},
- }
- return docs, options
-}
-
-// TestRewriteStreamMatchesRewrite checks that the streamed rewrite writes the
-// same bytes and keeps the same versions as Rewrite, over documents with the
-// filtered members in either order, duplicates, and values Rewrite copies
-// verbatim.
-func TestRewriteStreamMatchesRewrite(t *testing.T) {
- docs, options := streamCases()
- for docName, doc := range docs {
- for optName, opts := range options {
- t.Run(docName+"/"+optName, func(t *testing.T) {
- want, wantKept, err := Rewrite("demo", []byte(doc), opts)
- if err != nil {
- t.Fatalf("Rewrite: %v", err)
- }
- got, gotKept, err := rewriteStream("demo", []byte(doc), opts)
- if err != nil {
- t.Fatalf("RewriteStream: %v", err)
- }
- // Rewrite keeps the whitespace inside the members of a
- // filtered time map, which the stream doesn't see.
- if !bytes.Equal(compact(t, got), compact(t, want)) {
- t.Errorf("RewriteStream =\n%s\nRewrite =\n%s", got, want)
- }
- if docName == "verbatim values" && !bytes.Contains(got, []byte(`"_big":12345678901234567890,"readme":"&","nested":{"a":[1, 2 ,{"b":null}]}`)) {
- t.Errorf("values not copied verbatim: %s", got)
- }
- slices.Sort(gotKept)
- slices.Sort(wantKept)
- if !slices.Equal(gotKept, wantKept) {
- t.Errorf("kept %v, Rewrite kept %v", gotKept, wantKept)
- }
- })
- }
- }
-}
-
-func compact(t *testing.T, doc []byte) []byte {
- t.Helper()
- var out bytes.Buffer
- if err := json.Compact(&out, doc); err != nil {
- t.Fatalf("output is not JSON: %v\n%s", err, doc)
- }
- return out.Bytes()
-}
-
-func TestRewriteStreamCallbacks(t *testing.T) {
- var filtered []string
- var rewritten []int
- options := Options{ProxyURL: "http://proxy", Cooldown: &cooldown.Config{Default: "7d"},
- EvaluatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
- OnCooldownFilter: func(version, _ string) { filtered = append(filtered, version) },
- OnTarballsRewritten: func(count int) { rewritten = append(rewritten, count) },
- }
- if _, _, err := rewriteStream("@example/widgets", packument, options); err != nil {
- t.Fatal(err)
- }
- if !slices.Equal(filtered, []string{"3.0.0"}) || !slices.Equal(rewritten, []int{2}) {
- t.Errorf("filtered %v, rewritten %v; want [3.0.0] and [2]", filtered, rewritten)
- }
-}
-
-func TestRewriteStreamNotStreamable(t *testing.T) {
- for name, tc := range map[string]struct {
- doc string
- options Options
- }{
- "single version": {`{"name":"demo","version":"1.0.0","dist":{"tarball":"t"}}`, Options{}},
- "versions array": {`{"name":"demo","versions":[{"a":1}]}`, Options{}},
- "versions null": {`{"name":"demo","versions":null}`, Options{}},
- "pushed versions": {`{"versions":{}}`, Options{LocalVersions: []LocalVersion{{Version: "1.0.0", Entry: []byte(`{}`)}}}},
- } {
- t.Run(name, func(t *testing.T) {
- var out bytes.Buffer
- scan, err := ScanPackument(strings.NewReader(tc.doc))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := RewriteStream("demo", scan, strings.NewReader(tc.doc), &out, tc.options); !errors.Is(err, ErrNotStreamable) {
- t.Errorf("err = %v, want ErrNotStreamable", err)
- }
- if out.Len() != 0 {
- t.Errorf("wrote %q before refusing", out.String())
- }
- })
- }
-}
-
-func TestScanPackument(t *testing.T) {
- for _, body := range []string{`{"versions": {}`, `[1]`, `{"versions": {}} trailing`, `{"a":}`, ``} {
- if _, err := ScanPackument(strings.NewReader(body)); err == nil {
- t.Errorf("ScanPackument(%q): expected an error", body)
- }
- }
- for body, want := range map[string]bool{
- `{"name":"demo","modified":"2024-01-01T00:00:00Z","versions":{}}`: true,
- `{"name":"demo","modified":"2024-01-01T00:00:00Z","time":{},"versions":{}}`: false,
- `{"name":"demo","versions":{}}`: false,
- } {
- scan, err := ScanPackument(strings.NewReader(body))
- if err != nil {
- t.Fatal(err)
- }
- if scan.Abbreviated() != want || Abbreviated([]byte(body)) != want {
- t.Errorf("Abbreviated(%s) = %v, want %v", body, scan.Abbreviated(), want)
- }
- }
-}
-
-// TestVersionStreamMatchesVersion checks that a single version resolved from
-// the stream is the one Version serves, with the same errors.
-func TestVersionStreamMatchesVersion(t *testing.T) {
- docs, options := streamCases()
- specifiers := []string{"latest", "beta", "next", "old", "1.0.0", "2.0.0", "3.0.0", "9.0.0", "missing"}
- for docName, doc := range docs {
- scan, err := ScanPackument(strings.NewReader(doc))
- if err != nil {
- t.Fatalf("%s: %v", docName, err)
- }
- for optName, opts := range options {
- for _, spec := range specifiers {
- want, wantErr := Version("demo", spec, []byte(doc), opts)
- got, gotErr := VersionStream("demo", spec, scan, strings.NewReader(doc), opts)
- name := docName + "/" + optName + "/" + spec
- switch {
- case wantErr != nil || gotErr != nil:
- if !errors.Is(gotErr, ErrVersionDenied) == errors.Is(wantErr, ErrVersionDenied) ||
- !errors.Is(gotErr, ErrVersionNotFound) == errors.Is(wantErr, ErrVersionNotFound) ||
- (gotErr == nil) != (wantErr == nil) {
- t.Errorf("%s: error %v, Version gave %v", name, gotErr, wantErr)
- }
- case !bytes.Equal(got, want):
- t.Errorf("%s:\n%s\nVersion:\n%s", name, got, want)
- }
- }
- }
- }
-}
-
-func TestLookupVersion(t *testing.T) {
- doc := `{"modified":"x","versions":{"1.0.0":{"a":1}},"time":{"1.0.0":"2020-01-01T00:00:00Z","2.0.0":7},` +
- `"versions":{"1.0.0":{"a":2},"2.0.0":{"b":1}}}`
- for version, want := range map[string]VersionEntry{
- "1.0.0": {Entry: json.RawMessage(`{"a":2}`), Published: "2020-01-01T00:00:00Z"},
- "2.0.0": {Entry: json.RawMessage(`{"b":1}`)},
- "3.0.0": {},
- } {
- got, err := LookupVersion(strings.NewReader(doc), version)
- if err != nil {
- t.Fatal(err)
- }
- if string(got.Entry) != string(want.Entry) || got.Published != want.Published || got.Abbreviated {
- t.Errorf("LookupVersion(%s) = %+v, want %+v", version, got, want)
- }
- }
- got, err := LookupVersion(strings.NewReader(`{"modified":"x","versions":{}}`), "1.0.0")
- if err != nil || !got.Abbreviated {
- t.Errorf("abbreviated packument: %+v, %v", got, err)
- }
- if _, err := LookupVersion(strings.NewReader(`{"versions":{}`), "1.0.0"); err == nil {
- t.Error("expected an error for truncated JSON")
- }
-}
-
-// TestStreamReadsJSONAsEncodingJSON checks what the streaming readers accept
-// matches what Rewrite's encoding/json does: names repeated at any depth,
-// invalid UTF-8 in strings, and a version entry's bytes kept as they are.
-func TestStreamReadsJSONAsEncodingJSON(t *testing.T) {
- entry := "{ \"name\" : \"demo\",\n \"x\":1, \"x\":2, \"note\":\"caf\xe9\" }"
- doc := "{\"readme\":\"bad \xff byte\",\"meta\":{\"k\":1,\"k\":2},\"dist-tags\":{\"latest\":\"0.9.0\",\"latest\":\"1.0.0\"}," +
- "\"versions\":{\"1.0.0\":" + entry + "},\"time\":{\"1.0.0\":\"2020-01-01T00:00:00Z\"}}\n"
- if !json.Valid([]byte(strings.ToValidUTF8(doc, "?"))) {
- t.Fatal("test document isn't JSON")
- }
-
- scan, err := ScanPackument(strings.NewReader(doc))
- if err != nil {
- t.Fatalf("ScanPackument: %v", err)
- }
- if !slices.Equal(scan.versions, []string{"1.0.0"}) || !reflect.DeepEqual(scan.distTags, map[string]any{"latest": "1.0.0"}) {
- t.Errorf("scan versions %v, dist-tags %v", scan.versions, scan.distTags)
- }
-
- got, err := LookupVersion(strings.NewReader(doc), "1.0.0")
- if err != nil {
- t.Fatalf("LookupVersion: %v", err)
- }
- if string(got.Entry) != entry || got.Published != "2020-01-01T00:00:00Z" {
- t.Errorf("entry %q, published %q; want the entry's bytes %q", got.Entry, got.Published, entry)
- }
-}
diff --git a/internal/metadata/npm/testdata/packument.json b/internal/metadata/npm/testdata/packument.json
deleted file mode 100644
index cdb36b5..0000000
--- a/internal/metadata/npm/testdata/packument.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "name": "@example/widgets",
- "dist-tags": {"latest": "3.0.0", "beta": "2.0.0"},
- "versions": {
- "1.0.0": {"name": "@example/widgets", "version": "1.0.0", "dependencies": {"left-pad": "^1.3.0"}, "dist": {"integrity": "sha512-example", "tarball": "https://registry.npmjs.org/@example/widgets/-/widgets-1.0.0.tgz"}},
- "2.0.0": {"name": "@example/widgets", "version": "2.0.0", "dist": {"tarball": "https://registry.npmjs.org/@example/widgets/-/widgets-2.0.0.tgz"}},
- "3.0.0": {"name": "@example/widgets", "version": "3.0.0", "dist": {"tarball": "https://cdn.example/abcdef.tgz"}}
- },
- "time": {"created": "2020-01-01T00:00:00Z", "1.0.0": "2020-01-01T00:00:00Z", "2.0.0": "2021-01-01T00:00:00Z", "3.0.0": "2099-01-01T00:00:00Z"},
- "readme": "Widgets & tools",
- "downloads": 12345678901234567890
-}
diff --git a/internal/metrics/analytics_test.go b/internal/metrics/analytics_test.go
deleted file mode 100644
index 427c38f..0000000
--- a/internal/metrics/analytics_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package metrics
-
-import (
- "testing"
-
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-func TestUpdateEcosystemStats(t *testing.T) {
- UpdateEcosystemStats([]EcosystemStats{
- {Ecosystem: "npm", Packages: 10, Versions: 25, Artifacts: 40, CacheSize: 1500, Downloads: 4, DownloadedBytes: 3500},
- {Ecosystem: "cargo", Packages: 2, Versions: 3, Artifacts: 3, CacheSize: 200, Downloads: 10, DownloadedBytes: 2000},
- })
-
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("npm")); got != 3500 {
- t.Errorf("npm downloaded bytes = %v, want 3500", got)
- }
- if got := testutil.ToFloat64(EcosystemDownloads.WithLabelValues("npm")); got != 4 {
- t.Errorf("npm downloads = %v, want 4", got)
- }
- if got := testutil.ToFloat64(EcosystemCacheSize.WithLabelValues("cargo")); got != 200 {
- t.Errorf("cargo cache size = %v, want 200", got)
- }
- if got := testutil.ToFloat64(EcosystemCachedArtifacts.WithLabelValues("npm")); got != 40 {
- t.Errorf("npm cached artifacts = %v, want 40", got)
- }
- if got := testutil.ToFloat64(EcosystemPackages.WithLabelValues("npm")); got != 10 {
- t.Errorf("npm packages = %v, want 10", got)
- }
- if got := testutil.ToFloat64(EcosystemVersions.WithLabelValues("npm")); got != 25 {
- t.Errorf("npm versions = %v, want 25", got)
- }
-}
-
-// A refresh that no longer mentions an ecosystem must drop its series rather
-// than leave it frozen at the last observed value.
-func TestUpdateEcosystemStatsDropsStaleSeries(t *testing.T) {
- UpdateEcosystemStats([]EcosystemStats{
- {Ecosystem: "npm", DownloadedBytes: 3500},
- {Ecosystem: "gem", DownloadedBytes: 900},
- })
- if got := testutil.CollectAndCount(EcosystemDownloadedBytes); got != 2 {
- t.Fatalf("expected 2 series after first refresh, got %d", got)
- }
-
- UpdateEcosystemStats([]EcosystemStats{{Ecosystem: "npm", DownloadedBytes: 4000}})
-
- if got := testutil.CollectAndCount(EcosystemDownloadedBytes); got != 1 {
- t.Errorf("expected 1 series after gem disappeared, got %d", got)
- }
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("npm")); got != 4000 {
- t.Errorf("npm downloaded bytes = %v, want 4000", got)
- }
-}
-
-// Ecosystem labels are normalized so these gauges join against the other
-// metrics that take their ecosystem from a package record.
-func TestUpdateEcosystemStatsNormalizesLabels(t *testing.T) {
- UpdateEcosystemStats([]EcosystemStats{{Ecosystem: "NPM", DownloadedBytes: 12}})
-
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("npm")); got != 12 {
- t.Errorf("normalized npm gauge = %v, want 12", got)
- }
-}
-
-// GetEcosystemStats groups by the raw packages.ecosystem column, so a database
-// carrying both spellings of an aliased ecosystem yields two rows that
-// normalize to one label. They must sum rather than overwrite each other.
-func TestUpdateEcosystemStatsSumsAliasedRows(t *testing.T) {
- UpdateEcosystemStats([]EcosystemStats{
- {Ecosystem: "gem", DownloadedBytes: 100, CacheSize: 10, Packages: 1},
- {Ecosystem: "rubygems", DownloadedBytes: 200, CacheSize: 20, Packages: 2},
- {Ecosystem: "npm", DownloadedBytes: 50, CacheSize: 5, Packages: 3},
- })
-
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("rubygems")); got != 300 {
- t.Errorf("rubygems downloaded bytes = %v, want 300", got)
- }
- if got := testutil.ToFloat64(EcosystemCacheSize.WithLabelValues("rubygems")); got != 30 {
- t.Errorf("rubygems cache size = %v, want 30", got)
- }
- if got := testutil.ToFloat64(EcosystemPackages.WithLabelValues("rubygems")); got != 3 {
- t.Errorf("rubygems packages = %v, want 3", got)
- }
- // An unaliased ecosystem is unaffected.
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("npm")); got != 50 {
- t.Errorf("npm downloaded bytes = %v, want 50", got)
- }
-
- // A later refresh replaces the value rather than adding to it.
- UpdateEcosystemStats([]EcosystemStats{{Ecosystem: "npm", DownloadedBytes: 50}})
- if got := testutil.ToFloat64(EcosystemDownloadedBytes.WithLabelValues("npm")); got != 50 {
- t.Errorf("npm downloaded bytes after a second refresh = %v, want 50", got)
- }
-}
diff --git a/internal/metrics/failures.go b/internal/metrics/failures.go
deleted file mode 100644
index a5f276b..0000000
--- a/internal/metrics/failures.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package metrics
-
-import (
- "net/url"
- "regexp"
- "sync"
- "time"
-)
-
-// recentFailuresPerSeries bounds how many failures are kept for each series of
-// a failure counter. The series set is already bounded by the counter's
-// labels, so this caps the whole store.
-const recentFailuresPerSeries = 20
-
-// FailureDetail is what a failure counter's call site knows beyond the
-// counter's labels: which artifact failed, where it came from or was going,
-// and why. Every field is optional.
-//
-// None of it becomes a metric label. Package names and URLs are unbounded, so
-// they are kept in a small in-memory store for /ui/analytics instead.
-type FailureDetail struct {
- PURL string
- Filename string
- URL string
- Path string
- Err error
-}
-
-// Failure is one recorded failure, with credentials already stripped from the
-// URL and the error text.
-type Failure struct {
- At time.Time
- PURL string
- Filename string
- URL string
- Path string
- Error string
-}
-
-// recentFailures holds the latest failures per counter series. It lives in
-// process memory only, so like the counters themselves it starts empty when
-// the proxy starts.
-var recentFailures = struct {
- sync.Mutex
- series map[string][]Failure
-}{series: make(map[string][]Failure)}
-
-// RecentFailures returns the latest failures recorded for one series of a
-// failure counter, newest first, or nil when the series has none.
-func RecentFailures(name string, labels map[string]string) []Failure {
- recentFailures.Lock()
- defer recentFailures.Unlock()
-
- stored := recentFailures.series[failureKey(name, labels)]
- if len(stored) == 0 {
- return nil
- }
- return append([]Failure(nil), stored...)
-}
-
-func recordFailure(name string, labels map[string]string, detail FailureDetail) {
- failure := Failure{
- At: time.Now(),
- PURL: detail.PURL,
- Filename: detail.Filename,
- URL: redactURL(detail.URL),
- Path: detail.Path,
- }
- if detail.Err != nil {
- failure.Error = redactURLsIn(detail.Err.Error())
- }
-
- key := failureKey(name, labels)
-
- recentFailures.Lock()
- defer recentFailures.Unlock()
-
- stored := append([]Failure{failure}, recentFailures.series[key]...)
- if len(stored) > recentFailuresPerSeries {
- stored = stored[:recentFailuresPerSeries]
- }
- recentFailures.series[key] = stored
-}
-
-func failureKey(name string, labels map[string]string) string {
- return name + "{" + labelKey(labels) + "}"
-}
-
-var urlInText = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.-]*://[^\s"'<>]+`)
-
-// redactURLsIn strips credentials from every URL embedded in text, such as the
-// one net/http quotes in a failed request's error.
-func redactURLsIn(text string) string {
- return urlInText.ReplaceAllStringFunc(text, redactURL)
-}
-
-// redactURL drops the userinfo, query and fragment, which is where registry
-// tokens and signed-URL signatures live.
-func redactURL(raw string) string {
- if raw == "" {
- return ""
- }
- u, err := url.Parse(raw)
- if err != nil {
- return "(unparseable URL)"
- }
- u.User = nil
- u.RawQuery = ""
- u.ForceQuery = false
- u.Fragment = ""
- u.RawFragment = ""
- return u.String()
-}
diff --git a/internal/metrics/failures_test.go b/internal/metrics/failures_test.go
deleted file mode 100644
index 2301f8b..0000000
--- a/internal/metrics/failures_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package metrics
-
-import (
- "errors"
- "fmt"
- "strings"
- "testing"
-
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// isolateRecentFailures empties the process-wide failure history for the
-// test and again when it ends, so neither earlier tests nor repeated runs
-// reach its assertions.
-func isolateRecentFailures(t *testing.T) {
- t.Helper()
- reset := func() {
- recentFailures.Lock()
- defer recentFailures.Unlock()
- recentFailures.series = make(map[string][]Failure)
- }
- reset()
- t.Cleanup(reset)
-}
-
-func TestRecentFailuresNewestFirst(t *testing.T) {
- isolateRecentFailures(t)
-
- RecordUpstreamError("npm", "fetch_failed", FailureDetail{PURL: "pkg:npm/left-pad@1.0.0"})
- RecordUpstreamError("npm", "fetch_failed", FailureDetail{PURL: "pkg:npm/right-pad@2.0.0"})
-
- got := RecentFailures("proxy_upstream_errors_total", map[string]string{"ecosystem": "npm", "error_type": "fetch_failed"})
- if len(got) != 2 {
- t.Fatalf("got %d failures, want 2", len(got))
- }
- if got[0].PURL != "pkg:npm/right-pad@2.0.0" || got[1].PURL != "pkg:npm/left-pad@1.0.0" {
- t.Errorf("failures not newest first: %q, %q", got[0].PURL, got[1].PURL)
- }
- if got[0].At.IsZero() {
- t.Error("failure has no timestamp")
- }
-}
-
-func TestRecentFailuresAreBoundedPerSeries(t *testing.T) {
- isolateRecentFailures(t)
-
- for i := range recentFailuresPerSeries + 5 {
- RecordStorageError("write", FailureDetail{Path: fmt.Sprintf("npm/pkg-%d.tgz", i)})
- }
-
- got := RecentFailures("proxy_storage_errors_total", map[string]string{"operation": "write"})
- if len(got) != recentFailuresPerSeries {
- t.Fatalf("got %d failures, want the cap of %d", len(got), recentFailuresPerSeries)
- }
- if want := fmt.Sprintf("npm/pkg-%d.tgz", recentFailuresPerSeries+4); got[0].Path != want {
- t.Errorf("newest failure = %q, want %q", got[0].Path, want)
- }
-}
-
-func TestRecentFailuresAreKeptPerSeries(t *testing.T) {
- isolateRecentFailures(t)
-
- RecordIntegrityFailure("npm", FailureDetail{PURL: "pkg:npm/a@1"})
- RecordIntegrityFailure("pypi", FailureDetail{PURL: "pkg:pypi/b@1"})
-
- npm := RecentFailures("proxy_integrity_failures_total", map[string]string{"ecosystem": "npm"})
- if len(npm) != 1 || npm[0].PURL != "pkg:npm/a@1" {
- t.Errorf("npm series = %+v, want only pkg:npm/a@1", npm)
- }
- if got := RecentFailures("proxy_integrity_failures_total", map[string]string{"ecosystem": "deb"}); got != nil {
- t.Errorf("unseen series = %+v, want nil", got)
- }
-}
-
-func TestRecordFailureStillCountsTheMetric(t *testing.T) {
- isolateRecentFailures(t)
-
- before := testutil.ToFloat64(HealthProbeFailures.WithLabelValues("read"))
- RecordHealthProbeFailure("read", FailureDetail{Err: errors.New("disk gone")})
- if diff := testutil.ToFloat64(HealthProbeFailures.WithLabelValues("read")) - before; diff != 1 {
- t.Errorf("counter delta = %.0f, want 1", diff)
- }
-
- got := RecentFailures("proxy_health_probe_failures_total", map[string]string{"step": "read"})
- if len(got) != 1 || got[0].Error != "disk gone" {
- t.Errorf("failures = %+v, want one carrying the error text", got)
- }
-}
-
-func TestScanErrorRecordsDetail(t *testing.T) {
- isolateRecentFailures(t)
-
- RecordScanError("NPM", "clamav", "timeout", FailureDetail{PURL: "pkg:npm/a@1", Filename: "a-1.tgz"})
-
- got := RecentFailures("proxy_scan_errors_total",
- map[string]string{"ecosystem": "npm", "scanner": "clamav", "error_type": "timeout"})
- if len(got) != 1 || got[0].Filename != "a-1.tgz" {
- t.Errorf("failures = %+v, want one keyed by the normalised ecosystem", got)
- }
-}
-
-// The analytics page is unauthenticated, and private registry URLs carry
-// credentials in the userinfo or the query string. Neither may be kept.
-func TestRecentFailuresStripCredentials(t *testing.T) {
- isolateRecentFailures(t)
-
- RecordUpstreamError("npm", "fetch_failed", FailureDetail{
- URL: "https://user:s3cret@registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc#frag",
- Err: errors.New(`Get "https://user:s3cret@registry.example.com/pkg?sig=xyz": connection refused`),
- })
-
- got := RecentFailures("proxy_upstream_errors_total", map[string]string{"ecosystem": "npm", "error_type": "fetch_failed"})
- if len(got) != 1 {
- t.Fatalf("got %d failures, want 1", len(got))
- }
- if want := "https://registry.example.com/pkg/-/pkg-1.0.0.tgz"; got[0].URL != want {
- t.Errorf("URL = %q, want %q", got[0].URL, want)
- }
- if want := `Get "https://registry.example.com/pkg": connection refused`; got[0].Error != want {
- t.Errorf("Error = %q, want %q", got[0].Error, want)
- }
- for _, secret := range []string{"s3cret", "token", "sig=", "user:"} {
- if strings.Contains(got[0].URL+got[0].Error, secret) {
- t.Errorf("recorded failure still contains %q", secret)
- }
- }
-}
-
-func TestRecentFailuresReturnsACopy(t *testing.T) {
- isolateRecentFailures(t)
-
- RecordStorageError("read", FailureDetail{Path: "a"})
- labels := map[string]string{"operation": "read"}
- RecentFailures("proxy_storage_errors_total", labels)[0].Path = "mutated"
-
- if got := RecentFailures("proxy_storage_errors_total", labels)[0].Path; got != "a" {
- t.Errorf("stored failure was mutated through the returned slice: %q", got)
- }
-}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 85eeef4..da8bde6 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -4,10 +4,8 @@ package metrics
import (
"net/http"
"strconv"
- "sync"
"time"
- "github.com/git-pkgs/purl"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -35,23 +33,15 @@ var (
CacheHits = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_cache_hits_total",
- Help: "Total number of cache hits by ecosystem and kind (artifact or metadata)",
+ Help: "Total number of cache hits by ecosystem",
},
- []string{"ecosystem", "kind"},
+ []string{"ecosystem"},
)
CacheMisses = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_cache_misses_total",
- Help: "Total number of cache misses by ecosystem and kind (artifact or metadata)",
- },
- []string{"ecosystem", "kind"},
- )
-
- MetadataServedBytes = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_metadata_cache_served_bytes_total",
- Help: "Total metadata bytes served from the metadata cache, by ecosystem",
+ Help: "Total number of cache misses by ecosystem",
},
[]string{"ecosystem"},
)
@@ -70,38 +60,6 @@ var (
},
)
- ArtifactsEvicted = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_artifacts_evicted_total",
- Help: "Total number of cached artifacts evicted, by reason (lru or retention) and ecosystem",
- },
- []string{"reason", "ecosystem"},
- )
-
- MetadataEvicted = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_metadata_evicted_total",
- Help: "Total number of cached metadata documents evicted by the size limit, by ecosystem",
- },
- []string{"ecosystem"},
- )
-
- MetadataExpiredByChangeFeed = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_metadata_expired_by_change_feed_total",
- Help: "Cached metadata documents set to revalidate because an upstream change feed listed them, by ecosystem",
- },
- []string{"ecosystem"},
- )
-
- FetchLeaseWaits = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_fetch_lease_waits_total",
- Help: "Cache misses that waited for another instance's upstream fetch, by kind (artifact or metadata)",
- },
- []string{"kind"},
- )
-
// Upstream metrics
UpstreamFetchDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
@@ -137,14 +95,6 @@ var (
[]string{"registry"},
)
- UpstreamRateLimitHolds = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_upstream_rate_limit_holds_total",
- Help: "Holds placed on an upstream host after it rate limited the proxy",
- },
- []string{"host"},
- )
-
// Storage metrics
StorageOperationDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
@@ -170,149 +120,6 @@ var (
Help: "Number of currently active requests",
},
)
-
- ArtifactsServedUncached = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_artifacts_served_uncached_total",
- Help: "Artifacts sent to the client on a cache miss that could not be cached, such as when storage is full",
- },
- []string{"ecosystem"},
- )
-
- IntegrityFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_integrity_failures_total",
- Help: "Cached artifacts that failed hash verification on read",
- },
- []string{"ecosystem"},
- )
-
- HealthProbeFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_health_probe_failures_total",
- Help: "Total number of storage health probe failures, by step (write|size|read|verify|delete).",
- },
- []string{"step"},
- )
-
- // Per-ecosystem gauges, derived from the database rather than incremented
- // in the request path, and refreshed on the same tick as the cache gauges
- // above.
- EcosystemDownloadedBytes = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_downloaded_bytes",
- Help: "Accumulated bytes served from cache per ecosystem (cache hits x artifact size)",
- },
- []string{"ecosystem"},
- )
-
- EcosystemDownloads = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_artifact_downloads",
- Help: "Accumulated artifact downloads served from cache per ecosystem",
- },
- []string{"ecosystem"},
- )
-
- EcosystemCacheSize = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_cache_size_bytes",
- Help: "Size of cached artifacts per ecosystem in bytes",
- },
- []string{"ecosystem"},
- )
-
- EcosystemCachedArtifacts = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_cached_artifacts",
- Help: "Number of cached artifacts per ecosystem",
- },
- []string{"ecosystem"},
- )
-
- EcosystemPackages = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_packages",
- Help: "Number of known packages per ecosystem",
- },
- []string{"ecosystem"},
- )
-
- EcosystemVersions = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_ecosystem_versions",
- Help: "Number of known package versions per ecosystem",
- },
- []string{"ecosystem"},
- )
-
- // Metadata cache gauges, derived from the metadata_cache table on the
- // same tick as the per-ecosystem gauges above.
- MetadataCacheSize = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_metadata_cache_size_bytes",
- Help: "Size of cached metadata documents per ecosystem in bytes",
- },
- []string{"ecosystem"},
- )
-
- MetadataCacheEntries = prometheus.NewGaugeVec(
- prometheus.GaugeOpts{
- Name: "proxy_metadata_cache_entries",
- Help: "Number of cached metadata documents per ecosystem",
- },
- []string{"ecosystem"},
- )
-
- ResponseBytes = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_response_bytes_total",
- Help: "Total response body bytes written to clients, by ecosystem",
- },
- []string{"ecosystem"},
- )
-
- ClientRequests = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_client_requests_total",
- Help: "Total requests by client tool, as identified from the User-Agent",
- },
- []string{"client"},
- )
-
- ClientResponseBytes = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_client_response_bytes_total",
- Help: "Total response body bytes written to clients, by client tool",
- },
- []string{"client"},
- )
-
- // Scanning metrics
- ScanDuration = prometheus.NewHistogramVec(
- prometheus.HistogramOpts{
- Name: "proxy_scan_duration_seconds",
- Help: "Pre-cache artifact scan duration in seconds, by ecosystem and scanner",
- Buckets: prometheus.DefBuckets,
- },
- []string{"ecosystem", "scanner"},
- )
-
- ScanBlocked = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_scan_blocked_total",
- Help: "Total number of artifacts blocked by a pre-cache scan, by ecosystem and scanner",
- },
- []string{"ecosystem", "scanner"},
- )
-
- ScanErrors = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_scan_errors_total",
- Help: "Total number of pre-cache scan errors, by ecosystem, scanner, and error type",
- },
- []string{"ecosystem", "scanner", "error_type"},
- )
)
func init() {
@@ -322,38 +129,15 @@ func init() {
RequestDuration,
CacheHits,
CacheMisses,
- MetadataServedBytes,
CacheSize,
CachedArtifacts,
- ArtifactsEvicted,
- MetadataEvicted,
- MetadataExpiredByChangeFeed,
- FetchLeaseWaits,
UpstreamFetchDuration,
UpstreamErrors,
CircuitBreakerState,
CircuitBreakerTrips,
- UpstreamRateLimitHolds,
StorageOperationDuration,
StorageErrors,
- ArtifactsServedUncached,
ActiveRequests,
- IntegrityFailures,
- HealthProbeFailures,
- EcosystemDownloadedBytes,
- EcosystemDownloads,
- EcosystemCacheSize,
- EcosystemCachedArtifacts,
- EcosystemPackages,
- EcosystemVersions,
- MetadataCacheSize,
- MetadataCacheEntries,
- ResponseBytes,
- ClientRequests,
- ClientResponseBytes,
- ScanDuration,
- ScanBlocked,
- ScanErrors,
)
}
@@ -369,85 +153,14 @@ func RecordRequest(ecosystem string, status int, duration time.Duration) {
RequestDuration.WithLabelValues(ecosystem, statusStr).Observe(duration.Seconds())
}
-// RecordResponse tracks what a client actually downloaded.
-//
-// Distinct from proxy_ecosystem_downloaded_bytes: that gauge is derived from
-// the database and counts cache hits multiplied by artifact size, while this
-// counts body bytes as they are written, including metadata responses and
-// cache misses.
-//
-// client must come from a closed set -- a User-Agent is attacker-controlled, so
-// passing it through raw would mint a time series per request.
-func RecordResponse(ecosystem, client string, bytes int64) {
- ClientRequests.WithLabelValues(client).Inc()
- if bytes <= 0 {
- return
- }
- ResponseBytes.WithLabelValues(ecosystem).Add(float64(bytes))
- ClientResponseBytes.WithLabelValues(client).Add(float64(bytes))
+// RecordCacheHit increments cache hit counter.
+func RecordCacheHit(ecosystem string) {
+ CacheHits.WithLabelValues(ecosystem).Inc()
}
-// Values of the kind label on proxy_cache_hits_total and
-// proxy_cache_misses_total. Artifacts and metadata are separate caches with
-// very different costs, so a hit on one says nothing about the other.
-const (
- CacheKindArtifact = "artifact"
- CacheKindMetadata = "metadata"
-)
-
-// RecordArtifactCacheHit counts an artifact served from storage.
-func RecordArtifactCacheHit(ecosystem string) {
- CacheHits.WithLabelValues(purl.NormalizeEcosystem(ecosystem), CacheKindArtifact).Inc()
-}
-
-// RecordMetadataCacheHit counts a metadata document served from the metadata
-// cache, along with the bytes it served.
-func RecordMetadataCacheHit(ecosystem string, bytes int) {
- ecosystem = purl.NormalizeEcosystem(ecosystem)
- CacheHits.WithLabelValues(ecosystem, CacheKindMetadata).Inc()
- if bytes > 0 {
- MetadataServedBytes.WithLabelValues(ecosystem).Add(float64(bytes))
- }
-}
-
-// RecordArtifactEvicted counts one evicted artifact. ecosystem is the
-// package record's value, normalized like the cache hit counter; an artifact
-// without a package record counts as "other".
-func RecordArtifactEvicted(reason, ecosystem string) {
- if ecosystem == "" {
- ecosystem = "other"
- }
- ArtifactsEvicted.WithLabelValues(reason, purl.NormalizeEcosystem(ecosystem)).Inc()
-}
-
-// RecordMetadataEvicted counts one metadata document evicted by the size
-// limit. ecosystem is the metadata cache's, normalized like the cache hit
-// counter.
-func RecordMetadataEvicted(ecosystem string) {
- MetadataEvicted.WithLabelValues(purl.NormalizeEcosystem(ecosystem)).Inc()
-}
-
-// RecordMetadataExpiredByChangeFeed counts n metadata documents an upstream
-// change feed listed as changed. ecosystem is the metadata cache's,
-// normalized like the cache hit counter.
-func RecordMetadataExpiredByChangeFeed(ecosystem string, n int) {
- MetadataExpiredByChangeFeed.WithLabelValues(purl.NormalizeEcosystem(ecosystem)).Add(float64(n))
-}
-
-// RecordFetchLeaseWait counts a cache miss that found another instance
-// sharing the database already fetching it.
-func RecordFetchLeaseWait(kind string) {
- FetchLeaseWaits.WithLabelValues(kind).Inc()
-}
-
-// RecordArtifactCacheMiss counts an artifact that had to be fetched upstream.
-func RecordArtifactCacheMiss(ecosystem string) {
- CacheMisses.WithLabelValues(purl.NormalizeEcosystem(ecosystem), CacheKindArtifact).Inc()
-}
-
-// RecordMetadataCacheMiss counts a metadata lookup the cache could not serve.
-func RecordMetadataCacheMiss(ecosystem string) {
- CacheMisses.WithLabelValues(purl.NormalizeEcosystem(ecosystem), CacheKindMetadata).Inc()
+// RecordCacheMiss increments cache miss counter.
+func RecordCacheMiss(ecosystem string) {
+ CacheMisses.WithLabelValues(ecosystem).Inc()
}
// RecordUpstreamFetch tracks upstream fetch duration.
@@ -455,12 +168,9 @@ func RecordUpstreamFetch(ecosystem string, duration time.Duration) {
UpstreamFetchDuration.WithLabelValues(ecosystem).Observe(duration.Seconds())
}
-// RecordUpstreamError increments upstream error counter and keeps the detail
-// for /ui/analytics.
-func RecordUpstreamError(ecosystem, errorType string, detail FailureDetail) {
+// RecordUpstreamError increments upstream error counter.
+func RecordUpstreamError(ecosystem, errorType string) {
UpstreamErrors.WithLabelValues(ecosystem, errorType).Inc()
- recordFailure("proxy_upstream_errors_total",
- map[string]string{"ecosystem": ecosystem, "error_type": errorType}, detail)
}
// RecordStorageOperation tracks storage operation duration.
@@ -468,53 +178,9 @@ func RecordStorageOperation(operation string, duration time.Duration) {
StorageOperationDuration.WithLabelValues(operation).Observe(duration.Seconds())
}
-// RecordIntegrityFailure increments the integrity failure counter and keeps
-// the detail for /ui/analytics.
-func RecordIntegrityFailure(ecosystem string, detail FailureDetail) {
- IntegrityFailures.WithLabelValues(ecosystem).Inc()
- recordFailure("proxy_integrity_failures_total", map[string]string{"ecosystem": ecosystem}, detail)
-}
-
-// RecordHealthProbeFailure increments the health probe failure counter and
-// keeps the detail for /ui/analytics.
-// step is one of: "write", "size", "read", "verify", "delete".
-func RecordHealthProbeFailure(step string, detail FailureDetail) {
- HealthProbeFailures.WithLabelValues(step).Inc()
- recordFailure("proxy_health_probe_failures_total", map[string]string{"step": step}, detail)
-}
-
-// RecordStorageError increments storage error counter and keeps the detail
-// for /ui/analytics.
-func RecordStorageError(operation string, detail FailureDetail) {
+// RecordStorageError increments storage error counter.
+func RecordStorageError(operation string) {
StorageErrors.WithLabelValues(operation).Inc()
- recordFailure("proxy_storage_errors_total", map[string]string{"operation": operation}, detail)
-}
-
-// RecordArtifactServedUncached counts an artifact a client got on a cache
-// miss although storing it failed, and keeps the detail for /ui/analytics.
-func RecordArtifactServedUncached(ecosystem string, detail FailureDetail) {
- ecosystem = purl.NormalizeEcosystem(ecosystem)
- ArtifactsServedUncached.WithLabelValues(ecosystem).Inc()
- recordFailure("proxy_artifacts_served_uncached_total", map[string]string{"ecosystem": ecosystem}, detail)
-}
-
-// RecordScanResult tracks a completed pre-cache scan call.
-func RecordScanResult(ecosystem, scannerName string, allowed bool, duration time.Duration) {
- ecosystem = purl.NormalizeEcosystem(ecosystem)
- ScanDuration.WithLabelValues(ecosystem, scannerName).Observe(duration.Seconds())
- if !allowed {
- ScanBlocked.WithLabelValues(ecosystem, scannerName).Inc()
- }
-}
-
-// RecordScanError increments the scan error counter.
-// errorType is one of: "error" (scanner call failed), "timeout", "cancelled".
-// The detail is kept for /ui/analytics.
-func RecordScanError(ecosystem, scannerName, errorType string, detail FailureDetail) {
- ecosystem = purl.NormalizeEcosystem(ecosystem)
- ScanErrors.WithLabelValues(ecosystem, scannerName, errorType).Inc()
- recordFailure("proxy_scan_errors_total",
- map[string]string{"ecosystem": ecosystem, "scanner": scannerName, "error_type": errorType}, detail)
}
// UpdateCacheStats updates cache size and artifact count gauges.
@@ -523,123 +189,6 @@ func UpdateCacheStats(sizeBytes, artifactCount int64) {
CachedArtifacts.Set(float64(artifactCount))
}
-// EcosystemStats is one ecosystem's row of the snapshot published as gauges.
-type EcosystemStats struct {
- Ecosystem string
- Packages int64
- Versions int64
- Artifacts int64
- CacheSize int64
- Downloads int64
- DownloadedBytes int64
-}
-
-// publishedEcosystems tracks which labels the per-ecosystem gauges currently
-// carry, so a label that disappears can be deleted individually.
-var (
- publishedMu sync.Mutex
- publishedEcosystems = map[string]bool{}
- // publishedMetadataEcosystems does the same for the metadata cache gauges.
- publishedMetadataEcosystems = map[string]bool{}
-)
-
-// UpdateEcosystemStats republishes the per-ecosystem gauges from a fresh snapshot.
-//
-// Rows are summed by label before anything is published, because normalizing
-// collapses aliases: a database holding both "gem" and "rubygems" rows -- the
-// proxy writes the former, git-pkgs the latter -- arrives as two rows belonging
-// to one label, and publishing them one at a time would leave only the last.
-//
-// The vectors are not Reset() first. Reset followed by a repopulating loop
-// leaves a window in which a scrape sees the families empty or half filled,
-// which renders as a spurious gap on any panel built from them. Each series is
-// Set instead, and only labels that have actually disappeared are deleted.
-func UpdateEcosystemStats(stats []EcosystemStats) {
- totals := make(map[string]EcosystemStats, len(stats))
- for _, s := range stats {
- ecosystem := purl.NormalizeEcosystem(s.Ecosystem)
- t := totals[ecosystem]
- t.Packages += s.Packages
- t.Versions += s.Versions
- t.Artifacts += s.Artifacts
- t.CacheSize += s.CacheSize
- t.Downloads += s.Downloads
- t.DownloadedBytes += s.DownloadedBytes
- totals[ecosystem] = t
- }
-
- for ecosystem, t := range totals {
- EcosystemDownloadedBytes.WithLabelValues(ecosystem).Set(float64(t.DownloadedBytes))
- EcosystemDownloads.WithLabelValues(ecosystem).Set(float64(t.Downloads))
- EcosystemCacheSize.WithLabelValues(ecosystem).Set(float64(t.CacheSize))
- EcosystemCachedArtifacts.WithLabelValues(ecosystem).Set(float64(t.Artifacts))
- EcosystemPackages.WithLabelValues(ecosystem).Set(float64(t.Packages))
- EcosystemVersions.WithLabelValues(ecosystem).Set(float64(t.Versions))
- }
-
- publishedMu.Lock()
- defer publishedMu.Unlock()
-
- for ecosystem := range publishedEcosystems {
- if _, still := totals[ecosystem]; still {
- continue
- }
- EcosystemDownloadedBytes.DeleteLabelValues(ecosystem)
- EcosystemDownloads.DeleteLabelValues(ecosystem)
- EcosystemCacheSize.DeleteLabelValues(ecosystem)
- EcosystemCachedArtifacts.DeleteLabelValues(ecosystem)
- EcosystemPackages.DeleteLabelValues(ecosystem)
- EcosystemVersions.DeleteLabelValues(ecosystem)
- }
-
- publishedEcosystems = make(map[string]bool, len(totals))
- for ecosystem := range totals {
- publishedEcosystems[ecosystem] = true
- }
-}
-
-// MetadataCacheStats is one ecosystem's share of the metadata cache.
-type MetadataCacheStats struct {
- Ecosystem string
- Entries int64
- Size int64
-}
-
-// UpdateMetadataCacheStats republishes the metadata cache gauges from a fresh
-// snapshot. Like UpdateEcosystemStats it sums rows by normalized label, sets
-// each series in place and deletes only labels that have disappeared.
-func UpdateMetadataCacheStats(stats []MetadataCacheStats) {
- totals := make(map[string]MetadataCacheStats, len(stats))
- for _, s := range stats {
- ecosystem := purl.NormalizeEcosystem(s.Ecosystem)
- t := totals[ecosystem]
- t.Entries += s.Entries
- t.Size += s.Size
- totals[ecosystem] = t
- }
-
- for ecosystem, t := range totals {
- MetadataCacheEntries.WithLabelValues(ecosystem).Set(float64(t.Entries))
- MetadataCacheSize.WithLabelValues(ecosystem).Set(float64(t.Size))
- }
-
- publishedMu.Lock()
- defer publishedMu.Unlock()
-
- for ecosystem := range publishedMetadataEcosystems {
- if _, still := totals[ecosystem]; still {
- continue
- }
- MetadataCacheEntries.DeleteLabelValues(ecosystem)
- MetadataCacheSize.DeleteLabelValues(ecosystem)
- }
-
- publishedMetadataEcosystems = make(map[string]bool, len(totals))
- for ecosystem := range totals {
- publishedMetadataEcosystems[ecosystem] = true
- }
-}
-
// UpdateCircuitBreakerState updates circuit breaker state gauge.
// state: 0=closed, 1=half-open, 2=open
func UpdateCircuitBreakerState(registry string, state int) {
@@ -651,11 +200,6 @@ func RecordCircuitBreakerTrip(registry string) {
CircuitBreakerTrips.WithLabelValues(registry).Inc()
}
-// RecordUpstreamRateLimitHold counts a hold placed on host.
-func RecordUpstreamRateLimitHold(host string) {
- UpstreamRateLimitHolds.WithLabelValues(host).Inc()
-}
-
// IncrementActiveRequests increments the active request counter.
func IncrementActiveRequests() {
ActiveRequests.Inc()
diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go
index 25ae1cd..db7b097 100644
--- a/internal/metrics/metrics_test.go
+++ b/internal/metrics/metrics_test.go
@@ -6,7 +6,6 @@ import (
"time"
"github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
)
@@ -21,38 +20,35 @@ func TestRecordRequest(t *testing.T) {
}
func TestRecordCacheOperations(t *testing.T) {
- RecordArtifactCacheHit("npm")
- RecordArtifactCacheHit("npm")
- RecordArtifactCacheMiss("npm")
- RecordMetadataCacheHit("pypi", 128)
- RecordMetadataCacheMiss("pypi")
+ RecordCacheHit("npm")
+ RecordCacheHit("npm")
+ RecordCacheMiss("npm")
+ RecordCacheMiss("pypi")
// No panics = success
}
func TestRecordUpstreamOperations(t *testing.T) {
- isolateRecentFailures(t)
RecordUpstreamFetch("npm", 500*time.Millisecond)
RecordUpstreamFetch("pypi", 1*time.Second)
- RecordUpstreamError("npm", "fetch_failed", FailureDetail{})
- RecordUpstreamError("pypi", "not_found", FailureDetail{})
+ RecordUpstreamError("npm", "fetch_failed")
+ RecordUpstreamError("pypi", "not_found")
// No panics = success
}
func TestRecordStorageOperations(t *testing.T) {
- isolateRecentFailures(t)
RecordStorageOperation("read", 10*time.Millisecond)
RecordStorageOperation("write", 50*time.Millisecond)
- RecordStorageError("read", FailureDetail{})
- RecordStorageError("write", FailureDetail{})
+ RecordStorageError("read")
+ RecordStorageError("write")
// No panics = success
}
func TestUpdateCacheStats(t *testing.T) {
UpdateCacheStats(1024*1024*1024, 100) // 1GB, 100 artifacts
- UpdateCacheStats(0, 0) // Empty cache
+ UpdateCacheStats(0, 0) // Empty cache
// No panics = success
}
@@ -89,7 +85,6 @@ func TestMetricsAreRegistered(t *testing.T) {
CachedArtifacts,
CircuitBreakerState,
CircuitBreakerTrips,
- UpstreamRateLimitHolds,
ActiveRequests,
}
@@ -115,9 +110,9 @@ func TestMetricsAreRegistered(t *testing.T) {
func TestCacheHitRatio(t *testing.T) {
// Reset by recording some hits and misses
- RecordArtifactCacheHit("test")
- RecordArtifactCacheHit("test")
- RecordArtifactCacheMiss("test")
+ RecordCacheHit("test")
+ RecordCacheHit("test")
+ RecordCacheMiss("test")
// Collect metrics
hits := getMetricValue(t, CacheHits, "test")
@@ -180,7 +175,7 @@ func getMetricValue(t *testing.T, collector prometheus.Collector, labelValue str
func TestMetricsEndpointOutput(t *testing.T) {
// Record some test metrics
RecordRequest("npm", 200, 50*time.Millisecond)
- RecordArtifactCacheHit("npm")
+ RecordCacheHit("npm")
UpdateCacheStats(1024*1024, 10)
// Get the handler and check it produces output
@@ -196,120 +191,22 @@ func TestMetricsEndpointOutput(t *testing.T) {
func TestMetricsLabeling(t *testing.T) {
// Test that different ecosystems are properly labeled
- ecosystems := []struct {
- input string
- label string
- }{
- {input: "npm", label: "npm"},
- {input: "pypi", label: "pypi"},
- {input: "cargo", label: "cargo"},
- {input: "gem", label: "rubygems"},
- }
+ ecosystems := []string{"npm", "pypi", "cargo", "gem"}
for _, eco := range ecosystems {
- RecordRequest(eco.input, 200, 10*time.Millisecond)
- RecordArtifactCacheHit(eco.input)
+ RecordRequest(eco, 200, 10*time.Millisecond)
+ RecordCacheHit(eco)
}
// Verify each ecosystem has metrics
for _, eco := range ecosystems {
- val := getMetricValue(t, CacheHits, eco.label)
+ val := getMetricValue(t, CacheHits, eco)
if val == 0 {
- t.Errorf("no cache hits recorded for %s", eco.label)
+ t.Errorf("no cache hits recorded for %s", eco)
}
}
}
-func TestCacheMetricLabelsAreNormalized(t *testing.T) {
- rubyHitsBefore := testutil.ToFloat64(CacheHits.WithLabelValues("rubygems", CacheKindArtifact))
- composerMissesBefore := testutil.ToFloat64(CacheMisses.WithLabelValues("packagist", CacheKindArtifact))
- goMetadataHitsBefore := testutil.ToFloat64(CacheHits.WithLabelValues("golang", CacheKindMetadata))
- goServedBefore := testutil.ToFloat64(MetadataServedBytes.WithLabelValues("golang"))
-
- RecordArtifactCacheHit("gem")
- RecordArtifactCacheMiss("composer")
- RecordMetadataCacheHit("go", 10)
-
- if diff := testutil.ToFloat64(CacheHits.WithLabelValues("rubygems", CacheKindArtifact)) - rubyHitsBefore; diff != 1 {
- t.Errorf("rubygems cache hits delta = %.0f, want 1", diff)
- }
- if diff := testutil.ToFloat64(CacheMisses.WithLabelValues("packagist", CacheKindArtifact)) - composerMissesBefore; diff != 1 {
- t.Errorf("packagist cache misses delta = %.0f, want 1", diff)
- }
- if diff := testutil.ToFloat64(CacheHits.WithLabelValues("golang", CacheKindMetadata)) - goMetadataHitsBefore; diff != 1 {
- t.Errorf("golang metadata hits delta = %.0f, want 1", diff)
- }
- if diff := testutil.ToFloat64(MetadataServedBytes.WithLabelValues("golang")) - goServedBefore; diff != 10 {
- t.Errorf("golang metadata served bytes delta = %.0f, want 10", diff)
- }
-}
-
-// TestCacheKindsAreCountedApart checks an artifact hit and a metadata hit land
-// in different series. Only metadata hits add served bytes.
-func TestCacheKindsAreCountedApart(t *testing.T) {
- const eco = "kind-test"
- artifactHits := func() float64 { return testutil.ToFloat64(CacheHits.WithLabelValues(eco, CacheKindArtifact)) }
- metadataHits := func() float64 { return testutil.ToFloat64(CacheHits.WithLabelValues(eco, CacheKindMetadata)) }
- artifactMisses := func() float64 { return testutil.ToFloat64(CacheMisses.WithLabelValues(eco, CacheKindArtifact)) }
- metadataMisses := func() float64 { return testutil.ToFloat64(CacheMisses.WithLabelValues(eco, CacheKindMetadata)) }
- served := func() float64 { return testutil.ToFloat64(MetadataServedBytes.WithLabelValues(eco)) }
-
- aHits, mHits, aMisses, mMisses, bytes := artifactHits(), metadataHits(), artifactMisses(), metadataMisses(), served()
-
- RecordArtifactCacheHit(eco)
- RecordMetadataCacheHit(eco, 300)
- RecordMetadataCacheHit(eco, 0)
- RecordMetadataCacheMiss(eco)
-
- checks := []struct {
- name string
- got, want float64
- }{
- {"artifact hits", artifactHits() - aHits, 1},
- {"metadata hits", metadataHits() - mHits, 2},
- {"artifact misses", artifactMisses() - aMisses, 0},
- {"metadata misses", metadataMisses() - mMisses, 1},
- {"metadata served bytes", served() - bytes, 300},
- }
- for _, c := range checks {
- if c.got != c.want {
- t.Errorf("%s delta = %.0f, want %.0f", c.name, c.got, c.want)
- }
- }
-}
-
-func TestUpdateMetadataCacheStats(t *testing.T) {
- UpdateMetadataCacheStats([]MetadataCacheStats{
- {Ecosystem: "gem", Entries: 2, Size: 100},
- {Ecosystem: "rubygems", Entries: 3, Size: 50},
- {Ecosystem: "npm", Entries: 1, Size: 10},
- })
-
- if got := testutil.ToFloat64(MetadataCacheEntries.WithLabelValues("rubygems")); got != 5 {
- t.Errorf("rubygems entries = %.0f, want 5 (aliases summed)", got)
- }
- if got := testutil.ToFloat64(MetadataCacheSize.WithLabelValues("rubygems")); got != 150 {
- t.Errorf("rubygems size = %.0f, want 150 (aliases summed)", got)
- }
- if got := testutil.CollectAndCount(MetadataCacheSize); got != 2 {
- t.Errorf("size series = %d, want 2", got)
- }
-
- UpdateMetadataCacheStats([]MetadataCacheStats{{Ecosystem: "npm", Entries: 4, Size: 40}})
-
- if got := testutil.CollectAndCount(MetadataCacheSize); got != 1 {
- t.Errorf("size series after rubygems disappeared = %d, want 1", got)
- }
- if got := testutil.CollectAndCount(MetadataCacheEntries); got != 1 {
- t.Errorf("entry series after rubygems disappeared = %d, want 1", got)
- }
- if got := testutil.ToFloat64(MetadataCacheSize.WithLabelValues("npm")); got != 40 {
- t.Errorf("npm size = %.0f, want 40", got)
- }
-
- UpdateMetadataCacheStats(nil)
-}
-
func TestMetricNames(t *testing.T) {
// Verify metric names follow Prometheus naming conventions
expectedMetrics := []string{
@@ -317,9 +214,6 @@ func TestMetricNames(t *testing.T) {
"proxy_request_duration_seconds",
"proxy_cache_hits_total",
"proxy_cache_misses_total",
- "proxy_metadata_cache_served_bytes_total",
- "proxy_metadata_cache_size_bytes",
- "proxy_metadata_cache_entries",
"proxy_upstream_fetch_duration_seconds",
"proxy_upstream_errors_total",
"proxy_storage_operation_duration_seconds",
diff --git a/internal/metrics/snapshot.go b/internal/metrics/snapshot.go
deleted file mode 100644
index 2c96dff..0000000
--- a/internal/metrics/snapshot.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package metrics
-
-import (
- "fmt"
- "sort"
-
- "github.com/prometheus/client_golang/prometheus"
- dto "github.com/prometheus/client_model/go"
-)
-
-// Sample is one time series read out of the registry at a point in time.
-//
-// Counters and gauges carry their value in Value. Histograms carry their
-// observation count and total in Count and Sum, and leave Value at zero —
-// there is no single "value" for a histogram, and the UI shows a mean derived
-// from Sum/Count rather than pretending otherwise.
-type Sample struct {
- Labels map[string]string
- Value float64
- Count uint64
- Sum float64
-}
-
-// Label returns the value of one label, or "" if absent.
-func (s Sample) Label(name string) string {
- return s.Labels[name]
-}
-
-// Mean returns the average observation of a histogram sample, or 0 when
-// nothing has been observed yet.
-func (s Sample) Mean() float64 {
- if s.Count == 0 {
- return 0
- }
- return s.Sum / float64(s.Count)
-}
-
-// Snapshot is the value of every registered proxy metric at one instant.
-//
-// These are process-lifetime figures held in the Prometheus registry, not
-// database state: they start at zero when the proxy starts and are lost on
-// restart. Anything that needs to survive a restart, or needs history, has to
-// come from a Prometheus server scraping /metrics.
-type Snapshot struct {
- families map[string][]Sample
-}
-
-// Gather reads the default Prometheus registry. It is the same data /metrics
-// serves, shaped for rendering rather than for scraping.
-func Gather() (*Snapshot, error) {
- return GatherFrom(prometheus.DefaultGatherer)
-}
-
-// GatherFrom reads an explicit gatherer, so tests can supply their own registry.
-func GatherFrom(g prometheus.Gatherer) (*Snapshot, error) {
- families, err := g.Gather()
- if err != nil {
- return nil, fmt.Errorf("gathering metrics: %w", err)
- }
-
- snap := &Snapshot{families: make(map[string][]Sample, len(families))}
- for _, mf := range families {
- samples := make([]Sample, 0, len(mf.GetMetric()))
- for _, m := range mf.GetMetric() {
- samples = append(samples, sampleOf(m))
- }
- snap.families[mf.GetName()] = samples
- }
- return snap, nil
-}
-
-func sampleOf(m *dto.Metric) Sample {
- s := Sample{Labels: make(map[string]string, len(m.GetLabel()))}
- for _, lp := range m.GetLabel() {
- s.Labels[lp.GetName()] = lp.GetValue()
- }
-
- switch {
- case m.GetCounter() != nil:
- s.Value = m.GetCounter().GetValue()
- case m.GetGauge() != nil:
- s.Value = m.GetGauge().GetValue()
- case m.GetHistogram() != nil:
- h := m.GetHistogram()
- s.Count = h.GetSampleCount()
- s.Sum = h.GetSampleSum()
- case m.GetSummary() != nil:
- sm := m.GetSummary()
- s.Count = sm.GetSampleCount()
- s.Sum = sm.GetSampleSum()
- case m.GetUntyped() != nil:
- s.Value = m.GetUntyped().GetValue()
- }
- return s
-}
-
-// Samples returns every series of a metric, or nil when it has never been
-// observed. Series are ordered by their label values so that rendering is
-// stable between scrapes.
-func (s *Snapshot) Samples(name string) []Sample {
- if s == nil {
- return nil
- }
- out := append([]Sample(nil), s.families[name]...)
- sort.Slice(out, func(i, j int) bool {
- return labelKey(out[i].Labels) < labelKey(out[j].Labels)
- })
- return out
-}
-
-func labelKey(labels map[string]string) string {
- names := make([]string, 0, len(labels))
- for n := range labels {
- names = append(names, n)
- }
- sort.Strings(names)
-
- key := ""
- for _, n := range names {
- key += n + "=" + labels[n] + ","
- }
- return key
-}
-
-// Sum totals every series of a counter or gauge.
-func (s *Snapshot) Sum(name string) float64 {
- var total float64
- for _, sample := range s.Samples(name) {
- total += sample.Value
- }
- return total
-}
-
-// Count adds up the observation counts of a histogram across every series.
-func (s *Snapshot) Count(name string) uint64 {
- var total uint64
- for _, sample := range s.Samples(name) {
- total += sample.Count
- }
- return total
-}
-
-// Mean returns the average observation of a histogram across every series, or
-// 0 when nothing has been observed.
-func (s *Snapshot) Mean(name string) float64 {
- var count uint64
- var sum float64
- for _, sample := range s.Samples(name) {
- count += sample.Count
- sum += sample.Sum
- }
- if count == 0 {
- return 0
- }
- return sum / float64(count)
-}
-
-// SumBy groups a metric by one label and totals each group.
-func (s *Snapshot) SumBy(name, label string) map[string]float64 {
- out := make(map[string]float64)
- for _, sample := range s.Samples(name) {
- out[sample.Label(label)] += sample.Value
- }
- return out
-}
diff --git a/internal/metrics/snapshot_test.go b/internal/metrics/snapshot_test.go
deleted file mode 100644
index 5b6c7fd..0000000
--- a/internal/metrics/snapshot_test.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package metrics
-
-import (
- "testing"
-
- "github.com/prometheus/client_golang/prometheus"
-)
-
-// newTestRegistry builds an isolated registry so these tests are not affected
-// by counters other tests in this package have already incremented.
-func newTestRegistry(t *testing.T) (*prometheus.Registry, *prometheus.CounterVec, *prometheus.HistogramVec, *prometheus.GaugeVec) {
- t.Helper()
-
- reg := prometheus.NewRegistry()
- counter := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "test_errors_total", Help: "t"},
- []string{"ecosystem", "kind"},
- )
- hist := prometheus.NewHistogramVec(
- prometheus.HistogramOpts{Name: "test_duration_seconds", Help: "t"},
- []string{"op"},
- )
- gauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "test_active", Help: "t"}, []string{})
- reg.MustRegister(counter, hist, gauge)
- return reg, counter, hist, gauge
-}
-
-func TestSnapshotSumAndSumBy(t *testing.T) {
- reg, counter, _, gauge := newTestRegistry(t)
- counter.WithLabelValues("npm", "timeout").Add(3)
- counter.WithLabelValues("npm", "refused").Add(2)
- counter.WithLabelValues("pypi", "timeout").Add(5)
- gauge.WithLabelValues().Set(7)
-
- snap, err := GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
-
- if got := snap.Sum("test_errors_total"); got != 10 {
- t.Errorf("Sum = %v, want 10", got)
- }
- if got := snap.Sum("test_active"); got != 7 {
- t.Errorf("gauge Sum = %v, want 7", got)
- }
-
- byEco := snap.SumBy("test_errors_total", "ecosystem")
- if byEco["npm"] != 5 || byEco["pypi"] != 5 {
- t.Errorf("SumBy(ecosystem) = %v, want npm=5 pypi=5", byEco)
- }
-}
-
-func TestSnapshotHistogram(t *testing.T) {
- reg, _, hist, _ := newTestRegistry(t)
- hist.WithLabelValues("get").Observe(0.010)
- hist.WithLabelValues("get").Observe(0.030)
- hist.WithLabelValues("put").Observe(0.100)
-
- snap, err := GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
-
- if got := snap.Count("test_duration_seconds"); got != 3 {
- t.Errorf("Count = %d, want 3", got)
- }
- // (0.010 + 0.030 + 0.100) / 3
- if got := snap.Mean("test_duration_seconds"); got < 0.0466 || got > 0.0467 {
- t.Errorf("Mean = %v, want ~0.04667", got)
- }
-
- // A histogram carries no single value, so Value stays zero.
- for _, s := range snap.Samples("test_duration_seconds") {
- if s.Value != 0 {
- t.Errorf("histogram sample carries Value %v, want 0", s.Value)
- }
- if s.Label("op") == "get" && s.Count != 2 {
- t.Errorf("get count = %d, want 2", s.Count)
- }
- }
-}
-
-func TestSnapshotSamplesAreOrdered(t *testing.T) {
- reg, counter, _, _ := newTestRegistry(t)
- for _, eco := range []string{"pypi", "npm", "cargo"} {
- counter.WithLabelValues(eco, "timeout").Inc()
- }
-
- snap, err := GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
-
- var got []string
- for _, s := range snap.Samples("test_errors_total") {
- got = append(got, s.Label("ecosystem"))
- }
- want := []string{"cargo", "npm", "pypi"}
- for i := range want {
- if got[i] != want[i] {
- t.Fatalf("sample order = %v, want %v (stable rendering depends on it)", got, want)
- }
- }
-}
-
-func TestSnapshotMissingMetric(t *testing.T) {
- reg, _, _, _ := newTestRegistry(t)
- snap, err := GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
-
- if got := snap.Samples("nope_total"); got != nil {
- t.Errorf("Samples of an unknown metric = %v, want nil", got)
- }
- if got := snap.Sum("nope_total"); got != 0 {
- t.Errorf("Sum of an unknown metric = %v, want 0", got)
- }
- if got := snap.Mean("nope_total"); got != 0 {
- t.Errorf("Mean of an unknown metric = %v, want 0", got)
- }
-}
-
-// Gather reads the real registry, so every metric this package registers must
-// come back. This is the check that a newly added metric is reachable by the UI.
-func TestGatherSeesRegisteredMetrics(t *testing.T) {
- isolateRecentFailures(t)
- RecordArtifactCacheHit("npm")
- RecordScanError("npm", "clamav", "timeout", FailureDetail{})
-
- snap, err := Gather()
- if err != nil {
- t.Fatalf("Gather: %v", err)
- }
-
- for _, name := range []string{"proxy_cache_hits_total", "proxy_scan_errors_total"} {
- if len(snap.Samples(name)) == 0 {
- t.Errorf("%s is registered but absent from the snapshot", name)
- }
- }
-}
diff --git a/internal/mirror/apk.go b/internal/mirror/apk.go
deleted file mode 100644
index 30a9771..0000000
--- a/internal/mirror/apk.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "fmt"
- "regexp"
- "strings"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/registries"
-)
-
-// apkIndexTTL is how long a repository's APKINDEX is reused, so mirroring
-// the packages of an image reads each index once.
-const apkIndexTTL = 5 * time.Minute
-
-var (
- // apkDistroPattern takes the release from syft's distro qualifier, such
- // as alpine-3.20.3 for v3.20. Edge, as alpine-3.21.0_alpha20240807,
- // isn't a release and doesn't match.
- apkDistroPattern = regexp.MustCompile(`^alpine-(\d+)\.(\d+)(?:\.\d+)?$`)
- apkArchPattern = regexp.MustCompile(`^[a-z0-9_]+$`)
- // apkComponents are the repositories of a release a package is looked
- // up in, in order.
- apkComponents = []string{"main", "community"}
-)
-
-// apkIndexes keeps one registries client per repository directory, which
-// holds the parsed APKINDEX.
-type apkIndexes struct {
- mu sync.Mutex
- regs map[string]apkIndex
-}
-
-type apkIndex struct {
- reg registries.Registry
- fetched time.Time
-}
-
-// mirrorAPK mirrors an Alpine package under the path the handler caches it
-// at, {repository}/v{release}/{component}/{arch}/{file}. The PURL namespace
-// names the upstream.apk repository; the distro and arch qualifiers give
-// the release and architecture, and the package is looked up in the main
-// and community indexes of that release.
-func (m *Mirror) mirrorAPK(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- fail := func(err error) { m.recordFailure(tracker, pv, err) }
- repository, name := pv.Namespace, pv.Name
- if repository == "" {
- repository = "alpine"
- }
- repositoryURL, ok := handler.APKRepositories(m.APKRepositories)[repository]
- if !ok {
- fail(fmt.Errorf("unknown APK repository %q: it isn't in upstream.apk", repository))
- return
- }
- match := apkDistroPattern.FindStringSubmatch(pv.Qualifiers["distro"])
- if match == nil {
- fail(fmt.Errorf("apk packages need a distro qualifier with the Alpine release, such as distro=alpine-3.20.3, not %q", pv.Qualifiers["distro"]))
- return
- }
- release := "v" + match[1] + "." + match[2]
- arch := pv.Qualifiers["arch"]
- if !apkArchPattern.MatchString(arch) {
- fail(fmt.Errorf("apk packages need an arch qualifier, such as arch=x86_64, not %q", arch))
- return
- }
-
- for _, component := range apkComponents {
- dir := release + "/" + component + "/" + arch
- artifact, found, err := m.apkArtifact(ctx, repositoryURL+"/"+dir, name, pv.Version)
- if err != nil {
- fail(fmt.Errorf("reading %s %s/APKINDEX.tar.gz: %w", repository, dir, err))
- return
- }
- if !found {
- continue
- }
- cacheFilename := handler.APKCacheFilename(repository, dir+"/"+artifact.Filename)
- result, err := m.proxy.GetOrFetchArtifactFromURL(ctx, pv.Ecosystem, name, pv.Version, cacheFilename, artifact.URL)
- if err != nil {
- fail(err)
- return
- }
- m.recordResult(tracker, pv, result)
- return
- }
- fail(fmt.Errorf("%s %s isn't in %s of %s %s for %s", name, pv.Version, strings.Join(apkComponents, " or "), repository, release, arch))
-}
-
-// apkArtifact returns the file of version of name that the index in dirURL
-// lists, and whether it lists one.
-func (m *Mirror) apkArtifact(ctx context.Context, dirURL, name, version string) (registries.Artifact, bool, error) {
- reg, err := m.apkRegistry(dirURL)
- if err != nil {
- return registries.Artifact{}, false, err
- }
- versions, err := reg.FetchVersions(ctx, name)
- if isNotFound(err) {
- return registries.Artifact{}, false, nil
- }
- if err != nil {
- m.apk.forget(dirURL)
- return registries.Artifact{}, false, err
- }
- for _, v := range versions {
- if v.Number == version && len(v.Artifacts) > 0 {
- return v.Artifacts[0], true, nil
- }
- }
- return registries.Artifact{}, false, nil
-}
-
-// apkRegistry returns the client for the repository directory at dirURL,
-// reusing it, and the index it read, for apkIndexTTL.
-func (m *Mirror) apkRegistry(dirURL string) (registries.Registry, error) { //nolint:ireturn // registries.New returns the interface
- m.apk.mu.Lock()
- defer m.apk.mu.Unlock()
- if idx, ok := m.apk.regs[dirURL]; ok && time.Since(idx.fetched) < apkIndexTTL {
- return idx.reg, nil
- }
- r, _ := m.Versions.(RegistryVersions)
- // Naming the v2 index reads it also where Packages.adb is served beside
- // it, which the client would try first and refuse.
- reg, err := registries.New("apk", dirURL+"/APKINDEX.tar.gz", r.client())
- if err != nil {
- return nil, err
- }
- if m.apk.regs == nil {
- m.apk.regs = map[string]apkIndex{}
- }
- m.apk.regs[dirURL] = apkIndex{reg: reg, fetched: time.Now()}
- return reg, nil
-}
-
-// forget drops the client for dirURL, so an index that failed to load is
-// read again next time.
-func (a *apkIndexes) forget(dirURL string) {
- a.mu.Lock()
- defer a.mu.Unlock()
- delete(a.regs, dirURL)
-}
-
-// isNotFound reports whether err means a package or index doesn't exist,
-// such as a release without a community repository for an architecture.
-func isNotFound(err error) bool {
- if errors.Is(err, registries.ErrNotFound) {
- return true
- }
- var httpErr *registries.HTTPError
- return errors.As(err, &httpErr) && httpErr.IsNotFound()
-}
diff --git a/internal/mirror/brew_test.go b/internal/mirror/brew_test.go
deleted file mode 100644
index 80e5a26..0000000
--- a/internal/mirror/brew_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "strings"
- "sync"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-// fakeBottles resolves every formula version to homebrew/core/ tagged
-// -1, like a formula whose bottles were rebuilt once.
-type fakeBottles struct {
- err error
-}
-
-func (f fakeBottles) BottleImage(_ context.Context, formula, version string) (handler.ImageRef, error) {
- if f.err != nil {
- return handler.ImageRef{}, f.err
- }
- return handler.ImageRef{Name: "homebrew/core/" + formula, References: []string{version + "-1"}}, nil
-}
-
-// platformImages records the images mirrored and the platforms asked for.
-type platformImages struct {
- mu sync.Mutex
- refs []handler.ImageRef
- platforms [][]handler.Platform
-}
-
-func (p *platformImages) MirrorImage(_ context.Context, ref handler.ImageRef, platforms []handler.Platform) (handler.ImageResult, error) {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.refs = append(p.refs, ref)
- p.platforms = append(p.platforms, platforms)
- return handler.ImageResult{Fetched: 2, Bytes: 10}, nil
-}
-
-type stubVersions map[string][]string
-
-func (s stubVersions) ListVersions(_ context.Context, ecosystem, name string) ([]string, error) {
- return s[ecosystem+"/"+name], nil
-}
-
-func TestMirrorBrewMirrorsBottleImage(t *testing.T) {
- m := setupTestMirror(t, 1)
- images := &platformImages{}
- m.Images, m.Bottles = images, fakeBottles{}
-
- source := &PURLSource{
- PURLs: []string{"pkg:brew/hello@2.12.3", "pkg:brew/jq"},
- Platforms: []string{"linux/arm64"},
- Versions: stubVersions{"brew/jq": {"1.8.1"}},
- }
- progress, err := m.Run(context.Background(), source)
- if err != nil {
- t.Fatal(err)
- }
- if progress.Completed != 2 || progress.Failed != 0 {
- t.Fatalf("progress = %+v, errors %v", progress, progress.Errors)
- }
- got := map[string]string{}
- for i, ref := range images.refs {
- if len(images.platforms[i]) != 1 || images.platforms[i][0].OS != "linux" || images.platforms[i][0].Architecture != "arm64" {
- t.Errorf("%s: platforms = %+v, want linux/arm64", ref.Name, images.platforms[i])
- }
- got[ref.Name] = strings.Join(ref.References, ",")
- }
- if got["homebrew/core/hello"] != "2.12.3-1" || got["homebrew/core/jq"] != "1.8.1-1" {
- t.Errorf("images = %v", got)
- }
-}
-
-func TestMirrorBrewFailures(t *testing.T) {
- m := setupTestMirror(t, 1)
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:brew/hello@2.12.3"}})
- if err != nil {
- t.Fatal(err)
- }
- if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, "isn't available") {
- t.Errorf("without a bottle resolver: %+v", progress)
- }
-
- images := &platformImages{}
- m.Images, m.Bottles = images, fakeBottles{err: errors.New(`homebrew/core has no formula "nope"`)}
- progress, err = m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:brew/nope@1.0"}})
- if err != nil {
- t.Fatal(err)
- }
- if progress.Failed != 1 || len(images.refs) != 0 {
- t.Errorf("unknown formula: %+v, mirrored %v", progress, images.refs)
- }
-}
diff --git a/internal/mirror/cache_test.go b/internal/mirror/cache_test.go
deleted file mode 100644
index 661e408..0000000
--- a/internal/mirror/cache_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
-
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
-)
-
-type mirrorCacheRegistry struct {
- downloadURL string
- resolutions atomic.Int64
-}
-
-type rejectingMirrorTransport struct{}
-
-func (rejectingMirrorTransport) RoundTrip(*http.Request) (*http.Response, error) {
- return nil, errors.New("unexpected artifact download on a cache hit")
-}
-
-func (*mirrorCacheRegistry) Ecosystem() string { return "npm" }
-func (*mirrorCacheRegistry) FetchVersions(context.Context, string) ([]registries.Version, error) {
- return nil, errors.New("unexpected metadata request")
-}
-func (r *mirrorCacheRegistry) URLs() client.URLBuilder { //nolint:ireturn // required by fetch.Registry
- return &client.BaseURLs{DownloadFn: func(_, _ string) string {
- r.resolutions.Add(1)
- return r.downloadURL
- }}
-}
-
-func TestMirrorRepeatedRunSkipsCachedArtifact(t *testing.T) {
- var downloads atomic.Int64
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/example-1.0.0.tgz" {
- t.Errorf("unexpected artifact path: %s", r.URL.Path)
- http.NotFound(w, r)
- return
- }
- downloads.Add(1)
- _, _ = io.WriteString(w, "tarball bytes")
- }))
- defer upstream.Close()
- m := setupTestMirror(t, 1)
- registry := &mirrorCacheRegistry{downloadURL: upstream.URL + "/example-1.0.0.tgz"}
- m.proxy.Resolver.RegisterRegistry(registry)
- fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
- t.Cleanup(func() { _ = fetcher.Close() })
- m.proxy.Fetcher = fetcher
- source := &PURLSource{PURLs: []string{"pkg:npm/example@1.0.0"}}
- first, err := m.Run(context.Background(), source)
- if err != nil || first.Completed != 1 || first.Skipped != 0 || first.Failed != 0 {
- t.Fatalf("first mirror: progress=%+v err=%v", first, err)
- }
- before, err := m.db.GetArtifact("pkg:npm/example@1.0.0", "example-1.0.0.tgz")
- if err != nil {
- t.Fatal(err)
- }
- // The second run must succeed even when the artifact server is offline.
- upstream.Close()
- second, err := m.Run(context.Background(), source)
- if err != nil || second.Completed != 0 || second.Skipped != 1 || second.Failed != 0 {
- t.Fatalf("second mirror: progress=%+v err=%v", second, err)
- }
- after, err := m.db.GetArtifact("pkg:npm/example@1.0.0", "example-1.0.0.tgz")
- if err != nil {
- t.Fatal(err)
- }
- if before.FetchedAt != after.FetchedAt || before.ContentHash != after.ContentHash || before.StoragePath != after.StoragePath {
- t.Fatal("second mirror changed the cached artifact")
- }
- if downloads.Load() != 1 || registry.resolutions.Load() != 2 {
- t.Fatalf("downloads=%d resolutions=%d; want 1 and 2", downloads.Load(), registry.resolutions.Load())
- }
-}
diff --git a/internal/mirror/cran.go b/internal/mirror/cran.go
deleted file mode 100644
index 02d26bd..0000000
--- a/internal/mirror/cran.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package mirror
-
-import (
- "bufio"
- "bytes"
- "compress/gzip"
- "context"
- "fmt"
- "io"
- "net/url"
- "regexp"
- "strings"
- "sync"
- "time"
-
- "github.com/git-pkgs/registries"
-)
-
-const (
- cranDefaultURL = "https://cloud.r-project.org"
- // cranIndexTTL is how long a fetched src/contrib/PACKAGES is reused, so
- // mirroring many packages, or every version of one, reads it once.
- cranIndexTTL = 5 * time.Minute
- // cranMaxIndexSize limits the decompressed PACKAGES.
- // CRAN's PACKAGES is about 7 MB.
- cranMaxIndexSize = 64 << 20
- // cranMaxLine limits a line of PACKAGES.
- cranMaxLine = 1 << 20
-)
-
-var (
- cranNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.]*$`)
- cranVersionPattern = regexp.MustCompile(`^[0-9]+[0-9.-]*$`)
-)
-
-// cranIndex caches the MD5 sums src/contrib/PACKAGES lists for the current
-// source packages of a CRAN repository, by file name.
-type cranIndex struct {
- mu sync.Mutex
- baseURL string
- fetched time.Time
- md5s map[string]string
-}
-
-// mirrorCRAN mirrors a CRAN source package under ("cran", name, version,
-// name_version.tar.gz), the one entry the handler serves for both
-// src/contrib and src/contrib/Archive.
-func (m *Mirror) mirrorCRAN(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- fail := func(err error) { m.recordFailure(tracker, pv, err) }
- if !cranNamePattern.MatchString(pv.Name) {
- fail(fmt.Errorf("invalid CRAN package name %q", pv.Name))
- return
- }
- if !cranVersionPattern.MatchString(pv.Version) {
- fail(fmt.Errorf("invalid CRAN version %q", pv.Version))
- return
- }
- baseURL, client, err := m.cranRepository(pv.Name)
- if err != nil {
- fail(err)
- return
- }
- filename := pv.Name + "_" + pv.Version + ".tar.gz"
-
- current, err := m.cranCurrentMD5s(ctx, baseURL, client)
- if err != nil {
- fail(fmt.Errorf("reading src/contrib/PACKAGES: %w", err))
- return
- }
- downloadURL := baseURL + "/src/contrib/" + filename
- sum, ok := current[filename]
- if !ok {
- downloadURL = baseURL + "/src/contrib/Archive/" + url.PathEscape(pv.Name) + "/" + filename
- }
- var declared string
- if sum != "" {
- declared = "md5-" + sum
- }
- result, err := m.proxy.GetOrFetchArtifactFromURLWithIntegrity(ctx, pv.Ecosystem, pv.Name, pv.Version, filename, downloadURL, declared)
- if err != nil {
- fail(err)
- return
- }
- m.recordResult(tracker, pv, result)
-}
-
-// cranRepository returns the repository to mirror a CRAN package from and
-// the client to read its indexes with: those Versions lists versions with,
-// which come from upstream.cran, or the public CRAN.
-func (m *Mirror) cranRepository(name string) (string, *registries.Client, error) {
- r, _ := m.Versions.(RegistryVersions)
- baseURL := cranDefaultURL
- if r.URL != nil {
- configured, err := r.URL("cran", name)
- if err != nil {
- return "", nil, err
- }
- if configured != "" {
- baseURL = strings.TrimSuffix(configured, "/")
- }
- }
- client := r.Client
- if client == nil {
- client = registries.DefaultClient()
- }
- return baseURL, client, nil
-}
-
-// cranCurrentMD5s returns the MD5 sums of the current source packages of the
-// repository at baseURL, reading src/contrib/PACKAGES.gz unless it was read
-// within cranIndexTTL.
-func (m *Mirror) cranCurrentMD5s(ctx context.Context, baseURL string, client *registries.Client) (map[string]string, error) {
- m.cran.mu.Lock()
- defer m.cran.mu.Unlock()
- if m.cran.md5s != nil && m.cran.baseURL == baseURL && time.Since(m.cran.fetched) < cranIndexTTL {
- return m.cran.md5s, nil
- }
- body, err := client.GetBody(ctx, baseURL+"/src/contrib/PACKAGES.gz")
- if err != nil {
- return nil, err
- }
- data, err := gunzipLimited(body)
- if err != nil {
- return nil, err
- }
- sums, err := parseCRANPackages(data)
- if err != nil {
- return nil, err
- }
- m.cran.md5s = sums
- m.cran.baseURL = baseURL
- m.cran.fetched = time.Now()
- return m.cran.md5s, nil
-}
-
-// gunzipLimited decompresses data, or returns it as is when it isn't
-// gzipped.
-func gunzipLimited(data []byte) ([]byte, error) {
- if !bytes.HasPrefix(data, []byte{0x1f, 0x8b}) {
- return data, nil
- }
- zr, err := gzip.NewReader(bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- defer func() { _ = zr.Close() }()
- out, err := io.ReadAll(io.LimitReader(zr, cranMaxIndexSize+1))
- if err != nil {
- return nil, err
- }
- if len(out) > cranMaxIndexSize {
- return nil, fmt.Errorf("index is larger than %d bytes", cranMaxIndexSize)
- }
- return out, nil
-}
-
-// parseCRANPackages returns the MD5sum of each source package a PACKAGES
-// file lists, by file name. Entries without one are listed with "".
-func parseCRANPackages(data []byte) (map[string]string, error) {
- sums := map[string]string{}
- var name, version, sum string
- flush := func() {
- if name != "" && version != "" {
- sums[name+"_"+version+".tar.gz"] = sum
- }
- name, version, sum = "", "", ""
- }
- scanner := bufio.NewScanner(bytes.NewReader(data))
- scanner.Buffer(nil, cranMaxLine)
- for scanner.Scan() {
- line := scanner.Text()
- if strings.TrimSpace(line) == "" {
- flush()
- continue
- }
- key, value, ok := strings.Cut(line, ":")
- if !ok || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
- continue
- }
- switch value = strings.TrimSpace(value); key {
- case "Package":
- name = value
- case "Version":
- version = value
- case "MD5sum":
- sum = value
- }
- }
- flush()
- return sums, scanner.Err()
-}
diff --git a/internal/mirror/cran_test.go b/internal/mirror/cran_test.go
deleted file mode 100644
index 3eae797..0000000
--- a/internal/mirror/cran_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package mirror
-
-import (
- "maps"
- "testing"
-)
-
-func TestParseCRANPackages(t *testing.T) {
- data := []byte(`Package: A3
-Version: 1.0.0
-Depends: R (>= 2.15.0), xtable,
- pbapply
-MD5sum: 027ebdd8affce8f0effaecfcd5f5ade2
-NeedsCompilation: no
-
-Package: abc
-Version: 2.2.1
-NeedsCompilation: no
-`)
- want := map[string]string{
- "A3_1.0.0.tar.gz": "027ebdd8affce8f0effaecfcd5f5ade2",
- "abc_2.2.1.tar.gz": "",
- }
- if got, err := parseCRANPackages(data); err != nil || !maps.Equal(got, want) {
- t.Errorf("parseCRANPackages = %v, %v, want %v", got, err, want)
- }
-}
diff --git a/internal/mirror/denylist_test.go b/internal/mirror/denylist_test.go
deleted file mode 100644
index 5676e87..0000000
--- a/internal/mirror/denylist_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package mirror
-
-import (
- "context"
- "io"
- "log/slog"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/denylist"
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-func TestMirrorOneDenylist(t *testing.T) {
- policy, err := denylist.New([]string{"pkg:npm/demo@1.0.0"})
- if err != nil {
- t.Fatal(err)
- }
- // No database, storage, or fetcher is needed: the denial comes first.
- p := &handler.Proxy{Denylist: policy}
- m := New(p, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
- tracker := newProgressTracker()
- m.mirrorOne(context.Background(), PackageVersion{Ecosystem: "npm", Name: "demo", Version: "1.0.0"}, tracker)
- progress := tracker.snapshot()
- if progress.Failed != 1 || progress.Completed != 0 || progress.Skipped != 0 || len(progress.Errors) != 1 {
- t.Fatalf("progress = %+v", progress)
- }
- if !strings.Contains(progress.Errors[0].Error, "denylist") {
- t.Fatalf("unexpected error: %s", progress.Errors[0].Error)
- }
-}
diff --git a/internal/mirror/files.go b/internal/mirror/files.go
deleted file mode 100644
index ab6bafd..0000000
--- a/internal/mirror/files.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package mirror
-
-import (
- "fmt"
- "path"
- "regexp"
- "slices"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/registries"
-)
-
-// fileEcosystem describes an ecosystem whose versions publish several files,
-// each mirrored on its own.
-type fileEcosystem struct {
- // qualifiers are the PURL qualifiers that select files. A file is
- // mirrored when each one given matches the value attrs reports for it.
- qualifiers []string
- attrs func(file registries.Artifact) map[string]string
- // cacheKey returns the name, version and filename the download handler
- // caches file under, or false when the handler would not cache it.
- cacheKey func(pv PackageVersion, file registries.Artifact) (name, version, filename string, ok bool)
- // check refuses a package the ecosystem can't mirror, before its files
- // are listed.
- check func(pv PackageVersion) error
- // variants name the kinds of a file, such as each tag combination of a
- // wheel or a conda package's platform, so a refresh mirrors the same
- // kinds of files of a new version as were cached of an old one.
- // cachedVariants names them for the filename cacheKey returns.
- variants func(file registries.Artifact) []string
- cachedVariants func(cacheFilename string) []string
-}
-
-var fileEcosystems = map[string]fileEcosystem{
- "pypi": {
- qualifiers: []string{"file_name"},
- attrs: func(file registries.Artifact) map[string]string {
- return map[string]string{"file_name": file.Filename}
- },
- cacheKey: func(pv PackageVersion, file registries.Artifact) (string, string, string, bool) {
- name, version := handler.PyPIFilenameIdentity(file.Filename)
- if name == "" {
- name, version = pv.Name, pv.Version
- }
- return name, version, file.Filename, true
- },
- variants: func(file registries.Artifact) []string { return pypiVariants(file.Filename) },
- cachedVariants: pypiVariants,
- },
- "conda": {
- qualifiers: []string{"subdir", "build", "type"},
- attrs: func(file registries.Artifact) map[string]string {
- subdir, _ := file.Metadata["subdir"].(string)
- build, _ := file.Metadata["build"].(string)
- return map[string]string{"subdir": subdir, "build": build, "type": condaFileType(file.Filename)}
- },
- cacheKey: func(pv PackageVersion, file registries.Artifact) (string, string, string, bool) {
- channel, _, _ := strings.Cut(pv.Name, "/")
- subdir, _ := file.Metadata["subdir"].(string)
- name, version := handler.CondaFilenameIdentity(file.Filename)
- if name == "" || subdir == "" || condaFileType(file.Filename) == "" {
- return "", "", "", false
- }
- return channel + "/" + name, version, subdir + "/" + file.Filename, true
- },
- check: func(pv PackageVersion) error {
- if !strings.Contains(pv.Name, "/") {
- return fmt.Errorf("conda packages need a channel, such as pkg:conda/conda-forge/%s", pv.Name)
- }
- return nil
- },
- variants: func(file registries.Artifact) []string {
- subdir, _ := file.Metadata["subdir"].(string)
- return []string{condaVariant(subdir, file.Filename)}
- },
- cachedVariants: func(cacheFilename string) []string {
- subdir, filename := path.Split(cacheFilename)
- return []string{condaVariant(strings.TrimSuffix(subdir, "/"), filename)}
- },
- },
-}
-
-// pypiVariants names a wheel by each combination of its python, ABI and
-// platform tags, which are alternatives where a tag lists several joined by
-// dots, with the platform reduced to OS and architecture. Every sdist is
-// one variant.
-func pypiVariants(filename string) []string {
- stem, wheel := strings.CutSuffix(filename, ".whl")
- if !wheel {
- return []string{"sdist"}
- }
- parts := strings.Split(stem, "-")
- if len(parts) < 5 { //nolint:mnd // name, version and three tags
- return []string{stem}
- }
- tags := parts[len(parts)-3:]
- var variants []string
- for _, python := range strings.Split(tags[0], ".") {
- for _, abi := range strings.Split(tags[1], ".") {
- for _, platform := range strings.Split(tags[2], ".") {
- v := wheelPython(python, abi) + "-" + abi + "-" + wheelPlatform(platform)
- if !slices.Contains(variants, v) {
- variants = append(variants, v)
- }
- }
- }
- }
- return variants
-}
-
-// wheelPython reduces the python tag of an abi3 wheel, a minimum such as
-// cp39, to its interpreter, so cp38-abi3 and cp311-abi3 wheels are the same
-// kind.
-func wheelPython(python, abi string) string {
- if abi != "abi3" {
- return python
- }
- return strings.TrimRight(python, "0123456789")
-}
-
-// wheelPlatformMinimum matches the glibc, musl or macOS minimum version in a
-// platform tag, such as the 2_17 in manylinux_2_17_x86_64 or the 2014 in
-// manylinux2014_x86_64.
-var wheelPlatformMinimum = regexp.MustCompile(`^(manylinux|musllinux|macosx)_?(?:\d+_\d+|1|2010|2014)_`)
-
-// wheelPlatform reduces a platform tag to OS and architecture, so
-// manylinux_2_17_x86_64 and manylinux_2_28_x86_64 name the same platform.
-func wheelPlatform(tag string) string {
- return wheelPlatformMinimum.ReplaceAllString(tag, "${1}_")
-}
-
-// condaBuildHash matches the hash and build number in a conda build string,
-// such as h8753938_0 in py312h8753938_0 or h5f976f7_0 in
-// h5f976f7_0_cpython.
-var condaBuildHash = regexp.MustCompile(`h[0-9a-f]{7}_\d+`)
-
-// condaVariant names a conda package file by its subdir, its build string
-// without the hash, such as py312 or _cpython, and its type.
-func condaVariant(subdir, filename string) string {
- fileType := condaFileType(filename)
- stem := strings.TrimSuffix(strings.TrimSuffix(filename, ".conda"), ".tar.bz2")
- build := stem[strings.LastIndex(stem, "-")+1:]
- build = condaBuildHash.ReplaceAllString(build, "")
- return subdir + "/" + build + "/" + fileType
-}
-
-// condaFileType returns the PURL type qualifier of a conda package file.
-func condaFileType(filename string) string {
- switch {
- case strings.HasSuffix(filename, ".conda"):
- return "conda"
- case strings.HasSuffix(filename, ".tar.bz2"):
- return "tar.bz2"
- default:
- return ""
- }
-}
-
-// selection returns the qualifiers of pv that select files, as
-// "key=value" pairs, for error messages.
-func (e fileEcosystem) selection(pv PackageVersion) string {
- if pv.variants != nil {
- return "the kinds of files cached of earlier versions"
- }
- var pairs []string
- for _, key := range e.qualifiers {
- if value := pv.Qualifiers[key]; value != "" {
- pairs = append(pairs, key+"="+value)
- }
- }
- return strings.Join(pairs, " ")
-}
-
-// selects reports whether file matches every selecting qualifier of pv.
-func (e fileEcosystem) selects(pv PackageVersion, file registries.Artifact) bool {
- if pv.variants != nil && !slices.ContainsFunc(e.variants(file), func(v string) bool { return pv.variants[v] }) {
- return false
- }
- attrs := e.attrs(file)
- for _, key := range e.qualifiers {
- if want := pv.Qualifiers[key]; want != "" && attrs[key] != want {
- return false
- }
- }
- return true
-}
-
-// fileQualifiers keeps the qualifiers of a PURL that select files of its
-// ecosystem, or returns nil.
-func fileQualifiers(ecosystem string, qualifiers map[string]string) map[string]string {
- e, ok := fileEcosystems[ecosystem]
- if !ok {
- return nil
- }
- var kept map[string]string
- for key, value := range qualifiers {
- if value != "" && slices.Contains(e.qualifiers, key) {
- if kept == nil {
- kept = map[string]string{}
- }
- kept[key] = value
- }
- }
- return kept
-}
diff --git a/internal/mirror/files_test.go b/internal/mirror/files_test.go
deleted file mode 100644
index d84af72..0000000
--- a/internal/mirror/files_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package mirror
-
-import (
- "reflect"
- "testing"
-)
-
-func TestPURLSourceCondaChannelAndQualifiers(t *testing.T) {
- versions := &fakeVersions{versions: []string{"1.0", "1.1"}}
- source := &PURLSource{
- PURLs: []string{
- "pkg:conda/conda-forge/numpy@2.1.0?subdir=linux-64&build=py312h_0&type=conda",
- "pkg:conda/numpy@2.1.0?channel=conda-forge&repository_url=https://example.com",
- "pkg:conda/numpy@2.1.0",
- "pkg:conda/numpy",
- "pkg:conda/numpy?channel=bioconda&subdir=noarch",
- },
- Versions: versions,
- }
-
- got := enumerate(t, source)
-
- want := []PackageVersion{
- {Ecosystem: "conda", Name: "conda-forge/numpy", Version: "2.1.0",
- Qualifiers: map[string]string{"subdir": "linux-64", "build": "py312h_0", "type": "conda"}},
- {Ecosystem: "conda", Name: "conda-forge/numpy", Version: "2.1.0"},
- {Ecosystem: "conda", Name: "numpy", Version: "2.1.0"},
- {Ecosystem: "conda", Name: "numpy"},
- {Ecosystem: "conda", Name: "bioconda/numpy", Version: "1.0", Qualifiers: map[string]string{"subdir": "noarch"}},
- {Ecosystem: "conda", Name: "bioconda/numpy", Version: "1.1", Qualifiers: map[string]string{"subdir": "noarch"}},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("items = %+v\nwant %+v", got, want)
- }
- if !reflect.DeepEqual(versions.asked, []string{"conda/bioconda/numpy"}) {
- t.Errorf("listed versions of %v, want only the package with a channel", versions.asked)
- }
-}
diff --git a/internal/mirror/git.go b/internal/mirror/git.go
deleted file mode 100644
index e9c04ff..0000000
--- a/internal/mirror/git.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
-
- "github.com/git-pkgs/proxy/internal/gitcache"
-)
-
-// GitMirrorer caches Git repositories for pkg:git items. location is the
-// repository's host and path, such as codeberg.org/forgejo/forgejo, and
-// version a full commit ID or a tag, or empty to cache the repository's
-// branches and tags only.
-type GitMirrorer interface {
- Mirror(ctx context.Context, location, version string) (gitcache.MirrorResult, error)
-}
-
-// UnavailableGit fails every pkg:git item with Reason.
-type UnavailableGit struct {
- Reason string
-}
-
-func (u UnavailableGit) Mirror(context.Context, string, string) (gitcache.MirrorResult, error) {
- return gitcache.MirrorResult{}, errors.New(u.Reason)
-}
-
-var errNoGitUpstream = errors.New("git repositories are mirrored from upstream.git, and none is configured")
-
-// mirrorGit caches a pkg:git repository, and the commit its version names
-// with the submodules it pins, in the cache /git serves clones from.
-func (m *Mirror) mirrorGit(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- if m.Git == nil {
- m.recordFailure(tracker, pv, errNoGitUpstream)
- return
- }
- result, err := m.Git.Mirror(ctx, pv.Name, pv.Version)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- if result.Cached {
- tracker.skipped.Add(1)
- m.logger.Debug("already cached", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version)
- return
- }
- tracker.completed.Add(1)
- tracker.bytes.Add(result.Bytes)
- m.logger.Info("mirrored", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "size", result.Bytes)
-}
diff --git a/internal/mirror/git_test.go b/internal/mirror/git_test.go
deleted file mode 100644
index aa3aed4..0000000
--- a/internal/mirror/git_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "sync"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/gitcache"
-)
-
-type gitCall struct{ location, commit string }
-
-type fakeGit struct {
- mu sync.Mutex
- calls []gitCall
- result gitcache.MirrorResult
- err error
-}
-
-func (f *fakeGit) Mirror(_ context.Context, location, commit string) (gitcache.MirrorResult, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.calls = append(f.calls, gitCall{location, commit})
- return f.result, f.err
-}
-
-const gitTestCommit = "a72d2c07cfca03b55371089de6aa230d8c951fa0"
-
-func TestMirrorRunCountsGitRepositories(t *testing.T) {
- tests := []struct {
- name string
- result gitcache.MirrorResult
- err error
- wantCompleted, wantSkipped, wantFailed int64
- }{
- {name: "fetched", result: gitcache.MirrorResult{Bytes: 42}, wantCompleted: 1},
- {name: "cached", result: gitcache.MirrorResult{Cached: true}, wantSkipped: 1},
- {name: "failed", err: errors.New("boom"), wantFailed: 1},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- m := setupTestMirror(t, 1)
- git := &fakeGit{result: tt.result, err: tt.err}
- m.Git = git
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:git/codeberg.org/forgejo/forgejo@" + gitTestCommit}})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if progress.Completed != tt.wantCompleted || progress.Skipped != tt.wantSkipped || progress.Failed != tt.wantFailed {
- t.Errorf("progress = %+v", progress)
- }
- if progress.Bytes != tt.result.Bytes {
- t.Errorf("bytes = %d, want %d", progress.Bytes, tt.result.Bytes)
- }
- want := gitCall{"codeberg.org/forgejo/forgejo", gitTestCommit}
- if len(git.calls) != 1 || git.calls[0] != want {
- t.Errorf("calls = %+v, want %+v", git.calls, want)
- }
- })
- }
-}
-
-// TestMirrorRunGitRepositoryWithoutCommit checks a pkg:git PURL without a
-// version mirrors the repository instead of listing versions.
-func TestMirrorRunGitRepositoryWithoutCommit(t *testing.T) {
- m := setupTestMirror(t, 1)
- git := &fakeGit{}
- m.Git = git
- versions := &fakeVersions{err: errors.New("listed versions")}
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:git/codeberg.org/forgejo/forgejo"}, Versions: versions})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if len(versions.asked) != 0 {
- t.Errorf("listed versions of %v", versions.asked)
- }
- if progress.Failed != 0 || len(git.calls) != 1 || git.calls[0] != (gitCall{"codeberg.org/forgejo/forgejo", ""}) {
- t.Errorf("progress = %+v, calls = %+v", progress, git.calls)
- }
-}
-
-func TestMirrorRunGitWithoutUpstream(t *testing.T) {
- m := setupTestMirror(t, 1)
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:git/codeberg.org/forgejo/forgejo@" + gitTestCommit}})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if progress.Failed != 1 {
- t.Errorf("progress = %+v, want one failure", progress)
- }
-}
diff --git a/internal/mirror/helm.go b/internal/mirror/helm.go
deleted file mode 100644
index bda16dd..0000000
--- a/internal/mirror/helm.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-// ChartMirrorer caches Helm charts from the configured repositories the way
-// the Helm handler serves them.
-type ChartMirrorer interface {
- MirrorChart(ctx context.Context, repository, chart, version string) (*handler.CacheResult, error)
-}
-
-var (
- errHelmNoRepository = errors.New("helm charts need the upstream.helm repository as the namespace, such as pkg:helm/{repository}/{chart}@{version}")
- errHelmUnavailable = errors.New("mirroring Helm charts isn't available here")
-)
-
-// mirrorHelm mirrors a chart release. The PURL namespace names the
-// upstream.helm repository, since the handler caches charts per repository.
-func (m *Mirror) mirrorHelm(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- repository, chart, ok := strings.Cut(pv.Name, "/")
- if !ok || repository == "" || chart == "" {
- m.recordFailure(tracker, pv, errHelmNoRepository)
- return
- }
- if m.Charts == nil {
- m.recordFailure(tracker, pv, errHelmUnavailable)
- return
- }
- result, err := m.Charts.MirrorChart(ctx, repository, chart, pv.Version)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- m.recordResult(tracker, pv, result)
-}
diff --git a/internal/mirror/image.go b/internal/mirror/image.go
deleted file mode 100644
index 5116f26..0000000
--- a/internal/mirror/image.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-// ImageMirrorer fetches container images into the cache.
-type ImageMirrorer interface {
- MirrorImage(ctx context.Context, ref handler.ImageRef, platforms []handler.Platform) (handler.ImageResult, error)
-}
-
-// UnavailableImages refuses every image with the given reason.
-type UnavailableImages struct {
- Reason string
-}
-
-func (u UnavailableImages) MirrorImage(context.Context, handler.ImageRef, []handler.Platform) (handler.ImageResult, error) {
- return handler.ImageResult{}, errors.New(u.Reason)
-}
-
-func isImageEcosystem(ecosystem string) bool {
- return ecosystem == "docker" || ecosystem == "oci"
-}
-
-// BottleResolver resolves a Homebrew formula version to the image its
-// bottles are published under.
-type BottleResolver interface {
- BottleImage(ctx context.Context, formula, version string) (handler.ImageRef, error)
-}
-
-// takesPlatforms reports whether --platform applies to an ecosystem: images,
-// and Homebrew bottles, which are published as images.
-func takesPlatforms(ecosystem string) bool {
- return isImageEcosystem(ecosystem) || ecosystem == "brew"
-}
-
-// ParsePlatforms parses os/architecture[/variant] values.
-func ParsePlatforms(values []string) ([]handler.Platform, error) {
- var platforms []handler.Platform
- for _, value := range values {
- for _, item := range strings.Split(value, ",") {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- platform, err := handler.ParsePlatform(item)
- if err != nil {
- return nil, err
- }
- platforms = append(platforms, platform)
- }
- }
- return platforms, nil
-}
-
-// imageRef maps a docker or oci PURL to the image reference and platforms to
-// mirror.
-//
-// - pkg:docker//@, optionally with
-// repository_url=[/].
-// - pkg:oci/@?repository_url=/, optionally
-// with tag= and arch=. OCI PURLs have no default
-// registry, so repository_url is required.
-func imageRef(pv PackageVersion) (handler.ImageRef, []handler.Platform, error) {
- platforms, err := ParsePlatforms(pv.Platforms)
- if err != nil {
- return handler.ImageRef{}, nil, err
- }
- ref := handler.ImageRef{Name: pv.Name}
- if pv.Version != "" {
- ref.References = []string{pv.Version}
- }
- repositoryURL := pv.Qualifiers["repository_url"]
- if pv.Ecosystem == "docker" && repositoryURL == "" {
- host, repository, found := strings.Cut(pv.Name, "/")
- if found && (strings.ContainsAny(host, ".:") || host == "localhost") {
- ref.Registry = host
- ref.Name = repository
- }
- }
- if pv.Ecosystem == "oci" {
- if repositoryURL == "" {
- return ref, nil, errors.New("pkg:oci needs a repository_url qualifier")
- }
- if tag := pv.Qualifiers["tag"]; tag != "" && tag != pv.Version {
- ref.References = append(ref.References, tag)
- }
- if arch := pv.Qualifiers["arch"]; arch != "" && len(platforms) == 0 {
- platforms = []handler.Platform{{Architecture: arch}}
- }
- }
- if repositoryURL != "" {
- repositoryURL = strings.TrimPrefix(strings.TrimPrefix(repositoryURL, "https://"), "http://")
- host, repository, _ := strings.Cut(strings.Trim(repositoryURL, "/"), "/")
- ref.Registry = host
- switch {
- case pv.Ecosystem == "oci" && repository != "":
- // The repository URL names the repository itself.
- ref.Name = repository
- case repository != "":
- ref.Name = repository + "/" + pv.Name
- }
- }
- return ref, platforms, nil
-}
-
-func (m *Mirror) mirrorImage(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- ref, platforms, err := imageRef(pv)
- m.mirrorImageRef(ctx, pv, ref, platforms, err, tracker)
-}
-
-// mirrorBrew mirrors the bottles of a Homebrew formula version, which brew
-// pulls as an image from homebrew/core on its artifact domain.
-func (m *Mirror) mirrorBrew(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- if m.Images == nil || m.Bottles == nil {
- m.recordFailure(tracker, pv, errors.New("mirroring Homebrew bottles isn't available here"))
- return
- }
- platforms, err := ParsePlatforms(pv.Platforms)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- ref, err := m.Bottles.BottleImage(ctx, pv.Name, pv.Version)
- m.mirrorImageRef(ctx, pv, ref, platforms, err, tracker)
-}
-
-// mirrorImageRef mirrors the image ref for pv, or records refErr, the error
-// resolving it.
-func (m *Mirror) mirrorImageRef(ctx context.Context, pv PackageVersion, ref handler.ImageRef, platforms []handler.Platform, refErr error, tracker *progressTracker) {
- fail := func(err error) {
- tracker.failed.Add(1)
- tracker.addError(pv.Ecosystem, pv.Name, pv.Version, err.Error())
- m.logger.Warn("mirror failed", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "error", err)
- }
- if m.proxy != nil && m.proxy.Denylist.Denied(pv.String()) {
- fail(fmt.Errorf("%w: %s", handler.ErrVersionDenied, pv))
- return
- }
- if refErr != nil {
- fail(refErr)
- return
- }
- result, err := m.Images.MirrorImage(ctx, ref, platforms)
- for _, warning := range result.Warnings {
- m.logger.Warn("mirror warning", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "warning", warning)
- }
- tracker.bytes.Add(result.Bytes)
- if err != nil {
- fail(err)
- return
- }
- if result.Fetched == 0 {
- tracker.skipped.Add(1)
- m.logger.Debug("already cached", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version)
- return
- }
- tracker.completed.Add(1)
- m.logger.Info("mirrored", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "size", result.Bytes)
-}
diff --git a/internal/mirror/image_test.go b/internal/mirror/image_test.go
deleted file mode 100644
index c38ddf7..0000000
--- a/internal/mirror/image_test.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "reflect"
- "strings"
- "sync"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/denylist"
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-func TestImageRefFromPURL(t *testing.T) {
- const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
- tests := []struct {
- purl string
- platforms []string
- wantRef handler.ImageRef
- wantPlatforms []handler.Platform
- wantErr string
- }{
- {
- purl: "pkg:docker/library/alpine@3.20",
- wantRef: handler.ImageRef{Name: "library/alpine", References: []string{"3.20"}},
- },
- {
- purl: "pkg:docker/ghcr.io/team/app@1.0",
- wantRef: handler.ImageRef{Registry: "ghcr.io", Name: "team/app", References: []string{"1.0"}},
- },
- {
- purl: "pkg:docker/registry.example:5000/team/app@" + digest,
- wantRef: handler.ImageRef{Registry: "registry.example:5000", Name: "team/app", References: []string{digest}},
- },
- {
- purl: "pkg:docker/localhost/app@1.0",
- wantRef: handler.ImageRef{Registry: "localhost", Name: "app", References: []string{"1.0"}},
- },
- {
- purl: "pkg:docker/localhost:5000/team/app@1.0",
- wantRef: handler.ImageRef{Registry: "localhost:5000", Name: "team/app", References: []string{"1.0"}},
- },
- {
- purl: "pkg:docker/alpine@3.20?repository_url=ghcr.io",
- platforms: []string{"linux/arm64/v8"},
- wantRef: handler.ImageRef{Registry: "ghcr.io", Name: "alpine", References: []string{"3.20"}},
- wantPlatforms: []handler.Platform{{OS: "linux", Architecture: "arm64", Variant: "v8"}},
- },
- {
- purl: "pkg:docker/app@1.0?repository_url=https://registry.example/team",
- wantRef: handler.ImageRef{Registry: "registry.example", Name: "team/app", References: []string{"1.0"}},
- },
- {
- purl: "pkg:oci/debian@" + digest + "?repository_url=docker.io/library/debian&tag=bookworm&arch=arm64",
- wantRef: handler.ImageRef{Registry: "docker.io", Name: "library/debian", References: []string{digest, "bookworm"}},
- wantPlatforms: []handler.Platform{{Architecture: "arm64"}},
- },
- {
- purl: "pkg:oci/debian@" + digest + "?repository_url=docker.io/library/debian&arch=arm64",
- platforms: []string{"linux/amd64"},
- wantRef: handler.ImageRef{Registry: "docker.io", Name: "library/debian", References: []string{digest}},
- wantPlatforms: []handler.Platform{{OS: "linux", Architecture: "amd64"}},
- },
- {
- purl: "pkg:oci/debian@" + digest,
- wantErr: "repository_url",
- },
- }
- for _, tt := range tests {
- t.Run(tt.purl, func(t *testing.T) {
- var items []PackageVersion
- source := &PURLSource{PURLs: []string{tt.purl}, Platforms: tt.platforms}
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- }); err != nil {
- t.Fatalf("Enumerate: %v", err)
- }
- if len(items) != 1 {
- t.Fatalf("items = %d, want 1", len(items))
- }
- ref, platforms, err := imageRef(items[0])
- if tt.wantErr != "" {
- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
- t.Fatalf("err = %v, want %q", err, tt.wantErr)
- }
- return
- }
- if err != nil {
- t.Fatalf("imageRef: %v", err)
- }
- if !reflect.DeepEqual(ref, tt.wantRef) {
- t.Errorf("ref = %+v, want %+v", ref, tt.wantRef)
- }
- if !reflect.DeepEqual(platforms, tt.wantPlatforms) {
- t.Errorf("platforms = %+v, want %+v", platforms, tt.wantPlatforms)
- }
- })
- }
-}
-
-func TestUnversionedImagePURLIsNotExpanded(t *testing.T) {
- var items []PackageVersion
- source := &PURLSource{PURLs: []string{"pkg:docker/library/alpine"}}
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- }); err != nil {
- t.Fatalf("Enumerate: %v", err)
- }
- if len(items) != 1 || items[0].Version != "" {
- t.Fatalf("items = %+v, want one unversioned item", items)
- }
-}
-
-type fakeImages struct {
- mu sync.Mutex
- refs []handler.ImageRef
- result handler.ImageResult
- err error
-}
-
-func (f *fakeImages) MirrorImage(_ context.Context, ref handler.ImageRef, _ []handler.Platform) (handler.ImageResult, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.refs = append(f.refs, ref)
- return f.result, f.err
-}
-
-func TestMirrorRunCountsImages(t *testing.T) {
- tests := []struct {
- name string
- result handler.ImageResult
- err error
- wantCompleted int64
- wantSkipped int64
- wantFailed int64
- }{
- {name: "fetched", result: handler.ImageResult{Fetched: 3, Bytes: 42}, wantCompleted: 1},
- {name: "cached", result: handler.ImageResult{}, wantSkipped: 1},
- {name: "failed", err: errors.New("boom"), wantFailed: 1},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- m := setupTestMirror(t, 1)
- images := &fakeImages{result: tt.result, err: tt.err}
- m.Images = images
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:docker/library/alpine@3.20"}})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if progress.Completed != tt.wantCompleted || progress.Skipped != tt.wantSkipped || progress.Failed != tt.wantFailed {
- t.Errorf("progress = %+v", progress)
- }
- if progress.Bytes != tt.result.Bytes {
- t.Errorf("bytes = %d, want %d", progress.Bytes, tt.result.Bytes)
- }
- if len(images.refs) != 1 || images.refs[0].Name != "library/alpine" {
- t.Errorf("refs = %+v", images.refs)
- }
- })
- }
-}
-
-func TestMirrorRunDeniedImage(t *testing.T) {
- m := setupTestMirror(t, 1)
- policy, err := denylist.New([]string{"pkg:docker/library/alpine@3.20"})
- if err != nil {
- t.Fatal(err)
- }
- m.proxy.Denylist = policy
- images := &fakeImages{}
- m.Images = images
-
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:docker/library/alpine@3.20"}})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if progress.Failed != 1 || len(images.refs) != 0 {
- t.Errorf("denied image: failed = %d, mirror calls = %d", progress.Failed, len(images.refs))
- }
-}
-
-func TestJobRequestRejectsInvalidPlatform(t *testing.T) {
- js := &JobStore{}
- if _, err := js.sourceFromRequest(JobRequest{PURLs: []string{"pkg:docker/library/alpine@3.20"}, Platforms: []string{"linux"}}); err == nil {
- t.Error("invalid platform was accepted")
- }
-}
diff --git a/internal/mirror/job.go b/internal/mirror/job.go
deleted file mode 100644
index 841c7c9..0000000
--- a/internal/mirror/job.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package mirror
-
-import (
- "context"
- "crypto/rand"
- "encoding/json"
- "fmt"
- "sync"
- "time"
-)
-
-// JobState represents the current state of a mirror job.
-type JobState string
-
-const (
- JobStatePending JobState = "pending"
- JobStateRunning JobState = "running"
- JobStateComplete JobState = "complete"
- JobStateFailed JobState = "failed"
- JobStateCanceled JobState = "canceled"
-)
-
-const jobTTL = 1 * time.Hour
-const cleanupInterval = 5 * time.Minute //nolint:mnd // cleanup ticker
-
-// Job represents an async mirror operation.
-type Job struct {
- ID string `json:"id"`
- State JobState `json:"state"`
- Progress Progress `json:"progress"`
- CreatedAt time.Time `json:"created_at"`
- Error string `json:"error,omitempty"`
-
- cancel context.CancelFunc
-}
-
-// JobRequest is the JSON body for starting a mirror job via the API.
-type JobRequest struct {
- PURLs []string `json:"purls,omitempty"`
- SBOM json.RawMessage `json:"sbom,omitempty"`
- Registry string `json:"registry,omitempty"`
- // Platforms limit which platforms of container images are mirrored,
- // as os/architecture[/variant].
- Platforms []string `json:"platforms,omitempty"`
- // Latest mirrors only the newest version of PURLs without a version.
- Latest bool `json:"latest,omitempty"`
-}
-
-// JobStore manages in-memory mirror jobs.
-type JobStore struct {
- mu sync.RWMutex
- jobs map[string]*Job
- mirror *Mirror
- parentCtx context.Context
- // running counts the jobs whose goroutine has not returned yet.
- running sync.WaitGroup
-}
-
-// NewJobStore creates a new job store. The parent context is used as the base
-// for all job contexts so that jobs are canceled when the server shuts down.
-func NewJobStore(ctx context.Context, m *Mirror) *JobStore {
- return &JobStore{
- jobs: make(map[string]*Job),
- mirror: m,
- parentCtx: ctx,
- }
-}
-
-// Create starts a new mirror job and returns its ID.
-func (js *JobStore) Create(req JobRequest) (string, error) {
- source, err := js.sourceFromRequest(req)
- if err != nil {
- return "", err
- }
-
- id := newJobID()
- ctx, cancel := context.WithCancel(js.parentCtx)
-
- job := &Job{
- ID: id,
- State: JobStatePending,
- CreatedAt: time.Now(),
- cancel: cancel,
- }
-
- js.mu.Lock()
- js.jobs[id] = job
- js.mu.Unlock()
-
- js.running.Go(func() { js.runJob(ctx, cancel, job, source) })
-
- return id, nil
-}
-
-// Get returns a snapshot of a job by ID. The returned copy is safe to
-// serialize without holding the lock.
-func (js *JobStore) Get(id string) *Job {
- js.mu.RLock()
- defer js.mu.RUnlock()
- job := js.jobs[id]
- if job == nil {
- return nil
- }
- snapshot := *job
- snapshot.cancel = nil // don't leak cancel func
- if len(job.Progress.Errors) > 0 {
- snapshot.Progress.Errors = make([]MirrorError, len(job.Progress.Errors))
- copy(snapshot.Progress.Errors, job.Progress.Errors)
- }
- return &snapshot
-}
-
-// Cancel cancels a running job.
-func (js *JobStore) Cancel(id string) bool {
- js.mu.Lock()
- defer js.mu.Unlock()
-
- job := js.jobs[id]
- if job == nil || job.cancel == nil {
- return false
- }
-
- if job.State != JobStatePending && job.State != JobStateRunning {
- return false
- }
-
- job.cancel()
- job.State = JobStateCanceled
- return true
-}
-
-// Wait blocks until every job has returned. Cancel the parent context first
-// to stop running jobs.
-func (js *JobStore) Wait() {
- js.running.Wait()
-}
-
-// Cleanup removes completed/failed/canceled jobs older than jobTTL.
-func (js *JobStore) Cleanup() {
- js.mu.Lock()
- defer js.mu.Unlock()
- for id, job := range js.jobs {
- if job.State == JobStateComplete || job.State == JobStateFailed || job.State == JobStateCanceled {
- if time.Since(job.CreatedAt) > jobTTL {
- delete(js.jobs, id)
- }
- }
- }
-}
-
-// StartCleanup runs periodic cleanup of old jobs until the context is canceled.
-func (js *JobStore) StartCleanup(ctx context.Context) {
- ticker := time.NewTicker(cleanupInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- js.Cleanup()
- }
- }
-}
-
-func (js *JobStore) runJob(ctx context.Context, cancel context.CancelFunc, job *Job, source Source) {
- defer cancel()
-
- js.mu.Lock()
- if job.State == JobStateCanceled {
- js.mu.Unlock()
- return
- }
- job.State = JobStateRunning
- js.mu.Unlock()
-
- progress, err := js.mirror.Run(ctx, source, func(p Progress) {
- js.mu.Lock()
- defer js.mu.Unlock()
- if job.State == JobStateRunning {
- job.Progress = p
- }
- })
-
- js.mu.Lock()
- defer js.mu.Unlock()
-
- // Cancel() may have already set the state; don't overwrite it.
- if job.State == JobStateCanceled {
- return
- }
-
- if err != nil {
- job.State = JobStateFailed
- job.Error = err.Error()
- return
- }
-
- job.Progress = *progress
- if progress.Failed > 0 && progress.Completed == 0 {
- job.State = JobStateFailed
- } else {
- job.State = JobStateComplete
- }
-}
-
-func (js *JobStore) sourceFromRequest(req JobRequest) (Source, error) { //nolint:ireturn // interface return is the design
- if _, err := ParsePlatforms(req.Platforms); err != nil {
- return nil, err
- }
- switch {
- case len(req.PURLs) > 0 && len(req.SBOM) > 0:
- return nil, fmt.Errorf("request must include only one of purls or sbom")
- case len(req.PURLs) > 0:
- return &PURLSource{PURLs: req.PURLs, Versions: js.mirror.Versions, Latest: req.Latest, Platforms: req.Platforms}, nil
- case len(req.SBOM) > 0:
- return &SBOMSource{Data: req.SBOM, Versions: js.mirror.Versions, Latest: req.Latest, Platforms: req.Platforms}, nil
- case req.Registry != "":
- return nil, fmt.Errorf("registry mirroring is not yet implemented; use purls instead")
- default:
- return nil, fmt.Errorf("request must include purls or sbom")
- }
-}
-
-// newJobID generates a random hex job ID.
-func newJobID() string {
- b := make([]byte, 16) //nolint:mnd // 128-bit ID
- _, _ = rand.Read(b)
- return fmt.Sprintf("%x", b)
-}
diff --git a/internal/mirror/job_test.go b/internal/mirror/job_test.go
deleted file mode 100644
index 7b4c88e..0000000
--- a/internal/mirror/job_test.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "testing"
- "time"
-)
-
-// newTestJobStore returns a job store whose jobs are stopped and waited for
-// before the test's database and directories are removed.
-func newTestJobStore(t *testing.T) *JobStore {
- t.Helper()
- m := setupTestMirror(t, 1)
- ctx, cancel := context.WithCancel(context.Background())
- js := NewJobStore(ctx, m)
- t.Cleanup(func() {
- cancel()
- js.Wait()
- })
- return js
-}
-
-func TestJobStoreCreateAndGet(t *testing.T) {
- js := newTestJobStore(t)
-
- id, err := js.Create(JobRequest{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- })
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- if id == "" {
- t.Fatal("expected non-empty job ID")
- }
-
- // Wait for the job to start (it runs async)
- time.Sleep(100 * time.Millisecond)
-
- job := js.Get(id)
- if job == nil {
- t.Fatal("Get() returned nil")
- }
- if job.ID != id {
- t.Errorf("job ID = %q, want %q", job.ID, id)
- }
-}
-
-func TestJobStoreGetNotFound(t *testing.T) {
- js := newTestJobStore(t)
-
- job := js.Get("nonexistent")
- if job != nil {
- t.Errorf("expected nil for nonexistent job, got %v", job)
- }
-}
-
-func TestJobStoreCancelNotFound(t *testing.T) {
- js := newTestJobStore(t)
-
- if js.Cancel("nonexistent") {
- t.Error("expected Cancel to return false for nonexistent job")
- }
-}
-
-func TestJobStoreCreateInvalidRequest(t *testing.T) {
- js := newTestJobStore(t)
-
- _, err := js.Create(JobRequest{})
- if err == nil {
- t.Fatal("expected error for empty request")
- }
-}
-
-func TestJobStoreMultipleJobs(t *testing.T) {
- js := newTestJobStore(t)
-
- id1, err := js.Create(JobRequest{PURLs: []string{"pkg:npm/lodash@4.17.21"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- id2, err := js.Create(JobRequest{PURLs: []string{"pkg:cargo/serde@1.0.0"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- if id1 == id2 {
- t.Error("expected different job IDs")
- }
-
- job1 := js.Get(id1)
- job2 := js.Get(id2)
- if job1 == nil || job2 == nil {
- t.Fatal("expected both jobs to exist")
- }
-}
-
-func TestSourceFromRequestPURLs(t *testing.T) {
- js := newTestJobStore(t)
-
- source, err := js.sourceFromRequest(JobRequest{PURLs: []string{"pkg:npm/lodash@1.0.0"}})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if _, ok := source.(*PURLSource); !ok {
- t.Errorf("expected *PURLSource, got %T", source)
- }
-}
-
-func TestSourceFromRequestSBOM(t *testing.T) {
- js := newTestJobStore(t)
- sbom := json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`)
-
- source, err := js.sourceFromRequest(JobRequest{SBOM: sbom})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- sbomSource, ok := source.(*SBOMSource)
- if !ok {
- t.Fatalf("expected *SBOMSource, got %T", source)
- }
- if got := string(sbomSource.Data); got != string(sbom) {
- t.Errorf("SBOM data = %q, want %q", got, sbom)
- }
-}
-
-func TestSourceFromRequestRejectsPURLsAndSBOM(t *testing.T) {
- js := newTestJobStore(t)
-
- _, err := js.sourceFromRequest(JobRequest{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- SBOM: json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`),
- })
- if err == nil {
- t.Fatal("expected error when both purls and sbom are provided")
- }
-}
-
-func TestSourceFromRequestRegistryRejected(t *testing.T) {
- js := newTestJobStore(t)
-
- _, err := js.sourceFromRequest(JobRequest{Registry: "npm"})
- if err == nil {
- t.Fatal("expected error for registry request")
- }
-}
-
-func TestJobStoreCleanup(t *testing.T) {
- js := newTestJobStore(t)
-
- // Add a completed job with old CreatedAt
- js.mu.Lock()
- js.jobs["old-job"] = &Job{
- ID: "old-job",
- State: JobStateComplete,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- }
- js.jobs["recent-job"] = &Job{
- ID: "recent-job",
- State: JobStateComplete,
- CreatedAt: time.Now(),
- }
- js.jobs["running-job"] = &Job{
- ID: "running-job",
- State: JobStateRunning,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- }
- js.mu.Unlock()
-
- js.Cleanup()
-
- if js.Get("old-job") != nil {
- t.Error("expected old completed job to be cleaned up")
- }
- if js.Get("recent-job") == nil {
- t.Error("expected recent completed job to be kept")
- }
- if js.Get("running-job") == nil {
- t.Error("expected running job to be kept regardless of age")
- }
-}
-
-func TestJobStoreCancelPreservesStateAfterRunJob(t *testing.T) {
- js := newTestJobStore(t)
-
- // Create a job with a PURL that will fail (no real upstream in test)
- id, err := js.Create(JobRequest{PURLs: []string{"pkg:npm/nonexistent-pkg@0.0.0"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- // Cancel immediately -- the job may already be running
- js.Cancel(id)
-
- // Wait for runJob goroutine to finish
- time.Sleep(200 * time.Millisecond)
-
- job := js.Get(id)
- if job == nil {
- t.Fatal("Get() returned nil")
- }
- if job.State != JobStateCanceled {
- t.Errorf("state = %q, want %q (cancel should not be overwritten by runJob)", job.State, JobStateCanceled)
- }
-}
-
-func TestNewJobIDUnique(t *testing.T) {
- ids := make(map[string]bool)
- for range 100 {
- id := newJobID()
- if ids[id] {
- t.Fatalf("duplicate job ID: %s", id)
- }
- ids[id] = true
- }
-}
diff --git a/internal/mirror/julia.go b/internal/mirror/julia.go
deleted file mode 100644
index 613ba76..0000000
--- a/internal/mirror/julia.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/registries"
-)
-
-// PackageFinder finds a package's registry entry, which for Julia gives the
-// UUID the package is registered under.
-type PackageFinder interface {
- FindPackage(ctx context.Context, ecosystem, name string) (*registries.Package, error)
-}
-
-// FindPackage implements PackageFinder.
-func (r RegistryVersions) FindPackage(ctx context.Context, ecosystem, name string) (*registries.Package, error) {
- reg, err := r.registry(ecosystem, name)
- if err != nil {
- return nil, err
- }
- return reg.FetchPackage(ctx, name)
-}
-
-// mirrorJulia fetches the tarball unchecked because Pkg checks the tree hash
-// of what it extracts.
-func (m *Mirror) mirrorJulia(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- fail := func(err error) { m.recordFailure(tracker, pv, err) }
- uuid := strings.ToLower(pv.Qualifiers["uuid"])
- if uuid == "" {
- fail(fmt.Errorf("julia packages need a uuid qualifier, such as pkg:julia/%s@%s?uuid=", pv.Name, pv.Version))
- return
- }
-
- pkg, err := m.packageFinder().FindPackage(ctx, pv.Ecosystem, pv.Name)
- if err != nil {
- fail(err)
- return
- }
- registered, _ := pkg.Metadata["uuid"].(string)
- if !strings.EqualFold(registered, uuid) {
- fail(fmt.Errorf("%s in the General registry has uuid %s, not %s; only General packages can be mirrored", pv.Name, registered, uuid))
- return
- }
- // The handler names packages as the registry does.
- name := pkg.Name
- if name == "" {
- name = pv.Name
- }
-
- v, err := m.versionFinder().FindVersion(ctx, pv.Ecosystem, name, pv.Version)
- if err != nil {
- fail(err)
- return
- }
- treeHash, _ := v.Metadata["git-tree-sha1"].(string)
- if treeHash == "" {
- fail(errors.New("the registry lists no tree hash for this version"))
- return
- }
- url, filename, err := handler.JuliaPackageDownload(m.JuliaUpstream, uuid, treeHash)
- if err != nil {
- fail(err)
- return
- }
- result, err := m.proxy.GetOrFetchArtifactFromURL(ctx, pv.Ecosystem, name, treeHash, filename, url)
- if err != nil {
- fail(err)
- return
- }
- m.recordResult(tracker, pv, result)
-}
-
-// packageFinder finds packages where Versions lists versions, so both come
-// from the same registry.
-func (m *Mirror) packageFinder() PackageFinder { //nolint:ireturn // any finder will do
- if finder, ok := m.Versions.(PackageFinder); ok {
- return finder
- }
- return RegistryVersions{}
-}
diff --git a/internal/mirror/latest_test.go b/internal/mirror/latest_test.go
deleted file mode 100644
index 8135116..0000000
--- a/internal/mirror/latest_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "reflect"
- "testing"
-)
-
-func TestPURLSourceLatestMirrorsNewestVersion(t *testing.T) {
- versions := &fakeVersions{versions: []string{"1.0.0", "1.10.0", "1.2.0"}}
- source := &PURLSource{PURLs: []string{"pkg:npm/left-pad", "pkg:npm/is-odd@3.0.0"}, Versions: versions, Latest: true}
-
- items := enumerate(t, source)
-
- want := []PackageVersion{
- {Ecosystem: "npm", Name: "left-pad", Version: "1.10.0"},
- {Ecosystem: "npm", Name: "is-odd", Version: "3.0.0"},
- }
- if !reflect.DeepEqual(items, want) {
- t.Errorf("items = %v, want %v", items, want)
- }
-}
-
-func TestPURLSourceLatestWithoutVersions(t *testing.T) {
- source := &PURLSource{PURLs: []string{"pkg:npm/left-pad"}, Versions: &fakeVersions{}, Latest: true}
- if items := enumerate(t, source); len(items) != 0 {
- t.Errorf("items = %v, want none", items)
- }
-}
-
-func TestSBOMSourceLatest(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- "components": []map[string]any{{"type": "library", "name": "left-pad", "purl": "pkg:npm/left-pad"}},
- }
- source := &SBOMSource{Data: marshalJSON(t, bom), Versions: &fakeVersions{versions: []string{"1.0.0", "1.3.0"}}, Latest: true}
-
- items := enumerate(t, source)
-
- if len(items) != 1 || items[0].Version != "1.3.0" {
- t.Errorf("items = %v, want only left-pad@1.3.0", items)
- }
-}
-
-// TestRegistryVersionsLatestSkipsYanked checks that without an advertised
-// latest version the newest version by publish time that isn't yanked wins.
-func TestRegistryVersionsLatestSkipsYanked(t *testing.T) {
- registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/crates/demo" {
- http.NotFound(w, r)
- return
- }
- _ = json.NewEncoder(w).Encode(map[string]any{
- "crate": map[string]any{"name": "demo"},
- "versions": []map[string]any{
- {"num": "1.3.0", "yanked": true, "created_at": "2026-03-01T00:00:00Z"},
- {"num": "1.2.0", "created_at": "2026-02-01T00:00:00Z"},
- {"num": "1.10.0-alpha.1", "created_at": "2026-01-01T00:00:00Z"},
- },
- })
- }))
- defer registry.Close()
- versions := RegistryVersions{URL: func(string, string) (string, error) { return registry.URL, nil }}
-
- got, err := versions.LatestVersion(context.Background(), "cargo", "demo")
- if err != nil {
- t.Fatalf("LatestVersion() error = %v", err)
- }
- if got != "1.2.0" {
- t.Errorf("LatestVersion() = %q, want 1.2.0", got)
- }
-}
-
-// TestRegistryVersionsLatestPrefersAdvertised checks that the version the
-// registry advertises as latest wins over a higher one, here a 4.0.0
-// published by mistake and deprecated.
-func TestRegistryVersionsLatestPrefersAdvertised(t *testing.T) {
- registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/left-pad" {
- http.NotFound(w, r)
- return
- }
- _ = json.NewEncoder(w).Encode(map[string]any{
- "name": "left-pad",
- "dist-tags": map[string]any{"latest": "3.4.0"},
- "versions": map[string]any{
- "3.4.0": map[string]any{"name": "left-pad", "version": "3.4.0"},
- "4.0.0": map[string]any{"name": "left-pad", "version": "4.0.0", "deprecated": "published by mistake"},
- },
- "time": map[string]any{"3.4.0": "2026-01-01T00:00:00Z", "4.0.0": "2026-02-01T00:00:00Z"},
- })
- }))
- defer registry.Close()
- versions := RegistryVersions{URL: func(string, string) (string, error) { return registry.URL, nil }}
-
- got, err := versions.LatestVersion(context.Background(), "npm", "left-pad")
- if err != nil {
- t.Fatalf("LatestVersion() error = %v", err)
- }
- if got != "3.4.0" {
- t.Errorf("LatestVersion() = %q, want the advertised 3.4.0", got)
- }
-}
-
-func TestSourceFromRequestPassesLatest(t *testing.T) {
- js := NewJobStore(context.Background(), setupTestMirror(t, 1))
-
- source, err := js.sourceFromRequest(JobRequest{PURLs: []string{"pkg:npm/left-pad"}, Latest: true})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if !source.(*PURLSource).Latest {
- t.Error("PURL source mirrors every version, want only the newest")
- }
-
- source, err = js.sourceFromRequest(JobRequest{SBOM: json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`), Latest: true})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if !source.(*SBOMSource).Latest {
- t.Error("SBOM source mirrors every version, want only the newest")
- }
-}
diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go
deleted file mode 100644
index 1dded24..0000000
--- a/internal/mirror/mirror.go
+++ /dev/null
@@ -1,448 +0,0 @@
-// Package mirror provides selective package mirroring for pre-populating the proxy cache.
-package mirror
-
-import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/storage"
- "golang.org/x/sync/errgroup"
-)
-
-// Mirror pre-populates the proxy cache from various input sources.
-type Mirror struct {
- proxy *handler.Proxy
- db *database.DB
- storage storage.Storage
- logger *slog.Logger
- workers int
-
- // Images mirrors docker and oci items. Without it they fail like any
- // other unsupported ecosystem.
- Images ImageMirrorer
-
- // Charts mirrors helm charts. Without it they fail.
- Charts ChartMirrorer
-
- // Git mirrors pkg:git repositories into the upstream.git cache. Without
- // it they fail.
- Git GitMirrorer
-
- // Bottles resolves Homebrew formula versions to the images their
- // bottles are published under, which Images mirrors. Without it brew
- // PURLs fail.
- Bottles BottleResolver
-
- // Versions lists the versions of unversioned PURLs in mirror jobs and
- // schedules. Nil lists them from the public registries.
- Versions VersionLister
-
- // JuliaUpstream is the Pkg server Julia packages are fetched from. Empty
- // uses https://pkg.julialang.org.
- JuliaUpstream string
-
- // APKRepositories are the upstream.apk repositories by name. Nil uses
- // the official Alpine mirror as "alpine".
- APKRepositories map[string]string
-
- // RefusePackages, when set, fails every item Images does not take with
- // this reason instead of fetching it, so nothing is cached.
- RefusePackages string
-
- cran cranIndex
- apk apkIndexes
-}
-
-// New creates a new Mirror with the given dependencies.
-func New(proxy *handler.Proxy, db *database.DB, store storage.Storage, logger *slog.Logger, workers int) *Mirror {
- if workers < 1 {
- workers = 1
- }
- return &Mirror{
- proxy: proxy,
- db: db,
- storage: store,
- logger: logger,
- workers: workers,
- }
-}
-
-// Progress tracks the state of a mirror operation.
-type Progress struct {
- Total int64 `json:"total"`
- Completed int64 `json:"completed"`
- Skipped int64 `json:"skipped"`
- Failed int64 `json:"failed"`
- Bytes int64 `json:"bytes"`
- Errors []MirrorError `json:"errors,omitempty"`
- StartedAt time.Time `json:"started_at"`
- Phase string `json:"phase"`
-}
-
-// MirrorError records a single failed mirror attempt.
-type MirrorError struct {
- Ecosystem string `json:"ecosystem"`
- Name string `json:"name"`
- Version string `json:"version"`
- Error string `json:"error"`
-}
-
-type progressTracker struct {
- total atomic.Int64
- completed atomic.Int64
- skipped atomic.Int64
- failed atomic.Int64
- bytes atomic.Int64
- mu sync.Mutex
- errors []MirrorError
- startedAt time.Time
- phase atomic.Value // string
-}
-
-func newProgressTracker() *progressTracker {
- pt := &progressTracker{
- startedAt: time.Now(),
- }
- pt.phase.Store("resolving")
- return pt
-}
-
-const maxTrackedErrors = 1000
-const progressReportInterval = 500 * time.Millisecond //nolint:mnd // progress update frequency
-
-func (pt *progressTracker) addError(eco, name, version, err string) {
- pt.mu.Lock()
- if len(pt.errors) < maxTrackedErrors {
- pt.errors = append(pt.errors, MirrorError{
- Ecosystem: eco,
- Name: name,
- Version: version,
- Error: err,
- })
- }
- pt.mu.Unlock()
-}
-
-func (pt *progressTracker) snapshot() Progress {
- pt.mu.Lock()
- errs := make([]MirrorError, len(pt.errors))
- copy(errs, pt.errors)
- pt.mu.Unlock()
-
- phase, _ := pt.phase.Load().(string)
- return Progress{
- Total: pt.total.Load(),
- Completed: pt.completed.Load(),
- Skipped: pt.skipped.Load(),
- Failed: pt.failed.Load(),
- Bytes: pt.bytes.Load(),
- Errors: errs,
- StartedAt: pt.startedAt,
- Phase: phase,
- }
-}
-
-// ProgressFunc is called periodically with a snapshot of the current progress.
-type ProgressFunc func(Progress)
-
-// Run mirrors all packages from the source using a bounded worker pool.
-// It returns the final progress when complete. If onProgress is non-nil,
-// it is called with progress snapshots as work proceeds.
-func (m *Mirror) Run(ctx context.Context, source Source, onProgress ...ProgressFunc) (*Progress, error) {
- tracker := newProgressTracker()
-
- // Collect items from source
- var items []PackageVersion
- tracker.phase.Store("resolving")
- err := source.Enumerate(ctx, func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("enumerating packages: %w", err)
- }
-
- tracker.total.Store(int64(len(items)))
- tracker.phase.Store("downloading")
-
- // Start periodic progress reporting if a callback was provided
- var progressFn ProgressFunc
- if len(onProgress) > 0 && onProgress[0] != nil {
- progressFn = onProgress[0]
- }
- progressDone := make(chan struct{})
- if progressFn != nil {
- progressFn(tracker.snapshot()) // initial snapshot with total set
- go func() {
- ticker := time.NewTicker(progressReportInterval)
- defer ticker.Stop()
- for {
- select {
- case <-progressDone:
- return
- case <-ticker.C:
- progressFn(tracker.snapshot())
- }
- }
- }()
- }
-
- // Process items with bounded concurrency
- g, gctx := errgroup.WithContext(ctx)
- g.SetLimit(m.workers)
-
- for _, item := range items {
- g.Go(func() error {
- m.mirrorRecovered(gctx, item, tracker)
- return nil // never fail the group; errors are tracked
- })
- }
-
- _ = g.Wait()
-
- close(progressDone) // stop the progress reporter goroutine
-
- tracker.phase.Store("complete")
- p := tracker.snapshot()
-
- // Send final snapshot
- if progressFn != nil {
- progressFn(p)
- }
-
- return &p, nil
-}
-
-// RunDryRun enumerates what would be mirrored without downloading.
-func (m *Mirror) RunDryRun(ctx context.Context, source Source) ([]PackageVersion, error) {
- var items []PackageVersion
- err := source.Enumerate(ctx, func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- return items, err
-}
-
-// mirrorRecovered is mirrorOne, counting a panic as a failure.
-func (m *Mirror) mirrorRecovered(ctx context.Context, item PackageVersion, tracker *progressTracker) {
- defer func() {
- if r := recover(); r != nil {
- m.logger.Error("panic in mirror worker", "recover", r,
- "ecosystem", item.Ecosystem, "name", item.Name, "version", item.Version)
- tracker.failed.Add(1)
- tracker.addError(item.Ecosystem, item.Name, item.Version, fmt.Sprintf("panic: %v", r))
- }
- }()
- m.mirrorOne(ctx, item, tracker)
-}
-
-func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- if isImageEcosystem(pv.Ecosystem) && m.Images != nil {
- m.mirrorImage(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "brew" {
- m.mirrorBrew(ctx, pv, tracker)
- return
- }
- if m.RefusePackages != "" {
- tracker.failed.Add(1)
- tracker.addError(pv.Ecosystem, pv.Name, pv.Version, m.RefusePackages)
- m.logger.Warn("mirror refused",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "reason", m.RefusePackages)
- return
- }
- if pv.fileURL != "" {
- result, err := m.proxy.GetOrFetchArtifactFromURLWithDigest(ctx, pv.Ecosystem, pv.Name, pv.Version, pv.cacheFilename, pv.fileURL, pv.digest)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- m.recordResult(tracker, pv, result)
- return
- }
- if eco, ok := fileEcosystems[pv.Ecosystem]; ok {
- m.mirrorFiles(ctx, pv, eco, tracker)
- return
- }
- if pv.Ecosystem == "composer" {
- m.mirrorComposer(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "helm" {
- m.mirrorHelm(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "julia" {
- m.mirrorJulia(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "swift" {
- m.mirrorSwift(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "cran" {
- m.mirrorCRAN(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "alpine" {
- m.mirrorAPK(ctx, pv, tracker)
- return
- }
- if pv.Ecosystem == "git" {
- m.mirrorGit(ctx, pv, tracker)
- return
- }
- result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "")
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- m.recordResult(tracker, pv, result)
-}
-
-// recordFailure counts and logs a package that could not be mirrored.
-func (m *Mirror) recordFailure(tracker *progressTracker, pv PackageVersion, err error) {
- tracker.failed.Add(1)
- tracker.addError(pv.Ecosystem, pv.Name, pv.Version, err.Error())
- m.logger.Warn("mirror failed",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "error", err)
-}
-
-// recordResult closes the fetched artifact and counts it as mirrored, or as
-// skipped when it was already cached.
-func (m *Mirror) recordResult(tracker *progressTracker, pv PackageVersion, result *handler.CacheResult) {
- if result.Reader != nil {
- _ = result.Reader.Close()
- }
-
- if result.Cached {
- tracker.skipped.Add(1)
- m.logger.Debug("already cached",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version)
- } else {
- tracker.completed.Add(1)
- tracker.bytes.Add(result.Artifact.Size)
- m.logger.Info("mirrored",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version,
- "size", result.Artifact.Size)
- }
-}
-
-// mirrorComposer mirrors the archive of a tagged Composer release under the
-// file name the Composer handler serves it as, checked against the listed
-// shasum, and records the release's publish time, which the handler needs to
-// serve the archive from the cache without the metadata.
-func (m *Mirror) mirrorComposer(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- // Packagist names are lowercase, and so are the paths Composer requests.
- name := strings.ToLower(pv.Name)
- v, err := m.versionFinder().FindVersion(ctx, pv.Ecosystem, name, pv.Version)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- distURL, _ := v.Metadata["dist_url"].(string)
- distType, _ := v.Metadata["dist_type"].(string)
- filename, declared, err := handler.ComposerReleaseArchive(v.Number, distURL, distType, strings.TrimPrefix(v.Integrity, "sha1-"))
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- result, err := m.proxy.GetOrFetchArtifactFromURLWithIntegrity(ctx, pv.Ecosystem, name, v.Number, filename, distURL, declared)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- if err := m.proxy.RecordComposerPublishTime(name, v.Number, v.PublishedAt); err != nil {
- m.logger.Warn("failed to store composer publish time",
- "name", name, "version", v.Number, "error", err)
- }
- m.recordResult(tracker, pv, result)
-}
-
-// mirrorFiles mirrors every file of a version, or those its qualifiers
-// select. Each file is cached under the name and version the download
-// handler reads from its filename, so clients are served it.
-func (m *Mirror) mirrorFiles(ctx context.Context, pv PackageVersion, eco fileEcosystem, tracker *progressTracker) {
- fail := func(err error) { m.recordFailure(tracker, pv, err) }
- if eco.check != nil {
- if err := eco.check(pv); err != nil {
- fail(err)
- return
- }
- }
- v, err := m.versionFinder().FindVersion(ctx, pv.Ecosystem, pv.Name, pv.Version)
- if err != nil {
- fail(fmt.Errorf("listing files: %w", err))
- return
- }
- var matched, fetched int
- var size int64
- for _, file := range v.Artifacts {
- if !eco.selects(pv, file) {
- continue
- }
- name, version, filename, ok := eco.cacheKey(pv, file)
- if !ok {
- continue
- }
- matched++
- result, err := m.proxy.GetOrFetchArtifactFromURLWithDigest(ctx, pv.Ecosystem, name, version, filename, file.URL, fileDigest(file.Integrity))
- if err != nil {
- fail(fmt.Errorf("%s: %w", file.Filename, err))
- return
- }
- if result.Reader != nil {
- _ = result.Reader.Close()
- }
- if !result.Cached {
- fetched++
- size += result.Artifact.Size
- }
- }
- switch selection := eco.selection(pv); {
- case matched == 0 && selection != "":
- fail(fmt.Errorf("no file matches %s", selection))
- case matched == 0:
- fail(errors.New("version has no files"))
- case fetched == 0:
- tracker.skipped.Add(1)
- m.logger.Debug("already cached",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version)
- default:
- tracker.completed.Add(1)
- tracker.bytes.Add(size)
- m.logger.Info("mirrored",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version,
- "files", fetched, "size", size)
- }
-}
-
-// fileDigest returns the SHA-256 of one file as a digest, which is checked
-// against that file alone. The integrity GetOrFetchArtifactFromURLWithIntegrity
-// takes is kept with the version instead, and the files of a version that
-// share a name and version would overwrite each other's. Other checksums are
-// not checked, as the download handlers don't check them either.
-func fileDigest(integrity string) string {
- if sum, ok := strings.CutPrefix(integrity, "sha256-"); ok {
- return "sha256:" + sum
- }
- return ""
-}
-
-// versionFinder finds versions where Versions lists them, so both come from
-// the configured upstream.
-func (m *Mirror) versionFinder() VersionFinder { //nolint:ireturn // any finder will do
- if finder, ok := m.Versions.(VersionFinder); ok {
- return finder
- }
- return RegistryVersions{}
-}
diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go
deleted file mode 100644
index 6852687..0000000
--- a/internal/mirror/mirror_test.go
+++ /dev/null
@@ -1,352 +0,0 @@
-package mirror
-
-import (
- "context"
- "database/sql"
- "errors"
- "log/slog"
- "net/http"
- "os"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// setupTestMirror creates a Mirror with real DB and filesystem storage for integration tests.
-func setupTestMirror(t *testing.T, workers int) *Mirror {
- t.Helper()
-
- dbPath := t.TempDir() + "/test.db"
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("creating database: %v", err)
- }
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("migrating schema: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
-
- storeDir := t.TempDir()
- store, err := storage.OpenBucket(context.Background(), "file://"+storeDir)
- if err != nil {
- t.Fatalf("opening storage: %v", err)
- }
-
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
- fetcher := fetch.NewFetcher()
- resolver := fetch.NewResolver()
- proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
-
- return New(proxy, db, store, logger, workers)
-}
-
-const testPackageLodash = "lodash"
-
-type signedURLStorage struct {
- storage.Storage
-}
-
-func (signedURLStorage) SignedURL(context.Context, string, time.Duration) (string, error) {
- return "https://storage.example/artifact", nil
-}
-
-func TestMirrorRunEmptySource(t *testing.T) {
- m := setupTestMirror(t, 2)
-
- source := &PURLSource{PURLs: []string{}}
- progress, err := m.Run(context.Background(), source)
- if err != nil {
- t.Fatalf("Run() error = %v", err)
- }
-
- if progress.Total != 0 {
- t.Errorf("total = %d, want 0", progress.Total)
- }
- if progress.Phase != "complete" {
- t.Errorf("phase = %q, want %q", progress.Phase, "complete")
- }
-}
-
-func TestMirrorRunDryRun(t *testing.T) {
- m := setupTestMirror(t, 1)
-
- source := &PURLSource{
- PURLs: []string{
- "pkg:npm/lodash@4.17.21",
- "pkg:cargo/serde@1.0.0",
- },
- }
-
- items, err := m.RunDryRun(context.Background(), source)
- if err != nil {
- t.Fatalf("RunDryRun() error = %v", err)
- }
-
- if len(items) != 2 {
- t.Fatalf("got %d items, want 2", len(items))
- }
-
- // Dry run should not modify the database
- stats, err := m.db.GetCacheStats()
- if err != nil {
- t.Fatalf("GetCacheStats() error = %v", err)
- }
- if stats.TotalArtifacts != 0 {
- t.Errorf("artifacts = %d, want 0 (dry run should not cache)", stats.TotalArtifacts)
- }
-}
-
-func TestMirrorRunCanceled(t *testing.T) {
- m := setupTestMirror(t, 1)
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel() // cancel immediately
-
- // Use a source that produces items but they'll all fail due to canceled context
- source := &PURLSource{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- }
-
- progress, err := m.Run(ctx, source)
- if err != nil {
- t.Fatalf("Run() error = %v", err)
- }
-
- // With a canceled context, the fetch should fail
- if progress.Failed != 1 {
- t.Errorf("failed = %d, want 1", progress.Failed)
- }
-}
-
-func TestMirrorOneDirectServeCacheHit(t *testing.T) {
- m := setupTestMirror(t, 1)
- fetcher := fetch.NewFetcher(
- fetch.WithHTTPClient(&http.Client{Transport: rejectingMirrorTransport{}}), fetch.WithMaxRetries(0))
- t.Cleanup(func() { _ = fetcher.Close() })
- m.proxy.Fetcher = fetcher
- m.proxy.DirectServe = true
- m.proxy.Storage = signedURLStorage{Storage: m.storage}
-
- packagePURL := "pkg:npm/example"
- versionPURL := packagePURL + "@1.0.0"
- if err := m.db.UpsertPackage(&database.Package{
- PURL: packagePURL,
- Ecosystem: "npm",
- Name: "example",
- }); err != nil {
- t.Fatalf("UpsertPackage() error = %v", err)
- }
- if err := m.db.UpsertVersion(&database.Version{
- PURL: versionPURL,
- PackagePURL: packagePURL,
- }); err != nil {
- t.Fatalf("UpsertVersion() error = %v", err)
- }
- const storagePath = "npm/example/1.0.0/example-1.0.0.tgz"
- size, hash, err := m.storage.Store(context.Background(), storagePath, strings.NewReader("tarball bytes"))
- if err != nil {
- t.Fatal(err)
- }
- if err := m.db.UpsertArtifact(&database.Artifact{
- VersionPURL: versionPURL,
- Filename: "example-1.0.0.tgz",
- UpstreamURL: "https://registry.example/artifact",
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- ContentHash: sql.NullString{String: hash, Valid: true},
- Size: sql.NullInt64{Int64: size, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }); err != nil {
- t.Fatalf("UpsertArtifact() error = %v", err)
- }
-
- tracker := newProgressTracker()
- m.mirrorOne(context.Background(), PackageVersion{
- Ecosystem: "npm",
- Name: "example",
- Version: "1.0.0",
- }, tracker)
-
- progress := tracker.snapshot()
- if progress.Skipped != 1 {
- t.Errorf("skipped = %d, want 1", progress.Skipped)
- }
- if progress.Failed != 0 {
- t.Errorf("failed = %d, want 0", progress.Failed)
- }
-}
-
-func TestProgressTrackerSnapshot(t *testing.T) {
- pt := newProgressTracker()
- pt.total.Store(10)
- pt.completed.Store(5)
- pt.skipped.Store(3)
- pt.failed.Store(2)
- pt.bytes.Store(1024)
- pt.phase.Store("downloading")
- pt.addError("npm", testPackageLodash, "4.17.21", "fetch failed")
-
- snap := pt.snapshot()
- if snap.Total != 10 {
- t.Errorf("total = %d, want 10", snap.Total)
- }
- if snap.Completed != 5 {
- t.Errorf("completed = %d, want 5", snap.Completed)
- }
- if snap.Skipped != 3 {
- t.Errorf("skipped = %d, want 3", snap.Skipped)
- }
- if snap.Failed != 2 {
- t.Errorf("failed = %d, want 2", snap.Failed)
- }
- if snap.Bytes != 1024 {
- t.Errorf("bytes = %d, want 1024", snap.Bytes)
- }
- if snap.Phase != "downloading" {
- t.Errorf("phase = %q, want %q", snap.Phase, "downloading")
- }
- if len(snap.Errors) != 1 {
- t.Fatalf("errors = %d, want 1", len(snap.Errors))
- }
- if snap.Errors[0].Name != testPackageLodash {
- t.Errorf("error name = %q, want %q", snap.Errors[0].Name, testPackageLodash)
- }
- if snap.StartedAt.IsZero() {
- t.Error("started_at should not be zero")
- }
-}
-
-func TestProgressTrackerConcurrentAccess(t *testing.T) {
- pt := newProgressTracker()
- done := make(chan struct{})
-
- for range 10 {
- go func() {
- pt.completed.Add(1)
- pt.addError("npm", "test", "1.0.0", "error")
- _ = pt.snapshot()
- done <- struct{}{}
- }()
- }
-
- timeout := time.After(5 * time.Second)
- for range 10 {
- select {
- case <-done:
- case <-timeout:
- t.Fatal("timed out waiting for goroutines")
- }
- }
-
- snap := pt.snapshot()
- if snap.Completed != 10 {
- t.Errorf("completed = %d, want 10", snap.Completed)
- }
- if len(snap.Errors) != 10 {
- t.Errorf("errors = %d, want 10", len(snap.Errors))
- }
-}
-
-func TestNewMirrorDefaultWorkers(t *testing.T) {
- m := New(nil, nil, nil, slog.Default(), 0)
- if m.workers != 1 {
- t.Errorf("workers = %d, want 1 (minimum)", m.workers)
- }
-
- m = New(nil, nil, nil, slog.Default(), -5)
- if m.workers != 1 {
- t.Errorf("workers = %d, want 1 (minimum)", m.workers)
- }
-}
-
-// recordingFetcher fails every fetch and records the URLs it was asked for, so
-// a test can show nothing reached upstream.
-type recordingFetcher struct {
- mu sync.Mutex
- urls []string
-}
-
-func (f *recordingFetcher) record(url string) error {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.urls = append(f.urls, url)
- return errors.New("recordingFetcher: no upstream in this test")
-}
-
-func (f *recordingFetcher) Fetch(_ context.Context, url string) (*fetch.Artifact, error) {
- return nil, f.record(url)
-}
-
-func (f *recordingFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
- return nil, f.record(url)
-}
-
-func (f *recordingFetcher) Head(_ context.Context, url string) (int64, string, error) {
- return 0, "", f.record(url)
-}
-
-func (f *recordingFetcher) fetched() []string {
- f.mu.Lock()
- defer f.mu.Unlock()
- return append([]string(nil), f.urls...)
-}
-
-func TestMirrorRunRefusesPackages(t *testing.T) {
- m := setupTestMirror(t, 2)
- fetcher := &recordingFetcher{}
- m.proxy.Fetcher = fetcher
- const reason = "packages cannot be mirrored here"
- m.RefusePackages = reason
-
- source := &PURLSource{PURLs: []string{"pkg:npm/lodash@4.17.21", "pkg:cargo/serde@1.0.0"}}
- progress, err := m.Run(context.Background(), source)
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- if progress.Failed != 2 || progress.Completed != 0 || progress.Skipped != 0 {
- t.Errorf("progress = %d failed, %d completed, %d skipped; want 2 failed only",
- progress.Failed, progress.Completed, progress.Skipped)
- }
- if len(progress.Errors) != 2 {
- t.Fatalf("errors = %+v, want one per package", progress.Errors)
- }
- for _, e := range progress.Errors {
- if e.Error != reason {
- t.Errorf("%s/%s error = %q, want %q", e.Ecosystem, e.Name, e.Error, reason)
- }
- }
- if urls := fetcher.fetched(); len(urls) != 0 {
- t.Errorf("refused packages were fetched from upstream: %v", urls)
- }
-
- stats, err := m.db.GetCacheStats()
- if err != nil {
- t.Fatalf("GetCacheStats: %v", err)
- }
- if stats.TotalArtifacts != 0 {
- t.Errorf("cached artifacts = %d, want 0", stats.TotalArtifacts)
- }
-}
-
-// Images keep their own refusal, so a mirror refusing packages still hands
-// docker and oci items to Images.
-func TestMirrorRefusePackagesLeavesImagesToImages(t *testing.T) {
- m := setupTestMirror(t, 1)
- m.proxy.Fetcher = &recordingFetcher{}
- m.RefusePackages = "packages refused"
- m.Images = UnavailableImages{Reason: "images refused"}
-
- progress, err := m.Run(context.Background(), &PURLSource{PURLs: []string{"pkg:docker/library/alpine@3.20"}})
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
- if len(progress.Errors) != 1 || progress.Errors[0].Error != "images refused" {
- t.Errorf("errors = %+v, want the image refusal", progress.Errors)
- }
-}
diff --git a/internal/mirror/refresh.go b/internal/mirror/refresh.go
deleted file mode 100644
index 3c2e094..0000000
--- a/internal/mirror/refresh.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package mirror
-
-import (
- "context"
- "fmt"
- "slices"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries"
-)
-
-// Refresh mirrors new releases of the packages clients download most, so
-// they are cached before the first client asks for them.
-type Refresh struct {
- // Top is how many packages per ecosystem are refreshed.
- Top int
- // Since is how recently a package must have been downloaded.
- Since time.Duration
- // MaxVersions stops a run after mirroring this many versions. 0 means
- // no cap.
- MaxVersions int
- // MaxBytes stops a run once it has downloaded this much. It is checked
- // before each version, so the last one may go over it. 0 means no cap.
- MaxBytes int64
-}
-
-func (r Refresh) spent(tracker *progressTracker) bool {
- return (r.MaxVersions > 0 && tracker.completed.Load() >= int64(r.MaxVersions)) ||
- (r.MaxBytes > 0 && tracker.bytes.Load() >= r.MaxBytes)
-}
-
-// ReleaseLister lists every release of a package, with its publish time and
-// status, for refreshes.
-type ReleaseLister interface {
- ListReleases(ctx context.Context, ecosystem, name string) ([]registries.Version, error)
-}
-
-// ListReleases implements ReleaseLister.
-func (r RegistryVersions) ListReleases(ctx context.Context, ecosystem, name string) ([]registries.Version, error) {
- return r.fetchVersions(ctx, ecosystem, name)
-}
-
-// releaseLister lists releases where Versions lists versions, so both come
-// from the configured upstream.
-func (m *Mirror) releaseLister() ReleaseLister { //nolint:ireturn // any lister will do
- if lister, ok := m.Versions.(ReleaseLister); ok {
- return lister
- }
- return RegistryVersions{}
-}
-
-// Refresh mirrors, for each of the most used packages, the newest release
-// of each major line it has a version cached on, as clients would be served
-// it: releases within cooldown or on the denylist are left out. Packages it
-// can't list are counted as failed.
-func (m *Mirror) Refresh(ctx context.Context, r Refresh) (*Progress, error) {
- ctx = handler.MirrorFetch(ctx)
- cutoff := time.Now().Add(-r.Since)
- packages, err := m.db.MostUsedPackages(cutoff, r.Top)
- if err != nil {
- return nil, fmt.Errorf("listing the most used packages: %w", err)
- }
- tracker := newProgressTracker()
- tracker.phase.Store("downloading")
- run := func(items []PackageVersion) bool {
- for _, item := range items {
- if ctx.Err() != nil {
- return false
- }
- if r.spent(tracker) {
- m.logger.Info("mirror refresh reached its cap for one run",
- "mirrored", tracker.completed.Load(), "bytes", tracker.bytes.Load())
- return false
- }
- tracker.total.Add(1)
- m.mirrorRecovered(ctx, item, tracker)
- }
- return true
- }
- for _, group := range byEcosystem(packages) {
- if ix, ok := osIndexes[group[0].Ecosystem]; ok {
- items, errs := m.indexRefreshItems(ctx, ix, group, cutoff)
- for _, err := range errs {
- m.recordFailure(tracker, PackageVersion{Ecosystem: ix.ecosystem}, err)
- }
- if !run(items) {
- break
- }
- continue
- }
- done := true
- for _, pkg := range group {
- items, err := m.refreshItems(ctx, pkg)
- if err != nil {
- m.recordFailure(tracker, PackageVersion{Ecosystem: pkg.Ecosystem, Name: pkg.Name}, err)
- continue
- }
- if done = run(items); !done {
- break
- }
- }
- if !done {
- break
- }
- }
- tracker.phase.Store("complete")
- p := tracker.snapshot()
- return &p, nil
-}
-
-// byEcosystem splits packages, ordered by ecosystem, into one group per
-// ecosystem.
-func byEcosystem(packages []database.UsedPackage) [][]database.UsedPackage {
- var groups [][]database.UsedPackage
- for i, pkg := range packages {
- if i == 0 || pkg.Ecosystem != packages[i-1].Ecosystem {
- groups = append(groups, nil)
- }
- groups[len(groups)-1] = append(groups[len(groups)-1], pkg)
- }
- return groups
-}
-
-// refreshItems returns the versions to mirror for a used package.
-func (m *Mirror) refreshItems(ctx context.Context, pkg database.UsedPackage) ([]PackageVersion, error) {
- p, err := purl.Parse(pkg.PURL)
- if err != nil {
- return nil, err
- }
- ecosystem, name, _ := mirrorTarget(p)
- if !refreshable(ecosystem) {
- return nil, nil
- }
- releases, err := m.releaseLister().ListReleases(ctx, ecosystem, name)
- if err != nil {
- return nil, fmt.Errorf("listing releases: %w", err)
- }
-
- var cached []string
- seen := map[string]bool{}
- for _, f := range pkg.Files {
- if !seen[f.Version] {
- seen[f.Version] = true
- cached = append(cached, f.Version)
- }
- }
- allowed := func(v registries.Version) bool {
- return m.proxy == nil || m.proxy.ReleaseAllowed(pkg.Ecosystem, pkg.Name, v.Number, v.PublishedAt)
- }
-
- var items []PackageVersion
- for _, version := range newestPerCachedLine(ecosystem, cached, releases, allowed) {
- pv := PackageVersion{Ecosystem: ecosystem, Name: name, Version: version}
- if eco, ok := fileEcosystems[ecosystem]; ok {
- pv.variants = map[string]bool{}
- for _, f := range pkg.Files {
- for _, v := range eco.cachedVariants(f.Filename) {
- pv.variants[v] = true
- }
- }
- }
- items = append(items, pv)
- }
- return items, nil
-}
-
-// refreshable reports whether a refresh lists the releases of an
-// ecosystem's packages. Images follow mirror.schedules, OS packages their
-// repository indexes, and Julia files are cached by tree hash rather than by
-// version.
-func refreshable(ecosystem string) bool {
- if isImageEcosystem(ecosystem) || osIndexes[ecosystem].ecosystem != "" || ecosystem == "julia" {
- return false
- }
- return slices.Contains(registries.SupportedEcosystems(), purl.EcosystemToPURLType(ecosystem))
-}
diff --git a/internal/mirror/refresh_harden_test.go b/internal/mirror/refresh_harden_test.go
deleted file mode 100644
index 0af855d..0000000
--- a/internal/mirror/refresh_harden_test.go
+++ /dev/null
@@ -1,438 +0,0 @@
-package mirror
-
-import (
- "context"
- "net/http"
- "reflect"
- "slices"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/denylist"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// debCurlPackages returns a Packages index listing curl at version.
-func debCurlPackages(t *testing.T, version string) []byte {
- t.Helper()
- return gzipped(t, "Package: curl\nVersion: "+version+"\nArchitecture: amd64\nFilename: pool/main/c/curl/curl_"+version+"_amd64.deb\n\n")
-}
-
-// TestRefreshDebianIndexPerSuite checks that suites sharing a pool are
-// refreshed each on its own: a newer curl cached from trixie must not keep
-// bookworm's security update from being mirrored.
-func TestRefreshDebianIndexPerSuite(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n")
- pool := idx.URL + "/debian/pool/main/c/curl/"
- for suite, version := range map[string]string{"bookworm": "7.88.1-10", "trixie": "8.14.1-2"} {
- idx.set("/debian/dists/"+suite+"/InRelease", release)
- idx.set("/debian/dists/"+suite+"/main/binary-amd64/Packages.gz", debCurlPackages(t, version))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/"+suite+"/InRelease",
- "deb", version, "curl_"+version+"_amd64.deb", pool+"curl_"+version+"_amd64.deb")
- }
-
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", debCurlPackages(t, "7.88.1-10+deb12u8"))
- idx.set("/debian/dists/trixie/main/binary-amd64/Packages.gz", debCurlPackages(t, "8.14.1-2+deb13u1"))
- fetcher.reset()
- progress := refreshOnce(t, m)
-
- want := []string{pool + "curl_7.88.1-10+deb12u8_amd64.deb", pool + "curl_8.14.1-2+deb13u1_amd64.deb"}
- got := fetcher.fetched()
- slices.Sort(got)
- if !slices.Equal(got, want) || progress.Completed != 2 {
- t.Errorf("progress = %+v, fetched %v; want %v", progress, got, want)
- }
-}
-
-// TestRefreshDebianIndexSkipsOtherSuite checks that a suite's version of a
-// package is not mirrored when only a newer one is cached: clients of that
-// suite never fetched the package.
-func TestRefreshDebianIndexSkipsOtherSuite(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n")
- pool := idx.URL + "/debian/pool/main/c/curl/"
- idx.set("/debian/dists/trixie/InRelease", release)
- idx.set("/debian/dists/trixie/main/binary-amd64/Packages.gz", debCurlPackages(t, "8.14.1-2"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/trixie/InRelease",
- "deb", "8.14.1-2", "curl_8.14.1-2_amd64.deb", pool+"curl_8.14.1-2_amd64.deb")
- // A bookworm client fetched its index but never curl.
- idx.set("/debian/dists/bookworm/InRelease", release)
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", debCurlPackages(t, "7.88.1-10+deb12u8"))
- if _, _, err := m.proxy.FetchOrCacheMetadata(t.Context(), "debian", "key:bookworm", idx.URL+"/debian/dists/bookworm/InRelease", "*/*"); err != nil {
- t.Fatal(err)
- }
- fetcher.reset()
-
- if progress := refreshOnce(t, m); progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshRPMIndexListingSeveralVersions checks that a repository
-// listing old versions next to the newest mirrors only the newest, and
-// nothing once it is cached.
-func TestRefreshRPMIndexListingSeveralVersions(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- base := "/fedora/updates/40/Everything/x86_64"
- primary := func(rels ...string) []byte {
- var pkgs string
- for _, rel := range rels {
- pkgs += `curlx86_64`
- }
- return gzipped(t, ``+pkgs+``)
- }
- idx.set(base+"/repodata/repomd.xml", []byte(``))
- idx.set(base+"/repodata/primary.xml.gz", primary("10"))
- clientFetchesCurl(t, m, "rpm", idx.URL+base+"/repodata/repomd.xml",
- "rpm", "8.6.0-10.fc40", "curl-8.6.0-10.fc40.x86_64.rpm", idx.URL+base+"/Packages/c/curl-8.6.0-10.fc40.x86_64.rpm")
-
- idx.set(base+"/repodata/primary.xml.gz", primary("10", "12", "11"))
- fetcher.reset()
- progress := refreshOnce(t, m)
- want := []string{idx.URL + base + "/Packages/c/curl-8.6.0-12.fc40.x86_64.rpm"}
- if !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 {
- t.Fatalf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
-
- fetcher.reset()
- if progress := refreshOnce(t, m); progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("second run: progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshIndexFailureDoesNotStopRun checks that an index that can't be
-// read is counted as failed and the other indexes are still refreshed.
-func TestRefreshIndexFailureDoesNotStopRun(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n")
- pool := idx.URL + "/debian/pool/main/c/curl/"
- for suite, version := range map[string]string{"bookworm": "7.88.1-10", "trixie": "8.14.1-2"} {
- idx.set("/debian/dists/"+suite+"/InRelease", release)
- idx.set("/debian/dists/"+suite+"/main/binary-amd64/Packages.gz", debCurlPackages(t, version))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/"+suite+"/InRelease",
- "deb", version, "curl_"+version+"_amd64.deb", pool+"curl_"+version+"_amd64.deb")
- }
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", []byte("not gzip"))
- idx.set("/debian/dists/trixie/main/binary-amd64/Packages.gz", debCurlPackages(t, "8.14.1-2+deb13u1"))
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- want := []string{pool + "curl_8.14.1-2+deb13u1_amd64.deb"}
- if !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 || progress.Failed != 1 {
- t.Errorf("progress = %+v, fetched %v; want %v and one failure", progress, fetcher.fetched(), want)
- }
-}
-
-func TestNewestPerCachedLineGo(t *testing.T) {
- allowAll := func(registries.Version) bool { return true }
- for _, tt := range []struct {
- name string
- cached []string
- releases []registries.Version
- want []string
- }{
- {"v prefix", []string{"v1.2.3"}, releases("v1.2.3", "v1.3.0"), []string{"v1.3.0"}},
- {"incompatible is its own line", []string{"v1.2.3"}, releases("v1.2.3", "v2.0.0+incompatible"), nil},
- {"pseudo-versions are prereleases", []string{"v1.2.3"}, releases("v1.2.3", "v1.2.4-0.20260101000000-abcdefabcdef"), nil},
- } {
- t.Run(tt.name, func(t *testing.T) {
- if got := newestPerCachedLine("golang", tt.cached, tt.releases, allowAll); !reflect.DeepEqual(got, tt.want) {
- t.Errorf("newestPerCachedLine = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestRefreshScopedNPMPackage(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "@babel/core", "7.24.0")
- m.Versions = stubReleases{"npm/@babel/core": oldReleases("7.24.0", "7.25.2")}
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if urls := fetcher.fetched(); progress.Completed != 1 || len(fetchedMatching(urls, "core-7.25.2.tgz")) != 1 {
- t.Errorf("progress = %+v, fetched %v; want @babel/core 7.25.2", progress, urls)
- }
-}
-
-func TestRefreshSkipsDeniedRelease(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{"npm/lodash": oldReleases("4.17.20", "4.17.21", "4.17.22")}
- policy, err := denylist.New([]string{"pkg:npm/lodash@4.17.22"})
- if err != nil {
- t.Fatal(err)
- }
- m.proxy.Denylist = policy
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if urls := fetcher.fetched(); progress.Completed != 1 || len(fetchedMatching(urls, "lodash-4.17.21.tgz")) != 1 || len(fetchedMatching(urls, "4.17.22")) != 0 {
- t.Errorf("progress = %+v, fetched %v; want 4.17.21, not the denied 4.17.22", progress, urls)
- }
-}
-
-func TestRefreshListingFailureDoesNotStopOthers(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- cacheNPM(t, m, "react", "18.3.0")
- m.Versions = stubReleases{"npm/react": oldReleases("18.3.0", "18.3.1")}
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if urls := fetcher.fetched(); progress.Completed != 1 || progress.Failed != 1 || len(fetchedMatching(urls, "react-18.3.1.tgz")) != 1 {
- t.Errorf("progress = %+v, fetched %v; want react refreshed and lodash failed", progress, urls)
- }
-}
-
-func TestRefreshStopsWhenCancelled(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{"npm/lodash": oldReleases("4.17.20", "4.17.21")}
- fetcher.reset()
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- progress, err := m.Refresh(ctx, Refresh{Top: 10, Since: time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want nothing after cancelling", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshDebianArchitectureAll checks that a package for every
-// architecture, listed in each binary-* index rather than one of its own,
-// is refreshed.
-func TestRefreshDebianArchitectureAll(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- packages := func(version string) []byte {
- return gzipped(t, "Package: ca-certificates\nVersion: "+version+"\nArchitecture: all\nFilename: pool/main/c/ca-certificates/ca-certificates_"+version+"_all.deb\n\n")
- }
- idx.set("/debian/dists/bookworm/InRelease", []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n"))
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", packages("20230311"))
- pool := idx.URL + "/debian/pool/main/c/ca-certificates/"
- if _, _, err := m.proxy.FetchOrCacheMetadata(t.Context(), "debian", "key:bookworm", idx.URL+"/debian/dists/bookworm/InRelease", "*/*"); err != nil {
- t.Fatal(err)
- }
- result, err := m.proxy.GetOrFetchArtifactFromURL(t.Context(), "deb", "ca-certificates", "20230311", "ca-certificates_20230311_all.deb", pool+"ca-certificates_20230311_all.deb")
- if err != nil {
- t.Fatal(err)
- }
- _ = result.Reader.Close()
-
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", packages("20230311+deb12u1"))
- fetcher.reset()
- progress := refreshOnce(t, m)
- if want := []string{pool + "ca-certificates_20230311+deb12u1_all.deb"}; !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 {
- t.Errorf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
-}
-
-// TestRefreshSkipsIndexPathsOutsideTheRepository checks that an index entry
-// whose path leaves the repository base is not fetched.
-func TestRefreshSkipsIndexPathsOutsideTheRepository(t *testing.T) {
- for _, filename := range []string{
- "../other/pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb",
- "pool/main/../../../other/curl_7.88.1-10+deb12u8_amd64.deb",
- "/other/curl_7.88.1-10+deb12u8_amd64.deb",
- } {
- t.Run(filename, func(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n")
- pool := idx.URL + "/debian/pool/main/c/curl/"
- idx.set("/debian/dists/bookworm/InRelease", release)
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", debCurlPackages(t, "7.88.1-10"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/bookworm/InRelease",
- "deb", "7.88.1-10", "curl_7.88.1-10_amd64.deb", pool+"curl_7.88.1-10_amd64.deb")
-
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", gzipped(t,
- "Package: curl\nVersion: 7.88.1-10+deb12u8\nArchitecture: amd64\nFilename: "+filename+"\n\n"))
- fetcher.reset()
-
- if progress := refreshOnce(t, m); progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
- })
- }
-}
-
-// TestRefreshRefusesIndexesOverTheSizeLimit checks that an index that
-// decompresses to more than maxIndexBytes fails instead of being parsed in
-// full.
-func TestRefreshRefusesIndexesOverTheSizeLimit(t *testing.T) {
- limit := maxIndexBytes
- maxIndexBytes = 4 << 10
- t.Cleanup(func() { maxIndexBytes = limit })
-
- m, fetcher, idx := setupIndexRefresh(t)
- idx.set("/debian/dists/bookworm/InRelease", []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n"))
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", debCurlPackages(t, "7.88.1-10"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/bookworm/InRelease",
- "deb", "7.88.1-10", "curl_7.88.1-10_amd64.deb", idx.URL+"/debian/pool/main/c/curl/curl_7.88.1-10_amd64.deb")
-
- padding := strings.Repeat("Package: filler\nVersion: 1\nArchitecture: amd64\nFilename: pool/main/f/filler/filler_1_amd64.deb\n\n", 100)
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", gzipped(t,
- "Package: curl\nVersion: 7.88.1-10+deb12u8\nArchitecture: amd64\nFilename: pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb\n\n"+padding))
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, "larger than") || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want the index failing for its size", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshRefusesAPKIndexesOverTheSizeLimit checks that the limit also
-// covers APKINDEX.tar.gz, which comes from the metadata cache rather than
-// readIndex.
-func TestRefreshRefusesAPKIndexesOverTheSizeLimit(t *testing.T) {
- limit := maxIndexBytes
- maxIndexBytes = 4 << 10
- t.Cleanup(func() { maxIndexBytes = limit })
-
- m, fetcher, idx := setupIndexRefresh(t)
- dir := "/alpine/v3.20/main/aarch64"
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.0-r0\nA:aarch64\n\n"))
- clientFetchesCurl(t, m, "alpine", idx.URL+dir+"/APKINDEX.tar.gz",
- "alpine", "8.9.0-r0", "edge/v3.20/main/aarch64/curl-8.9.0-r0.apk", idx.URL+dir+"/curl-8.9.0-r0.apk")
-
- padding := strings.Repeat("P:filler\nV:1-r0\nA:aarch64\n\n", 400)
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.1-r0\nA:aarch64\n\n"+padding))
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, "larger than") || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want the index failing for its size", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshRefusesIndexDocumentsOverTheMetadataLimit checks that an
-// InRelease larger than a metadata document may be fails its index.
-func TestRefreshRefusesIndexDocumentsOverTheMetadataLimit(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := "SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n"
- idx.set("/debian/dists/bookworm/InRelease", []byte(release))
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", debCurlPackages(t, "7.88.1-10+deb12u8"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/bookworm/InRelease",
- "deb", "7.88.1-10", "curl_7.88.1-10_amd64.deb", idx.URL+"/debian/pool/main/c/curl/curl_7.88.1-10_amd64.deb")
-
- m.proxy.MetadataMaxSize = int64(len(release))
- idx.set("/debian/dists/bookworm/InRelease", []byte(release+" 0000 1 contrib/binary-amd64/Packages.gz\n"))
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, handler.ErrMetadataTooLarge.Error()) || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want the index failing for its size", progress, fetcher.fetched())
- }
-}
-
-// TestRefreshDebianSecurityPool checks the security archive's layout, whose
-// pool is pool/updates/main while its InRelease lists main/binary-*.
-func TestRefreshDebianSecurityPool(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- pool := idx.URL + "/debian-security/pool/updates/main/c/curl/"
- packages := func(version string) []byte {
- return gzipped(t, "Package: curl\nVersion: "+version+"\nArchitecture: amd64\nFilename: pool/updates/main/c/curl/curl_"+version+"_amd64.deb\n\n")
- }
- idx.set("/debian-security/dists/bookworm-security/InRelease", []byte("SHA256:\n 0000 1 main/binary-amd64/Packages.gz\n"))
- idx.set("/debian-security/dists/bookworm-security/main/binary-amd64/Packages.gz", packages("7.88.1-10+deb12u7"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian-security/dists/bookworm-security/InRelease",
- "deb", "7.88.1-10+deb12u7", "curl_7.88.1-10+deb12u7_amd64.deb", pool+"curl_7.88.1-10+deb12u7_amd64.deb")
-
- idx.set("/debian-security/dists/bookworm-security/main/binary-amd64/Packages.gz", packages("7.88.1-10+deb12u8"))
- fetcher.reset()
- progress := refreshOnce(t, m)
-
- want := []string{pool + "curl_7.88.1-10+deb12u8_amd64.deb"}
- if !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 || progress.Failed != 0 {
- t.Errorf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
-}
-
-// TestRefreshDownloadsDoNotCountAsUse checks that a package nobody fetched
-// within since drops out even though the refresh itself downloaded it.
-func TestRefreshDownloadsDoNotCountAsUse(t *testing.T) {
- m, _ := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{"npm/lodash": oldReleases("4.17.20", "4.17.21")}
- if progress := refreshOnce(t, m); progress.Completed != 1 {
- t.Fatalf("progress = %+v, want 4.17.21 mirrored", progress)
- }
- old := time.Now().Add(-48 * time.Hour).UTC()
- if _, err := m.db.Exec(m.db.Rebind("UPDATE artifacts SET fetched_at = ? WHERE filename = ?"), old, "lodash-4.17.20.tgz"); err != nil {
- t.Fatal(err)
- }
-
- used, err := m.db.MostUsedPackages(time.Now().Add(-24*time.Hour), 10)
- if err != nil {
- t.Fatal(err)
- }
- if len(used) != 0 {
- t.Errorf("MostUsedPackages = %+v, want none: only the refresh fetched lodash within since", used)
- }
-}
-
-// TestRefreshIndexReadIsNotAClientAccess checks that a refresh reading an
-// index again doesn't keep it among those clients fetched within since.
-func TestRefreshIndexReadIsNotAClientAccess(t *testing.T) {
- m, _, idx := setupIndexRefresh(t)
- dir := "/alpine/v3.20/main/aarch64"
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.0-r0\nA:aarch64\n\n"))
- clientFetchesCurl(t, m, "alpine", idx.URL+dir+"/APKINDEX.tar.gz",
- "alpine", "8.9.0-r0", "edge/v3.20/main/aarch64/curl-8.9.0-r0.apk", idx.URL+dir+"/curl-8.9.0-r0.apk")
- old := time.Now().Add(-2 * time.Hour).UTC()
- if _, err := m.db.Exec(m.db.Rebind("UPDATE metadata_cache SET last_accessed_at = ?, updated_at = ?"), old, old); err != nil {
- t.Fatal(err)
- }
-
- if _, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 3 * time.Hour}); err != nil {
- t.Fatal(err)
- }
- docs, err := m.db.RecentMetadataDocuments("alpine", time.Now().Add(-time.Hour))
- if err != nil {
- t.Fatal(err)
- }
- if len(docs) != 0 {
- t.Errorf("recent indexes = %+v, want none: only the refresh read the index", docs)
- }
-}
-
-// panickingFetcher serves files like servingFetcher but panics on URLs
-// containing panicOn.
-type panickingFetcher struct {
- *servingFetcher
- panicOn string
-}
-
-func (f *panickingFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
- return f.FetchWithHeaders(ctx, url, nil)
-}
-
-func (f *panickingFetcher) FetchWithHeaders(ctx context.Context, url string, h http.Header) (*fetch.Artifact, error) {
- if strings.Contains(url, f.panicOn) {
- panic("fetcher bug")
- }
- return f.servingFetcher.FetchWithHeaders(ctx, url, h)
-}
-
-func TestRefreshRecoversFromPanic(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- cacheNPM(t, m, "react", "18.3.0")
- m.Versions = stubReleases{
- "npm/lodash": oldReleases("4.17.20", "4.17.21"),
- "npm/react": oldReleases("18.3.0", "18.3.1"),
- }
- m.proxy.Fetcher = &panickingFetcher{servingFetcher: fetcher, panicOn: "lodash-4.17.21"}
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if progress.Failed != 1 || progress.Completed != 1 || len(fetchedMatching(fetcher.fetched(), "react-18.3.1.tgz")) != 1 {
- t.Errorf("progress = %+v, fetched %v; want lodash failed and react mirrored", progress, fetcher.fetched())
- }
-}
diff --git a/internal/mirror/refresh_index.go b/internal/mirror/refresh_index.go
deleted file mode 100644
index c54224e..0000000
--- a/internal/mirror/refresh_index.go
+++ /dev/null
@@ -1,350 +0,0 @@
-package mirror
-
-import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
- "path"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/vers"
-)
-
-// A Debian, Alpine or RPM client finds new versions in a repository index
-// rather than in metadata per package. A refresh reads the indexes clients
-// fetched again, from the URLs the metadata cache recorded, and mirrors the
-// newer files they list of the packages downloaded most.
-
-// osIndex describes the repository index of one ecosystem.
-type osIndex struct {
- // ecosystem is what package files are cached under, metaEcosystem what
- // the index documents are cached under.
- ecosystem, metaEcosystem string
- // scheme orders versions.
- scheme string
- // base returns the repository base package paths are relative to, for
- // the URL of an index document a refresh starts from, or false for
- // other documents.
- base func(indexURL string) (string, bool)
- // files lists the package files of the index document body fetched
- // from indexURL. cached are the files of the most used packages under
- // the repository base, for indexes that list their files in parts.
- files func(ctx context.Context, m *Mirror, indexURL string, body []byte, cached []cachedOSFile) ([]indexFile, error)
-}
-
-var osIndexes = map[string]osIndex{
- "alpine": {
- ecosystem: "alpine", metaEcosystem: "alpine", scheme: "alpine",
- base: func(u string) (string, bool) {
- return strings.CutSuffix(u, "/APKINDEX.tar.gz")
- },
- files: func(_ context.Context, _ *Mirror, indexURL string, body []byte, _ []cachedOSFile) ([]indexFile, error) {
- return parseAPKIndex(bytes.NewReader(body), indexURL)
- },
- },
- "deb": {
- ecosystem: "deb", metaEcosystem: "debian", scheme: "deb",
- base: func(u string) (string, bool) {
- if !strings.HasSuffix(u, "/InRelease") && !strings.HasSuffix(u, "/Release") {
- return "", false
- }
- base, _, ok := strings.Cut(u, "/dists/")
- return base, ok
- },
- files: debIndexFiles,
- },
- "rpm": {
- ecosystem: "rpm", metaEcosystem: "rpm", scheme: "rpm",
- base: func(u string) (string, bool) {
- return strings.CutSuffix(u, "/repodata/repomd.xml")
- },
- files: rpmIndexFiles,
- },
-}
-
-// cachedOSFile is a cached file of a most used package, by its path below a
-// repository base.
-type cachedOSFile struct {
- file database.UsedFile
- rel string
- name, version, arch string
-}
-
-func (f cachedOSFile) key() string { return f.name + "\x00" + f.arch }
-
-// indexRefreshItems returns the newer files the recently fetched indexes of
-// an ecosystem list for packages, and an error per index it couldn't read.
-func (m *Mirror) indexRefreshItems(ctx context.Context, ix osIndex, packages []database.UsedPackage, cutoff time.Time) ([]PackageVersion, []error) {
- docs, err := m.db.RecentMetadataDocuments(ix.metaEcosystem, cutoff)
- if err != nil {
- return nil, []error{fmt.Errorf("listing %s indexes: %w", ix.metaEcosystem, err)}
- }
- var items []PackageVersion
- var errs []error
- seenIndex := map[string]bool{}
- seenFile := map[string]bool{}
- for _, doc := range docs {
- base, ok := ix.base(doc.UpstreamURL)
- if !ok || seenIndex[urlDir(doc.UpstreamURL)] {
- continue
- }
- cached := cachedUnder(ix.ecosystem, base, packages)
- if len(cached) == 0 {
- continue
- }
- seenIndex[urlDir(doc.UpstreamURL)] = true
- body, err := m.readIndexDocument(ctx, doc.UpstreamURL)
- if err != nil {
- errs = append(errs, fmt.Errorf("%s: %w", doc.UpstreamURL, err))
- continue
- }
- files, err := ix.files(ctx, m, doc.UpstreamURL, body, cached)
- if err != nil {
- errs = append(errs, fmt.Errorf("%s: %w", doc.UpstreamURL, err))
- continue
- }
- for _, item := range m.newerIndexFiles(ix, base, cached, files) {
- if !seenFile[item.fileURL] {
- seenFile[item.fileURL] = true
- items = append(items, item)
- }
- }
- }
- return items, errs
-}
-
-// cachedUnder returns the cached files of packages below base.
-func cachedUnder(ecosystem, base string, packages []database.UsedPackage) []cachedOSFile {
- var cached []cachedOSFile
- for _, pkg := range packages {
- for _, f := range pkg.Files {
- rel, ok := strings.CutPrefix(f.UpstreamURL, base+"/")
- if !ok {
- continue
- }
- name, version, arch := handler.OSPackageIdentity(ecosystem, rel)
- if name != "" {
- cached = append(cached, cachedOSFile{file: f, rel: rel, name: name, version: version, arch: arch})
- }
- }
- }
- return cached
-}
-
-// newerIndexFiles returns, per package and architecture cached below base,
-// the newest file the index lists when it isn't cached and an older version
-// is. Suites sharing a pool are told apart that way: a newer version cached
-// from another suite doesn't hide an update to this one, and a package only
-// cached in a newer version than this suite has isn't mirrored for it.
-func (m *Mirror) newerIndexFiles(ix osIndex, base string, cached []cachedOSFile, files []indexFile) []PackageVersion {
- cachedByKey := map[string][]cachedOSFile{}
- for _, c := range cached {
- cachedByKey[c.key()] = append(cachedByKey[c.key()], c)
- }
- type candidate struct {
- file indexFile
- name, version string
- }
- newest := map[string]candidate{}
- var order []string
- for _, f := range files {
- if !confinedIndexPath(f.Path) {
- continue
- }
- name, version, arch := handler.OSPackageIdentity(ix.ecosystem, f.Path)
- key := name + "\x00" + arch
- if name == "" || len(cachedByKey[key]) == 0 {
- continue
- }
- if m.proxy != nil && !m.proxy.ReleaseAllowed(ix.ecosystem, name, version, time.Time{}) {
- continue
- }
- current, seen := newest[key]
- if !seen {
- order = append(order, key)
- }
- if !seen || vers.CompareWithScheme(version, current.version, ix.scheme) > 0 {
- newest[key] = candidate{file: f, name: name, version: version}
- }
- }
-
- items := make([]PackageVersion, 0, len(order))
- for _, key := range order {
- n := newest[key]
- var older *cachedOSFile
- for i, c := range cachedByKey[key] {
- cmp := vers.CompareWithScheme(n.version, c.version, ix.scheme)
- if cmp == 0 {
- older = nil
- break
- }
- if cmp > 0 && older == nil {
- older = &cachedByKey[key][i]
- }
- }
- if older == nil {
- continue
- }
- cacheFilename := path.Base(n.file.Path)
- if dir := path.Dir(older.file.Filename); dir != "." {
- cacheFilename = dir + "/" + cacheFilename
- }
- items = append(items, PackageVersion{
- Ecosystem: ix.ecosystem, Name: n.name, Version: n.version,
- fileURL: base + "/" + n.file.Path, cacheFilename: cacheFilename, digest: n.file.Digest,
- })
- }
- return items
-}
-
-// confinedIndexPath reports whether an index entry's path is relative and
-// stays below the repository base.
-func confinedIndexPath(p string) bool {
- return p != "" && !path.IsAbs(p) && path.Clean(p) == p && p != ".." && !strings.HasPrefix(p, "../")
-}
-
-// debIndexFiles reads the Packages indexes an InRelease or Release file
-// lists for the components and architectures of the cached files.
-func debIndexFiles(ctx context.Context, m *Mirror, indexURL string, body []byte, cached []cachedOSFile) ([]indexFile, error) {
- indexes := debPackagesIndexes(body)
- wanted := map[string]bool{}
- for _, c := range cached {
- component, ok := debComponent(c.rel, indexes)
- if !ok {
- continue
- }
- if dir := component + "/binary-" + c.arch; indexes[dir] != "" {
- wanted[dir] = true
- continue
- }
- // Files for every architecture are listed in each binary-* index.
- for dir := range indexes {
- if strings.HasPrefix(dir, component+"/binary-") {
- wanted[dir] = true
- break
- }
- }
- }
- suite := urlDir(indexURL)
- var files []indexFile
- for dir := range wanted {
- listed, err := m.readIndex(ctx, suite+"/"+indexes[dir], parseDebPackages)
- if err != nil {
- return nil, err
- }
- files = append(files, listed...)
- }
- return files, nil
-}
-
-// debComponent returns the component of a pool file that the release file
-// lists indexes for. It is the first segment after pool/, or the second, as
-// in the security archive's pool/updates/main.
-func debComponent(rel string, indexes map[string]string) (string, bool) {
- rest, ok := strings.CutPrefix(rel, "pool/")
- if !ok {
- return "", false
- }
- first, rest, _ := strings.Cut(rest, "/")
- second, _, _ := strings.Cut(rest, "/")
- for _, component := range []string{first, second} {
- for dir := range indexes {
- if strings.HasPrefix(dir, component+"/binary-") {
- return component, true
- }
- }
- }
- return "", false
-}
-
-// rpmIndexFiles reads the primary metadata a repomd.xml points at.
-func rpmIndexFiles(ctx context.Context, m *Mirror, indexURL string, body []byte, _ []cachedOSFile) ([]indexFile, error) {
- primary, err := repomdPrimary(body)
- if err != nil {
- return nil, err
- }
- base := strings.TrimSuffix(indexURL, "/repodata/repomd.xml")
- return m.readIndex(ctx, base+"/"+primary, parseRPMPrimary)
-}
-
-// readIndexDocument reads the index document a refresh starts from directly
-// from upstream. Read through the metadata cache it would count as a client
-// fetching it, and the suite would never age out of since. It is held in
-// memory, so it is limited like any metadata document.
-func (m *Mirror) readIndexDocument(ctx context.Context, indexURL string) ([]byte, error) {
- body, err := m.openIndex(ctx, indexURL)
- if err != nil {
- return nil, err
- }
- defer func() { _ = body.Close() }()
- return m.proxy.ReadMetadata(body)
-}
-
-// openIndex starts reading an index from upstream.
-func (m *Mirror) openIndex(ctx context.Context, indexURL string) (io.ReadCloser, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil)
- if err != nil {
- return nil, err
- }
- resp, err := m.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- _ = resp.Body.Close()
- return nil, fmt.Errorf("%s: upstream returned %d", indexURL, resp.StatusCode)
- }
- return resp.Body, nil
-}
-
-// readIndex streams an index from upstream through parse, decompressed as
-// its name says and limited to maxIndexBytes. It is read directly rather than through the metadata
-// cache, since it can be far larger than a metadata document may be.
-func (m *Mirror) readIndex(ctx context.Context, indexURL string, parse func(io.Reader) ([]indexFile, error)) ([]indexFile, error) {
- body, err := m.openIndex(ctx, indexURL)
- if err != nil {
- return nil, err
- }
- defer func() { _ = body.Close() }()
- r, err := decompressByName(indexURL, body)
- if err != nil {
- return nil, err
- }
- defer func() { _ = r.Close() }()
- return parse(&indexLimitReader{r: r, url: indexURL, remaining: maxIndexBytes})
-}
-
-// maxIndexBytes bounds an index once decompressed. Debian's largest
-// Packages files are under 100 MB and Fedora's primary.xml under 200 MB.
-var maxIndexBytes int64 = 512 << 20
-
-// indexLimitReader fails once more than maxIndexBytes have been read, so an
-// oversized index fails rather than looking complete.
-type indexLimitReader struct {
- r io.Reader
- url string
- remaining int64
-}
-
-func (l *indexLimitReader) Read(p []byte) (int, error) {
- if int64(len(p)) > l.remaining+1 {
- p = p[:l.remaining+1]
- }
- n, err := l.r.Read(p)
- if int64(n) > l.remaining {
- return int(l.remaining), fmt.Errorf("%s: index larger than %d bytes", l.url, maxIndexBytes)
- }
- l.remaining -= int64(n)
- return n, err
-}
-
-// urlDir returns a URL without its last path segment. path.Dir would fold
-// the slashes after the scheme.
-func urlDir(u string) string {
- return u[:strings.LastIndex(u, "/")]
-}
diff --git a/internal/mirror/refresh_index_limit_test.go b/internal/mirror/refresh_index_limit_test.go
deleted file mode 100644
index 7512fc5..0000000
--- a/internal/mirror/refresh_index_limit_test.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package mirror
-
-import (
- "io"
- "strings"
- "testing"
-)
-
-func TestIndexLimitReaderAllowsExactlyTheLimit(t *testing.T) {
- limit := maxIndexBytes
- maxIndexBytes = 8
- t.Cleanup(func() { maxIndexBytes = limit })
-
- if _, err := io.ReadAll(&indexLimitReader{r: strings.NewReader("12345678"), remaining: maxIndexBytes}); err != nil {
- t.Errorf("index of exactly the limit: %v", err)
- }
- if _, err := io.ReadAll(&indexLimitReader{r: strings.NewReader("123456789"), remaining: maxIndexBytes}); err == nil {
- t.Error("index one byte over the limit read without an error")
- }
-}
diff --git a/internal/mirror/refresh_index_parse.go b/internal/mirror/refresh_index_parse.go
deleted file mode 100644
index 9b0cd27..0000000
--- a/internal/mirror/refresh_index_parse.go
+++ /dev/null
@@ -1,239 +0,0 @@
-package mirror
-
-import (
- "archive/tar"
- "bufio"
- "compress/bzip2"
- "compress/gzip"
- "encoding/xml"
- "errors"
- "fmt"
- "io"
- "strings"
-
- "github.com/klauspost/compress/zstd"
- "github.com/ulikunitz/xz"
-)
-
-// indexFile is a package file a repository index lists: its path below the
-// repository base and, where the index gives one, its SHA-256 digest.
-type indexFile struct {
- Path string
- Digest string
-}
-
-// maxIndexLine bounds one line of a Debian or Alpine index.
-const maxIndexLine = 1 << 20
-
-// parseAPKIndex reads the files an APKINDEX.tar.gz lists, limited to
-// maxIndexBytes once decompressed. Its checksums cover only the control
-// section of a package, so no digest is returned.
-func parseAPKIndex(r io.Reader, indexURL string) ([]indexFile, error) {
- gz, err := gzip.NewReader(r)
- if err != nil {
- return nil, err
- }
- defer func() { _ = gz.Close() }()
- // An APKINDEX.tar.gz may be several gzip streams: the signature, then the
- // index. Reading on across them finds the index whichever comes first.
- tr := tar.NewReader(&indexLimitReader{r: gz, url: indexURL, remaining: maxIndexBytes})
- for {
- hdr, err := tr.Next()
- if errors.Is(err, io.EOF) {
- return nil, errors.New("APKINDEX.tar.gz has no APKINDEX")
- }
- if err != nil {
- return nil, err
- }
- if hdr.Name == "APKINDEX" {
- return parseAPKIndexText(tr)
- }
- }
-}
-
-func parseAPKIndexText(r io.Reader) ([]indexFile, error) {
- var files []indexFile
- var name, version string
- flush := func() {
- if name != "" && version != "" {
- files = append(files, indexFile{Path: name + "-" + version + ".apk"})
- }
- name, version = "", ""
- }
- scanner := bufio.NewScanner(r)
- scanner.Buffer(nil, maxIndexLine)
- for scanner.Scan() {
- line := scanner.Text()
- switch {
- case line == "":
- flush()
- case strings.HasPrefix(line, "P:"):
- name = line[2:]
- case strings.HasPrefix(line, "V:"):
- version = line[2:]
- }
- }
- flush()
- return files, scanner.Err()
-}
-
-// debIndexPreference ranks the compressions of a Packages index, best first.
-var debIndexPreference = []string{".xz", ".gz", ".bz2", ""}
-
-// debPackagesIndexes returns, per component/binary-arch directory, the path
-// of the Packages index a Release or InRelease file lists, in the best
-// compression it offers.
-func debPackagesIndexes(release []byte) map[string]string {
- listed := map[string]bool{}
- inSHA256 := false
- for _, line := range strings.Split(string(release), "\n") {
- line = strings.TrimRight(line, "\r")
- if !strings.HasPrefix(line, " ") {
- inSHA256 = line == "SHA256:"
- continue
- }
- if !inSHA256 {
- continue
- }
- fields := strings.Fields(line)
- if len(fields) == 3 { //nolint:mnd // checksum, size, path
- listed[fields[2]] = true
- }
- }
- indexes := map[string]string{}
- for path := range listed {
- dir, file, ok := strings.Cut(path, "/Packages")
- if !ok || !strings.Contains(dir, "/binary-") || strings.Contains(file, "/") {
- continue
- }
- for _, ext := range debIndexPreference {
- if listed[dir+"/Packages"+ext] {
- indexes[dir] = dir + "/Packages" + ext
- break
- }
- }
- }
- return indexes
-}
-
-// parseDebPackages reads the files a Packages index lists.
-func parseDebPackages(r io.Reader) ([]indexFile, error) {
- var files []indexFile
- var current indexFile
- flush := func() {
- if current.Path != "" {
- files = append(files, current)
- }
- current = indexFile{}
- }
- scanner := bufio.NewScanner(r)
- scanner.Buffer(nil, maxIndexLine)
- for scanner.Scan() {
- line := scanner.Text()
- if line == "" {
- flush()
- continue
- }
- key, value, ok := strings.Cut(line, ": ")
- if !ok || strings.HasPrefix(line, " ") {
- continue
- }
- switch key {
- case "Filename":
- current.Path = value
- case "SHA256":
- current.Digest = "sha256:" + value
- }
- }
- flush()
- return files, scanner.Err()
-}
-
-// repomdPrimary returns the location of the primary metadata a repomd.xml
-// points at.
-func repomdPrimary(body []byte) (string, error) {
- var doc struct {
- Data []struct {
- Type string `xml:"type,attr"`
- Location struct {
- Href string `xml:"href,attr"`
- } `xml:"location"`
- } `xml:"data"`
- }
- if err := xml.Unmarshal(body, &doc); err != nil {
- return "", err
- }
- for _, d := range doc.Data {
- if d.Type == "primary" && d.Location.Href != "" {
- return d.Location.Href, nil
- }
- }
- return "", errors.New("repomd.xml lists no primary metadata")
-}
-
-// parseRPMPrimary reads the binary packages a primary.xml lists, one at a
-// time. Source packages are left out.
-func parseRPMPrimary(r io.Reader) ([]indexFile, error) {
- dec := xml.NewDecoder(r)
- var files []indexFile
- for {
- tok, err := dec.Token()
- if errors.Is(err, io.EOF) {
- return files, nil
- }
- if err != nil {
- return nil, err
- }
- start, ok := tok.(xml.StartElement)
- if !ok || start.Name.Local != "package" {
- continue
- }
- var pkg struct {
- Arch string `xml:"arch"`
- Checksum struct {
- Type string `xml:"type,attr"`
- Value string `xml:",chardata"`
- } `xml:"checksum"`
- Location struct {
- Href string `xml:"href,attr"`
- } `xml:"location"`
- }
- if err := dec.DecodeElement(&pkg, &start); err != nil {
- return nil, err
- }
- if pkg.Arch == "src" || pkg.Location.Href == "" {
- continue
- }
- file := indexFile{Path: pkg.Location.Href}
- if pkg.Checksum.Type == "sha256" {
- file.Digest = "sha256:" + strings.TrimSpace(pkg.Checksum.Value)
- }
- files = append(files, file)
- }
-}
-
-// decompressByName returns r decompressed as the extension of name says.
-func decompressByName(name string, r io.Reader) (io.ReadCloser, error) {
- switch {
- case strings.HasSuffix(name, ".gz"):
- return gzip.NewReader(r)
- case strings.HasSuffix(name, ".xz"):
- x, err := xz.NewReader(r)
- if err != nil {
- return nil, err
- }
- return io.NopCloser(x), nil
- case strings.HasSuffix(name, ".zst"):
- z, err := zstd.NewReader(r)
- if err != nil {
- return nil, err
- }
- return z.IOReadCloser(), nil
- case strings.HasSuffix(name, ".bz2"):
- return io.NopCloser(bzip2.NewReader(r)), nil
- case strings.HasSuffix(name, ".xml"), !strings.Contains(name[strings.LastIndex(name, "/")+1:], "."):
- return io.NopCloser(r), nil
- default:
- return nil, fmt.Errorf("unsupported compression: %s", name)
- }
-}
diff --git a/internal/mirror/refresh_index_parse_test.go b/internal/mirror/refresh_index_parse_test.go
deleted file mode 100644
index 934256e..0000000
--- a/internal/mirror/refresh_index_parse_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package mirror
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "reflect"
- "strings"
- "testing"
-
- "github.com/klauspost/compress/zstd"
-)
-
-// apkIndexArchive packs an APKINDEX into the tar.gz Alpine serves.
-func apkIndexArchive(t *testing.T, index string) []byte {
- t.Helper()
- var buf bytes.Buffer
- gz := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gz)
- for name, body := range map[string]string{"DESCRIPTION": "v3.20.3-1-g0000000", "APKINDEX": index} {
- if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
- t.Fatal(err)
- }
- if _, err := tw.Write([]byte(body)); err != nil {
- t.Fatal(err)
- }
- }
- if err := tw.Close(); err != nil {
- t.Fatal(err)
- }
- if err := gz.Close(); err != nil {
- t.Fatal(err)
- }
- return buf.Bytes()
-}
-
-func TestParseAPKIndex(t *testing.T) {
- index := "C:Q1abc=\nP:curl\nV:8.9.1-r0\nA:aarch64\nS:100\n\nC:Q1def=\nP:busybox\nV:1.36.1-r29\nA:aarch64\n\n"
- got, err := parseAPKIndex(bytes.NewReader(apkIndexArchive(t, index)), "APKINDEX.tar.gz")
- if err != nil {
- t.Fatal(err)
- }
- want := []indexFile{{Path: "curl-8.9.1-r0.apk"}, {Path: "busybox-1.36.1-r29.apk"}}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseAPKIndex = %+v, want %+v", got, want)
- }
-}
-
-const debRelease = `-----BEGIN PGP SIGNED MESSAGE-----
-Hash: SHA256
-
-Origin: Debian
-Suite: stable
-Codename: bookworm
-Architectures: all amd64 arm64
-Components: main contrib
-MD5Sum:
- 0123 100 main/binary-amd64/Packages.xz
-SHA256:
- aaaa 1000 main/binary-amd64/Packages
- bbbb 200 main/binary-amd64/Packages.xz
- cccc 300 main/binary-amd64/Packages.gz
- dddd 400 main/binary-arm64/Packages.gz
- eeee 500 contrib/binary-amd64/Packages.xz
- ffff 600 main/i18n/Translation-en.bz2
------BEGIN PGP SIGNATURE-----
-xyz
------END PGP SIGNATURE-----
-`
-
-func TestDebPackagesIndexes(t *testing.T) {
- got := debPackagesIndexes([]byte(debRelease))
- want := map[string]string{
- "main/binary-amd64": "main/binary-amd64/Packages.xz",
- "main/binary-arm64": "main/binary-arm64/Packages.gz",
- "contrib/binary-amd64": "contrib/binary-amd64/Packages.xz",
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("debPackagesIndexes = %v, want %v", got, want)
- }
-}
-
-func TestParseDebPackages(t *testing.T) {
- packages := `Package: curl
-Version: 7.88.1-10+deb12u8
-Architecture: amd64
-Description: a tool
- with a long description
-Filename: pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb
-SHA256: 1111
-
-Package: zlib1g
-Version: 1:1.2.13.dfsg-1
-Architecture: amd64
-Filename: pool/main/z/zlib/zlib1g_1.2.13.dfsg-1_amd64.deb
-SHA256: 2222
-`
- got, err := parseDebPackages(strings.NewReader(packages))
- if err != nil {
- t.Fatal(err)
- }
- want := []indexFile{
- {Path: "pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb", Digest: "sha256:1111"},
- {Path: "pool/main/z/zlib/zlib1g_1.2.13.dfsg-1_amd64.deb", Digest: "sha256:2222"},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseDebPackages = %+v, want %+v", got, want)
- }
-}
-
-const repomd = `
-
- 1700000000
-
-
-
-`
-
-func TestRepomdPrimary(t *testing.T) {
- got, err := repomdPrimary([]byte(repomd))
- if err != nil || got != "repodata/bbbb-primary.xml.zst" {
- t.Errorf("repomdPrimary = %q, %v", got, err)
- }
- if _, err := repomdPrimary([]byte(``)); err == nil {
- t.Error("repomdPrimary accepted a repomd without primary")
- }
-}
-
-const rpmPrimary = `
-
-
- curl
- x86_64
-
- 3333
-
-
-
- curl
- src
-
- 4444
-
-
-
- tzdata
- noarch
-
- 5555
-
-
-
-`
-
-func TestParseRPMPrimary(t *testing.T) {
- got, err := parseRPMPrimary(strings.NewReader(rpmPrimary))
- if err != nil {
- t.Fatal(err)
- }
- want := []indexFile{
- {Path: "Packages/c/curl-8.6.0-10.fc40.x86_64.rpm", Digest: "sha256:3333"},
- {Path: "Packages/t/tzdata-2024a-5.fc40.noarch.rpm"},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseRPMPrimary = %+v, want %+v", got, want)
- }
-}
-
-func TestDecompressByName(t *testing.T) {
- var gz bytes.Buffer
- w := gzip.NewWriter(&gz)
- _, _ = w.Write([]byte("hello"))
- _ = w.Close()
- enc, _ := zstd.NewWriter(nil)
- zst := enc.EncodeAll([]byte("hello"), nil)
-
- for name, data := range map[string][]byte{"Packages": []byte("hello"), "Packages.gz": gz.Bytes(), "primary.xml.zst": zst} {
- r, err := decompressByName(name, bytes.NewReader(data))
- if err != nil {
- t.Fatalf("%s: %v", name, err)
- }
- var out bytes.Buffer
- if _, err := out.ReadFrom(r); err != nil || out.String() != "hello" {
- t.Errorf("%s: got %q, %v", name, out.String(), err)
- }
- _ = r.Close()
- }
-}
diff --git a/internal/mirror/refresh_index_test.go b/internal/mirror/refresh_index_test.go
deleted file mode 100644
index 1f66c38..0000000
--- a/internal/mirror/refresh_index_test.go
+++ /dev/null
@@ -1,231 +0,0 @@
-package mirror
-
-import (
- "bytes"
- "compress/gzip"
- "context"
- "net/http"
- "net/http/httptest"
- "slices"
- "sync"
- "testing"
- "time"
-)
-
-// indexServer serves repository index documents that a test can change.
-type indexServer struct {
- *httptest.Server
- mu sync.Mutex
- docs map[string][]byte
-}
-
-func newIndexServer(t *testing.T) *indexServer {
- t.Helper()
- s := &indexServer{docs: map[string][]byte{}}
- s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- s.mu.Lock()
- body, ok := s.docs[r.URL.Path]
- s.mu.Unlock()
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write(body)
- }))
- t.Cleanup(s.Close)
- return s
-}
-
-func (s *indexServer) set(path string, body []byte) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.docs[path] = body
-}
-
-func gzipped(t *testing.T, s string) []byte {
- t.Helper()
- var buf bytes.Buffer
- w := gzip.NewWriter(&buf)
- if _, err := w.Write([]byte(s)); err != nil {
- t.Fatal(err)
- }
- if err := w.Close(); err != nil {
- t.Fatal(err)
- }
- return buf.Bytes()
-}
-
-// setupIndexRefresh returns a mirror whose package files come from a
-// servingFetcher and whose indexes come from an indexServer.
-func setupIndexRefresh(t *testing.T) (*Mirror, *servingFetcher, *indexServer) {
- t.Helper()
- m, fetcher := setupRefreshMirror(t, "package")
- idx := newIndexServer(t)
- m.proxy.HTTPClient = idx.Client()
- m.proxy.CacheMetadata = true
- return m, fetcher, idx
-}
-
-// clientFetchesCurl caches an index document and a curl package file the
-// way the handlers do for a client.
-func clientFetchesCurl(t *testing.T, m *Mirror, metaEcosystem, indexURL, ecosystem, version, cacheFilename, fileURL string) {
- t.Helper()
- ctx := context.Background()
- if _, _, err := m.proxy.FetchOrCacheMetadata(ctx, metaEcosystem, "key:"+indexURL, indexURL, "*/*"); err != nil {
- t.Fatalf("fetching %s: %v", indexURL, err)
- }
- result, err := m.proxy.GetOrFetchArtifactFromURL(ctx, ecosystem, "curl", version, cacheFilename, fileURL)
- if err != nil {
- t.Fatalf("fetching %s: %v", fileURL, err)
- }
- _ = result.Reader.Close()
-}
-
-func refreshOnce(t *testing.T, m *Mirror) *Progress {
- t.Helper()
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- return progress
-}
-
-func cachedNames(t *testing.T, m *Mirror, ecosystem, filename string) []string {
- t.Helper()
- names, err := m.db.CachedArtifactNames(ecosystem, filename)
- if err != nil {
- t.Fatal(err)
- }
- return names
-}
-
-func TestRefreshAlpineIndex(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- dir := "/alpine/v3.20/main/aarch64"
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.0-r0\nA:aarch64\n\nP:busybox\nV:1.36.1-r29\n\n"))
- clientFetchesCurl(t, m, "alpine", idx.URL+dir+"/APKINDEX.tar.gz",
- "alpine", "8.9.0-r0", "edge/v3.20/main/aarch64/curl-8.9.0-r0.apk", idx.URL+dir+"/curl-8.9.0-r0.apk")
-
- // A new release of curl; busybox was never downloaded and stays out.
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.1-r0\nA:aarch64\n\nP:busybox\nV:1.36.1-r30\n\n"))
- fetcher.reset()
- progress := refreshOnce(t, m)
-
- if want := []string{idx.URL + dir + "/curl-8.9.1-r0.apk"}; !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 {
- t.Fatalf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
- // Cached where the APK handler looks for it.
- if names := cachedNames(t, m, "alpine", "edge/v3.20/main/aarch64/curl-8.9.1-r0.apk"); !slices.Equal(names, []string{"curl"}) {
- t.Errorf("cached names = %v, want [curl]", names)
- }
-
- fetcher.reset()
- if progress := refreshOnce(t, m); progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("second run: progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
-}
-
-func TestRefreshIndexNeverDowngrades(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- dir := "/alpine/v3.20/main/aarch64"
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.1-r0\n\n"))
- clientFetchesCurl(t, m, "alpine", idx.URL+dir+"/APKINDEX.tar.gz",
- "alpine", "8.9.1-r0", "edge/v3.20/main/aarch64/curl-8.9.1-r0.apk", idx.URL+dir+"/curl-8.9.1-r0.apk")
- idx.set(dir+"/APKINDEX.tar.gz", apkIndexArchive(t, "P:curl\nV:8.9.0-r0\n\n"))
- fetcher.reset()
-
- if progress := refreshOnce(t, m); progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
-}
-
-func TestRefreshDebianIndex(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- release := []byte("Suite: stable\nSHA256:\n 0000 1 main/binary-amd64/Packages.gz\n 0000 1 main/binary-arm64/Packages.gz\n")
- packages := func(curl string) []byte {
- return gzipped(t, "Package: curl\nVersion: "+curl+"\nArchitecture: amd64\nFilename: pool/main/c/curl/curl_"+curl+"_amd64.deb\nSHA256: "+
- "bc4a71180870f7945155fbb02f4b0a2e3faa2a62d6d31b7039013055ed19869a\n\n"+
- "Package: zlib1g\nVersion: 1:1.2.13.dfsg-1\nArchitecture: amd64\nFilename: pool/main/z/zlib/zlib1g_1.2.13.dfsg-1_amd64.deb\n\n")
- }
- idx.set("/debian/dists/bookworm/InRelease", release)
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", packages("7.88.1-10"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/bookworm/InRelease",
- "deb", "7.88.1-10", "curl_7.88.1-10_amd64.deb", idx.URL+"/debian/pool/main/c/curl/curl_7.88.1-10_amd64.deb")
-
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages.gz", packages("7.88.1-10+deb12u8"))
- fetcher.reset()
- progress := refreshOnce(t, m)
-
- // Only the amd64 index is read: nothing was downloaded for arm64. The
- // file is checked against the index's SHA-256 of "package".
- want := []string{idx.URL + "/debian/pool/main/c/curl/curl_7.88.1-10+deb12u8_amd64.deb"}
- if !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 || progress.Failed != 0 {
- t.Fatalf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
- if names := cachedNames(t, m, "deb", "curl_7.88.1-10+deb12u8_amd64.deb"); !slices.Equal(names, []string{"curl"}) {
- t.Errorf("cached names = %v, want [curl]", names)
- }
-}
-
-func TestRefreshDebianIndexChecksDigest(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- idx.set("/debian/dists/bookworm/InRelease", []byte("SHA256:\n 0000 1 main/binary-amd64/Packages\n"))
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages", []byte("Package: curl\nFilename: pool/main/c/curl/curl_7.88.1-10_amd64.deb\n\n"))
- clientFetchesCurl(t, m, "debian", idx.URL+"/debian/dists/bookworm/InRelease",
- "deb", "7.88.1-10", "curl_7.88.1-10_amd64.deb", idx.URL+"/debian/pool/main/c/curl/curl_7.88.1-10_amd64.deb")
- idx.set("/debian/dists/bookworm/main/binary-amd64/Packages", []byte(
- "Package: curl\nFilename: pool/main/c/curl/curl_7.88.1-11_amd64.deb\nSHA256: 0000000000000000000000000000000000000000000000000000000000000000\n\n"))
- fetcher.reset()
-
- progress := refreshOnce(t, m)
- if progress.Failed != 1 || progress.Completed != 0 {
- t.Errorf("progress = %+v, want the file failing its digest", progress)
- }
- if names := cachedNames(t, m, "deb", "curl_7.88.1-11_amd64.deb"); len(names) != 0 {
- t.Errorf("a file failing its digest was cached as %v", names)
- }
-}
-
-func TestRefreshRPMIndex(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- base := "/fedora/updates/40/Everything/x86_64"
- primary := func(rel string) []byte {
- return gzipped(t, `curlx86_64`+
- ``)
- }
- repomdFor := func(href string) []byte {
- return []byte(``)
- }
- idx.set(base+"/repodata/repomd.xml", repomdFor("repodata/aaaa-primary.xml.gz"))
- idx.set(base+"/repodata/aaaa-primary.xml.gz", primary("10"))
- clientFetchesCurl(t, m, "rpm", idx.URL+base+"/repodata/repomd.xml",
- "rpm", "8.6.0-10.fc40", "curl-8.6.0-10.fc40.x86_64.rpm", idx.URL+base+"/Packages/c/curl-8.6.0-10.fc40.x86_64.rpm")
-
- idx.set(base+"/repodata/repomd.xml", repomdFor("repodata/bbbb-primary.xml.gz"))
- idx.set(base+"/repodata/bbbb-primary.xml.gz", primary("11"))
- fetcher.reset()
- progress := refreshOnce(t, m)
-
- want := []string{idx.URL + base + "/Packages/c/curl-8.6.0-11.fc40.x86_64.rpm"}
- if !slices.Equal(fetcher.fetched(), want) || progress.Completed != 1 {
- t.Fatalf("progress = %+v, fetched %v; want %v", progress, fetcher.fetched(), want)
- }
- if names := cachedNames(t, m, "rpm", "curl-8.6.0-11.fc40.x86_64.rpm"); !slices.Equal(names, []string{"curl"}) {
- t.Errorf("cached names = %v, want [curl]", names)
- }
-}
-
-func TestRefreshIndexWithoutRecordedURLDoesNothing(t *testing.T) {
- m, fetcher, idx := setupIndexRefresh(t)
- result, err := m.proxy.GetOrFetchArtifactFromURL(context.Background(), "alpine", "curl", "8.9.0-r0",
- "edge/v3.20/main/aarch64/curl-8.9.0-r0.apk", idx.URL+"/alpine/v3.20/main/aarch64/curl-8.9.0-r0.apk")
- if err != nil {
- t.Fatal(err)
- }
- _ = result.Reader.Close()
- fetcher.reset()
-
- if progress := refreshOnce(t, m); progress.Completed != 0 || progress.Failed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want nothing", progress, fetcher.fetched())
- }
-}
diff --git a/internal/mirror/refresh_test.go b/internal/mirror/refresh_test.go
deleted file mode 100644
index 97624d6..0000000
--- a/internal/mirror/refresh_test.go
+++ /dev/null
@@ -1,459 +0,0 @@
-package mirror
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "slices"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// servingFetcher answers every fetch with body and records the URLs.
-type servingFetcher struct {
- body string
- mu sync.Mutex
- urls []string
-}
-
-func (f *servingFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
- return f.FetchWithHeaders(ctx, url, nil)
-}
-
-func (f *servingFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
- f.mu.Lock()
- f.urls = append(f.urls, url)
- f.mu.Unlock()
- return &fetch.Artifact{Body: io.NopCloser(strings.NewReader(f.body)), Size: int64(len(f.body)), ContentType: "application/octet-stream"}, nil
-}
-
-func (f *servingFetcher) Head(context.Context, string) (int64, string, error) {
- return int64(len(f.body)), "application/octet-stream", nil
-}
-
-func (f *servingFetcher) fetched() []string {
- f.mu.Lock()
- defer f.mu.Unlock()
- return append([]string(nil), f.urls...)
-}
-
-func (f *servingFetcher) reset() {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.urls = nil
-}
-
-// stubReleases lists releases by "ecosystem/name".
-type stubReleases map[string][]registries.Version
-
-func (s stubReleases) ListReleases(_ context.Context, ecosystem, name string) ([]registries.Version, error) {
- r, ok := s[ecosystem+"/"+name]
- if !ok {
- return nil, fmt.Errorf("%w: %s/%s", registries.ErrNotFound, ecosystem, name)
- }
- return r, nil
-}
-
-func (s stubReleases) ListVersions(ctx context.Context, ecosystem, name string) ([]string, error) {
- r, err := s.ListReleases(ctx, ecosystem, name)
- numbers := make([]string, len(r))
- for i, v := range r {
- numbers[i] = v.Number
- }
- return numbers, err
-}
-
-func (s stubReleases) FindVersion(ctx context.Context, ecosystem, name, version string) (registries.Version, error) {
- r, err := s.ListReleases(ctx, ecosystem, name)
- if err != nil {
- return registries.Version{}, err
- }
- for _, v := range r {
- if v.Number == version {
- return v, nil
- }
- }
- return registries.Version{}, registries.ErrNotFound
-}
-
-func oldReleases(numbers ...string) []registries.Version {
- r := releases(numbers...)
- for i := range r {
- r[i].PublishedAt = time.Now().Add(-time.Duration(len(r)-i) * 30 * 24 * time.Hour)
- }
- return r
-}
-
-func setupRefreshMirror(t *testing.T, body string) (*Mirror, *servingFetcher) {
- t.Helper()
- m := setupTestMirror(t, 1)
- fetcher := &servingFetcher{body: body}
- m.proxy.Fetcher = fetcher
- return m, fetcher
-}
-
-// cacheNPM downloads a version the way a client would, so it counts as used.
-func cacheNPM(t *testing.T, m *Mirror, name, version string) {
- t.Helper()
- result, err := m.proxy.GetOrFetchArtifact(context.Background(), "npm", name, version, "")
- if err != nil {
- t.Fatalf("caching %s@%s: %v", name, version, err)
- }
- _ = result.Reader.Close()
-}
-
-func fetchedMatching(urls []string, part string) []string {
- var out []string
- for _, u := range urls {
- if strings.Contains(u, part) {
- out = append(out, u)
- }
- }
- return out
-}
-
-func TestRefreshMirrorsNewerVersionOnCachedLine(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{"npm/lodash": oldReleases("4.17.20", "4.17.21", "5.0.0")}
- fetcher.reset()
-
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Completed != 1 || progress.Failed != 0 {
- t.Errorf("progress = %+v, want one mirrored", progress)
- }
- urls := fetcher.fetched()
- if len(fetchedMatching(urls, "lodash-4.17.21.tgz")) != 1 || len(fetchedMatching(urls, "5.0.0")) != 0 {
- t.Errorf("fetched %v, want lodash 4.17.21 only", urls)
- }
-
- // The new version is cached now, so the next run has nothing to do.
- progress, err = m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("second Refresh: %v", err)
- }
- if progress.Completed != 0 || progress.Failed != 0 {
- t.Errorf("second run progress = %+v, want nothing to do", progress)
- }
-}
-
-func TestRefreshKeepsCooldown(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- r := oldReleases("4.17.20", "4.17.21")
- r[1].PublishedAt = time.Now().Add(-time.Hour)
- m.Versions = stubReleases{"npm/lodash": r}
- m.proxy.Cooldown = &cooldown.Config{Default: "3d"}
- fetcher.reset()
-
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Completed != 0 || len(fetcher.fetched()) != 0 {
- t.Errorf("progress = %+v, fetched %v; want a version within cooldown left alone", progress, fetcher.fetched())
- }
-}
-
-func TestRefreshOnlyTopPackagesRecentlyUsed(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- cacheNPM(t, m, "react", "18.3.0")
- cacheNPM(t, m, "react", "18.3.0") // a second download: react is used most
- m.Versions = stubReleases{
- "npm/lodash": oldReleases("4.17.20", "4.17.21"),
- "npm/react": oldReleases("18.3.0", "18.3.1"),
- }
- if err := m.db.RecordArtifactHit("pkg:npm/react@18.3.0", "react-18.3.0.tgz"); err != nil {
- t.Fatal(err)
- }
- fetcher.reset()
-
- progress, err := m.Refresh(context.Background(), Refresh{Top: 1, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- urls := fetcher.fetched()
- if progress.Completed != 1 || len(fetchedMatching(urls, "react-18.3.1.tgz")) != 1 || len(fetchedMatching(urls, "lodash")) != 0 {
- t.Errorf("progress = %+v, fetched %v; want only react refreshed", progress, urls)
- }
-}
-
-func TestRefreshStopsAtMaxVersions(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- cacheNPM(t, m, "react", "18.3.0")
- m.Versions = stubReleases{
- "npm/lodash": oldReleases("4.17.20", "4.17.21"),
- "npm/react": oldReleases("18.3.0", "18.3.1"),
- }
- fetcher.reset()
-
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour, MaxVersions: 1})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Completed != 1 || len(fetcher.fetched()) != 1 {
- t.Errorf("progress = %+v, fetched %v; want one version", progress, fetcher.fetched())
- }
-}
-
-func TestRefreshStopsAtMaxBytes(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "0123456789")
- cacheNPM(t, m, "lodash", "4.17.20")
- cacheNPM(t, m, "react", "18.3.0")
- m.Versions = stubReleases{
- "npm/lodash": oldReleases("4.17.20", "4.17.21"),
- "npm/react": oldReleases("18.3.0", "18.3.1"),
- }
- fetcher.reset()
-
- // The cap is checked before each download, so the first one goes over it.
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour, MaxBytes: 5})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Completed != 1 || progress.Bytes != 10 {
- t.Errorf("progress = %+v, want one version of 10 bytes", progress)
- }
-}
-
-func TestRefreshCountsListingFailure(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{}
- fetcher.reset()
-
- progress, err := m.Refresh(context.Background(), Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Failed != 1 || len(progress.Errors) != 1 || progress.Errors[0].Name != "lodash" {
- t.Errorf("progress = %+v, want lodash failed", progress)
- }
-}
-
-func TestRefreshMirrorsTheFileVariantsClientsFetched(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "wheel")
- ctx := context.Background()
- for _, f := range []string{"demo-1.0.0-py3-none-any.whl", "demo-1.0.0-cp312-cp312-manylinux_2_17_x86_64.whl"} {
- result, err := m.proxy.GetOrFetchArtifactFromURLWithDigest(ctx, "pypi", "demo", "1.0.0", f, "https://files.example/"+f, "")
- if err != nil {
- t.Fatal(err)
- }
- _ = result.Reader.Close()
- }
- files := func(version string, names ...string) registries.Version {
- v := registries.Version{Number: version, PublishedAt: time.Now().Add(-30 * 24 * time.Hour)}
- for _, n := range names {
- v.Artifacts = append(v.Artifacts, registries.Artifact{Filename: n, URL: "https://files.example/" + n})
- }
- return v
- }
- m.Versions = stubReleases{"pypi/demo": {
- files("1.0.0", "demo-1.0.0-py3-none-any.whl", "demo-1.0.0-cp312-cp312-manylinux_2_17_x86_64.whl"),
- files("1.1.0",
- "demo-1.1.0.tar.gz",
- "demo-1.1.0-py3-none-any.whl",
- "demo-1.1.0-cp312-cp312-manylinux_2_17_x86_64.whl",
- "demo-1.1.0-cp313-cp313-manylinux_2_17_x86_64.whl",
- "demo-1.1.0-cp312-cp312-win_amd64.whl"),
- }}
- fetcher.reset()
-
- progress, err := m.Refresh(ctx, Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- got := fetcher.fetched()
- slices.Sort(got)
- want := []string{
- "https://files.example/demo-1.1.0-cp312-cp312-manylinux_2_17_x86_64.whl",
- "https://files.example/demo-1.1.0-py3-none-any.whl",
- }
- if progress.Completed != 1 || !slices.Equal(got, want) {
- t.Errorf("progress = %+v, fetched %v; want %v", progress, got, want)
- }
-}
-
-func TestFileVariants(t *testing.T) {
- tests := []struct {
- ecosystem, cacheFilename, want string
- }{
- {"pypi", "demo-1.0.0-py3-none-any.whl", "py3-none-any"},
- {"pypi", "demo-1.0.0-1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "cp312-cp312-manylinux_x86_64"},
- {"pypi", "demo-1.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_17_aarch64.whl", "cp312-cp312-manylinux_x86_64 cp312-cp312-manylinux_aarch64"},
- {"pypi", "demo-1.0.0-py2.py3-none-any.whl", "py2-none-any py3-none-any"},
- {"pypi", "demo-1.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", "cp312-cp312-musllinux_x86_64"},
- {"pypi", "demo-1.0.0-cp312-cp312-macosx_10_13_universal2.whl", "cp312-cp312-macosx_universal2"},
- {"pypi", "demo-1.0.0-cp312-cp312-win_amd64.whl", "cp312-cp312-win_amd64"},
- {"pypi", "demo-1.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", "cp-abi3-manylinux_x86_64"},
- {"pypi", "demo-1.0.0.tar.gz", "sdist"},
- {"pypi", "demo-1.0.0.zip", "sdist"},
- {"conda", "linux-64/numpy-1.26.4-py312h8753938_0.conda", "linux-64/py312/conda"},
- {"conda", "noarch/requests-2.32.3-pyhd8ed1ab_0.conda", "noarch/py/conda"},
- {"conda", "osx-arm64/zlib-1.3.1-h8359307_2.tar.bz2", "osx-arm64//tar.bz2"},
- {"conda", "linux-64/python-3.12.16-h5f976f7_0_cpython.conda", "linux-64/_cpython/conda"},
- {"conda", "linux-64/python-3.12.16-h5f976f7_0_debug_cpython.conda", "linux-64/_debug_cpython/conda"},
- }
- for _, tt := range tests {
- if got := strings.Join(fileEcosystems[tt.ecosystem].cachedVariants(tt.cacheFilename), " "); got != tt.want {
- t.Errorf("%s variant of %q = %q, want %q", tt.ecosystem, tt.cacheFilename, got, tt.want)
- }
- }
-}
-
-func TestRefreshable(t *testing.T) {
- for ecosystem, want := range map[string]bool{
- "npm": true, "pypi": true, "conda": true, "composer": true,
- // Their registries need the configured upstream URL.
- "swift": true, "helm": true,
- // Julia files are cached by tree hash, not by version.
- "julia": false,
- // Images follow mirror.schedules; OS packages their indexes.
- "docker": false, "oci": false, "deb": false, "alpine": false, "rpm": false,
- "generic": false,
- } {
- if got := refreshable(ecosystem); got != want {
- t.Errorf("refreshable(%s) = %v, want %v", ecosystem, got, want)
- }
- }
-}
-
-// refreshFetches caches files of an old version of name the way a client
-// would, lists newFiles for newVersion and returns what a refresh fetched.
-func refreshFetches(t *testing.T, ecosystem, name, oldVersion string, cached []string, newVersion string, newFiles []registries.Artifact) []string {
- t.Helper()
- m, fetcher := setupRefreshMirror(t, "file")
- ctx := context.Background()
- old := registries.Version{Number: oldVersion, PublishedAt: time.Now().Add(-60 * 24 * time.Hour)}
- for _, f := range cached {
- result, err := m.proxy.GetOrFetchArtifactFromURLWithDigest(ctx, ecosystem, name, oldVersion, f, "https://files.example/"+f, "")
- if err != nil {
- t.Fatal(err)
- }
- _ = result.Reader.Close()
- }
- for i := range newFiles {
- newFiles[i].URL = "https://files.example/" + newFiles[i].Filename
- }
- m.Versions = stubReleases{ecosystem + "/" + name: {
- old,
- {Number: newVersion, PublishedAt: time.Now().Add(-30 * 24 * time.Hour), Artifacts: newFiles},
- }}
- fetcher.reset()
-
- progress, err := m.Refresh(ctx, Refresh{Top: 10, Since: 24 * time.Hour})
- if err != nil {
- t.Fatalf("Refresh: %v", err)
- }
- if progress.Failed != 0 {
- t.Errorf("progress = %+v, want no failures", progress)
- }
- got := fetcher.fetched()
- slices.Sort(got)
- return got
-}
-
-func wheels(names ...string) []registries.Artifact {
- files := make([]registries.Artifact, 0, len(names))
- for _, n := range names {
- files = append(files, registries.Artifact{Filename: n})
- }
- return files
-}
-
-// A new wheel built for a newer glibc or macOS still replaces the cached
-// one: platform tags match on OS and architecture.
-func TestRefreshMatchesWheelsAcrossPlatformMinimums(t *testing.T) {
- got := refreshFetches(t, "pypi", "numpy", "2.2.6",
- []string{"numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl"},
- "2.3.0", wheels(
- "numpy-2.3.0-cp312-cp312-manylinux_2_28_x86_64.whl",
- "numpy-2.3.0-cp312-cp312-manylinux_2_28_aarch64.whl",
- "numpy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl",
- "numpy-2.3.0-cp313-cp313-manylinux_2_28_x86_64.whl",
- "numpy-2.3.0-cp312-cp312-macosx_11_0_x86_64.whl",
- "numpy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl",
- "numpy-2.3.0-cp312-cp312-win_amd64.whl",
- ))
- want := []string{
- "https://files.example/numpy-2.3.0-cp312-cp312-macosx_11_0_x86_64.whl",
- "https://files.example/numpy-2.3.0-cp312-cp312-manylinux_2_28_x86_64.whl",
- }
- if !slices.Equal(got, want) {
- t.Errorf("fetched %v, want %v", got, want)
- }
-}
-
-// conda-forge's python builds carry the hash before a suffix; the suffix
-// stays part of the kind.
-func TestRefreshMatchesCondaBuildsWithHashInside(t *testing.T) {
- conda := func(subdir, filename, build string) registries.Artifact {
- return registries.Artifact{Filename: filename, Metadata: map[string]any{"subdir": subdir, "build": build}}
- }
- got := refreshFetches(t, "conda", "conda-forge/python", "3.12.15",
- []string{"linux-64/python-3.12.15-h5f976f7_0_cpython.conda"},
- "3.12.16", []registries.Artifact{
- conda("linux-64", "python-3.12.16-hab00c5b_1_cpython.conda", "hab00c5b_1_cpython"),
- conda("linux-64", "python-3.12.16-hd12c33a_1_debug_cpython.conda", "hd12c33a_1_debug_cpython"),
- conda("osx-arm64", "python-3.12.16-h99e199e_1_cpython.conda", "h99e199e_1_cpython"),
- })
- want := []string{"https://files.example/python-3.12.16-hab00c5b_1_cpython.conda"}
- if !slices.Equal(got, want) {
- t.Errorf("fetched %v, want %v", got, want)
- }
-}
-
-// The dot-separated tags of a wheel are alternatives: a wheel matches a
-// cached one when any of its tags does.
-func TestRefreshMatchesWheelsOnAnyAlternativeTag(t *testing.T) {
- got := refreshFetches(t, "pypi", "demo", "1.0.0",
- []string{"demo-1.0.0-cp312-cp312-manylinux_2_17_x86_64.whl", "demo-1.0.0-py2.py3-none-macosx_11_0_arm64.macosx_11_0_x86_64.whl"},
- "1.1.0", wheels(
- "demo-1.1.0-cp312-cp312-manylinux_2_17_x86_64.linux_x86_64.whl",
- "demo-1.1.0-py3-none-macosx_11_0_x86_64.whl",
- "demo-1.1.0-cp312-cp312-win_amd64.whl",
- ))
- want := []string{
- "https://files.example/demo-1.1.0-cp312-cp312-manylinux_2_17_x86_64.linux_x86_64.whl",
- "https://files.example/demo-1.1.0-py3-none-macosx_11_0_x86_64.whl",
- }
- if !slices.Equal(got, want) {
- t.Errorf("fetched %v, want %v", got, want)
- }
-}
-
-// In an abi3 wheel the python tag is a minimum: cryptography 44.0.0 ships
-// cp37-abi3 and cp39-abi3 wheels, 46.0.0 cp38-abi3 and cp311-abi3 ones. A
-// refresh stays on the cached major line, so the newer tags are on 44.1.0
-// here.
-func TestRefreshMatchesAbi3WheelsAcrossPythonMinimums(t *testing.T) {
- got := refreshFetches(t, "pypi", "cryptography", "44.0.0",
- []string{"cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl"},
- "44.1.0", wheels(
- "cryptography-44.1.0-cp38-abi3-manylinux_2_28_x86_64.whl",
- "cryptography-44.1.0-cp311-abi3-manylinux_2_28_x86_64.whl",
- "cryptography-44.1.0-cp311-abi3-manylinux_2_28_aarch64.whl",
- "cryptography-44.1.0-cp311-abi3-macosx_10_9_universal2.whl",
- "cryptography-44.1.0-cp311-abi3-win_amd64.whl",
- "cryptography-44.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl",
- ))
- want := []string{
- "https://files.example/cryptography-44.1.0-cp311-abi3-manylinux_2_28_x86_64.whl",
- "https://files.example/cryptography-44.1.0-cp38-abi3-manylinux_2_28_x86_64.whl",
- }
- if !slices.Equal(got, want) {
- t.Errorf("fetched %v, want %v", got, want)
- }
-}
diff --git a/internal/mirror/refresh_versions.go b/internal/mirror/refresh_versions.go
deleted file mode 100644
index 22fe7c1..0000000
--- a/internal/mirror/refresh_versions.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package mirror
-
-import (
- "strconv"
-
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/vers"
-)
-
-// newestPerCachedLine returns, for each major line a cached version is on,
-// the newest release of that line that allowed accepts, when it is newer
-// than every cached version of the line. A line is the major version, or
-// 0.minor below 1.0, as caret ranges read them, and calendar versions are
-// one line. Prereleases and yanked,
-// retracted or deprecated releases are skipped.
-func newestPerCachedLine(ecosystem string, cached []string, releases []registries.Version, allowed func(registries.Version) bool) []string {
- scheme := purl.EcosystemToPURLType(ecosystem)
- newestCached := map[string]string{}
- var lines []string
- for _, v := range cached {
- line, ok := versionLine(v)
- if !ok {
- continue
- }
- current, seen := newestCached[line]
- if !seen {
- lines = append(lines, line)
- }
- if !seen || vers.CompareWithScheme(v, current, scheme) > 0 {
- newestCached[line] = v
- }
- }
-
- newest := map[string]string{}
- for _, r := range releases {
- line, ok := versionLine(r.Number)
- if !ok || r.Status != registries.StatusNone || vers.IsPrereleaseWithScheme(r.Number, scheme) {
- continue
- }
- if _, cachedLine := newestCached[line]; !cachedLine || !allowed(r) {
- continue
- }
- if current, seen := newest[line]; !seen || vers.CompareWithScheme(r.Number, current, scheme) > 0 {
- newest[line] = r.Number
- }
- }
-
- var out []string
- for _, line := range lines {
- if v, ok := newest[line]; ok && vers.CompareWithScheme(v, newestCached[line], scheme) > 0 {
- out = append(out, v)
- }
- }
- return out
-}
-
-// firstCalendarYear is the smallest first number read as a year.
-const firstCalendarYear = 1000
-
-// versionLine returns the major line of a version. Calendar versions, whose
-// first number is a year, are all one line, so a new year's release follows
-// the last one's.
-func versionLine(v string) (string, bool) {
- info, err := vers.ParseVersion(v)
- if err != nil {
- return "", false
- }
- if info.Major >= firstCalendarYear {
- return "calendar", true
- }
- if info.Major == 0 {
- return "0." + strconv.Itoa(info.Minor), true
- }
- return strconv.Itoa(info.Major), true
-}
diff --git a/internal/mirror/refresh_versions_test.go b/internal/mirror/refresh_versions_test.go
deleted file mode 100644
index bc88947..0000000
--- a/internal/mirror/refresh_versions_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package mirror
-
-import (
- "reflect"
- "testing"
- "time"
-
- "github.com/git-pkgs/registries"
-)
-
-func releases(numbers ...string) []registries.Version {
- out := make([]registries.Version, len(numbers))
- for i, n := range numbers {
- out[i] = registries.Version{Number: n, PublishedAt: time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)}
- }
- return out
-}
-
-func TestNewestPerCachedLine(t *testing.T) {
- allowAll := func(registries.Version) bool { return true }
- yanked := releases("4.17.20", "4.17.21", "4.17.22")
- yanked[2].Status = registries.StatusYanked
-
- tests := []struct {
- name string
- cached []string
- releases []registries.Version
- allowed func(registries.Version) bool
- want []string
- }{
- {name: "newer patch on the cached line", cached: []string{"4.17.20"},
- releases: releases("4.17.20", "4.17.21", "5.0.0"), want: []string{"4.17.21"}},
- {name: "newest already cached", cached: []string{"4.17.21"},
- releases: releases("4.17.20", "4.17.21", "5.0.0")},
- {name: "one per cached major line", cached: []string{"1.2.0", "2.0.0"},
- releases: releases("1.2.0", "1.3.0", "2.0.0", "2.1.0", "3.0.0"), want: []string{"1.3.0", "2.1.0"}},
- {name: "zero major lines are minor lines", cached: []string{"0.3.1"},
- releases: releases("0.3.1", "0.3.2", "0.4.0"), want: []string{"0.3.2"}},
- {name: "prereleases skipped", cached: []string{"2.0.0"},
- releases: releases("2.0.0", "2.1.0-beta.1")},
- {name: "yanked skipped", cached: []string{"4.17.20"},
- releases: yanked, want: []string{"4.17.21"}},
- {name: "newest withheld, older newer one taken", cached: []string{"4.17.20"},
- releases: releases("4.17.20", "4.17.21", "4.17.22"),
- allowed: func(v registries.Version) bool { return v.Number != "4.17.22" },
- want: []string{"4.17.21"}},
- {name: "nothing allowed newer than cached", cached: []string{"4.17.20"},
- releases: releases("4.17.20", "4.17.21"),
- allowed: func(v registries.Version) bool { return v.Number == "4.17.20" }},
- {name: "cached newer than every listed one", cached: []string{"4.17.25"},
- releases: releases("4.17.20", "4.17.21")},
- {name: "only the newest cached of a line counts", cached: []string{"4.17.19", "4.17.21"},
- releases: releases("4.17.19", "4.17.20", "4.17.21")},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- allowed := tt.allowed
- if allowed == nil {
- allowed = allowAll
- }
- got := newestPerCachedLine("npm", tt.cached, tt.releases, allowed)
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("newestPerCachedLine = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestNewestPerCachedLineUsesEcosystemOrdering(t *testing.T) {
- // Maven orders 1.0.10 after 1.0.9, and a gem's 1.0.0.rc1 is a prerelease.
- got := newestPerCachedLine("maven", []string{"1.0.9"}, releases("1.0.9", "1.0.10"), func(registries.Version) bool { return true })
- if !reflect.DeepEqual(got, []string{"1.0.10"}) {
- t.Errorf("maven: got %v, want [1.0.10]", got)
- }
- got = newestPerCachedLine("gem", []string{"1.0.0"}, releases("1.0.0", "1.0.1.rc1"), func(registries.Version) bool { return true })
- if got != nil {
- t.Errorf("gem: got %v, want nothing", got)
- }
-}
-
-func TestNewestPerCachedLineCalendarVersions(t *testing.T) {
- allowAll := func(registries.Version) bool { return true }
- // certifi and tzdata number releases by year: 2025.1.31 follows 2024.12.14.
- got := newestPerCachedLine("pypi", []string{"2024.8.30"}, releases("2024.8.30", "2024.12.14", "2025.1.31"), allowAll)
- if !reflect.DeepEqual(got, []string{"2025.1.31"}) {
- t.Errorf("certifi: got %v, want [2025.1.31]", got)
- }
- got = newestPerCachedLine("pypi", []string{"2024.2"}, releases("2024.2", "2025.1"), allowAll)
- if !reflect.DeepEqual(got, []string{"2025.1"}) {
- t.Errorf("tzdata: got %v, want [2025.1]", got)
- }
-}
diff --git a/internal/mirror/sbom_version_test.go b/internal/mirror/sbom_version_test.go
deleted file mode 100644
index 43dde04..0000000
--- a/internal/mirror/sbom_version_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package mirror
-
-import (
- "context"
- "net/http"
- "reflect"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/registries"
-)
-
-func TestSBOMSourceUsesComponentVersion(t *testing.T) {
- for name, data := range map[string]string{
- "CycloneDX JSON": `{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/ghcr.io/team/app"}]}`,
- "CycloneDX XML": `app1.0pkg:docker/ghcr.io/team/app`,
- "SPDX JSON": `{"spdxVersion":"SPDX-2.3","packages":[{"name":"app","versionInfo":"1.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:docker/ghcr.io/team/app"}]}]}`,
- "SPDX tag-value": "DataLicense: CC0-1.0\nSPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT\nDocumentName: example\nDocumentNamespace: https://example.com/sbom\nPackageName: app\nSPDXID: SPDXRef-app\nPackageVersion: 1.0\nExternalRef: PACKAGE-MANAGER purl pkg:docker/ghcr.io/team/app\n",
- } {
- t.Run(name, func(t *testing.T) {
- source := &SBOMSource{Data: []byte(data)}
- var got []PackageVersion
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error { got = append(got, pv); return nil }); err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 || got[0].Version != "1.0" {
- t.Fatalf("Enumerate = %+v, want one image at 1.0", got)
- }
- })
- }
-}
-
-func TestSBOMSourceVersionPrecedence(t *testing.T) {
- for _, test := range []struct{ name, componentVersion, imagePURL, wantVersion string }{
- {"PURL version wins", "2.0", "pkg:docker/team/app@1.0", "1.0"},
- {"digest from component", "sha256:abc123", "pkg:docker/team/app", "sha256:abc123"},
- {"unknown version", "", "pkg:docker/team/app", ""},
- {"package version", "1.0", "pkg:npm/example", "1.0"},
- } {
- t.Run(test.name, func(t *testing.T) {
- data := marshalJSON(t, map[string]any{"components": []map[string]any{{"name": "app", "version": test.componentVersion, "purl": test.imagePURL}}})
- var got []PackageVersion
- source := &SBOMSource{Data: data, Versions: RegistryVersions{Client: registries.NewClient(registries.WithHTTPClient(&http.Client{Transport: rejectingMirrorTransport{}}))}}
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error { got = append(got, pv); return nil }); err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 || got[0].Version != test.wantVersion {
- t.Fatalf("Enumerate = %+v, want version %q", got, test.wantVersion)
- }
- })
- }
-}
-
-func TestMirrorRunSBOMDockerReference(t *testing.T) {
- m := setupTestMirror(t, 1)
- images := &fakeImages{result: handler.ImageResult{Fetched: 1}}
- m.Images = images
- source := &SBOMSource{
- Data: []byte(`{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/registry.example:5000/team/app?arch=arm64"}]}`),
- Platforms: []string{"linux/arm64"},
- }
- progress, err := m.Run(context.Background(), source)
- if err != nil {
- t.Fatal(err)
- }
- if progress.Completed != 1 || progress.Failed != 0 {
- t.Fatalf("progress = %+v", progress)
- }
- want := []handler.ImageRef{{Registry: "registry.example:5000", Name: "team/app", References: []string{"1.0"}}}
- if !reflect.DeepEqual(images.refs, want) {
- t.Errorf("image refs = %+v, want %+v", images.refs, want)
- }
-}
diff --git a/internal/mirror/schedule.go b/internal/mirror/schedule.go
deleted file mode 100644
index 244fd89..0000000
--- a/internal/mirror/schedule.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package mirror
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "log/slog"
- "math/rand/v2"
- "sync"
- "time"
-)
-
-const (
- maxFirstScheduleDelay = 10 * time.Minute
- // scheduleJitter spreads later runs by ±interval/scheduleJitter.
- scheduleJitter = 10
-)
-
-// Schedule mirrors a set of image PURLs every Interval, or refreshes the
-// most used packages when Refresh is set.
-type Schedule struct {
- PURLs []string
- Platforms []string
- Refresh *Refresh
- Interval time.Duration
-}
-
-// Scheduler runs schedules in the background until its context ends.
-type Scheduler struct {
- mirror *Mirror
- schedules []Schedule
- logger *slog.Logger
- wg sync.WaitGroup
- // Claim, when set, reports whether this instance should run a schedule
- // that no instance sharing the database started within minGap. Each
- // instance keeps its own timer, so without it every instance would run
- // every schedule.
- Claim func(key string, minGap time.Duration) (bool, error)
- // Release, when set, drops the claim of a run that was interrupted, so
- // the next attempt runs it rather than waiting an interval.
- Release func(key string) error
- // randN returns a value in [0, n); tests replace it.
- randN func(n int64) int64
-}
-
-// NewScheduler creates a scheduler that runs the schedules through m.
-func NewScheduler(m *Mirror, schedules []Schedule, logger *slog.Logger) *Scheduler {
- return &Scheduler{
- mirror: m,
- schedules: schedules,
- logger: logger,
- randN: rand.Int64N, //nolint:gosec // jitter, not security
- }
-}
-
-// Start starts one goroutine per schedule.
-func (s *Scheduler) Start(ctx context.Context) {
- for i, schedule := range s.schedules {
- if schedule.Interval <= 0 {
- s.logger.Error("mirror schedule has no interval, skipped", "schedule", i)
- continue
- }
- s.wg.Add(1)
- go s.loop(ctx, i, schedule)
- }
-}
-
-// Wait blocks until every schedule has stopped after its context ended.
-func (s *Scheduler) Wait() {
- s.wg.Wait()
-}
-
-func (s *Scheduler) loop(ctx context.Context, index int, schedule Schedule) {
- defer s.wg.Done()
- timer := time.NewTimer(scheduleDelay(schedule.Interval, true, s.randN))
- defer timer.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-timer.C:
- }
- s.run(ctx, index, schedule)
- // The next run is timed from the end of this one, so runs never overlap.
- timer.Reset(scheduleDelay(schedule.Interval, false, s.randN))
- }
-}
-
-func (s *Scheduler) run(ctx context.Context, index int, schedule Schedule) {
- key := scheduleKey(schedule)
- if s.Claim != nil {
- // Later runs come at least interval - 2*spread apart, so a run
- // started more recently than that was another instance's.
- minGap := schedule.Interval - 2*(schedule.Interval/scheduleJitter) //nolint:mnd // two spreads of margin
- claimed, err := s.Claim(key, minGap)
- if err != nil {
- s.logger.Warn("mirror schedule: failed to claim run, skipping it", "schedule", index, "error", err)
- return
- }
- if !claimed {
- s.logger.Debug("mirror schedule: another instance ran it, skipping", "schedule", index)
- return
- }
- }
- started := time.Now()
- var progress *Progress
- var err error
- if schedule.Refresh != nil {
- progress, err = s.mirror.Refresh(ctx, *schedule.Refresh)
- } else {
- progress, err = s.mirror.Run(ctx, &PURLSource{PURLs: schedule.PURLs, Versions: s.mirror.Versions, Platforms: schedule.Platforms})
- }
- if ctx.Err() != nil {
- if s.Claim != nil && s.Release != nil {
- if err := s.Release(key); err != nil {
- s.logger.Warn("mirror schedule: failed to release interrupted run", "schedule", index, "error", err)
- }
- }
- return
- }
- if err != nil {
- s.logger.Error("mirror schedule failed", "schedule", index, "error", err)
- return
- }
- level := slog.LevelInfo
- if progress.Failed > 0 {
- level = slog.LevelWarn
- }
- s.logger.Log(ctx, level, "mirror schedule run",
- "schedule", index, "mirrored", progress.Completed, "unchanged", progress.Skipped,
- "failed", progress.Failed, "bytes", progress.Bytes, "duration", time.Since(started).Round(time.Millisecond))
-}
-
-// scheduleDelay spreads runs so instances and schedules started together
-// don't hit the registry at once. The first run comes within ten minutes, so
-// a restart doesn't push a refresh back by a whole interval.
-func scheduleDelay(interval time.Duration, first bool, randN func(int64) int64) time.Duration {
- if first {
- return time.Duration(randN(int64(min(interval, maxFirstScheduleDelay))))
- }
- spread := interval / scheduleJitter
- return interval - spread + time.Duration(randN(int64(2*spread)+1)) //nolint:mnd // spread on both sides
-}
-
-// scheduleKey names a schedule by what it mirrors and how often, so
-// instances with the same configuration agree on it and a changed schedule
-// does not inherit the old one's last run.
-func scheduleKey(schedule Schedule) string {
- h := sha256.New()
- if r := schedule.Refresh; r != nil {
- _, _ = fmt.Fprintf(h, "refresh top %d since %s max_versions %d max_bytes %d\n", r.Top, r.Since, r.MaxVersions, r.MaxBytes)
- }
- for _, purl := range schedule.PURLs {
- _, _ = fmt.Fprintf(h, "purl %s\n", purl)
- }
- for _, platform := range schedule.Platforms {
- _, _ = fmt.Fprintf(h, "platform %s\n", platform)
- }
- _, _ = fmt.Fprintf(h, "interval %s\n", schedule.Interval)
- return "mirror-schedule:" + hex.EncodeToString(h.Sum(nil))[:32]
-}
diff --git a/internal/mirror/schedule_test.go b/internal/mirror/schedule_test.go
deleted file mode 100644
index 0a6edc3..0000000
--- a/internal/mirror/schedule_test.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package mirror
-
-import (
- "context"
- "errors"
- "io"
- "log/slog"
- "math/rand/v2"
- "reflect"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-func TestScheduleDelay(t *testing.T) {
- lowest := func(int64) int64 { return 0 }
- highest := func(n int64) int64 { return n - 1 }
- tests := []struct {
- name string
- interval time.Duration
- first bool
- randN func(int64) int64
- want time.Duration
- }{
- {name: "first run lowest", interval: 6 * time.Hour, first: true, randN: lowest, want: 0},
- {name: "first run capped at ten minutes", interval: 6 * time.Hour, first: true, randN: highest, want: 10*time.Minute - 1},
- {name: "first run within a short interval", interval: 2 * time.Minute, first: true, randN: highest, want: 2*time.Minute - 1},
- {name: "later run lowest", interval: 6 * time.Hour, randN: lowest, want: 6*time.Hour - 36*time.Minute},
- {name: "later run highest", interval: 6 * time.Hour, randN: highest, want: 6*time.Hour + 36*time.Minute},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := scheduleDelay(tt.interval, tt.first, tt.randN); got != tt.want {
- t.Errorf("scheduleDelay() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-// scheduleImages records MirrorImage calls and can block or fail them.
-type scheduleImages struct {
- mu sync.Mutex
- refs []handler.ImageRef
- platforms [][]handler.Platform
- calls atomic.Int32
- running atomic.Int32
- overlap atomic.Bool
- block chan struct{}
- err error
-}
-
-func (f *scheduleImages) MirrorImage(ctx context.Context, ref handler.ImageRef, platforms []handler.Platform) (handler.ImageResult, error) {
- if f.running.Add(1) > 1 {
- f.overlap.Store(true)
- }
- defer f.running.Add(-1)
- f.mu.Lock()
- f.refs = append(f.refs, ref)
- f.platforms = append(f.platforms, platforms)
- f.mu.Unlock()
- f.calls.Add(1)
- if f.block != nil {
- select {
- case <-f.block:
- case <-ctx.Done():
- return handler.ImageResult{}, ctx.Err()
- }
- }
- return handler.ImageResult{}, f.err
-}
-
-func newTestScheduler(images ImageMirrorer, schedules ...Schedule) *Scheduler {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- m := New(nil, nil, nil, logger, 1)
- m.Images = images
- s := NewScheduler(m, schedules, logger)
- s.randN = func(int64) int64 { return 0 }
- return s
-}
-
-func waitFor(t *testing.T, what string, cond func() bool) {
- t.Helper()
- deadline := time.Now().Add(5 * time.Second)
- for !cond() {
- if time.Now().After(deadline) {
- t.Fatalf("timed out waiting for %s", what)
- }
- time.Sleep(time.Millisecond)
- }
-}
-
-func TestSchedulerRepeatsRunsAndStops(t *testing.T) {
- images := &scheduleImages{err: errors.New("upstream down")}
- s := newTestScheduler(images, Schedule{
- PURLs: []string{"pkg:docker/library/alpine@3.20"},
- Platforms: []string{"linux/arm64"},
- Interval: time.Millisecond,
- })
- ctx, cancel := context.WithCancel(context.Background())
- s.Start(ctx)
- // A failing run must not end the schedule.
- waitFor(t, "three runs", func() bool { return images.calls.Load() >= 3 })
- cancel()
- s.Wait()
-
- images.mu.Lock()
- defer images.mu.Unlock()
- wantRef := handler.ImageRef{Name: "library/alpine", References: []string{"3.20"}}
- if !reflect.DeepEqual(images.refs[0], wantRef) {
- t.Errorf("ref = %+v, want %+v", images.refs[0], wantRef)
- }
- wantPlatforms := []handler.Platform{{OS: "linux", Architecture: "arm64"}}
- if !reflect.DeepEqual(images.platforms[0], wantPlatforms) {
- t.Errorf("platforms = %+v, want %+v", images.platforms[0], wantPlatforms)
- }
-}
-
-func TestSchedulerRunsDoNotOverlap(t *testing.T) {
- images := &scheduleImages{block: make(chan struct{})}
- s := newTestScheduler(images, Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Interval: time.Millisecond})
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- s.Start(ctx)
-
- waitFor(t, "first run", func() bool { return images.calls.Load() == 1 })
- // Many intervals pass while the run blocks; none may start a second run.
- time.Sleep(50 * time.Millisecond)
- if got := images.calls.Load(); got != 1 {
- t.Fatalf("calls while the first run blocks = %d, want 1", got)
- }
- images.block <- struct{}{}
- waitFor(t, "second run", func() bool { return images.calls.Load() == 2 })
- cancel()
- s.Wait()
- if images.overlap.Load() {
- t.Error("two runs of one schedule overlapped")
- }
-}
-
-func TestSchedulerWaitReturnsAfterCancelDuringRun(t *testing.T) {
- images := &scheduleImages{block: make(chan struct{})}
- s := newTestScheduler(images,
- Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Interval: time.Hour},
- Schedule{PURLs: []string{"pkg:docker/nginx@stable"}, Interval: time.Hour})
- ctx, cancel := context.WithCancel(context.Background())
- s.Start(ctx)
- waitFor(t, "both schedules running", func() bool { return images.calls.Load() == 2 })
-
- cancel()
- done := make(chan struct{})
- go func() {
- s.Wait()
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("Wait did not return after cancel")
- }
-}
-
-func TestSchedulerSkipsScheduleWithoutInterval(t *testing.T) {
- images := &scheduleImages{}
- s := newTestScheduler(images, Schedule{PURLs: []string{"pkg:docker/alpine@3"}})
- // The real random source panics on a zero interval.
- s.randN = rand.Int64N
- ctx, cancel := context.WithCancel(context.Background())
- s.Start(ctx)
- cancel()
- s.Wait()
- if got := images.calls.Load(); got != 0 {
- t.Errorf("calls = %d, want 0", got)
- }
-}
-
-func TestSchedulerSkipsRunClaimedElsewhere(t *testing.T) {
- images := &scheduleImages{}
- schedule := Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Interval: time.Hour}
- s := newTestScheduler(images, schedule)
- var mu sync.Mutex
- var keys []string
- var gaps []time.Duration
- claim := true
- s.Claim = func(key string, minGap time.Duration) (bool, error) {
- mu.Lock()
- defer mu.Unlock()
- keys = append(keys, key)
- gaps = append(gaps, minGap)
- return claim, nil
- }
-
- s.run(context.Background(), 0, schedule)
- if got := images.calls.Load(); got != 1 {
- t.Fatalf("calls after a claimed run = %d, want 1", got)
- }
- claim = false
- s.run(context.Background(), 0, schedule)
- if got := images.calls.Load(); got != 1 {
- t.Fatalf("calls after a run claimed elsewhere = %d, want 1", got)
- }
- s.Claim = func(string, time.Duration) (bool, error) { return false, errors.New("database down") }
- s.run(context.Background(), 0, schedule)
- if got := images.calls.Load(); got != 1 {
- t.Fatalf("calls after a failed claim = %d, want 1", got)
- }
-
- if keys[0] != keys[1] {
- t.Errorf("claim keys differ between runs: %q, %q", keys[0], keys[1])
- }
- // Runs come at least 0.9 intervals apart; the gap stays below that.
- if want := 48 * time.Minute; gaps[0] != want {
- t.Errorf("minGap = %s, want %s", gaps[0], want)
- }
-}
-
-// TestSchedulerReleasesInterruptedRun checks that a run cut off by shutdown
-// does not count as done: the next attempt, on a restarted instance or
-// another one, runs the schedule again.
-func TestSchedulerReleasesInterruptedRun(t *testing.T) {
- images := &scheduleImages{block: make(chan struct{})}
- schedule := Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Interval: time.Hour}
- s := newTestScheduler(images, schedule)
- // The claims of a shared database: a claimed key is not claimed again.
- var mu sync.Mutex
- claims := map[string]bool{}
- s.Claim = func(key string, _ time.Duration) (bool, error) {
- mu.Lock()
- defer mu.Unlock()
- if claims[key] {
- return false, nil
- }
- claims[key] = true
- return true, nil
- }
- s.Release = func(key string) error {
- mu.Lock()
- defer mu.Unlock()
- delete(claims, key)
- return nil
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- done := make(chan struct{})
- go func() {
- s.run(ctx, 0, schedule)
- close(done)
- }()
- waitFor(t, "the run to start", func() bool { return images.calls.Load() == 1 })
- cancel()
- <-done
-
- close(images.block)
- s.run(context.Background(), 0, schedule)
- if got := images.calls.Load(); got != 2 {
- t.Fatalf("calls = %d, want 2: the interrupted run blocked the next one", got)
- }
- // A finished run keeps its claim.
- s.run(context.Background(), 0, schedule)
- if got := images.calls.Load(); got != 2 {
- t.Fatalf("calls = %d, want 2 after a finished run", got)
- }
-}
-
-func TestScheduleKey(t *testing.T) {
- base := Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Platforms: []string{"linux/amd64"}, Interval: time.Hour}
- if scheduleKey(base) != scheduleKey(Schedule{PURLs: []string{"pkg:docker/alpine@3"}, Platforms: []string{"linux/amd64"}, Interval: time.Hour}) {
- t.Error("same schedule gave different keys")
- }
- for name, other := range map[string]Schedule{
- "purls": {PURLs: []string{"pkg:docker/alpine@4"}, Platforms: base.Platforms, Interval: base.Interval},
- "platforms": {PURLs: base.PURLs, Platforms: []string{"linux/arm64"}, Interval: base.Interval},
- "interval": {PURLs: base.PURLs, Platforms: base.Platforms, Interval: 2 * time.Hour},
- } {
- if scheduleKey(other) == scheduleKey(base) {
- t.Errorf("changing %s kept the key", name)
- }
- }
-}
-
-func TestSchedulerRunsRefresh(t *testing.T) {
- m, fetcher := setupRefreshMirror(t, "tarball")
- cacheNPM(t, m, "lodash", "4.17.20")
- m.Versions = stubReleases{"npm/lodash": oldReleases("4.17.20", "4.17.21")}
- fetcher.reset()
-
- schedule := Schedule{Refresh: &Refresh{Top: 10, Since: time.Hour}, Interval: time.Hour}
- s := NewScheduler(m, []Schedule{schedule}, m.logger)
- s.run(context.Background(), 0, schedule)
-
- if urls := fetcher.fetched(); len(urls) != 1 || len(fetchedMatching(urls, "lodash-4.17.21.tgz")) != 1 {
- t.Errorf("fetched %v, want lodash 4.17.21", urls)
- }
-}
-
-func TestScheduleKeyRefresh(t *testing.T) {
- base := Schedule{Refresh: &Refresh{Top: 50, Since: time.Hour}, Interval: time.Hour}
- same := Schedule{Refresh: &Refresh{Top: 50, Since: time.Hour}, Interval: time.Hour}
- if scheduleKey(base) != scheduleKey(same) {
- t.Error("same refresh gave different keys")
- }
- for name, other := range map[string]Schedule{
- "no refresh": {Interval: time.Hour},
- "top": {Refresh: &Refresh{Top: 10, Since: time.Hour}, Interval: time.Hour},
- "since": {Refresh: &Refresh{Top: 50, Since: 2 * time.Hour}, Interval: time.Hour},
- "interval": {Refresh: &Refresh{Top: 50, Since: time.Hour}, Interval: 2 * time.Hour},
- } {
- if scheduleKey(other) == scheduleKey(base) {
- t.Errorf("changing %s kept the key", name)
- }
- }
-}
diff --git a/internal/mirror/source.go b/internal/mirror/source.go
deleted file mode 100644
index bbfddf0..0000000
--- a/internal/mirror/source.go
+++ /dev/null
@@ -1,212 +0,0 @@
-package mirror
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries"
- "github.com/git-pkgs/sbom"
-)
-
-// PackageVersion identifies a specific package version to mirror.
-type PackageVersion struct {
- Ecosystem string
- Name string
- Version string
- // Namespace is the PURL namespace of apk packages, which Name leaves
- // out. It picks the upstream.apk repository.
- Namespace string
- // Qualifiers are the PURL qualifiers. Images use repository_url, tag and
- // arch; PyPI uses file_name, and conda subdir, build and type, to select
- // which files of the version are mirrored. Julia uses uuid to identify
- // the package.
- Qualifiers map[string]string
- // Platforms limit which platforms of an image index are mirrored.
- Platforms []string
- // variants, when set, limit the files of a version that are mirrored to
- // those of these variants (see fileEcosystem.variants).
- variants map[string]bool
- // fileURL, when set, is the one file to mirror, cached as cacheFilename
- // and checked against digest when there is one. A refresh finds it in
- // a repository index.
- fileURL, cacheFilename, digest string
-}
-
-func (pv PackageVersion) String() string {
- return fmt.Sprintf("pkg:%s/%s@%s", pv.Ecosystem, pv.Name, pv.Version)
-}
-
-// Source produces PackageVersion items for mirroring.
-type Source interface {
- Enumerate(ctx context.Context, fn func(PackageVersion) error) error
-}
-
-// PURLSource yields packages from PURL strings.
-// Versioned PURLs produce a single item. Unversioned PURLs produce one item
-// per version Versions lists, or only the newest with Latest.
-type PURLSource struct {
- PURLs []string
- // Versions lists the versions of unversioned PURLs. Nil lists them from
- // the public registries.
- Versions VersionLister
- // Latest mirrors only the newest version of unversioned PURLs.
- Latest bool
- // Platforms limit which platforms of container images are mirrored.
- Platforms []string
-}
-
-func (s *PURLSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error {
- versions := s.Versions
- if versions == nil {
- versions = RegistryVersions{}
- }
-
- for _, purlStr := range s.PURLs {
- p, err := purl.Parse(purlStr)
- if err != nil {
- return fmt.Errorf("parsing PURL %q: %w", purlStr, err)
- }
-
- ecosystem, name, qualifiers := mirrorTarget(p)
- var namespace string
- if ecosystem == "alpine" {
- namespace = p.Namespace
- }
-
- // Images are never expanded to all tags, and conda packages without
- // a channel aren't listed; both fail when they are mirrored. A Git
- // repository without a commit is mirrored as a whole.
- if p.Version != "" || isImageEcosystem(ecosystem) || ecosystem == "git" || (ecosystem == "conda" && !strings.Contains(name, "/")) {
- pv := PackageVersion{Ecosystem: ecosystem, Name: name, Version: p.Version, Namespace: namespace, Qualifiers: qualifiers}
- if isImageEcosystem(ecosystem) {
- pv.Qualifiers = p.Qualifiers.Map()
- }
- if takesPlatforms(ecosystem) {
- pv.Platforms = s.Platforms
- }
- if err := fn(pv); err != nil {
- return err
- }
- continue
- }
-
- // Unversioned: enumerate all versions, or the newest
- listed, err := s.listVersions(ctx, versions, ecosystem, name)
- if err != nil {
- return fmt.Errorf("fetching versions for %s/%s: %w", ecosystem, name, err)
- }
- for _, v := range listed {
- pv := PackageVersion{Ecosystem: ecosystem, Name: name, Version: v, Namespace: namespace, Qualifiers: qualifiers}
- if takesPlatforms(ecosystem) {
- pv.Platforms = s.Platforms
- }
- if err := fn(pv); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-func (s *PURLSource) listVersions(ctx context.Context, versions VersionLister, ecosystem, name string) ([]string, error) {
- if !s.Latest {
- return versions.ListVersions(ctx, ecosystem, name)
- }
- var latest string
- if finder, ok := versions.(LatestFinder); ok {
- v, err := finder.LatestVersion(ctx, ecosystem, name)
- if err != nil {
- return nil, err
- }
- latest = v
- } else {
- listed, err := versions.ListVersions(ctx, ecosystem, name)
- if err != nil {
- return nil, err
- }
- all := make([]registries.Version, len(listed))
- for i, v := range listed {
- all[i] = registries.Version{Number: v}
- }
- if v := registries.SelectLatestVersion(all, purl.EcosystemToPURLType(ecosystem), ""); v != nil {
- latest = v.Number
- }
- }
- if latest == "" {
- return nil, nil
- }
- return []string{latest}, nil
-}
-
-// mirrorTarget returns the ecosystem, name and file qualifiers a PURL is
-// mirrored under.
-func mirrorTarget(p *purl.PURL) (ecosystem, name string, qualifiers map[string]string) {
- ecosystem, name = packageurl.HandlerPackage(p)
- qualifiers = fileQualifiers(ecosystem, p.Qualifiers.Map())
- switch ecosystem {
- case "julia":
- qualifiers = map[string]string{"uuid": p.Qualifier("uuid")}
- case "alpine":
- qualifiers = map[string]string{"arch": p.Qualifier("arch"), "distro": p.Qualifier("distro")}
- }
- return ecosystem, name, qualifiers
-}
-
-// SBOMSource extracts package versions from CycloneDX or SPDX SBOM data.
-type SBOMSource struct {
- Data []byte
- Name string
- // Versions lists the versions of unversioned PURLs. Nil lists them from
- // the public registries.
- Versions VersionLister
- // Latest mirrors only the newest version of unversioned PURLs.
- Latest bool
- // Platforms limit which platforms of container images are mirrored.
- Platforms []string
-}
-
-func (s *SBOMSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error {
- purls, err := s.extractPURLs()
- if err != nil {
- if s.Name != "" {
- return fmt.Errorf("parsing SBOM %s: %w", s.Name, err)
- }
- return fmt.Errorf("parsing SBOM: %w", err)
- }
-
- inner := &PURLSource{PURLs: purls, Versions: s.Versions, Latest: s.Latest, Platforms: s.Platforms}
- return inner.Enumerate(ctx, fn)
-}
-
-func (s *SBOMSource) extractPURLs() ([]string, error) {
- doc, err := sbom.Parse(s.Data)
- if err != nil {
- return nil, err
- }
- var purls []string
- for _, p := range doc.Packages {
- for _, ref := range p.ExternalRefs {
- if ref.Type != "purl" || ref.Locator == "" {
- continue
- }
- locator := ref.Locator
- if p.Version != "" {
- parsed, err := purl.Parse(locator)
- if err != nil {
- return nil, fmt.Errorf("parsing PURL %q: %w", locator, err)
- }
- if parsed.Version == "" {
- locator = parsed.WithVersion(p.Version).String()
- }
- }
- purls = append(purls, locator)
- }
- }
- if len(purls) == 0 {
- return nil, fmt.Errorf("SBOM contains no package URLs")
- }
- return purls, nil
-}
diff --git a/internal/mirror/source_formats_test.go b/internal/mirror/source_formats_test.go
deleted file mode 100644
index 972c047..0000000
--- a/internal/mirror/source_formats_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package mirror
-
-import (
- "context"
- "reflect"
- "testing"
-)
-
-func TestSBOMSourcePreservesPackageIdentities(t *testing.T) {
- for name, data := range map[string]string{
- "CycloneDX JSON": `{"components":[
- {"name":"shared","version":"1.0","purl":"pkg:npm/%40alpha/shared@1.0"},
- {"name":"shared","version":"1.0","purl":"pkg:npm/%40beta/shared@1.0"}]}`,
- "CycloneDX XML": `
- shared1.0pkg:npm/%40alpha/shared@1.0
- shared1.0pkg:npm/%40beta/shared@1.0
- `,
- "SPDX JSON": `{"spdxVersion":"SPDX-2.3","packages":[{"name":"shared","versionInfo":"1.0","externalRefs":[
- {"referenceType":"purl","referenceLocator":"pkg:npm/%40alpha/shared@1.0"},
- {"referenceType":"purl","referenceLocator":"pkg:npm/%40beta/shared@1.0"}]}]}`,
- "SPDX tag-value": `DataLicense: CC0-1.0
-SPDXVersion: SPDX-2.3
-SPDXID: SPDXRef-DOCUMENT
-DocumentName: example
-DocumentNamespace: https://example.com/sbom
-PackageName: shared
-SPDXID: SPDXRef-shared
-PackageVersion: 1.0
-ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40alpha/shared@1.0
-ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40beta/shared@1.0
-`,
- } {
- t.Run(name, func(t *testing.T) {
- source := &SBOMSource{Data: []byte(data), Name: name}
- var got []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- got = append(got, pv)
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
- want := []PackageVersion{
- {Ecosystem: "npm", Name: "@alpha/shared", Version: "1.0"},
- {Ecosystem: "npm", Name: "@beta/shared", Version: "1.0"},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("Enumerate = %+v, want %+v", got, want)
- }
- })
- }
-}
-
-func TestSBOMSourcePreservesImageOptions(t *testing.T) {
- source := &SBOMSource{
- Data: []byte(`{"bomFormat":"CycloneDX","components":[{"name":"alpine","purl":"pkg:docker/library/alpine@3.20?repository_url=ghcr.io&arch=arm64"}]}`),
- Platforms: []string{"linux/amd64"},
- }
- var got []PackageVersion
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- got = append(got, pv)
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- want := []PackageVersion{{
- Ecosystem: "docker", Name: "library/alpine", Version: "3.20",
- Qualifiers: map[string]string{"repository_url": "ghcr.io", "arch": "arm64"},
- Platforms: []string{"linux/amd64"},
- }}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("Enumerate = %+v, want %+v", got, want)
- }
-}
diff --git a/internal/mirror/source_test.go b/internal/mirror/source_test.go
deleted file mode 100644
index faddf35..0000000
--- a/internal/mirror/source_test.go
+++ /dev/null
@@ -1,267 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "slices"
- "strings"
- "testing"
-)
-
-func TestPURLSourceVersioned(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{
- "pkg:npm/lodash@4.17.21",
- "pkg:cargo/serde@1.0.0",
- "pkg:pypi/requests@2.31.0",
- },
- }
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 3 {
- t.Fatalf("got %d items, want 3", len(items))
- }
-
- expected := []PackageVersion{
- {Ecosystem: "npm", Name: "lodash", Version: "4.17.21"},
- {Ecosystem: "cargo", Name: "serde", Version: "1.0.0"},
- {Ecosystem: "pypi", Name: "requests", Version: "2.31.0"},
- }
-
- for i, want := range expected {
- got := items[i]
- if got.Ecosystem != want.Ecosystem || got.Name != want.Name || got.Version != want.Version {
- t.Errorf("items[%d] = %v, want %v", i, got, want)
- }
- }
-}
-
-func TestPURLSourceScopedPackage(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"pkg:npm/%40babel/core@7.23.0"},
- }
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 1 {
- t.Fatalf("got %d items, want 1", len(items))
- }
-
- if items[0].Name != "@babel/core" {
- t.Errorf("name = %q, want %q", items[0].Name, "@babel/core")
- }
- if items[0].Version != "7.23.0" {
- t.Errorf("version = %q, want %q", items[0].Version, "7.23.0")
- }
-}
-
-func TestPURLSourceInvalid(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"not-a-purl"},
- }
-
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for invalid PURL")
- }
-}
-
-func TestPURLSourceCallbackError(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- }
-
- wantErr := context.Canceled
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return wantErr
- })
- if err != wantErr {
- t.Fatalf("got error %v, want %v", err, wantErr)
- }
-}
-
-func TestPackageVersionString(t *testing.T) {
- pv := PackageVersion{Ecosystem: "npm", Name: "lodash", Version: "4.17.21"}
- got := pv.String()
- want := "pkg:npm/lodash@4.17.21"
- if got != want {
- t.Errorf("String() = %q, want %q", got, want)
- }
-}
-
-func TestSBOMSourceCycloneDXJSON(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- "components": []map[string]any{
- {"type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21"},
- {"type": "library", "name": "serde", "version": "1.0.0", "purl": "pkg:cargo/serde@1.0.0"},
- },
- }
-
- source := &SBOMSource{Data: marshalJSON(t, bom)}
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 2 {
- t.Fatalf("got %d items, want 2", len(items))
- }
-
- if items[0].Ecosystem != "npm" || items[0].Name != "lodash" || items[0].Version != "4.17.21" {
- t.Errorf("items[0] = %v", items[0])
- }
- if items[1].Ecosystem != "cargo" || items[1].Name != "serde" || items[1].Version != "1.0.0" {
- t.Errorf("items[1] = %v", items[1])
- }
-}
-
-func TestSBOMSourceSPDXJSON(t *testing.T) {
- doc := map[string]any{
- "spdxVersion": "SPDX-2.3",
- "dataLicense": "CC0-1.0",
- "SPDXID": "SPDXRef-DOCUMENT",
- "name": "test",
- "documentNamespace": "https://example.com/test",
- "packages": []map[string]any{
- {
- "SPDXID": "SPDXRef-Package",
- "name": "lodash",
- "version": "4.17.21",
- "downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "externalRefs": []map[string]any{
- {
- "referenceCategory": "PACKAGE-MANAGER",
- "referenceType": "purl",
- "referenceLocator": "pkg:npm/lodash@4.17.21",
- },
- },
- },
- },
- }
-
- source := &SBOMSource{Data: marshalJSON(t, doc)}
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 1 {
- t.Fatalf("got %d items, want 1", len(items))
- }
-
- if items[0].Name != "lodash" || items[0].Version != "4.17.21" {
- t.Errorf("items[0] = %v", items[0])
- }
-}
-
-func TestSBOMSourceEmptyData(t *testing.T) {
- source := &SBOMSource{}
-
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for empty SBOM")
- }
-}
-
-func TestSBOMSourceInvalidFormat(t *testing.T) {
- source := &SBOMSource{Data: []byte("this is not an SBOM"), Name: "invalid.sbom"}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for invalid SBOM")
- }
- if !strings.Contains(err.Error(), "invalid.sbom") {
- t.Errorf("error = %q, want source name", err)
- }
-}
-
-func TestSBOMSourceEmptyCycloneDX(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- }
- source := &SBOMSource{Data: marshalJSON(t, bom)}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for empty SBOM")
- }
-}
-
-func marshalJSON(t *testing.T, v any) []byte {
- t.Helper()
- data, err := json.Marshal(v)
- if err != nil {
- t.Fatal(err)
- }
- return data
-}
-
-// The mirror files packages under the ecosystem and package names the
-// handlers use, so a mirrored package and the same package cached by a client
-// share one name.
-func TestPURLSourceUsesHandlerEcosystemNames(t *testing.T) {
- source := &PURLSource{PURLs: []string{
- "pkg:gem/rake@13.2.1",
- "pkg:composer/psr/log@3.0.2",
- "pkg:apk/alpine/curl@8.14.1-r2",
- "pkg:npm/left-pad@1.3.0",
- "pkg:maven/org.slf4j/slf4j-api@2.0.13",
- "pkg:pypi/Zope.Interface@6.4",
- "pkg:nuget/Newtonsoft.Json@13.0.3",
- }}
-
- var got []string
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- got = append(got, pv.Ecosystem+" "+pv.Name)
- return nil
- }); err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- want := []string{
- "gem rake",
- "composer psr/log",
- "alpine curl",
- "npm left-pad",
- "maven org.slf4j:slf4j-api",
- "pypi zope-interface",
- "nuget newtonsoft.json",
- }
- if !slices.Equal(got, want) {
- t.Errorf("packages = %q, want %q", got, want)
- }
-}
diff --git a/internal/mirror/swift.go b/internal/mirror/swift.go
deleted file mode 100644
index 153bb10..0000000
--- a/internal/mirror/swift.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package mirror
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/handler"
-)
-
-// mirrorSwift mirrors the source archive of a Swift registry release, with
-// its release metadata, from the registry the proxy serves Swift packages
-// from.
-func (m *Mirror) mirrorSwift(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- upstream, err := m.registryURL("swift", pv.Name)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- h := handler.NewSwiftHandler(m.proxy, "", upstream)
- var scope, name string
- if source := handler.SwiftSourceURL(pv.Name); source != "" {
- scope, name, err = h.ResolveIdentity(ctx, source)
- } else {
- scope, name, err = swiftScopeAndName(pv.Name)
- }
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- result, err := h.MirrorRelease(ctx, scope, name, pv.Version)
- if err != nil {
- m.recordFailure(tracker, pv, err)
- return
- }
- m.recordResult(tracker, pv, result)
-}
-
-// registryURL returns the registry base URL Versions lists purlType packages
-// from, which is the configured upstream, or empty for the default one.
-func (m *Mirror) registryURL(purlType, name string) (string, error) {
- if r, ok := m.Versions.(RegistryVersions); ok && r.URL != nil {
- return r.URL(purlType, name)
- }
- return "", nil
-}
-
-// swiftScopeAndName splits the name of a pkg:swift PURL whose namespace is
-// a registry scope, as in pkg:swift/apple/swift-nio for the identity
-// apple.swift-nio.
-func swiftScopeAndName(name string) (scope, pkg string, _ error) {
- scope, pkg, ok := strings.Cut(name, "/")
- if !ok || scope == "" || pkg == "" || strings.ContainsAny(scope, ".:") || strings.Contains(pkg, "/") {
- return "", "", fmt.Errorf("pkg:swift/%s needs a registry scope or a source repository as the namespace, such as pkg:swift/apple/swift-nio or pkg:swift/github.com/apple/swift-nio", name)
- }
- return scope, pkg, nil
-}
diff --git a/internal/mirror/swift_test.go b/internal/mirror/swift_test.go
deleted file mode 100644
index 4ae0764..0000000
--- a/internal/mirror/swift_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package mirror
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/git-pkgs/registries"
-)
-
-func TestSwiftIdentity(t *testing.T) {
- registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/identifiers" || r.Header.Get("Accept") != "application/vnd.swift.registry.v1+json" {
- http.Error(w, "unexpected request", http.StatusBadRequest)
- return
- }
- switch r.URL.Query().Get("url") {
- case "https://github.com/apple/swift-nio":
- _, _ = w.Write([]byte(`{"identifiers":["apple.swift-nio","mirror.swift-nio"]}`))
- case "https://github.com/acme/empty":
- _, _ = w.Write([]byte(`{"identifiers":[]}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer registry.Close()
-
- for name, want := range map[string]string{
- "apple/swift-nio": "apple.swift-nio",
- "github.com/apple/swift-nio": "apple.swift-nio",
- "github.com/acme/empty": "",
- "github.com/acme/unknown": "",
- "swift-nio": "",
- "apple/": "",
- "apple/swift-nio/extra": "",
- } {
- got, err := swiftIdentity(context.Background(), registries.DefaultClient(), registry.URL, name)
- if got != want || (want == "") != (err != nil) {
- t.Errorf("swiftIdentity(%q) = %q, %v; want %q", name, got, err, want)
- }
- }
-}
diff --git a/internal/mirror/versions.go b/internal/mirror/versions.go
deleted file mode 100644
index 0a7e1ba..0000000
--- a/internal/mirror/versions.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries"
- _ "github.com/git-pkgs/registries/all"
-)
-
-// VersionLister lists the versions of a package, for PURLs without one.
-type VersionLister interface {
- ListVersions(ctx context.Context, ecosystem, name string) ([]string, error)
-}
-
-// RegistryVersions lists versions through the registries clients.
-type RegistryVersions struct {
- // Client makes the requests. Nil uses registries.DefaultClient.
- Client *registries.Client
- // URL returns the registry base URL to list a package from, by PURL
- // type. Nil, or an empty URL, uses the public registry.
- URL func(purlType, name string) (string, error)
-}
-
-// VersionFinder finds one version of a package, with the files it
-// publishes, for ecosystems the mirror fetches from the version's metadata.
-type VersionFinder interface {
- FindVersion(ctx context.Context, ecosystem, name, version string) (registries.Version, error)
-}
-
-// LatestFinder picks the latest version of a package, for unversioned PURLs
-// when only the latest is mirrored. Listers without it fall back to
-// registries.SelectLatestVersion over ListVersions.
-type LatestFinder interface {
- LatestVersion(ctx context.Context, ecosystem, name string) (string, error)
-}
-
-// LatestVersion implements LatestFinder with registries.FetchLatestVersion:
-// the version the registry advertises as latest where it has one, such as
-// npm's dist-tags.latest, and otherwise the newest version by publish time
-// that isn't yanked, retracted or deprecated.
-func (r RegistryVersions) LatestVersion(ctx context.Context, ecosystem, name string) (string, error) {
- reg, name, err := r.registryFor(ctx, ecosystem, name)
- if err != nil {
- return "", err
- }
- v, err := registries.FetchLatestVersion(ctx, reg, name)
- if err != nil || v == nil {
- return "", err
- }
- return v.Number, nil
-}
-
-// ListVersions implements VersionLister.
-func (r RegistryVersions) ListVersions(ctx context.Context, ecosystem, name string) ([]string, error) {
- versions, err := r.fetchVersions(ctx, ecosystem, name)
- if err != nil {
- return nil, err
- }
- result := make([]string, len(versions))
- for i, v := range versions {
- result[i] = v.Number
- }
- return result, nil
-}
-
-// FindVersion implements VersionFinder.
-func (r RegistryVersions) FindVersion(ctx context.Context, ecosystem, name, version string) (registries.Version, error) {
- versions, err := r.fetchVersions(ctx, ecosystem, name)
- if err != nil {
- return registries.Version{}, err
- }
- for _, v := range versions {
- if v.Number == version {
- return v, nil
- }
- }
- return registries.Version{}, fmt.Errorf("%w: %s %s@%s", registries.ErrNotFound, ecosystem, name, version)
-}
-
-func (r RegistryVersions) fetchVersions(ctx context.Context, ecosystem, name string) ([]registries.Version, error) {
- reg, name, err := r.registryFor(ctx, ecosystem, name)
- if err != nil {
- return nil, err
- }
- return reg.FetchVersions(ctx, name)
-}
-
-// registryFor returns the client for the registry name is listed in, and
-// the name the registry knows the package by.
-func (r RegistryVersions) registryFor(ctx context.Context, ecosystem, name string) (registries.Registry, string, error) { //nolint:ireturn // registries.New returns the interface
- reg, err := r.registry(ecosystem, name)
- if err != nil {
- return nil, "", err
- }
- switch purl.EcosystemToPURLType(ecosystem) {
- case "helm":
- // The namespace names the repository, which URL resolves to the
- // index the chart is listed in.
- _, name, _ = strings.Cut(name, "/")
- case "swift":
- var baseURL string
- if baseURL, err = r.baseURL("swift", name); err != nil {
- return nil, "", err
- }
- if name, err = swiftIdentity(ctx, r.client(), baseURL, name); err != nil {
- return nil, "", err
- }
- }
- return reg, name, nil
-}
-
-// registry returns the client for the registry name is listed in.
-func (r RegistryVersions) registry(ecosystem, name string) (registries.Registry, error) { //nolint:ireturn // registries.New returns the interface
- purlType := purl.EcosystemToPURLType(ecosystem)
- baseURL, err := r.baseURL(purlType, name)
- if err != nil {
- return nil, err
- }
- return registries.New(purlType, baseURL, r.client())
-}
-
-func (r RegistryVersions) baseURL(purlType, name string) (string, error) {
- if r.URL == nil {
- return "", nil
- }
- return r.URL(purlType, name)
-}
-
-func (r RegistryVersions) client() *registries.Client {
- if r.Client == nil {
- return registries.DefaultClient()
- }
- return r.Client
-}
-
-// swiftIdentity returns the registry identity, scope.name, of the name of a
-// pkg:swift PURL, looking a source repository up on the registry at baseURL.
-func swiftIdentity(ctx context.Context, client *registries.Client, baseURL, name string) (string, error) {
- source := handler.SwiftSourceURL(name)
- if source == "" {
- scope, pkg, err := swiftScopeAndName(name)
- if err != nil {
- return "", err
- }
- return scope + "." + pkg, nil
- }
- if baseURL == "" {
- baseURL = config.DefaultSwiftUpstream
- }
- var body json.RawMessage
- _, err := client.GetJSONWithHeaders(ctx,
- strings.TrimSuffix(baseURL, "/")+"/identifiers?"+url.Values{"url": {source}}.Encode(),
- http.Header{"Accept": {"application/vnd.swift.registry.v1+json"}}, &body)
- if httpErr := (*registries.HTTPError)(nil); errors.As(err, &httpErr) && httpErr.IsNotFound() {
- return "", fmt.Errorf("the Swift registry has no package for %s", source)
- }
- if err != nil {
- return "", err
- }
- scope, pkg, err := handler.SwiftIdentityOf(body, source)
- if err != nil {
- return "", err
- }
- return scope + "." + pkg, nil
-}
diff --git a/internal/mirror/versions_test.go b/internal/mirror/versions_test.go
deleted file mode 100644
index 29f9f41..0000000
--- a/internal/mirror/versions_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "errors"
- "reflect"
- "testing"
-)
-
-// fakeVersions lists fixed versions and records what it was asked for.
-type fakeVersions struct {
- versions []string
- err error
- asked []string
-}
-
-func (f *fakeVersions) ListVersions(_ context.Context, ecosystem, name string) ([]string, error) {
- f.asked = append(f.asked, ecosystem+"/"+name)
- return f.versions, f.err
-}
-
-func enumerate(t *testing.T, source Source) []PackageVersion {
- t.Helper()
- var items []PackageVersion
- if err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- }); err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
- return items
-}
-
-func TestPURLSourceListsVersionsThroughLister(t *testing.T) {
- versions := &fakeVersions{versions: []string{"1.0.0", "1.1.0"}}
- source := &PURLSource{PURLs: []string{"pkg:npm/%40acme/widget"}, Versions: versions}
-
- items := enumerate(t, source)
-
- want := []PackageVersion{
- {Ecosystem: "npm", Name: "@acme/widget", Version: "1.0.0"},
- {Ecosystem: "npm", Name: "@acme/widget", Version: "1.1.0"},
- }
- if !reflect.DeepEqual(items, want) {
- t.Errorf("items = %v, want %v", items, want)
- }
- if !reflect.DeepEqual(versions.asked, []string{"npm/@acme/widget"}) {
- t.Errorf("lister asked for %v, want npm/@acme/widget", versions.asked)
- }
-}
-
-func TestPURLSourceReportsListerError(t *testing.T) {
- source := &PURLSource{PURLs: []string{"pkg:npm/left-pad"}, Versions: &fakeVersions{err: errors.New("upstream down")}}
- err := source.Enumerate(context.Background(), func(PackageVersion) error { return nil })
- if err == nil {
- t.Fatal("Enumerate() succeeded although listing versions failed")
- }
-}
-
-func TestSBOMSourceListsVersionsThroughLister(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- "components": []map[string]any{{"type": "library", "name": "left-pad", "purl": "pkg:npm/left-pad"}},
- }
- versions := &fakeVersions{versions: []string{"1.3.0"}}
- source := &SBOMSource{Data: marshalJSON(t, bom), Versions: versions}
-
- items := enumerate(t, source)
-
- if len(items) != 1 || items[0].Version != "1.3.0" {
- t.Errorf("items = %v, want left-pad@1.3.0 from the lister", items)
- }
-}
-
-func TestSourceFromRequestUsesMirrorVersions(t *testing.T) {
- m := setupTestMirror(t, 1)
- versions := &fakeVersions{}
- m.Versions = versions
- js := NewJobStore(context.Background(), m)
-
- source, err := js.sourceFromRequest(JobRequest{PURLs: []string{"pkg:npm/left-pad"}})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if got := source.(*PURLSource).Versions; got != versions {
- t.Errorf("PURL source lists versions through %v, want the mirror's lister", got)
- }
-
- source, err = js.sourceFromRequest(JobRequest{SBOM: json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`)})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if got := source.(*SBOMSource).Versions; got != versions {
- t.Errorf("SBOM source lists versions through %v, want the mirror's lister", got)
- }
-}
-
-func TestPURLSourceKeepsJuliaUUID(t *testing.T) {
- const uuid = "7876af07-990d-54b4-ab0e-23690620f79a"
- versions := &fakeVersions{versions: []string{"0.5.5"}}
- source := &PURLSource{
- PURLs: []string{"pkg:julia/Example@0.5.4?uuid=" + uuid, "pkg:julia/Example?uuid=" + uuid},
- Versions: versions,
- }
-
- items := enumerate(t, source)
-
- qualifiers := map[string]string{"uuid": uuid}
- want := []PackageVersion{
- {Ecosystem: "julia", Name: "Example", Version: "0.5.4", Qualifiers: qualifiers},
- {Ecosystem: "julia", Name: "Example", Version: "0.5.5", Qualifiers: qualifiers},
- }
- if !reflect.DeepEqual(items, want) {
- t.Errorf("items = %v, want %v", items, want)
- }
-}
diff --git a/internal/packageurl/handler.go b/internal/packageurl/handler.go
deleted file mode 100644
index 5edcf80..0000000
--- a/internal/packageurl/handler.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package packageurl
-
-import (
- "github.com/git-pkgs/purl"
-)
-
-// distroTypes are PURL types whose namespace names the distribution
-// ("alpine", "debian"), not part of the package name the handlers store.
-var distroTypes = map[string]bool{"alpm": true, "apk": true, "bsd": true, "deb": true, "rpm": true}
-
-// DistroType reports whether a PURL type's namespace names the distribution,
-// so that handlers ignore it.
-func DistroType(purlType string) bool {
- return distroTypes[purlType]
-}
-
-// HandlerEcosystem returns the ecosystem name the proxy's handlers store
-// packages of a PURL type under. purl names gem and composer after their
-// registries, rubygems and packagist; the handlers use the type.
-func HandlerEcosystem(purlType string) string {
- switch purlType {
- case "gem", "composer":
- return purlType
- default:
- return purl.PURLTypeToEcosystem(purlType)
- }
-}
-
-// HandlerPackage returns the ecosystem and package name the proxy's handlers
-// store p under, so a package named in a PURL, such as one from an SBOM,
-// matches the package a client fetched through the proxy:
-//
-// - apk, alpm, bsd, deb and rpm: the namespace names the distribution and
-// is dropped
-// - conda: the channel, from the namespace or the channel qualifier, is
-// part of the name ("conda-forge/numpy"); without either the name stays
-// bare
-// - pypi and nuget: the name in purl's comparison form, PEP 503 for PyPI
-// and lowercased for NuGet, as the handlers store them
-// - swift: namespace/name, the registry scope or the source repository
-// with the package, which the Swift mirror resolves; purl's full name
-// is the registry identity scope.name
-//
-// Other types use purl's full name, which keeps the namespace: "@babel/core",
-// or "org.slf4j:slf4j-api" for Maven, as its handler stores it.
-func HandlerPackage(p *purl.PURL) (ecosystem, name string) {
- ecosystem = HandlerEcosystem(p.Type)
- switch {
- case distroTypes[p.Type]:
- return ecosystem, p.Name
- case p.Type == "conda":
- return ecosystem, condaName(p.Namespace, p.Name, p.Qualifier("channel"))
- case p.Type == "pypi" || p.Type == "nuget":
- return ecosystem, p.ComparisonForm().FullName()
- case p.Type == "swift" && p.Namespace != "":
- return ecosystem, p.Namespace + "/" + p.Name
- }
- return ecosystem, p.FullName()
-}
-
-func condaName(namespace, name, channel string) string {
- switch {
- case namespace != "":
- return namespace + "/" + name
- case channel != "":
- return channel + "/" + name
- default:
- return name
- }
-}
diff --git a/internal/packageurl/handler_test.go b/internal/packageurl/handler_test.go
deleted file mode 100644
index 7283b9f..0000000
--- a/internal/packageurl/handler_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package packageurl
-
-import (
- "testing"
-
- "github.com/git-pkgs/purl"
-)
-
-// HandlerPackage is used by both the mirror and the project inventory, so a
-// mirrored package, a package listed in an SBOM and the same package fetched
-// by a client end up under one name.
-func TestHandlerPackage(t *testing.T) {
- tests := []struct {
- purl string
- ecosystem string
- name string
- }{
- // purl names these after their registries; the handlers use the type.
- {"pkg:gem/rake@13.2.1", "gem", "rake"},
- {"pkg:composer/psr/log@3.0.2", "composer", "psr/log"},
- // Distribution namespaces are dropped.
- {"pkg:apk/alpine/busybox@1.36.1-r15?arch=x86_64", "alpine", "busybox"},
- {"pkg:apk/curl@8.14.1-r2", "alpine", "curl"},
- {"pkg:alpm/arch/glibc@2.39-1", "arch", "glibc"},
- {"pkg:bsd/freebsd/curl@8.7.1", "freebsd", "curl"},
- {"pkg:deb/debian/libc6@2.36-9?arch=amd64", "deb", "libc6"},
- {"pkg:rpm/fedora/curl@8.6.0-7.fc40", "rpm", "curl"},
- // The conda channel is part of the name.
- {"pkg:conda/numpy@1.26.4?channel=conda-forge", "conda", "conda-forge/numpy"},
- {"pkg:conda/conda-forge/numpy@1.26.4", "conda", "conda-forge/numpy"},
- {"pkg:conda/numpy@1.26.4", "conda", "numpy"},
- // PyPI names are normalized as PEP 503 does, NuGet IDs lowercased.
- {"pkg:pypi/Zope.Interface@6.4", "pypi", "zope-interface"},
- {"pkg:pypi/zope_interface", "pypi", "zope-interface"},
- {"pkg:nuget/Newtonsoft.Json@13.0.3", "nuget", "newtonsoft.json"},
- // Other namespaces stay part of the name.
- {"pkg:npm/%40babel/core@7.23.0", "npm", "@babel/core"},
- // The Maven handler stores "group:artifact".
- {"pkg:maven/org.slf4j/slf4j-api@2.0.13", "maven", "org.slf4j:slf4j-api"},
- {"pkg:docker/library/alpine@3.20", "docker", "library/alpine"},
- {"pkg:julia/Example@0.5.3?uuid=7876af07-990d-54b4-ab0e-23690620f79a", "julia", "Example"},
- // Swift keeps namespace/name, the scope or the source repository,
- // which the Swift mirror splits again; purl's full name would be the
- // registry identity "scope.name".
- {"pkg:swift/Acme/Widget@1.0.0", "swift", "Acme/Widget"},
- {"pkg:swift/github.com/apple/swift-nio@2.62.0", "swift", "github.com/apple/swift-nio"},
- // Go module paths keep their case.
- {"pkg:golang/github.com/Azure/foo@v1.2.0", "golang", "github.com/Azure/foo"},
- }
- for _, tt := range tests {
- t.Run(tt.purl, func(t *testing.T) {
- p, err := purl.Parse(tt.purl)
- if err != nil {
- t.Fatal(err)
- }
- ecosystem, name := HandlerPackage(p)
- if ecosystem != tt.ecosystem || name != tt.name {
- t.Errorf("HandlerPackage(%q) = %q, %q, want %q, %q", tt.purl, ecosystem, name, tt.ecosystem, tt.name)
- }
- })
- }
-}
diff --git a/internal/packageurl/packageurl.go b/internal/packageurl/packageurl.go
deleted file mode 100644
index e01f4b2..0000000
--- a/internal/packageurl/packageurl.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// Package packageurl builds package URLs from ecosystem-native package names.
-package packageurl
-
-import (
- "strings"
-
- "github.com/git-pkgs/purl"
-)
-
-// Make constructs a package URL from an ecosystem-native package name.
-func Make(ecosystem, name, version string) *purl.PURL {
- return purl.MakePURL(ecosystem, name, version)
-}
-
-// MakeString constructs a package URL string. It returns an empty string when
-// the package identity cannot be represented as a PURL.
-func MakeString(ecosystem, name, version string) string {
- return purl.MakePURLString(ecosystem, name, version)
-}
-
-// PolicyPackageKey maps a canonical versionless PURL to its policy key.
-// Go policies match module names case-insensitively for compatibility.
-func PolicyPackageKey(packagePURL string) string {
- if strings.HasPrefix(packagePURL, "pkg:golang/") {
- return strings.ToLower(packagePURL)
- }
- return packagePURL
-}
-
-// WithVersionString returns a package PURL with its version replaced. It
-// returns an empty string when packagePURL is invalid.
-func WithVersionString(packagePURL, version string) string {
- pkg, err := purl.Parse(packagePURL)
- if err != nil {
- return ""
- }
- return pkg.WithVersion(version).String()
-}
-
-// MakeCacheStrings returns package and version PURLs suitable for artifact
-// cache records. Swift registry identities keep their generic storage PURLs.
-// Cache keys are independent of the configured upstream.
-func MakeCacheStrings(ecosystem, name, version string) (packagePURL, versionPURL string) {
- if purl.NormalizeEcosystem(ecosystem) == "swift" {
- if identity, ok := swiftRegistryIdentity(name); ok {
- pkg := purl.New("generic", "swift-registry", identity, "", nil)
- return pkg.String(), pkg.WithVersion(version).String()
- }
- }
- if pkg := Make(ecosystem, name, ""); pkg != nil {
- return pkg.String(), pkg.WithVersion(version).String()
- }
- return "", ""
-}
-
-func swiftRegistryIdentity(name string) (string, bool) {
- scope, packageName, found := strings.Cut(name, "/")
- if !found {
- scope, packageName, found = strings.Cut(name, ".")
- }
- if !found || scope == "" || packageName == "" || strings.ContainsAny(packageName, "/.") {
- return "", false
- }
- return strings.ToLower(scope) + "." + strings.ToLower(packageName), true
-}
diff --git a/internal/packageurl/packageurl_test.go b/internal/packageurl/packageurl_test.go
deleted file mode 100644
index 5a46776..0000000
--- a/internal/packageurl/packageurl_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package packageurl
-
-import "testing"
-
-func TestMakeSwiftRegistryPathUnsupported(t *testing.T) {
- if got := Make("swift", "apple/swift-argument-parser", "1.8.2"); got != nil {
- t.Errorf("Make() = %q, want nil", got.String())
- }
- if got := MakeString("swift", "apple/swift-argument-parser", "1.8.2"); got != "" {
- t.Errorf("MakeString() = %q, want empty string", got)
- }
-}
-
-func TestMakeStringSwiftSourceCoordinate(t *testing.T) {
- got := MakeString("swift", "github.com/apple/swift-package-manager", "1.7.0")
- want := "pkg:swift/github.com/apple/swift-package-manager@1.7.0"
- if got != want {
- t.Errorf("MakeString() = %q, want %q", got, want)
- }
-}
-
-func TestWithVersionStringPreservesQualifiers(t *testing.T) {
- packagePURL := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Fold.example%2Fswift"
- got := WithVersionString(packagePURL, "1.2.3")
- want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Fold.example%2Fswift"
- if got != want {
- t.Errorf("WithVersionString() = %q, want %q", got, want)
- }
-
- if got := WithVersionString("not a purl", "1.2.3"); got != "" {
- t.Errorf("WithVersionString() = %q for invalid PURL, want empty string", got)
- }
-}
-
-func TestMakeCacheStringsSwiftRegistryIdentity(t *testing.T) {
- packagePURL, versionPURL := MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
-
- wantPackage := "pkg:generic/swift-registry/apple.example"
- if packagePURL != wantPackage {
- t.Errorf("package PURL = %q, want %q", packagePURL, wantPackage)
- }
- wantVersion := "pkg:generic/swift-registry/apple.example@1.2.3"
- if versionPURL != wantVersion {
- t.Errorf("version PURL = %q, want %q", versionPURL, wantVersion)
- }
-
- for _, identity := range []string{"apple.example", "APPLE.EXAMPLE", "apple/example"} {
- dottedPackage, dottedVersion := MakeCacheStrings("Swift", identity, "1.2.3")
- if dottedPackage != packagePURL || dottedVersion != versionPURL {
- t.Errorf("%q cache PURLs = %q, %q; want %q, %q", identity, dottedPackage, dottedVersion, packagePURL, versionPURL)
- }
- }
-}
-
-func TestMakeCacheStringsUsesSourcePURLWhenAvailable(t *testing.T) {
- packagePURL, versionPURL := MakeCacheStrings("swift", "github.com/apple/swift-package-manager", "1.7.0")
-
- if packagePURL != "pkg:swift/github.com/apple/swift-package-manager" {
- t.Errorf("package PURL = %q", packagePURL)
- }
- if versionPURL != "pkg:swift/github.com/apple/swift-package-manager@1.7.0" {
- t.Errorf("version PURL = %q", versionPURL)
- }
-}
-
-func TestMakeStringDelegatesOtherEcosystems(t *testing.T) {
- got := MakeString("npm", "@babel/core", "7.23.0")
- want := "pkg:npm/%40babel/core@7.23.0"
- if got != want {
- t.Errorf("MakeString() = %q, want %q", got, want)
- }
-}
diff --git a/internal/retention/retention.go b/internal/retention/retention.go
deleted file mode 100644
index 156e410..0000000
--- a/internal/retention/retention.go
+++ /dev/null
@@ -1,272 +0,0 @@
-// Package retention holds the per-ecosystem rules for evicting cached
-// artifacts that have not been accessed for a configured time.
-//
-// An ecosystem takes part only once its handler registers a Spec with
-// Default, usually from an init function in the handler's own file. Until
-// then configuring retention for it is a validation error, and the global
-// default does not apply to it.
-package retention
-
-import (
- "fmt"
- "slices"
- "sort"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/packageurl"
- "github.com/git-pkgs/purl"
-)
-
-// KnownKeys lists the ecosystem keys storage.retention.ecosystems accepts.
-// They are the literals the handlers pass when they fetch an artifact. A key
-// outside this list is a typo; a key in it whose handler has not registered
-// yet is not supported by this version.
-var KnownKeys = []string{
- "alpine", "cargo", "composer", "conan", "conda", "cran", "deb", "freebsd", "gem",
- "generic", "golang", "helm", "hex", "julia", "maven", "npm", "nuget", "oci", "pub",
- "pypi", "rpm", "swift",
-}
-
-// IsKnown reports whether key is one of KnownKeys.
-func IsKnown(key string) bool {
- return slices.Contains(KnownKeys, key)
-}
-
-// Spec describes how one ecosystem takes part in retention.
-type Spec struct {
- // Key is the ecosystem's config key, one of KnownKeys.
- Key string
-
- // ExtraDBEcosystems lists packages.ecosystem values besides the ones
- // derived from Key that belong to this ecosystem.
- ExtraDBEcosystems []string
-
- // CanonicalPackage turns a package PURL from the config into a
- // policy key, reporting false when the
- // PURL does not belong to this ecosystem. Nil means the ecosystem accepts
- // no package overrides.
- CanonicalPackage func(p *purl.PURL) (string, bool)
-
- // SourcePackage reports a package PURL that names the package by where
- // its source lives, such as a Swift package by its repository. It has to
- // be resolved through the upstream before it can be compared, so the
- // config keeps it in Rules.Sources until then. Nil means the ecosystem
- // has no such form.
- SourcePackage func(p *purl.PURL) bool
-}
-
-// dbEcosystems returns every packages.ecosystem value that belongs to the
-// spec: the key, its PURL type and the ecosystem name that type maps back
-// to, the same spellings git-pkgs matches. The proxy's mirror command and
-// git-pkgs store "rubygems" where the gem handler stores "gem", for example.
-func (s Spec) dbEcosystems() []string {
- purlType := purl.EcosystemToPURLType(s.Key)
- values := []string{s.Key, purlType, purl.PURLTypeToEcosystem(purlType)}
- values = append(values, s.ExtraDBEcosystems...)
- slices.Sort(values)
- return slices.Compact(values)
-}
-
-// DefaultCanonical returns a CanonicalPackage for ecosystems that store the
-// package PURL exactly as purl.MakePURLString builds it from the PURL's full
-// name. It accepts a PURL only when building it back that way gives the same
-// package, so for an ecosystem that stores a different form, such as one
-// with a default namespace, it refuses the key instead of producing one
-// that never matches. Such ecosystems need their own CanonicalPackage.
-func DefaultCanonical(key string) func(p *purl.PURL) (string, bool) {
- purlType := purl.EcosystemToPURLType(key)
- return func(p *purl.PURL) (string, bool) {
- if p == nil || p.Type != purlType {
- return "", false
- }
- canonical := purl.MakePURLString(key, p.FullName(), "")
- if canonical == "" {
- return "", false
- }
- given := purl.New(p.Type, p.Namespace, p.Name, "", nil).String()
- return canonical, canonical == given
- }
-}
-
-// Registry maps ecosystem keys to their specs.
-type Registry struct {
- mu sync.RWMutex
- specs map[string]Spec
- byDB map[string]string
-}
-
-// NewRegistry returns an empty registry.
-func NewRegistry() *Registry {
- return &Registry{specs: map[string]Spec{}, byDB: map[string]string{}}
-}
-
-// Default is the registry the handlers register with.
-var Default = NewRegistry()
-
-// Register adds s. It panics when the key is not one of KnownKeys, is
-// already registered, or claims a packages.ecosystem value another spec
-// claims, since each of those is a programming error.
-func (r *Registry) Register(s Spec) {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- if !IsKnown(s.Key) {
- panic(fmt.Sprintf("retention: unknown ecosystem key %q", s.Key))
- }
- if _, ok := r.specs[s.Key]; ok {
- panic(fmt.Sprintf("retention: ecosystem %q registered twice", s.Key))
- }
- values := s.dbEcosystems()
- for _, v := range values {
- if owner, ok := r.byDB[v]; ok {
- panic(fmt.Sprintf("retention: ecosystem value %q claimed by both %q and %q", v, owner, s.Key))
- }
- }
- r.specs[s.Key] = s
- for _, v := range values {
- r.byDB[v] = s.Key
- }
-}
-
-// Lookup returns the spec registered for key.
-func (r *Registry) Lookup(key string) (Spec, bool) {
- r.mu.RLock()
- defer r.mu.RUnlock()
- s, ok := r.specs[key]
- return s, ok
-}
-
-// Keys returns the registered keys in sorted order.
-func (r *Registry) Keys() []string {
- r.mu.RLock()
- defer r.mu.RUnlock()
- keys := make([]string, 0, len(r.specs))
- for k := range r.specs {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- return keys
-}
-
-// KeyForDBEcosystem returns the registered key a packages.ecosystem value
-// belongs to.
-func (r *Registry) KeyForDBEcosystem(ecosystem string) (string, bool) {
- r.mu.RLock()
- defer r.mu.RUnlock()
- key, ok := r.byDB[ecosystem]
- return key, ok
-}
-
-// Canonical returns the policy key for a configured package PURL.
-func (r *Registry) Canonical(p *purl.PURL) (string, bool) {
- for _, key := range r.Keys() {
- s, _ := r.Lookup(key)
- if s.CanonicalPackage == nil {
- continue
- }
- if canonical, ok := s.CanonicalPackage(p); ok {
- return canonical, true
- }
- }
- return "", false
-}
-
-// Source returns the ecosystem key whose SourcePackage claims p.
-func (r *Registry) Source(p *purl.PURL) (string, bool) {
- for _, key := range r.Keys() {
- s, _ := r.Lookup(key)
- if s.SourcePackage != nil && s.SourcePackage(p) {
- return key, true
- }
- }
- return "", false
-}
-
-// Rules are resolved retention durations. A duration of zero means an
-// artifact is never evicted by age.
-type Rules struct {
- // Default applies to registered ecosystems without their own rule,
- // except generic, which requires an explicit rule.
- Default time.Duration
- // Ecosystems holds rules keyed by registered ecosystem key.
- Ecosystems map[string]time.Duration
- // Packages holds rules keyed by package PURL, with Go keys lowercased.
- Packages map[string]time.Duration
- // Sources holds package rules not resolved to a stored package PURL
- // yet, by ecosystem key and then by the configured PURL (see
- // Spec.SourcePackage). An ecosystem with any is not swept, since one of
- // them may keep an artifact the sweep would otherwise clear.
- Sources map[string]map[string]time.Duration
-}
-
-// Pending reports whether the ecosystem key has package rules still to be
-// resolved, and must not be swept.
-func (r Rules) Pending(key string) bool {
- return len(r.Sources[key]) > 0
-}
-
-// Resolve moves the rule for source in ecosystem key to every stored package
-// PURL it resolved to. A different rule already set for any of them is a
-// conflict, and the source stays pending with none of them set.
-func (r Rules) Resolve(key, source string, canonical []string) error {
- d, ok := r.Sources[key][source]
- if !ok {
- return nil
- }
- if len(canonical) == 0 {
- return fmt.Errorf("%s resolves to no stored package", source)
- }
- for _, c := range canonical {
- if prev, set := r.Packages[c]; set && prev != d {
- return fmt.Errorf("%s resolves to %s, which another rule sets differently", source, c)
- }
- }
- for _, c := range canonical {
- r.Packages[c] = d
- }
- delete(r.Sources[key], source)
- return nil
-}
-
-// For returns the retention for an artifact of the given ecosystem key and
-// stored package PURL: the package rule, else the ecosystem rule, else the
-// default. Generic artifacts require an explicit package or ecosystem rule.
-func (r Rules) For(key, packagePURL string) time.Duration {
- packagePURL = packageurl.PolicyPackageKey(packagePURL)
- if d, ok := r.Packages[packagePURL]; ok {
- return d
- }
- if d, ok := r.Ecosystems[key]; ok {
- return d
- }
- // Release assets may disappear upstream, so keep generic opt-in.
- if key == "generic" {
- return 0
- }
- return r.Default
-}
-
-// MinPositive returns the shortest non-zero duration among all rules, or
-// zero when every rule is zero.
-func (r Rules) MinPositive() time.Duration {
- minimum := time.Duration(0)
- consider := func(d time.Duration) {
- if d > 0 && (minimum == 0 || d < minimum) {
- minimum = d
- }
- }
- consider(r.Default)
- for _, d := range r.Ecosystems {
- consider(d)
- }
- for _, d := range r.Packages {
- consider(d)
- }
- for _, sources := range r.Sources {
- for _, d := range sources {
- consider(d)
- }
- }
- return minimum
-}
diff --git a/internal/retention/retention_test.go b/internal/retention/retention_test.go
deleted file mode 100644
index 5036375..0000000
--- a/internal/retention/retention_test.go
+++ /dev/null
@@ -1,244 +0,0 @@
-package retention
-
-import (
- "slices"
- "testing"
- "time"
-
- "github.com/git-pkgs/purl"
-)
-
-func TestSpecDBEcosystems(t *testing.T) {
- tests := []struct {
- key string
- want []string
- }{
- {"npm", []string{"npm"}},
- {"gem", []string{"gem", "rubygems"}},
- {"composer", []string{"composer", "packagist"}},
- {"alpine", []string{"alpine", "apk"}},
- {"golang", []string{"golang"}},
- {"deb", []string{"deb"}},
- }
- for _, tt := range tests {
- t.Run(tt.key, func(t *testing.T) {
- got := Spec{Key: tt.key}.dbEcosystems()
- if !slices.Equal(got, tt.want) {
- t.Errorf("dbEcosystems() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestRegistryRegister(t *testing.T) {
- reg := NewRegistry()
- reg.Register(Spec{Key: "gem"})
- reg.Register(Spec{Key: "npm"})
-
- if got := reg.Keys(); !slices.Equal(got, []string{"gem", "npm"}) {
- t.Errorf("Keys() = %v, want [gem npm]", got)
- }
- for _, value := range []string{"gem", "rubygems"} {
- if key, ok := reg.KeyForDBEcosystem(value); !ok || key != "gem" {
- t.Errorf("KeyForDBEcosystem(%q) = %q, %v, want gem, true", value, key, ok)
- }
- }
- if _, ok := reg.KeyForDBEcosystem("pypi"); ok {
- t.Error("KeyForDBEcosystem(pypi) found a key for an unregistered ecosystem")
- }
- if _, ok := reg.Lookup("pypi"); ok {
- t.Error("Lookup(pypi) found an unregistered ecosystem")
- }
-}
-
-func TestRegistryRegisterPanics(t *testing.T) {
- tests := []struct {
- name string
- specs []Spec
- }{
- {"unknown key", []Spec{{Key: "not-an-ecosystem"}}},
- {"duplicate key", []Spec{{Key: "npm"}, {Key: "npm"}}},
- {"overlapping ecosystem value", []Spec{{Key: "gem"}, {Key: "npm", ExtraDBEcosystems: []string{"rubygems"}}}},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- reg := NewRegistry()
- defer func() {
- if recover() == nil {
- t.Error("Register did not panic")
- }
- }()
- for _, s := range tt.specs {
- reg.Register(s)
- }
- })
- }
-}
-
-func mustParse(t *testing.T, s string) *purl.PURL {
- t.Helper()
- p, err := purl.Parse(s)
- if err != nil {
- t.Fatalf("parsing %q: %v", s, err)
- }
- return p
-}
-
-func TestDefaultCanonical(t *testing.T) {
- canonical := DefaultCanonical("npm")
-
- got, ok := canonical(mustParse(t, "pkg:npm/%40babel/core"))
- if !ok || got != "pkg:npm/%40babel/core" {
- t.Errorf("scoped package = %q, %v, want pkg:npm/%%40babel/core, true", got, ok)
- }
- if got, ok := canonical(mustParse(t, "pkg:pypi/requests")); ok {
- t.Errorf("other type accepted as %q", got)
- }
- if _, ok := canonical(nil); ok {
- t.Error("nil PURL accepted")
- }
-
- // alpine stores pkg:apk/alpine/{name} and deb stores pkg:deb/{name}, so
- // building their keys back from the full name gives a different package.
- // The default refuses them rather than produce keys that never match.
- for _, tt := range []struct{ key, purl string }{
- {"alpine", "pkg:apk/alpine/curl"},
- {"alpine", "pkg:apk/curl"},
- {"deb", "pkg:deb/debian/curl"},
- } {
- if got, ok := DefaultCanonical(tt.key)(mustParse(t, tt.purl)); ok {
- t.Errorf("DefaultCanonical(%q) accepted %s as %q", tt.key, tt.purl, got)
- }
- }
-}
-
-func TestRegistryCanonical(t *testing.T) {
- reg := NewRegistry()
- reg.Register(Spec{Key: "julia"})
- reg.Register(Spec{Key: "npm", CanonicalPackage: DefaultCanonical("npm")})
-
- if got, ok := reg.Canonical(mustParse(t, "pkg:npm/lodash")); !ok || got != "pkg:npm/lodash" {
- t.Errorf("Canonical(npm) = %q, %v, want pkg:npm/lodash, true", got, ok)
- }
- if got, ok := reg.Canonical(mustParse(t, "pkg:cargo/serde")); ok {
- t.Errorf("Canonical(cargo) = %q for an unregistered ecosystem", got)
- }
-}
-
-func TestRulesFor(t *testing.T) {
- rules := Rules{
- Default: 30 * 24 * time.Hour,
- Ecosystems: map[string]time.Duration{"npm": 14 * 24 * time.Hour, "maven": 0},
- Packages: map[string]time.Duration{"pkg:npm/lodash": 0, "pkg:maven/a/b": 90 * 24 * time.Hour},
- }
- tests := []struct {
- key, pkg string
- want time.Duration
- }{
- {"npm", "pkg:npm/lodash", 0},
- {"npm", "pkg:npm/left-pad", 14 * 24 * time.Hour},
- {"maven", "pkg:maven/a/b", 90 * 24 * time.Hour},
- {"maven", "pkg:maven/c/d", 0},
- {"cargo", "pkg:cargo/serde", 30 * 24 * time.Hour},
- }
- for _, tt := range tests {
- if got := rules.For(tt.key, tt.pkg); got != tt.want {
- t.Errorf("For(%q, %q) = %v, want %v", tt.key, tt.pkg, got, tt.want)
- }
- }
-}
-
-func TestGenericRulesRequireExplicitConfiguration(t *testing.T) {
- day := 24 * time.Hour
- const pkg = "pkg:generic/jqlang%2Fjq"
- tests := []struct {
- name string
- ecosystems map[string]time.Duration
- packages map[string]time.Duration
- want time.Duration
- }{
- {"global default ignored", nil, nil, 0},
- {"ecosystem rule", map[string]time.Duration{"generic": 7 * day}, nil, 7 * day},
- {"ecosystem disabled", map[string]time.Duration{"generic": 0}, nil, 0},
- {"package only", nil, map[string]time.Duration{pkg: 14 * day}, 14 * day},
- {"other package ignored", nil, map[string]time.Duration{"pkg:generic/other%2Frepo": day}, 0},
- {"package overrides ecosystem", map[string]time.Duration{"generic": 7 * day}, map[string]time.Duration{pkg: 14 * day}, 14 * day},
- {"package disables eviction", map[string]time.Duration{"generic": 7 * day}, map[string]time.Duration{pkg: 0}, 0},
- {"package enables eviction", map[string]time.Duration{"generic": 0}, map[string]time.Duration{pkg: day}, day},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rules := Rules{Default: 30 * day, Ecosystems: tt.ecosystems, Packages: tt.packages}
- if got := rules.For("generic", pkg); got != tt.want {
- t.Errorf("generic retention = %v, want %v", got, tt.want)
- }
- if got := rules.For("npm", "pkg:npm/lodash"); got != 30*day {
- t.Errorf("npm default retention = %v, want 30d", got)
- }
- })
- }
-}
-
-func TestRulesMinPositive(t *testing.T) {
- if got := (Rules{}).MinPositive(); got != 0 {
- t.Errorf("empty rules MinPositive() = %v, want 0", got)
- }
- rules := Rules{
- Default: 0,
- Ecosystems: map[string]time.Duration{"npm": 14 * time.Hour, "maven": 0},
- Packages: map[string]time.Duration{"pkg:npm/lodash": 2 * time.Hour},
- }
- if got := rules.MinPositive(); got != 2*time.Hour {
- t.Errorf("MinPositive() = %v, want 2h", got)
- }
-}
-
-func TestRulesResolveSources(t *testing.T) {
- rules := Rules{
- Packages: map[string]time.Duration{"pkg:x/kept": 0},
- Sources: map[string]map[string]time.Duration{"swift": {
- "pkg:swift/github.com/a/one": 0,
- "pkg:swift/github.com/a/two": time.Hour,
- }},
- }
- if got := rules.MinPositive(); got != time.Hour {
- t.Errorf("MinPositive = %v, want the pending rule's hour", got)
- }
- if err := rules.Resolve("swift", "pkg:swift/github.com/a/two", []string{"pkg:x/kept"}); err == nil {
- t.Error("a rule resolving to a package another rule sets differently was accepted")
- }
- if !rules.Pending("swift") {
- t.Fatal("conflicting rule no longer pending")
- }
- if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/kept"}); err != nil {
- t.Errorf("same duration for the same package: %v", err)
- }
- if err := rules.Resolve("swift", "pkg:swift/github.com/a/two", []string{"pkg:x/two"}); err != nil {
- t.Fatal(err)
- }
- if rules.Pending("swift") || rules.Packages["pkg:x/two"] != time.Hour {
- t.Errorf("after resolving: sources %v, packages %v", rules.Sources, rules.Packages)
- }
-}
-
-// A source that resolves to several stored packages sets the rule for all
-// of them, or, on a conflict with any, for none.
-func TestRulesResolveSeveralPackages(t *testing.T) {
- rules := Rules{
- Packages: map[string]time.Duration{"pkg:x/b": time.Hour},
- Sources: map[string]map[string]time.Duration{"swift": {"pkg:swift/github.com/a/one": 0}},
- }
- if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/a", "pkg:x/b"}); err == nil {
- t.Error("a rule conflicting for one of its packages was accepted")
- }
- if _, set := rules.Packages["pkg:x/a"]; set || !rules.Pending("swift") {
- t.Fatalf("conflicting rule partly applied: sources %v, packages %v", rules.Sources, rules.Packages)
- }
- delete(rules.Packages, "pkg:x/b")
- if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/a", "pkg:x/b"}); err != nil {
- t.Fatal(err)
- }
- if rules.Pending("swift") || len(rules.Packages) != 2 {
- t.Errorf("after resolving: sources %v, packages %v", rules.Sources, rules.Packages)
- }
-}
diff --git a/internal/scanner/group.go b/internal/scanner/group.go
deleted file mode 100644
index a4856e0..0000000
--- a/internal/scanner/group.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package scanner
-
-import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-const (
- modeBlock = "block"
- modeMonitor = "monitor"
-
- defaultTimeout = 30 * time.Second
-)
-
-type entry struct {
- scanner Scanner
- mode string
- ecosystems map[string]struct{} // nil/empty means all ecosystems
-}
-
-// Group runs a set of configured Scanners concurrently and turns their
-// individual verdicts into a single decision.
-type Group struct {
- entries []entry
- timeout time.Duration
- failOpen bool
- logger *slog.Logger
-}
-
-// NewGroup builds a Group from cfg. If cfg.Enabled is false, the returned
-// Group has no entries and Enabled() reports false, so callers can skip
-// the scan path entirely.
-func NewGroup(cfg config.ScanningConfig, logger *slog.Logger) (*Group, error) {
- g := &Group{
- timeout: defaultTimeout,
- failOpen: cfg.FailOpen,
- logger: logger,
- }
- if !cfg.Enabled {
- return g, nil
- }
- if cfg.SigningKeyExpanded() == "" {
- return nil, fmt.Errorf("scanning.signing_key is required when scanning.enabled is true")
- }
- if d, err := time.ParseDuration(cfg.Timeout); err == nil && d > 0 {
- g.timeout = d
- }
-
- for _, sc := range cfg.Scanners {
- mode := sc.Mode
- if mode == "" {
- mode = modeBlock
- }
- if mode != modeBlock && mode != modeMonitor {
- return nil, fmt.Errorf("scanner %q: invalid mode %q (must be %q or %q)", sc.Name, mode, modeBlock, modeMonitor)
- }
-
- var ecosystems map[string]struct{}
- if len(sc.Ecosystems) > 0 {
- ecosystems = make(map[string]struct{}, len(sc.Ecosystems))
- for _, eco := range sc.Ecosystems {
- key, err := config.ScannerEcosystemKey(eco)
- if err != nil {
- return nil, fmt.Errorf("scanner %q: %w", sc.Name, err)
- }
- ecosystems[key] = struct{}{}
- }
- }
-
- g.entries = append(g.entries, entry{
- scanner: NewHTTPScanner(sc.Name, sc.URL, sc.HeadersExpanded(), http.DefaultClient),
- mode: mode,
- ecosystems: ecosystems,
- })
- }
-
- return g, nil
-}
-
-// Enabled reports whether any scanner is configured.
-func (g *Group) Enabled() bool {
- return g != nil && len(g.entries) > 0
-}
-
-// Covers reports whether any scanner, in either mode, applies to ecosystem.
-func (g *Group) Covers(ecosystem string) bool {
- return g.Enabled() && len(g.applicable(ecosystem)) > 0
-}
-
-// Blocks reports whether a block-mode scanner applies to ecosystem, so an
-// artifact must be scanned whole before any of it is served.
-func (g *Group) Blocks(ecosystem string) bool {
- if !g.Enabled() {
- return false
- }
- for _, e := range g.applicable(ecosystem) {
- if e.mode == modeBlock {
- return true
- }
- }
- return false
-}
-
-// Timeout returns the per-scan-call timeout used to bound the signed fetch
-// URL's validity.
-func (g *Group) Timeout() time.Duration {
- return g.timeout
-}
-
-func (g *Group) applicable(ecosystem string) []entry {
- // Callers may pass an alias such as go from a PURL type.
- ecosystem = config.NormalizeScannerEcosystem(ecosystem)
- var out []entry
- for _, e := range g.entries {
- if len(e.ecosystems) == 0 {
- out = append(out, e)
- continue
- }
- if _, ok := e.ecosystems[ecosystem]; ok {
- out = append(out, e)
- }
- }
- return out
-}
-
-// Scan runs every scanner applicable to req.Ecosystem concurrently, never
-// sequentially, and returns a single decision.
-//
-// The moment any "block" mode scanner reports Allowed: false (or errors,
-// unless FailOpen is set), Scan cancels a context shared by every
-// goroutine: in-flight calls to the other scanners are aborted rather than
-// waited out, since a single block already decides the outcome. Scan still
-// waits for all goroutines to observe that cancellation and return before
-// it itself returns, so no scan call outlives this method call.
-//
-// If nothing blocks, Scan waits for every "block" mode scanner to finish
-// before reporting Allowed: true — an allow decision can't be finalized
-// until all of them have answered. "monitor" mode scanners never gate the
-// wait or trigger cancellation: a monitor verdict of Allowed: false is
-// logged and folded into Result.Findings, but never blocks.
-func (g *Group) Scan(ctx context.Context, req Request) Result {
- entries := g.applicable(req.Ecosystem)
- if len(entries) == 0 {
- return Result{Allowed: true}
- }
-
- scanCtx, cancel := context.WithTimeout(ctx, g.timeout)
- defer cancel()
-
- var (
- mu sync.Mutex
- findings []Finding
- blocked *Result
- )
-
- var wg sync.WaitGroup
- for _, e := range entries {
- wg.Add(1)
- go func(e entry) {
- defer wg.Done()
- entryFindings, entryBlock := g.evaluate(scanCtx, req, e)
-
- mu.Lock()
- findings = append(findings, entryFindings...)
- if entryBlock != nil && blocked == nil {
- blocked = entryBlock
- cancel()
- }
- mu.Unlock()
- }(e)
- }
-
- wg.Wait()
-
- if blocked != nil {
- blocked.Findings = findings
- return *blocked
- }
-
- return Result{Allowed: true, Findings: findings}
-}
-
-// evaluate runs a single scanner and reports its findings plus, if this
-// scanner's verdict should block the artifact, the Result to block with
-// (nil otherwise). It never sets Result.Findings on a returned block
-// Result — the caller assembles Findings from every entry once all of them
-// have finished.
-func (g *Group) evaluate(scanCtx context.Context, req Request, e entry) (findings []Finding, block *Result) {
- start := time.Now()
- res, err := e.scanner.Scan(scanCtx, req)
- duration := time.Since(start)
-
- if err != nil {
- errType := "error"
- switch {
- case errors.Is(scanCtx.Err(), context.DeadlineExceeded):
- errType = "timeout"
- case errors.Is(scanCtx.Err(), context.Canceled):
- // scanCtx was cancelled because another scanner in the group
- // already decided the verdict, not because this call itself
- // timed out or failed on its own.
- errType = "cancelled"
- }
- metrics.RecordScanError(req.Ecosystem, e.scanner.Name(), errType, metrics.FailureDetail{
- PURL: req.PURL, Filename: req.Filename, Err: err})
-
- if errType == "cancelled" {
- // Another scanner already decided the verdict and cancelled
- // scanCtx; this call didn't fail on its own, so don't log it
- // as if it did.
- return nil, nil
- }
-
- if e.mode == modeMonitor || g.failOpen {
- g.logger.Warn("scanner call failed, treating as allowed",
- "scanner", e.scanner.Name(), "mode", e.mode, "error", err)
- return nil, nil
- }
-
- g.logger.Warn("scanner call failed, blocking artifact",
- "scanner", e.scanner.Name(), "mode", e.mode, "error", err)
- return nil, &Result{
- Allowed: false,
- Reason: fmt.Sprintf("scanner %q failed: %v", e.scanner.Name(), err),
- ScannerName: e.scanner.Name(),
- InfraError: true,
- }
- }
-
- metrics.RecordScanResult(req.Ecosystem, e.scanner.Name(), res.Allowed, duration)
-
- if e.mode == modeMonitor {
- if !res.Allowed {
- g.logger.Warn("monitor scanner flagged artifact",
- "scanner", e.scanner.Name(), "reason", res.Reason)
- }
- return res.Findings, nil
- }
-
- if !res.Allowed {
- return res.Findings, &Result{Allowed: false, Reason: res.Reason, ScannerName: e.scanner.Name()}
- }
- return res.Findings, nil
-}
diff --git a/internal/scanner/group_test.go b/internal/scanner/group_test.go
deleted file mode 100644
index 5dca201..0000000
--- a/internal/scanner/group_test.go
+++ /dev/null
@@ -1,327 +0,0 @@
-package scanner
-
-import (
- "context"
- "errors"
- "io"
- "log/slog"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-func discardLogger() *slog.Logger {
- return slog.New(slog.NewTextHandler(io.Discard, nil))
-}
-
-// fakeScanner is a Scanner test double that can simulate a delay, a fixed
-// result or error, and report whether its context was cancelled before it
-// returned.
-type fakeScanner struct {
- name string
- delay time.Duration
- result Result
- err error
- cancelled *bool
-}
-
-func (f *fakeScanner) Name() string { return f.name }
-
-func (f *fakeScanner) Scan(ctx context.Context, _ Request) (Result, error) {
- select {
- case <-time.After(f.delay):
- case <-ctx.Done():
- if f.cancelled != nil {
- *f.cancelled = true
- }
- return Result{}, ctx.Err()
- }
- return f.result, f.err
-}
-
-func newTestGroup(entries []entry, failOpen bool) *Group {
- return &Group{
- entries: entries,
- timeout: time.Second,
- failOpen: failOpen,
- logger: discardLogger(),
- }
-}
-
-func TestGroup_Enabled(t *testing.T) {
- disabled, err := NewGroup(config.ScanningConfig{Enabled: false}, discardLogger())
- if err != nil {
- t.Fatalf("NewGroup() error: %v", err)
- }
- if disabled.Enabled() {
- t.Error("Enabled() = true for disabled config, want false")
- }
-
- enabled, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- Timeout: "10s",
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "clamav", URL: "http://clamav.invalid", Mode: "block"},
- },
- }, discardLogger())
- if err != nil {
- t.Fatalf("NewGroup() error: %v", err)
- }
- if !enabled.Enabled() {
- t.Error("Enabled() = false for configured scanner, want true")
- }
-}
-
-func TestGroup_Covers(t *testing.T) {
- var nilGroup *Group
- if nilGroup.Covers("npm") {
- t.Error("nil group Covers(npm) = true, want false")
- }
- g, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "trivy", URL: "http://trivy.invalid", Mode: "monitor", Ecosystems: []string{"npm"}},
- {Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"pypi"}},
- },
- }, discardLogger())
- if err != nil {
- t.Fatalf("NewGroup() error: %v", err)
- }
- for ecosystem, want := range map[string]bool{"npm": true, "pypi": true, "freebsd": false} {
- if got := g.Covers(ecosystem); got != want {
- t.Errorf("Covers(%s) = %v, want %v", ecosystem, got, want)
- }
- }
- all := newTestGroup([]entry{{mode: modeBlock}}, false)
- if !all.Covers("freebsd") {
- t.Error("unrestricted scanner: Covers(freebsd) = false, want true")
- }
-}
-
-func TestGroup_NewGroup_InvalidMode(t *testing.T) {
- _, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "bad", URL: "http://bad.invalid", Mode: "quarantine"},
- },
- }, discardLogger())
- if err == nil {
- t.Fatal("NewGroup() error = nil, want error for invalid mode")
- }
-}
-
-func TestGroup_NewGroup_MissingSigningKey(t *testing.T) {
- _, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- Scanners: []config.ScannerConfig{
- {Name: "clamav", URL: "http://clamav.invalid", Mode: "block"},
- },
- }, discardLogger())
- if err == nil {
- t.Fatal("NewGroup() error = nil, want error for missing signing key")
- }
-}
-
-func TestGroup_Scan_NoApplicableScanners(t *testing.T) {
- g := newTestGroup([]entry{
- {
- scanner: &fakeScanner{name: "npm-only", result: Result{Allowed: false}},
- mode: modeBlock,
- ecosystems: map[string]struct{}{"npm": {}},
- },
- }, false)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "pypi"})
- if !result.Allowed {
- t.Error("Allowed = false, want true when no scanner applies to the ecosystem")
- }
-}
-
-func TestGroup_Scan_Allowed(t *testing.T) {
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "clamav", result: Result{Allowed: true}}, mode: modeBlock},
- }, false)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- if !result.Allowed {
- t.Error("Allowed = false, want true")
- }
-}
-
-func TestGroup_Scan_Blocked(t *testing.T) {
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "clamav", result: Result{Allowed: false, Reason: "malware"}}, mode: modeBlock},
- }, false)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- if result.Allowed {
- t.Error("Allowed = true, want false")
- }
- if result.Reason != "malware" {
- t.Errorf("Reason = %q, want %q", result.Reason, "malware")
- }
- if result.ScannerName != "clamav" {
- t.Errorf("ScannerName = %q, want %q", result.ScannerName, "clamav")
- }
- if result.InfraError {
- t.Error("InfraError = true, want false — this is a genuine scanner verdict, not an infrastructure failure")
- }
-}
-
-func TestGroup_Scan_MonitorNeverBlocks(t *testing.T) {
- g := newTestGroup([]entry{
- {
- scanner: &fakeScanner{name: "trivy", result: Result{
- Allowed: false,
- Reason: "cve found",
- Findings: []Finding{{Severity: "medium", Title: "CVE-1234"}},
- }},
- mode: modeMonitor,
- },
- }, false)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- if !result.Allowed {
- t.Error("Allowed = false, want true — monitor mode must never block")
- }
- if len(result.Findings) != 1 || result.Findings[0].Title != "CVE-1234" {
- t.Errorf("Findings = %+v, want the monitor scanner's finding folded in", result.Findings)
- }
-}
-
-func TestGroup_Scan_FirstBlockCancelsOthers(t *testing.T) {
- var slowSawCancel bool
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "fast-block", result: Result{Allowed: false, Reason: "blocked"}}, mode: modeBlock},
- {scanner: &fakeScanner{name: "slow", delay: 2 * time.Second, cancelled: &slowSawCancel}, mode: modeBlock},
- }, false)
-
- start := time.Now()
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- elapsed := time.Since(start)
-
- if result.Allowed {
- t.Error("Allowed = true, want false")
- }
- if elapsed >= 2*time.Second {
- t.Errorf("Scan() took %v, want it to return promptly once the slow scanner's context was cancelled", elapsed)
- }
- if !slowSawCancel {
- t.Error("slow scanner never observed context cancellation")
- }
-
- if got := testutil.ToFloat64(metrics.ScanErrors.WithLabelValues("npm", "slow", "cancelled")); got != 1 {
- t.Errorf("scan_errors{error_type=cancelled} = %v, want 1 — the slow scanner was aborted by a sibling's block, not by its own timeout", got)
- }
- if got := testutil.ToFloat64(metrics.ScanErrors.WithLabelValues("npm", "slow", "timeout")); got != 0 {
- t.Errorf("scan_errors{error_type=timeout} = %v, want 0 — intra-group cancellation must not be mislabelled as a timeout", got)
- }
-}
-
-func TestGroup_Scan_WaitsForAllBlockScannersBeforeAllowing(t *testing.T) {
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "fast", result: Result{Allowed: true}}, mode: modeBlock},
- {scanner: &fakeScanner{name: "slow", delay: 30 * time.Millisecond, result: Result{Allowed: true}}, mode: modeBlock},
- }, false)
-
- start := time.Now()
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- elapsed := time.Since(start)
-
- if !result.Allowed {
- t.Error("Allowed = false, want true")
- }
- if elapsed < 30*time.Millisecond {
- t.Errorf("Scan() returned after %v, want it to wait for the slower block scanner", elapsed)
- }
-}
-
-func TestGroup_Scan_ErrorFailClosedByDefault(t *testing.T) {
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "flaky", err: errors.New("connection refused")}, mode: modeBlock},
- }, false)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- if result.Allowed {
- t.Error("Allowed = true, want false — default posture is fail-closed on scanner error")
- }
- if !result.InfraError {
- t.Error("InfraError = false, want true — the block came from a scanner call failure, not a verdict")
- }
- if !strings.Contains(result.Reason, "connection refused") {
- t.Errorf("Reason = %q, want it to include the underlying error for server-side logging", result.Reason)
- }
-}
-
-func TestGroup_Scan_ErrorFailOpen(t *testing.T) {
- g := newTestGroup([]entry{
- {scanner: &fakeScanner{name: "flaky", err: errors.New("connection refused")}, mode: modeBlock},
- }, true)
-
- result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
- if !result.Allowed {
- t.Error("Allowed = false, want true — FailOpen must treat scanner errors as allowed")
- }
-}
-
-func TestGroupBlocks(t *testing.T) {
- g, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- SigningKey: "key",
- Scanners: []config.ScannerConfig{
- {Name: "watch", URL: "http://watch.invalid", Mode: "monitor"},
- {Name: "gate", URL: "http://gate.invalid", Mode: "block", Ecosystems: []string{"npm"}},
- },
- }, slog.New(slog.NewTextHandler(io.Discard, nil)))
- if err != nil {
- t.Fatal(err)
- }
- if !g.Blocks("npm") {
- t.Error("Blocks(npm) = false with a block-mode scanner for npm")
- }
- if g.Blocks("pypi") {
- t.Error("Blocks(pypi) = true with only a monitor-mode scanner for it")
- }
- var none *Group
- if none.Blocks("npm") {
- t.Error("nil group blocks")
- }
-}
-
-func TestGroup_NormalisesEcosystemAliases(t *testing.T) {
- g, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"go", "docker", "rubygems"}},
- },
- }, discardLogger())
- if err != nil {
- t.Fatalf("NewGroup() error: %v", err)
- }
- for _, ecosystem := range []string{"golang", "oci", "gem", "go", "Docker", "rubygems"} {
- if !g.Blocks(ecosystem) {
- t.Errorf("Blocks(%s) = false, want true", ecosystem)
- }
- }
-}
-
-func TestNewGroup_RejectsUnknownEcosystem(t *testing.T) {
- _, err := NewGroup(config.ScanningConfig{
- Enabled: true,
- SigningKey: "test-signing-key",
- Scanners: []config.ScannerConfig{
- {Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"npm", "pypy"}},
- },
- }, discardLogger())
- if err == nil || !strings.Contains(err.Error(), "pypy") {
- t.Errorf("NewGroup() error = %v, want unknown ecosystem pypy", err)
- }
-}
diff --git a/internal/scanner/http.go b/internal/scanner/http.go
deleted file mode 100644
index 90a4e5e..0000000
--- a/internal/scanner/http.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package scanner
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
-)
-
-// HTTPScanner adapts an external HTTP scanning service to the Scanner
-// interface. It POSTs a small JSON notification describing the staged
-// artifact, including a signed fetch URL; the external service is
-// responsible for GETting that URL itself, running the real scan against
-// those bytes, and replying with a verdict before the request's deadline.
-//
-// Request body:
-//
-// {
-// "ecosystem": "npm", "name": "left-pad", "version": "1.0.0",
-// "filename": "left-pad-1.0.0.tgz", "purl": "pkg:npm/left-pad@1.0.0",
-// "content_type": "application/octet-stream", "size": 1234,
-// "fetch_url": "https://proxy.internal/_internal/scan-fetch?..."
-// }
-//
-// Response body:
-//
-// {
-// "allowed": true, "reason": "",
-// "findings": [{"severity": "high", "title": "...", "description": "..."}]
-// }
-//
-// Any compliant adapter — a trivy wrapper, a clamav-rest bridge, a Wiz
-// connector, or an in-house service — need only implement this contract.
-type HTTPScanner struct {
- name string
- url string
- headers map[string]string
- client *http.Client
-}
-
-// NewHTTPScanner creates an HTTPScanner named name that notifies url of
-// staged artifacts, attaching headers to every request (e.g. for auth).
-// If client is nil, http.DefaultClient is used.
-func NewHTTPScanner(name, url string, headers map[string]string, client *http.Client) *HTTPScanner {
- if client == nil {
- client = http.DefaultClient
- }
- return &HTTPScanner{name: name, url: url, headers: headers, client: client}
-}
-
-// Name returns the scanner's configured name.
-func (s *HTTPScanner) Name() string { return s.name }
-
-type httpScanResponse struct {
- Allowed bool `json:"allowed"`
- Reason string `json:"reason"`
- Findings []Finding `json:"findings"`
-}
-
-// Scan notifies the configured URL of req and waits for a verdict.
-func (s *HTTPScanner) Scan(ctx context.Context, req Request) (Result, error) {
- body, err := json.Marshal(req)
- if err != nil {
- return Result{}, fmt.Errorf("marshal scan request: %w", err)
- }
-
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
- if err != nil {
- return Result{}, fmt.Errorf("build scan request: %w", err)
- }
- httpReq.Header.Set("Content-Type", "application/json")
- for k, v := range s.headers {
- httpReq.Header.Set(k, v)
- }
-
- resp, err := s.client.Do(httpReq)
- if err != nil {
- return Result{}, fmt.Errorf("calling scanner %q: %w", s.name, err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return Result{}, fmt.Errorf("scanner %q returned status %d", s.name, resp.StatusCode)
- }
-
- var out httpScanResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- return Result{}, fmt.Errorf("decoding scanner %q response: %w", s.name, err)
- }
-
- return Result{
- Allowed: out.Allowed,
- Reason: out.Reason,
- Findings: out.Findings,
- }, nil
-}
diff --git a/internal/scanner/http_test.go b/internal/scanner/http_test.go
deleted file mode 100644
index 7597826..0000000
--- a/internal/scanner/http_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package scanner
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-)
-
-func TestHTTPScanner_Scan(t *testing.T) {
- tests := []struct {
- name string
- respStatus int
- respBody string
- wantAllowed bool
- wantReason string
- wantErr bool
- }{
- {
- name: "allowed",
- respStatus: http.StatusOK,
- respBody: `{"allowed": true}`,
- wantAllowed: true,
- },
- {
- name: "blocked with reason and findings",
- respStatus: http.StatusOK,
- respBody: `{"allowed": false, "reason": "malware detected", "findings": [{"severity": "high", "title": "EICAR", "description": "test signature"}]}`,
- wantAllowed: false,
- wantReason: "malware detected",
- },
- {
- name: "non-200 status is an error",
- respStatus: http.StatusInternalServerError,
- respBody: `{}`,
- wantErr: true,
- },
- {
- name: "malformed JSON is an error",
- respStatus: http.StatusOK,
- respBody: `not json`,
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var got Request
- if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
- t.Errorf("decode request body: %v", err)
- }
- if got.Ecosystem != "npm" || got.Name != "left-pad" || got.FetchURL == "" {
- t.Errorf("unexpected request body: %+v", got)
- }
- if r.Header.Get("Authorization") != "Bearer secret" {
- t.Errorf("missing/incorrect Authorization header: %q", r.Header.Get("Authorization"))
- }
-
- w.WriteHeader(tt.respStatus)
- _, _ = w.Write([]byte(tt.respBody))
- }))
- defer srv.Close()
-
- s := NewHTTPScanner("test", srv.URL, map[string]string{"Authorization": "Bearer secret"}, nil)
-
- result, err := s.Scan(context.Background(), Request{
- Ecosystem: "npm",
- Name: "left-pad",
- Version: "1.0.0",
- FetchURL: srv.URL + "/fetch",
- })
-
- if tt.wantErr {
- if err == nil {
- t.Fatalf("Scan() error = nil, want error")
- }
- return
- }
- if err != nil {
- t.Fatalf("Scan() unexpected error: %v", err)
- }
- if result.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v", result.Allowed, tt.wantAllowed)
- }
- if result.Reason != tt.wantReason {
- t.Errorf("Reason = %q, want %q", result.Reason, tt.wantReason)
- }
- })
- }
-}
-
-func TestHTTPScanner_Scan_ContextTimeout(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(50 * time.Millisecond)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"allowed": true}`))
- }))
- defer srv.Close()
-
- s := NewHTTPScanner("slow", srv.URL, nil, nil)
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
- defer cancel()
-
- _, err := s.Scan(ctx, Request{Ecosystem: "npm", Name: "left-pad"})
- if err == nil {
- t.Fatal("Scan() error = nil, want timeout error")
- }
-}
-
-func TestHTTPScanner_Name(t *testing.T) {
- s := NewHTTPScanner("clamav", "http://example.invalid", nil, nil)
- if got := s.Name(); got != "clamav" {
- t.Errorf("Name() = %q, want %q", got, "clamav")
- }
-}
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
deleted file mode 100644
index 380bb4d..0000000
--- a/internal/scanner/scanner.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// Package scanner provides pluggable pre-cache artifact scanning.
-//
-// A Scanner inspects an artifact staged in the proxy's own storage before
-// it becomes visible to clients, and returns a verdict on whether it may
-// be cached. The proxy never uploads artifact bytes to a scanner directly:
-// it hands the scanner a short-lived signed URL and the scanner pulls the
-// bytes itself. See HTTPScanner for the built-in adapter that implements
-// this over a small HTTP/JSON contract, letting trivy, ClamAV, Wiz, or any
-// custom service integrate without the proxy needing built-in knowledge of
-// any specific tool.
-package scanner
-
-import "context"
-
-// Request describes a staged artifact awaiting a scan verdict.
-type Request struct {
- Ecosystem string `json:"ecosystem"`
- Name string `json:"name"`
- Version string `json:"version"`
- Filename string `json:"filename"`
- PURL string `json:"purl"`
- ContentType string `json:"content_type"`
- Size int64 `json:"size"`
-
- // FetchURL is a short-lived signed URL the scanner must GET itself to
- // retrieve the exact bytes staged in the proxy's storage.
- FetchURL string `json:"fetch_url"`
-}
-
-// Finding describes a single issue reported by a scanner.
-type Finding struct {
- Severity string
- Title string
- Description string
-}
-
-// Result is a scanner's verdict for a Request.
-type Result struct {
- Allowed bool
- Reason string
- Findings []Finding
-
- // ScannerName identifies which scanner produced this result. Set by
- // Group, not by individual Scanner implementations.
- ScannerName string
-
- // InfraError reports whether Allowed: false was forced by a scanner
- // call failing (network error, timeout, bad response) rather than an
- // actual verdict from the scanner. Set by Group. Callers that surface
- // Reason to untrusted clients must not do so when this is true: it may
- // contain raw connection errors (internal hostnames, ports) instead of
- // a verdict meant to be shown outside the proxy.
- InfraError bool
-}
-
-// Scanner is the extension point for pluggable pre-cache scanning.
-type Scanner interface {
- // Name identifies this scanner in logs and metrics.
- Name() string
-
- // Scan requests a verdict for req. Implementations must respect ctx
- // cancellation: Group cancels in-flight scans once a blocking verdict
- // has already been decided by another scanner.
- Scan(ctx context.Context, req Request) (Result, error)
-}
diff --git a/internal/server/analytics.go b/internal/server/analytics.go
deleted file mode 100644
index 2e0e0ce..0000000
--- a/internal/server/analytics.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package server
-
-import (
- "fmt"
- "net/http"
- "strconv"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// analyticsTopSources caps how many callers the page lists; the rest are
-// summarised in the overflow row, and the access log has every one of them.
-const analyticsTopSources = 15
-
-// AnalyticsData contains data for rendering the analytics dashboard.
-type AnalyticsData struct {
- Layout
- Totals AnalyticsTotals
- Donut DonutView
- EnrichmentStats EnrichmentStatsView
- Ecosystems []EcosystemRow
- Metadata MetadataCacheView
- Runtime RuntimeView
- // StatsFailed records that the per-ecosystem query errored, so the page
- // can say the figures are unavailable instead of claiming an empty cache.
- StatsFailed bool
- // StatsStale is set when the query errored but a previous snapshot was
- // retained and is being shown. Without it a database that has been down
- // for an hour renders hour-old figures as current, with the failure
- // visible only in the logs.
- StatsStale bool
- // StatsAge is when the retained snapshot was read, phrased for display.
- StatsAge string
-}
-
-// AnalyticsTotals holds the headline figures across every ecosystem.
-type AnalyticsTotals struct {
- DownloadedBytes int64
- Downloaded string
- Downloads string
- CacheSize string
- CachedArtifacts string
- Packages string
- Versions string
- Ecosystems int
- ActiveEcosystems int
- // Amplification is accumulated download volume divided by the bytes
- // currently held in cache: how many times over the cache has served
- // what it stores. Empty when nothing is cached.
- Amplification string
-}
-
-// EcosystemRow is one ecosystem's row in the analytics table.
-type EcosystemRow struct {
- Ecosystem string
- DownloadedBytes int64
- Downloaded string
- Downloads string
- CacheSize string
- AvgArtifactSize string
- Artifacts string
- Packages string
- Versions string
- // SharePct is this ecosystem's share of the accumulated download total.
- SharePct string
-}
-
-// MetadataCacheView summarises the metadata cache, which is stored separately
-// from artifacts and so appears in none of the artifact figures.
-type MetadataCacheView struct {
- // Enabled reports whether cache_metadata is on. Entries can remain from
- // an earlier run with it on, so both are shown independently.
- Enabled bool
- // Failed records that the query errored, so the card can say so rather
- // than claim the cache is empty.
- Failed bool
- Entries string
- Size string
- Rows []MetadataCacheRow
-}
-
-// MetadataCacheRow is one ecosystem's share of the metadata cache.
-type MetadataCacheRow struct {
- Ecosystem string
- Entries string
- Size string
- SharePct string
-}
-
-// metadataCacheView turns per-ecosystem metadata cache rows into the card's
-// totals and table.
-func metadataCacheView(stats []database.MetadataCacheStats, enabled bool) MetadataCacheView {
- var entries, size int64
- for _, e := range stats {
- entries += e.Entries
- size += e.Size
- }
-
- v := MetadataCacheView{
- Enabled: enabled,
- Entries: formatCount(entries),
- Size: formatSize(size),
- Rows: make([]MetadataCacheRow, 0, len(stats)),
- }
- for _, e := range stats {
- v.Rows = append(v.Rows, MetadataCacheRow{
- Ecosystem: e.Ecosystem,
- Entries: formatCount(e.Entries),
- Size: formatSize(e.Size),
- SharePct: formatPercent(e.Size, size),
- })
- }
- return v
-}
-
-func formatCount(n int64) string {
- s := strconv.FormatInt(n, 10)
- neg := strings.HasPrefix(s, "-")
- if neg {
- s = s[1:]
- }
-
- var b strings.Builder
- for i, digit := range s {
- if i > 0 && (len(s)-i)%3 == 0 {
- b.WriteByte(',')
- }
- b.WriteRune(digit)
- }
- if neg {
- return "-" + b.String()
- }
- return b.String()
-}
-
-// percentOf renders part/whole as a percentage with one decimal place, always
-// as a bare number. formatPercent wraps it to catch the case where a real
-// share rounds away to zero.
-func percentOf(part, whole int64) string {
- if whole <= 0 {
- return "0.0"
- }
- return strconv.FormatFloat(float64(part)/float64(whole)*100, 'f', 1, 64) //nolint:mnd // percent
-}
-
-// formatPercent renders a share for display. A share that is real but rounds
-// to zero reads as "<0.1" rather than "0.0", which would claim the ecosystem
-// served nothing.
-func formatPercent(part, whole int64) string {
- pct := percentOf(part, whole)
- if pct == "0.0" && part > 0 && whole > 0 {
- return "<0.1"
- }
- return pct
-}
-
-// formatRatio renders an "N.Nx" multiplier, or "" when the denominator is zero.
-func formatRatio(numerator, denominator int64) string {
- if denominator <= 0 {
- return ""
- }
- return fmt.Sprintf("%.1fx", float64(numerator)/float64(denominator))
-}
-
-func avgArtifactSize(cacheSize, artifacts int64) string {
- if artifacts <= 0 {
- return "0 B"
- }
- return formatSize(cacheSize / artifacts)
-}
-
-// enrichmentStatsView maps enrichment totals onto the shared view struct used
-// by both the dashboard and the analytics page.
-func enrichmentStatsView(stats *database.EnrichmentStats) EnrichmentStatsView {
- return EnrichmentStatsView{
- EnrichedPackages: stats.EnrichedPackages,
- VulnSyncedPackages: stats.VulnSyncedPackages,
- TotalVulnerabilities: stats.TotalVulnerabilities,
- CriticalVulns: stats.CriticalVulns,
- HighVulns: stats.HighVulns,
- MediumVulns: stats.MediumVulns,
- LowVulns: stats.LowVulns,
- HasVulns: stats.TotalVulnerabilities > 0,
- }
-}
-
-// handleAnalytics renders the analytics dashboard: accumulated download volume
-// in total and per ecosystem, alongside the cache and enrichment figures the
-// main dashboard reports.
-func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
- ecosystems, err := s.ecoStats.Get(s.db)
- statsFailed := err != nil
- if err != nil {
- s.logger.Error("failed to get ecosystem stats", "error", err)
- }
-
- enrichStats, err := s.db.GetEnrichmentStats()
- if err != nil {
- s.logger.Error("failed to get enrichment stats", "error", err)
- enrichStats = &database.EnrichmentStats{}
- }
-
- data := AnalyticsData{
- Layout: s.layoutFor(r),
- EnrichmentStats: enrichmentStatsView(enrichStats),
- }
- data.StatsFailed = statsFailed
- data.StatsStale = statsFailed && len(ecosystems) > 0
- if data.StatsStale {
- data.StatsAge = formatTimeAgo(s.ecoStats.SnapshotAt())
- }
- data.Totals, data.Ecosystems = analyticsView(ecosystems)
- data.Donut = donutView(ecosystems, data.Totals.DownloadedBytes, data.Totals.Downloaded)
-
- // The same 60-second snapshot the metadata cache gauges are published
- // from, so a page load costs no query of its own. A failed read keeps the
- // last good rows, which the card shows in preference to the error.
- metadataStats, err := s.metaStats.Get(s.db)
- if err != nil {
- s.logger.Error("failed to get metadata cache stats", "error", err)
- }
- data.Metadata = metadataCacheView(metadataStats, s.cfg != nil && s.cfg.CacheMetadata)
- data.Metadata.Failed = err != nil
-
- // Process-lifetime counters come from the Prometheus registry rather than
- // the database; a failure here must not take the page down with it.
- snap, err := metrics.Gather()
- if err != nil {
- s.logger.Error("failed to gather runtime metrics", "error", err)
- } else {
- data.Runtime = runtimeView(snap, s.cfg != nil && s.cfg.UIFailureDetails)
- if s.cfg != nil && s.cfg.UIRequestSources {
- data.Runtime.SourcesOn = true
- data.Runtime.Sources = s.sources.Top(analyticsTopSources)
- data.Runtime.SourceCount = s.sources.Count()
- data.Runtime.TrustsForward = s.trustsForwardedFor()
- }
- }
-
- if err := s.templates.Render(w, "analytics", data); err != nil {
- s.logger.Error("failed to render analytics", "error", err)
- }
-}
-
-// analyticsView turns per-ecosystem database rows into the rendered totals and
-// table rows, preserving the order they arrive in.
-func analyticsView(stats []database.EcosystemStats) (AnalyticsTotals, []EcosystemRow) {
- var totals AnalyticsTotals
- var downloads, cacheSize, artifacts, packages, versions int64
-
- for _, e := range stats {
- totals.DownloadedBytes += e.DownloadedBytes
- downloads += e.Downloads
- cacheSize += e.CacheSize
- artifacts += e.Artifacts
- packages += e.Packages
- versions += e.Versions
- if e.DownloadedBytes > 0 {
- totals.ActiveEcosystems++
- }
- }
-
- totals.Downloaded = formatSize(totals.DownloadedBytes)
- totals.Downloads = formatCount(downloads)
- totals.CacheSize = formatSize(cacheSize)
- totals.CachedArtifacts = formatCount(artifacts)
- totals.Packages = formatCount(packages)
- totals.Versions = formatCount(versions)
- totals.Ecosystems = len(stats)
- totals.Amplification = formatRatio(totals.DownloadedBytes, cacheSize)
-
- rows := make([]EcosystemRow, 0, len(stats))
- for _, e := range stats {
- rows = append(rows, EcosystemRow{
- Ecosystem: e.Ecosystem,
- DownloadedBytes: e.DownloadedBytes,
- Downloaded: formatSize(e.DownloadedBytes),
- Downloads: formatCount(e.Downloads),
- CacheSize: formatSize(e.CacheSize),
- AvgArtifactSize: avgArtifactSize(e.CacheSize, e.Artifacts),
- Artifacts: formatCount(e.Artifacts),
- Packages: formatCount(e.Packages),
- Versions: formatCount(e.Versions),
- SharePct: formatPercent(e.DownloadedBytes, totals.DownloadedBytes),
- })
- }
- return totals, rows
-}
diff --git a/internal/server/analytics_coverage_test.go b/internal/server/analytics_coverage_test.go
deleted file mode 100644
index 02e67e2..0000000
--- a/internal/server/analytics_coverage_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package server
-
-import (
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus"
-)
-
-// metricSurface records where each registered metric is shown on /ui/analytics.
-//
-// This is a standing obligation, not a snapshot: registering a new metric
-// anywhere in internal/metrics fails the build here until it has both a tile on
-// the page and an entry below. That is the point -- the page is meant to be a
-// complete view of /metrics -- but it is a cost paid by every future metric, so
-// it is written down rather than discovered.
-var metricSurface = map[string]string{
- // Database-derived, shown in the donut, the KPI row and the breakdown table.
- "proxy_ecosystem_downloaded_bytes": "donut + breakdown table",
- "proxy_ecosystem_artifact_downloads": "Downloads tile + breakdown table",
- "proxy_ecosystem_cache_size_bytes": "breakdown table",
- "proxy_ecosystem_cached_artifacts": "breakdown table",
- "proxy_ecosystem_packages": "breakdown table",
- "proxy_ecosystem_versions": "breakdown table",
- "proxy_cache_size_bytes": "Cache size tile",
- "proxy_cached_artifacts_total": "Cached artifacts tile",
- "proxy_metadata_cache_size_bytes": "Metadata cache card",
- "proxy_metadata_cache_entries": "Metadata cache card",
-
- // Request-path counters.
- "proxy_response_bytes_total": "Runtime: Served",
- "proxy_client_requests_total": "Runtime: Request sources -- By client",
- "proxy_client_response_bytes_total": "Runtime: Request sources -- By client",
-
- // Registry-derived, shown in the Runtime card.
- "proxy_requests_total": "Runtime: Requests + Responses by status",
- "proxy_request_duration_seconds": "Runtime: Mean latency",
- "proxy_active_requests": "Runtime: In flight",
- "proxy_cache_hits_total": "Runtime: Artifact and metadata cache lookups + artifact hit rate",
- "proxy_cache_misses_total": "Runtime: Artifact and metadata cache lookups + artifact hit rate",
- "proxy_metadata_cache_served_bytes_total": "Runtime: Metadata cache lookups -- served from cache",
- "proxy_upstream_fetch_duration_seconds": "Runtime: Upstream fetches + mean fetch",
- "proxy_upstream_errors_total": "Runtime: Upstream errors",
- "proxy_storage_operation_duration_seconds": "Runtime: Storage operations",
- "proxy_storage_errors_total": "Runtime: Storage errors",
- "proxy_integrity_failures_total": "Runtime: Integrity failures",
- "proxy_health_probe_failures_total": "Runtime: Health probe failures",
- "proxy_circuit_breaker_state": "Runtime: Circuit breakers",
- "proxy_circuit_breaker_trips_total": "Runtime: Circuit breaker trips",
- "proxy_artifacts_evicted_total": "Runtime: Evicted artifacts",
- "proxy_metadata_evicted_total": "Runtime: Evicted metadata",
- "proxy_metadata_expired_by_change_feed_total": "Runtime: Metadata changed upstream",
- "proxy_scan_duration_seconds": "Runtime: Pre-cache scanning -- Scans",
- "proxy_scan_blocked_total": "Runtime: Pre-cache scanning -- Artifacts blocked",
- "proxy_scan_errors_total": "Runtime: Pre-cache scanning -- Scan errors",
-}
-
-// TestEveryMetricIsSurfaced fails when a metric is registered but has no home
-// on the analytics page. The page is meant to be a complete view of what
-// /metrics exposes, so a silently unsurfaced metric is a gap, not a detail.
-func TestEveryMetricIsSurfaced(t *testing.T) {
- // Touch every metric family so it is present in the registry output, since
- // a vector with no observed label values gathers as nothing at all.
- metrics.RecordRequest("npm", 200, 0)
- metrics.RecordResponse("npm", "npm", 1)
- metrics.RecordArtifactCacheHit("npm")
- metrics.RecordArtifactCacheMiss("npm")
- metrics.RecordMetadataCacheHit("npm", 1)
- metrics.RecordMetadataCacheMiss("npm")
- metrics.RecordUpstreamFetch("npm", 0)
- metrics.RecordUpstreamError("npm", "fetch_failed", metrics.FailureDetail{})
- metrics.RecordStorageOperation("get", 0)
- metrics.RecordStorageError("get", metrics.FailureDetail{})
- metrics.RecordIntegrityFailure("npm", metrics.FailureDetail{})
- metrics.RecordHealthProbeFailure("write", metrics.FailureDetail{})
- metrics.UpdateCircuitBreakerState("example.test", 0)
- metrics.RecordCircuitBreakerTrip("example.test")
- metrics.RecordScanResult("npm", "clamav", false, 0)
- metrics.RecordScanError("npm", "clamav", "timeout", metrics.FailureDetail{})
- metrics.UpdateCacheStats(1, 1)
- metrics.RecordArtifactEvicted("retention", "npm")
- metrics.RecordMetadataEvicted("npm")
- metrics.RecordMetadataExpiredByChangeFeed("npm", 1)
- metrics.UpdateEcosystemStats([]metrics.EcosystemStats{{Ecosystem: "npm", DownloadedBytes: 1}})
- metrics.UpdateMetadataCacheStats([]metrics.MetadataCacheStats{{Ecosystem: "npm", Entries: 1, Size: 1}})
-
- families, err := prometheus.DefaultGatherer.Gather()
- if err != nil {
- t.Fatalf("Gather: %v", err)
- }
-
- var registered []string
- for _, family := range families {
- name := family.GetName()
- if strings.HasPrefix(name, "proxy_") {
- registered = append(registered, name)
- }
- }
- if len(registered) == 0 {
- t.Fatal("no proxy_ metrics in the registry; the probe above is not working")
- }
-
- for _, name := range registered {
- if _, ok := metricSurface[name]; !ok {
- t.Errorf("%s is registered but has no home on /ui/analytics; "+
- "surface it and add it to metricSurface", name)
- }
- }
-
- for name := range metricSurface {
- found := false
- for _, r := range registered {
- if r == name {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("metricSurface lists %s, but it is not registered any more; drop the entry", name)
- }
- }
-}
diff --git a/internal/server/analytics_donut.go b/internal/server/analytics_donut.go
deleted file mode 100644
index 59abc85..0000000
--- a/internal/server/analytics_donut.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package server
-
-import (
- "math"
- "strconv"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// Donut geometry, in SVG user units. The ring is drawn as a dashed stroke on a
-// circle rather than as arc paths: one dash per slice, which makes the 2px
-// surface gap between slices fall out of the dash arithmetic.
-const (
- donutSize = 260.0
- donutRadius = 104.0
- donutStroke = 30.0
- // donutGap is the surface-coloured separator between touching slices.
- donutGap = 2.0
- // donutMinSlice keeps a slice that rounds to nothing from vanishing
- // entirely; the legend and table carry its real value.
- donutMinSlice = 1.5
- donutCircumference = 2 * math.Pi * donutRadius //nolint:mnd // circumference
-)
-
-// donutSlices is the most slices the ring will draw. Part-to-whole reads at a
-// glance only while the segment count stays small, so past this the tail is
-// folded into a single "Other" slice and the full detail lives in the table
-// below the chart.
-const donutSlices = 6
-
-const otherSliceLabel = "Other"
-
-// DonutSlice is one arc of the ring plus its legend row.
-type DonutSlice struct {
- // Index selects the fixed categorical slot. The hues themselves live in
- // the .donut-slot-N and .swatch-N rules in the analytics template, so the
- // validated palette has exactly one definition; slots are assigned here in
- // sequence and never cycled.
- Index int
- Label string
- Value string
- SharePct string
- Downloads string
- CacheSize string
- Dash string
- Gap string
- Offset string
- // IsOther marks the folded tail, which has no single ecosystem behind it.
- IsOther bool
- // Members lists what was folded in, for the tooltip.
- Members string
-}
-
-// DonutView is the whole chart: the ring, its centre figure and its legend.
-type DonutView struct {
- Slices []DonutSlice
- // Size, Radius, Stroke and Center are handed to the template so the SVG
- // geometry has exactly one definition.
- Size string
- Radius string
- Stroke string
- Center string
- // CenterValue is the accumulated download size across every ecosystem,
- // stated in the middle of the ring.
- CenterValue string
- CenterLabel string
- HasSlices bool
-}
-
-// donutView folds the per-ecosystem rows into at most donutSlices arcs and
-// computes the dash geometry for each.
-func donutView(stats []database.EcosystemStats, totalBytes int64, centerValue string) DonutView {
- view := DonutView{
- Size: trimFloat(donutSize),
- Radius: trimFloat(donutRadius),
- Stroke: trimFloat(donutStroke),
- Center: trimFloat(donutSize / 2), //nolint:mnd // the centre of the viewBox
- CenterValue: centerValue,
- CenterLabel: "accumulated download size",
- }
-
- // Only ecosystems that have actually served bytes get an arc; an
- // ecosystem at zero would be an invisible slice with a legend entry
- // claiming a share it does not have.
- active := make([]database.EcosystemStats, 0, len(stats))
- for _, e := range stats {
- if e.DownloadedBytes > 0 {
- active = append(active, e)
- }
- }
- if len(active) == 0 || totalBytes <= 0 {
- return view
- }
-
- head, tail := active, []database.EcosystemStats(nil)
- if len(active) > donutSlices {
- head, tail = active[:donutSlices-1], active[donutSlices-1:]
- }
-
- slices := make([]DonutSlice, 0, donutSlices)
- for i, e := range head {
- slices = append(slices, DonutSlice{
- Index: i,
- Label: ecosystemBadgeLabel(e.Ecosystem),
- Value: formatSize(e.DownloadedBytes),
- SharePct: formatPercent(e.DownloadedBytes, totalBytes),
- Downloads: formatCount(e.Downloads),
- CacheSize: formatSize(e.CacheSize),
- })
- }
-
- if len(tail) > 0 {
- var bytes, downloads, cacheSize int64
- members := ""
- for i, e := range tail {
- bytes += e.DownloadedBytes
- downloads += e.Downloads
- cacheSize += e.CacheSize
- if i > 0 {
- members += ", "
- }
- members += ecosystemBadgeLabel(e.Ecosystem)
- }
- slices = append(slices, DonutSlice{
- Index: donutSlices - 1,
- Label: otherSliceLabel,
- Value: formatSize(bytes),
- SharePct: formatPercent(bytes, totalBytes),
- Downloads: formatCount(downloads),
- CacheSize: formatSize(cacheSize),
- IsOther: true,
- Members: members,
- })
- }
-
- applyDonutGeometry(slices, active, tail, totalBytes)
- view.Slices = slices
- view.HasSlices = true
- return view
-}
-
-// applyDonutGeometry sets each slice's dash length and offset.
-func applyDonutGeometry(slices []DonutSlice, active, tail []database.EcosystemStats, totalBytes int64) {
- // Recover each slice's byte value in the same order the slices were built,
- // so the geometry is driven by the numbers rather than by the rendered text.
- values := make([]int64, 0, len(slices))
- head := active
- if len(tail) > 0 {
- head = active[:len(active)-len(tail)]
- }
- for _, e := range head {
- values = append(values, e.DownloadedBytes)
- }
- if len(tail) > 0 {
- var sum int64
- for _, e := range tail {
- sum += e.DownloadedBytes
- }
- values = append(values, sum)
- }
-
- dashes := make([]float64, len(slices))
- widest := 0
- for i := range slices {
- arc := donutCircumference * float64(values[i]) / float64(totalBytes)
-
- dashes[i] = arc - donutGap
- if dashes[i] < donutMinSlice {
- dashes[i] = donutMinSlice
- }
- if dashes[i] > dashes[widest] {
- widest = i
- }
- }
-
- // Widening a sliver to donutMinSlice buys visibility with room the ring
- // does not have, and the overshoot would wrap the last slice back over the
- // first. The widest slice gives the space back: it is the only one that can
- // lose a couple of units without becoming unreadable, and at six slices the
- // most that can be owed is donutSlices*(donutMinSlice+donutGap), far less
- // than the circumference.
- var needed float64
- for _, d := range dashes {
- needed += d + donutGap
- }
- if excess := needed - donutCircumference; excess > 0 {
- dashes[widest] = math.Max(donutMinSlice, dashes[widest]-excess)
- }
-
- var consumed float64
- for i := range slices {
- slices[i].Dash = trimFloat(dashes[i])
- slices[i].Gap = trimFloat(donutCircumference - dashes[i])
- // A dashoffset runs backwards around the circle, so the running total
- // is negated to lay slices out clockwise from twelve o'clock.
- slices[i].Offset = trimFloat(-consumed)
-
- // Advance by what is drawn: advancing by the smaller true arc would
- // start the next slice underneath a widened one, and the later colour
- // would win.
- consumed += dashes[i] + donutGap
- }
-}
-
-// trimFloat renders an SVG coordinate without trailing zeroes.
-func trimFloat(f float64) string {
- return strconv.FormatFloat(f, 'f', -1, 64)
-}
diff --git a/internal/server/analytics_donut_test.go b/internal/server/analytics_donut_test.go
deleted file mode 100644
index d2e8738..0000000
--- a/internal/server/analytics_donut_test.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package server
-
-import (
- "math"
- "strconv"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-func eco(name string, bytes int64) database.EcosystemStats {
- return database.EcosystemStats{Ecosystem: name, DownloadedBytes: bytes, Downloads: 1, CacheSize: bytes / 2}
-}
-
-func TestDonutViewFoldsTailIntoOther(t *testing.T) {
- // Nine active ecosystems: five keep their own slice, four fold into "Other".
- stats := []database.EcosystemStats{
- eco("npm", 900), eco("pypi", 800), eco("maven", 700), eco("cargo", 600),
- eco("gem", 500), eco("golang", 40), eco("nuget", 30), eco("deb", 20), eco("conda", 10),
- }
- var total int64
- for _, e := range stats {
- total += e.DownloadedBytes
- }
-
- view := donutView(stats, total, formatSize(total))
-
- if len(view.Slices) != donutSlices {
- t.Fatalf("expected %d slices, got %d", donutSlices, len(view.Slices))
- }
-
- last := view.Slices[donutSlices-1]
- if !last.IsOther {
- t.Fatalf("expected the final slice to be the folded tail, got %+v", last)
- }
- // 40 + 30 + 20 + 10 = 100
- if last.Value != formatSize(100) {
- t.Errorf("Other value = %q, want %q", last.Value, formatSize(100))
- }
- if last.Members != "golang, nuget, debian, conda" {
- t.Errorf("Other members = %q, want the four folded ecosystems by display name", last.Members)
- }
- // "Other" must take the last slot, never one of a real ecosystem's.
- if last.Index != donutSlices-1 {
- t.Errorf("Other took slot %d, want %d", last.Index, donutSlices-1)
- }
- for i, s := range view.Slices[:donutSlices-1] {
- if s.Index != i {
- t.Errorf("slice %d took slot %d; slots must be assigned in sequence", i, s.Index)
- }
- }
-}
-
-func TestDonutViewKeepsEveryEcosystemWhenFewEnough(t *testing.T) {
- stats := []database.EcosystemStats{eco("npm", 600), eco("pypi", 400)}
- view := donutView(stats, 1000, "1000 B")
-
- if len(view.Slices) != 2 {
- t.Fatalf("expected 2 slices, got %d", len(view.Slices))
- }
- for _, s := range view.Slices {
- if s.IsOther {
- t.Errorf("nothing should be folded with only 2 ecosystems, got %+v", s)
- }
- }
- if view.Slices[0].SharePct != "60.0" || view.Slices[1].SharePct != "40.0" {
- t.Errorf("shares = %q / %q, want 60.0 / 40.0", view.Slices[0].SharePct, view.Slices[1].SharePct)
- }
-}
-
-// The ring must account for the whole circle: the arcs, plus one gap each,
-// have to add back up to the circumference.
-func TestDonutGeometryCoversTheCircle(t *testing.T) {
- stats := []database.EcosystemStats{eco("npm", 500), eco("pypi", 300), eco("maven", 200)}
- view := donutView(stats, 1000, "1000 B")
-
- circumference := 2 * math.Pi * donutRadius
-
- var covered float64
- for _, s := range view.Slices {
- dash, err := strconv.ParseFloat(s.Dash, 64)
- if err != nil {
- t.Fatalf("dash %q: %v", s.Dash, err)
- }
- gap, err := strconv.ParseFloat(s.Gap, 64)
- if err != nil {
- t.Fatalf("gap %q: %v", s.Gap, err)
- }
- // Each slice is drawn as one dash followed by a gap spanning the
- // rest of the circle, so the pair always sums to the circumference.
- if math.Abs(dash+gap-circumference) > 0.01 {
- t.Errorf("slice %q: dash+gap = %v, want the circumference %v", s.Label, dash+gap, circumference)
- }
- covered += dash + donutGap
- }
-
- if math.Abs(covered-circumference) > 0.01 {
- t.Errorf("arcs plus gaps cover %v, want the full circumference %v", covered, circumference)
- }
-}
-
-// Offsets must advance monotonically so slices sit end to end instead of
-// stacking on top of each other.
-func TestDonutOffsetsAdvanceInOrder(t *testing.T) {
- stats := []database.EcosystemStats{eco("npm", 500), eco("pypi", 300), eco("maven", 200)}
- view := donutView(stats, 1000, "1000 B")
-
- circumference := 2 * math.Pi * donutRadius
- want := []float64{0, -circumference * 0.5, -circumference * 0.8}
-
- for i, s := range view.Slices {
- got, err := strconv.ParseFloat(s.Offset, 64)
- if err != nil {
- t.Fatalf("offset %q: %v", s.Offset, err)
- }
- if math.Abs(got-want[i]) > 0.01 {
- t.Errorf("slice %d offset = %v, want %v", i, got, want[i])
- }
- }
-}
-
-// A slice too small to render still has to be visible; the legend and table
-// carry its real value.
-func TestDonutTinySliceStaysVisible(t *testing.T) {
- stats := []database.EcosystemStats{eco("npm", 10_000_000), eco("composer", 1)}
- view := donutView(stats, 10_000_001, "9.5 MB")
-
- tiny := view.Slices[1]
- dash, err := strconv.ParseFloat(tiny.Dash, 64)
- if err != nil {
- t.Fatalf("dash %q: %v", tiny.Dash, err)
- }
- if dash < donutMinSlice {
- t.Errorf("tiny slice dash = %v, want at least %v so it stays visible", dash, donutMinSlice)
- }
- if tiny.SharePct != "<0.1" {
- t.Errorf("tiny slice share = %q, want %q", tiny.SharePct, "<0.1")
- }
-}
-
-func TestDonutViewEmpty(t *testing.T) {
- if view := donutView(nil, 0, "0 B"); view.HasSlices || len(view.Slices) != 0 {
- t.Errorf("expected an empty ring, got %+v", view)
- }
-
- // Ecosystems that exist but have served nothing get no arc at all.
- idle := []database.EcosystemStats{{Ecosystem: "rpm", Packages: 3}}
- if view := donutView(idle, 0, "0 B"); view.HasSlices {
- t.Errorf("an ecosystem with no traffic must not get a slice, got %+v", view.Slices)
- }
-}
-
-// A slice widened to stay visible must not be drawn over by its successor:
-// the next offset has to start where this slice actually ends.
-func TestDonutClampedSliceDoesNotOverdrawItsSuccessor(t *testing.T) {
- // Four slivers far below the minimum, followed by a large slice.
- stats := []database.EcosystemStats{
- eco("npm", 10_000_000), eco("a", 1), eco("b", 1), eco("c", 1), eco("d", 1),
- }
- view := donutView(stats, 10_000_004, "9.5 MB")
-
- for i := 0; i < len(view.Slices)-1; i++ {
- dash := mustFloat(t, view.Slices[i].Dash)
- start := -mustFloat(t, view.Slices[i].Offset)
- nextStart := -mustFloat(t, view.Slices[i+1].Offset)
-
- end := start + dash
- if nextStart < end-0.001 {
- t.Errorf("slice %d (%q) is drawn to %v but slice %d starts at %v: they overlap",
- i, view.Slices[i].Label, end, i+1, nextStart)
- }
- }
-}
-
-func mustFloat(t *testing.T, s string) float64 {
- t.Helper()
- f, err := strconv.ParseFloat(s, 64)
- if err != nil {
- t.Fatalf("parsing %q: %v", s, err)
- }
- return f
-}
-
-// Widening slivers must not push the ring past a full turn: the last slice
-// would wrap back over the first and repaint the leading ecosystem's arc.
-func TestDonutNeverExceedsOneTurn(t *testing.T) {
- // One dominant ecosystem and five far below the visible minimum.
- stats := []database.EcosystemStats{
- eco("npm", 100_000_000),
- eco("a", 1), eco("b", 1), eco("c", 1), eco("d", 1), eco("e", 1),
- }
- view := donutView(stats, 100_000_005, "95.4 MB")
-
- if len(view.Slices) != 6 {
- t.Fatalf("expected 6 slices, got %d", len(view.Slices))
- }
-
- last := view.Slices[len(view.Slices)-1]
- end := -mustFloat(t, last.Offset) + mustFloat(t, last.Dash)
- if end > donutCircumference+0.001 {
- t.Errorf("ring is drawn to %v, past the circumference %v: the last slice "+
- "wraps over the first", end, donutCircumference)
- }
-
- // Every sliver still has to be visible.
- for _, s := range view.Slices[1:] {
- if dash := mustFloat(t, s.Dash); dash < donutMinSlice {
- t.Errorf("slice %q dash = %v, want at least %v", s.Label, dash, donutMinSlice)
- }
- }
-}
diff --git a/internal/server/analytics_failures_test.go b/internal/server/analytics_failures_test.go
deleted file mode 100644
index aa89cb5..0000000
--- a/internal/server/analytics_failures_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package server
-
-import (
- "errors"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus"
-)
-
-func storageErrorSnapshot(t *testing.T, operation string) *metrics.Snapshot {
- t.Helper()
- reg := prometheus.NewRegistry()
- errs := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_storage_errors_total", Help: "t"}, []string{"operation"})
- reg.MustRegister(errs)
- errs.WithLabelValues(operation).Inc()
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- return snap
-}
-
-func TestRuntimeViewAttachesRecentFailuresWhenEnabled(t *testing.T) {
- const operation = "test-attach"
- metrics.RecordStorageError(operation, metrics.FailureDetail{
- PURL: "pkg:npm/left-pad@1.0.0", Filename: "left-pad-1.0.0.tgz", Path: "npm/left-pad", Err: errors.New("disk full")})
-
- v := runtimeView(storageErrorSnapshot(t, operation), true)
-
- if !v.FailureDetailsOn {
- t.Error("FailureDetailsOn = false, want true")
- }
- if len(v.StorageErrors) != 1 || len(v.StorageErrors[0].Recent) == 0 {
- t.Fatalf("StorageErrors = %+v, want one row with recent failures", v.StorageErrors)
- }
- got := v.StorageErrors[0].Recent[0]
- if got.PURL != "pkg:npm/left-pad@1.0.0" || got.Filename != "left-pad-1.0.0.tgz" ||
- got.Path != "npm/left-pad" || got.Error != "disk full" || got.Age == "" {
- t.Errorf("recent failure = %+v", got)
- }
-}
-
-func TestRuntimeViewOmitsRecentFailuresWhenDisabled(t *testing.T) {
- const operation = "test-omit"
- metrics.RecordStorageError(operation, metrics.FailureDetail{PURL: "pkg:npm/left-pad@1.0.0"})
-
- v := runtimeView(storageErrorSnapshot(t, operation), false)
-
- if v.FailureDetailsOn {
- t.Error("FailureDetailsOn = true, want false")
- }
- if len(v.StorageErrors) != 1 || v.StorageErrors[0].Recent != nil {
- t.Errorf("StorageErrors = %+v, want the count without recent failures", v.StorageErrors)
- }
-}
-
-// Package names and upstream URLs say what the proxy's users depend on, so like
-// the caller table they are published only when asked for.
-func TestAnalyticsPageHidesFailureDetailsByDefault(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- if ts.server.cfg.UIFailureDetails {
- t.Fatal("UIFailureDetails defaults to true; it must default off")
- }
- metrics.RecordUpstreamError("npm", "fetch_failed", metrics.FailureDetail{PURL: "pkg:npm/hidden-by-default@1.0.0"})
-
- body := ts.getOK(t, "/ui/analytics")
- if strings.Contains(body, "hidden-by-default") {
- t.Error("a failed package rendered with ui_failure_details off")
- }
- if !strings.Contains(body, "ui_failure_details") {
- t.Error("the off-state note naming the config key did not render")
- }
-}
-
-func TestAnalyticsPageShowsFailureDetailsWhenEnabled(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- ts.server.cfg.UIFailureDetails = true
- metrics.RecordUpstreamError("npm", "fetch_failed", metrics.FailureDetail{
- PURL: "pkg:npm/shown-when-enabled@1.0.0",
- URL: "https://registry.example.com/shown-when-enabled.tgz?token=secret",
- Err: errors.New("upstream returned 503"),
- })
-
- body := ts.getOK(t, "/ui/analytics")
- for _, want := range []string{
- "pkg:npm/shown-when-enabled@1.0.0",
- "https://registry.example.com/shown-when-enabled.tgz",
- "upstream returned 503",
- } {
- if !strings.Contains(body, want) {
- t.Errorf("rendered page missing %q", want)
- }
- }
- if strings.Contains(body, "token=secret") {
- t.Error("rendered page leaks the URL's query string")
- }
-}
diff --git a/internal/server/analytics_runtime.go b/internal/server/analytics_runtime.go
deleted file mode 100644
index 103e33e..0000000
--- a/internal/server/analytics_runtime.go
+++ /dev/null
@@ -1,375 +0,0 @@
-package server
-
-import (
- "fmt"
- "sort"
- "strconv"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// RuntimeView holds the process-lifetime figures read out of the Prometheus
-// registry. Unlike the cache figures, which are computed from the database and
-// survive a restart, everything here starts at zero when the proxy starts.
-type RuntimeView struct {
- Available bool
-
- Requests string
- ActiveRequests string
- RequestMean string
- StatusClasses []LabelledCount
- // Artifact and metadata lookups are counted apart: they are separate
- // caches. Only artifacts feed the database figures above.
- ArtifactCache CacheLookups
- MetadataCache CacheLookups
- MetadataServed string
-
- UpstreamFetches string
- UpstreamFetchMean string
- UpstreamErrors []LabelledCount
-
- StorageOps []LabelledStat
- StorageErrors []LabelledCount
- IntegrityFailures []LabelledCount
- ProbeFailures []LabelledCount
- Evictions []LabelledCount
- MetadataEvictions []LabelledCount
- ChangeFeedExpired []LabelledCount
-
- Breakers []BreakerRow
- BreakerTrips []LabelledCount
-
- Scans []LabelledStat
- ScansBlocked []LabelledCount
- ScanErrors []LabelledCount
- ScanningOn bool
-
- ResponseBytes string
- Clients []ClientRow
- Sources []SourceRow
- SourceCount int
- TrustsForward bool
- // SourcesOn gates the caller table. The page is unauthenticated, so the
- // addresses behind it are published only when asked for.
- SourcesOn bool
- // FailureDetailsOn gates the recent failures listed under each failure
- // row, for the same reason as SourcesOn.
- FailureDetailsOn bool
- // HasFailures is set when any failure counter has fired, which is when the
- // note about turning failure details on is worth showing.
- HasFailures bool
-}
-
-// CacheLookups is one cache's hits and misses since the process started.
-type CacheLookups struct {
- Hits string
- Misses string
- HitRatio string
- HasTraffic bool
-}
-
-// cacheLookups reads one kind's share of the hit and miss counters.
-func cacheLookups(hits, misses map[string]float64, kind string) CacheLookups {
- h, m := hits[kind], misses[kind]
- c := CacheLookups{
- Hits: formatCount(int64(h)),
- Misses: formatCount(int64(m)),
- HasTraffic: h+m > 0,
- }
- if c.HasTraffic {
- c.HitRatio = strconv.FormatFloat(h/(h+m)*100, 'f', 1, 64) //nolint:mnd // percent
- }
- return c
-}
-
-// ClientRow is one client tool's share of requests and bytes.
-type ClientRow struct {
- Client string
- Requests string
- Bytes string
-}
-
-// LabelledCount is a single counter series rendered as a row.
-type LabelledCount struct {
- Label string
- Count string
- // Bad marks a failure row, which the template tints.
- Bad bool
- // Recent lists the latest failures behind the count, newest first.
- Recent []FailureRow
- // series is the counter series the row was rendered from, for looking up
- // what was recorded against it.
- series map[string]string
-}
-
-// FailureRow is one recorded failure behind a failure counter.
-type FailureRow struct {
- Age string
- PURL string
- Filename string
- URL string
- Path string
- Error string
-}
-
-// LabelledStat is a histogram series rendered as a row: how many observations,
-// and their mean.
-type LabelledStat struct {
- Label string
- Count string
- Mean string
-}
-
-// BreakerRow is one upstream's circuit breaker state.
-type BreakerRow struct {
- Registry string
- State string
- Open bool
-}
-
-// runtimeView shapes a registry snapshot for the analytics page. With
-// failureDetails set, each failure row carries the latest failures behind it.
-func runtimeView(snap *metrics.Snapshot, failureDetails bool) RuntimeView {
- if snap == nil {
- return RuntimeView{}
- }
-
- v := RuntimeView{Available: true, FailureDetailsOn: failureDetails}
- failures := func(name string, labels ...string) []LabelledCount {
- return failureRows(snap, name, failureDetails, labels...)
- }
-
- requests := snap.Sum("proxy_requests_total")
- v.Requests = formatCount(int64(requests))
- v.ActiveRequests = formatCount(int64(snap.Sum("proxy_active_requests")))
- v.RequestMean = formatDuration(snap.Mean("proxy_request_duration_seconds"))
- v.StatusClasses = statusClasses(snap)
-
- hits := snap.SumBy("proxy_cache_hits_total", "kind")
- misses := snap.SumBy("proxy_cache_misses_total", "kind")
- v.ArtifactCache = cacheLookups(hits, misses, metrics.CacheKindArtifact)
- v.MetadataCache = cacheLookups(hits, misses, metrics.CacheKindMetadata)
- v.MetadataServed = formatSize(int64(snap.Sum("proxy_metadata_cache_served_bytes_total")))
-
- v.UpstreamFetches = formatCount(int64(snap.Count("proxy_upstream_fetch_duration_seconds")))
- v.UpstreamFetchMean = formatDuration(snap.Mean("proxy_upstream_fetch_duration_seconds"))
- v.UpstreamErrors = failures("proxy_upstream_errors_total", "ecosystem", "error_type")
-
- v.StorageOps = histogramRows(snap, "proxy_storage_operation_duration_seconds", "operation")
- v.StorageErrors = failures("proxy_storage_errors_total", "operation")
- v.IntegrityFailures = failures("proxy_integrity_failures_total", "ecosystem")
- v.ProbeFailures = failures("proxy_health_probe_failures_total", "step")
- v.Evictions = countRows(snap, "proxy_artifacts_evicted_total", "reason", "ecosystem")
- v.MetadataEvictions = countRows(snap, "proxy_metadata_evicted_total", "ecosystem")
- v.ChangeFeedExpired = countRows(snap, "proxy_metadata_expired_by_change_feed_total", "ecosystem")
-
- v.Breakers = breakerRows(snap)
- v.BreakerTrips = failures("proxy_circuit_breaker_trips_total", "registry")
-
- v.Scans = histogramRows(snap, "proxy_scan_duration_seconds", "ecosystem", "scanner")
- v.ScansBlocked = failures("proxy_scan_blocked_total", "ecosystem", "scanner")
- v.ScanErrors = failures("proxy_scan_errors_total", "ecosystem", "scanner", "error_type")
- v.ScanningOn = len(v.Scans) > 0 || len(v.ScansBlocked) > 0 || len(v.ScanErrors) > 0
- v.HasFailures = len(v.UpstreamErrors) > 0 || len(v.StorageErrors) > 0 || len(v.IntegrityFailures) > 0 ||
- len(v.ProbeFailures) > 0 || len(v.ScanErrors) > 0
-
- v.ResponseBytes = formatSize(int64(snap.Sum("proxy_response_bytes_total")))
- v.Clients = clientRows(snap)
-
- return v
-}
-
-// clientRows pairs each client tool's request count with the bytes it pulled,
-// busiest first. Both come from the same closed label set, so the two counters
-// line up row for row.
-func clientRows(snap *metrics.Snapshot) []ClientRow {
- bytesByClient := snap.SumBy("proxy_client_response_bytes_total", "client")
-
- type entry struct {
- client string
- requests float64
- bytes float64
- }
-
- entries := make([]entry, 0)
- for _, s := range snap.Samples("proxy_client_requests_total") {
- client := s.Label("client")
- entries = append(entries, entry{
- client: client,
- requests: s.Value,
- bytes: bytesByClient[client],
- })
- }
-
- sort.Slice(entries, func(i, j int) bool {
- a, b := entries[i], entries[j]
- if a.bytes != b.bytes {
- return a.bytes > b.bytes
- }
- return a.requests > b.requests
- })
-
- rows := make([]ClientRow, 0, len(entries))
- for _, e := range entries {
- rows = append(rows, ClientRow{
- Client: e.client,
- Requests: formatCount(int64(e.requests)),
- Bytes: formatSize(int64(e.bytes)),
- })
- }
- return rows
-}
-
-// statusClasses groups proxy_requests_total into 2xx/3xx/4xx/5xx buckets, which
-// is the breakdown worth showing; the per-code detail stays in Prometheus.
-func statusClasses(snap *metrics.Snapshot) []LabelledCount {
- byClass := make(map[string]float64)
- for _, s := range snap.Samples("proxy_requests_total") {
- status := s.Label("status")
- if len(status) == 0 {
- continue
- }
- byClass[string(status[0])+"xx"] += s.Value
- }
-
- classes := make([]string, 0, len(byClass))
- for c := range byClass {
- classes = append(classes, c)
- }
- sort.Strings(classes)
-
- rows := make([]LabelledCount, 0, len(classes))
- for _, c := range classes {
- rows = append(rows, LabelledCount{
- Label: c,
- Count: formatCount(int64(byClass[c])),
- Bad: c == "5xx",
- })
- }
- return rows
-}
-
-// failureRows renders every non-zero series of a failure counter, keyed by the
-// joined values of the given labels. Zero-valued series are dropped: a counter
-// that has never fired carries no information, and a wall of zeroes buries the
-// rows that matter. With withRecent set, each row carries the latest failures
-// recorded for its series.
-func failureRows(snap *metrics.Snapshot, name string, withRecent bool, labels ...string) []LabelledCount {
- rows := countRows(snap, name, labels...)
- for i := range rows {
- rows[i].Bad = true
- if withRecent {
- rows[i].Recent = recentFailureRows(metrics.RecentFailures(name, rows[i].series))
- }
- }
- return rows
-}
-
-// countRows renders every non-zero series of a counter as a row.
-func countRows(snap *metrics.Snapshot, name string, labels ...string) []LabelledCount {
- var rows []LabelledCount
- for _, s := range snap.Samples(name) {
- if s.Value == 0 {
- continue
- }
- rows = append(rows, LabelledCount{
- Label: joinLabels(s, labels...),
- Count: formatCount(int64(s.Value)),
- series: s.Labels,
- })
- }
- return rows
-}
-
-func recentFailureRows(failures []metrics.Failure) []FailureRow {
- if len(failures) == 0 {
- return nil
- }
- rows := make([]FailureRow, 0, len(failures))
- for _, f := range failures {
- rows = append(rows, FailureRow{
- Age: formatTimeAgo(f.At),
- PURL: f.PURL,
- Filename: f.Filename,
- URL: f.URL,
- Path: f.Path,
- Error: f.Error,
- })
- }
- return rows
-}
-
-// histogramRows renders every observed series of a histogram with its count and mean.
-func histogramRows(snap *metrics.Snapshot, name string, labels ...string) []LabelledStat {
- var rows []LabelledStat
- for _, s := range snap.Samples(name) {
- if s.Count == 0 {
- continue
- }
- rows = append(rows, LabelledStat{
- Label: joinLabels(s, labels...),
- Count: formatCount(int64(s.Count)),
- Mean: formatDuration(s.Mean()),
- })
- }
- return rows
-}
-
-func breakerRows(snap *metrics.Snapshot) []BreakerRow {
- var rows []BreakerRow
- for _, s := range snap.Samples("proxy_circuit_breaker_state") {
- state := "closed"
- switch s.Value {
- case 1:
- state = "half-open"
- case 2: //nolint:mnd // 2 = open, per the gauge's documented encoding
- state = "open"
- }
- rows = append(rows, BreakerRow{
- Registry: s.Label("registry"),
- State: state,
- Open: s.Value > 0,
- })
- }
- return rows
-}
-
-func joinLabels(s metrics.Sample, labels ...string) string {
- out := ""
- for _, l := range labels {
- v := s.Label(l)
- if v == "" {
- continue
- }
- if out != "" {
- out += " · "
- }
- out += v
- }
- if out == "" {
- return "-"
- }
- return out
-}
-
-// formatDuration renders a mean latency at a sensible unit. Storage operations
-// land in microseconds and upstream fetches in seconds, so a fixed unit would
-// read as either 0.000 or an unreadable pile of digits.
-func formatDuration(seconds float64) string {
- if seconds <= 0 {
- return "-"
- }
-
- d := time.Duration(seconds * float64(time.Second))
- switch {
- case d < time.Microsecond:
- return fmt.Sprintf("%.0f ns", float64(d))
- case d < time.Millisecond:
- return fmt.Sprintf("%.0f µs", float64(d)/float64(time.Microsecond))
- case d < time.Second:
- return fmt.Sprintf("%.1f ms", float64(d)/float64(time.Millisecond))
- default:
- return fmt.Sprintf("%.2f s", d.Seconds())
- }
-}
diff --git a/internal/server/analytics_runtime_test.go b/internal/server/analytics_runtime_test.go
deleted file mode 100644
index 03bc236..0000000
--- a/internal/server/analytics_runtime_test.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package server
-
-import (
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus"
-)
-
-func TestFormatDuration(t *testing.T) {
- tests := []struct {
- seconds float64
- want string
- }{
- {0, "-"},
- {-1, "-"},
- {0.0000004, "400 ns"},
- {0.00041, "410 µs"},
- {0.0081, "8.1 ms"},
- {1.25, "1.25 s"},
- }
- for _, tc := range tests {
- if got := formatDuration(tc.seconds); got != tc.want {
- t.Errorf("formatDuration(%v) = %q, want %q", tc.seconds, got, tc.want)
- }
- }
-}
-
-func TestRuntimeViewNilSnapshot(t *testing.T) {
- if v := runtimeView(nil, false); v.Available {
- t.Error("a nil snapshot must not report itself as available")
- }
-}
-
-func TestRuntimeViewShapesRegistry(t *testing.T) {
- reg := prometheus.NewRegistry()
-
- requests := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_requests_total", Help: "t"}, []string{"ecosystem", "status"})
- scanErrors := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_scan_errors_total", Help: "t"}, []string{"ecosystem", "scanner", "error_type"})
- hits := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_cache_hits_total", Help: "t"}, []string{"ecosystem", "kind"})
- misses := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_cache_misses_total", Help: "t"}, []string{"ecosystem", "kind"})
- breaker := prometheus.NewGaugeVec(
- prometheus.GaugeOpts{Name: "proxy_circuit_breaker_state", Help: "t"}, []string{"registry"})
- storage := prometheus.NewHistogramVec(
- prometheus.HistogramOpts{Name: "proxy_storage_operation_duration_seconds", Help: "t"}, []string{"operation"})
- reg.MustRegister(requests, scanErrors, hits, misses, breaker, storage)
-
- requests.WithLabelValues("npm", "200").Add(90)
- requests.WithLabelValues("npm", "304").Add(5)
- requests.WithLabelValues("npm", "500").Add(5)
- scanErrors.WithLabelValues("npm", "clamav", "timeout").Add(3)
- hits.WithLabelValues("npm", metrics.CacheKindArtifact).Add(80)
- misses.WithLabelValues("npm", metrics.CacheKindArtifact).Add(20)
- breaker.WithLabelValues("registry.npmjs.org").Set(0)
- breaker.WithLabelValues("static.crates.io").Set(2)
- storage.WithLabelValues("get").Observe(0.002)
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if !v.Available {
- t.Fatal("expected the view to be available")
- }
- if v.Requests != "100" {
- t.Errorf("Requests = %q, want %q", v.Requests, "100")
- }
- if a := v.ArtifactCache; a.HitRatio != "80.0" || !a.HasTraffic {
- t.Errorf("ArtifactCache = %+v, want an 80.0%% hit rate", a)
- }
-
- // Status codes collapse to classes; 5xx is flagged as a failure row.
- wantClasses := map[string]struct {
- count string
- bad bool
- }{"2xx": {"90", false}, "3xx": {"5", false}, "5xx": {"5", true}}
- if len(v.StatusClasses) != len(wantClasses) {
- t.Fatalf("got %d status classes, want %d: %+v", len(v.StatusClasses), len(wantClasses), v.StatusClasses)
- }
- for _, row := range v.StatusClasses {
- want, ok := wantClasses[row.Label]
- if !ok {
- t.Errorf("unexpected status class %q", row.Label)
- continue
- }
- if row.Count != want.count || row.Bad != want.bad {
- t.Errorf("%s = %q (bad=%v), want %q (bad=%v)", row.Label, row.Count, row.Bad, want.count, want.bad)
- }
- }
-
- // The metric that prompted this: it must reach the page with its labels joined.
- if len(v.ScanErrors) != 1 {
- t.Fatalf("expected 1 scan error row, got %+v", v.ScanErrors)
- }
- if v.ScanErrors[0].Label != "npm · clamav · timeout" || v.ScanErrors[0].Count != "3" {
- t.Errorf("scan error row = %+v, want npm · clamav · timeout = 3", v.ScanErrors[0])
- }
- if !v.ScanningOn {
- t.Error("scanning should read as on once a scan metric carries a value")
- }
-
- if len(v.Breakers) != 2 {
- t.Fatalf("expected 2 breakers, got %+v", v.Breakers)
- }
- var open, closed int
- for _, b := range v.Breakers {
- if b.Open {
- open++
- if b.State != "open" {
- t.Errorf("open breaker state = %q, want %q", b.State, "open")
- }
- } else {
- closed++
- }
- }
- if open != 1 || closed != 1 {
- t.Errorf("breakers: %d open / %d closed, want 1 / 1", open, closed)
- }
-
- if len(v.StorageOps) != 1 || v.StorageOps[0].Label != "get" || v.StorageOps[0].Mean != "2.0 ms" {
- t.Errorf("StorageOps = %+v, want one 'get' row with a 2.0 ms mean", v.StorageOps)
- }
-}
-
-// Evictions are routine housekeeping, not failures: they reach the page by
-// reason and ecosystem without the failure tint.
-func TestRuntimeViewShowsEvictions(t *testing.T) {
- reg := prometheus.NewRegistry()
- evicted := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_artifacts_evicted_total", Help: "t"}, []string{"reason", "ecosystem"})
- reg.MustRegister(evicted)
- evicted.WithLabelValues("retention", "npm").Add(4)
- evicted.WithLabelValues("lru", "cargo")
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if len(v.Evictions) != 1 {
- t.Fatalf("Evictions = %+v, want only the non-zero row", v.Evictions)
- }
- if got := v.Evictions[0]; got.Label != "retention · npm" || got.Count != "4" || got.Bad {
- t.Errorf("eviction row = %+v, want retention · npm = 4 without the failure tint", got)
- }
-}
-
-func TestRuntimeViewShowsMetadataEvictions(t *testing.T) {
- reg := prometheus.NewRegistry()
- evicted := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_metadata_evicted_total", Help: "t"}, []string{"ecosystem"})
- reg.MustRegister(evicted)
- evicted.WithLabelValues("golang").Add(3)
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if len(v.MetadataEvictions) != 1 || v.MetadataEvictions[0].Label != "golang" || v.MetadataEvictions[0].Count != "3" {
- t.Errorf("MetadataEvictions = %+v, want golang = 3", v.MetadataEvictions)
- }
-}
-
-func TestRuntimeViewShowsChangeFeedExpiries(t *testing.T) {
- reg := prometheus.NewRegistry()
- expired := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_metadata_expired_by_change_feed_total", Help: "t"}, []string{"ecosystem"})
- reg.MustRegister(expired)
- expired.WithLabelValues("packagist").Add(2)
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if len(v.ChangeFeedExpired) != 1 || v.ChangeFeedExpired[0].Label != "packagist" || v.ChangeFeedExpired[0].Count != "2" {
- t.Errorf("ChangeFeedExpired = %+v, want packagist = 2", v.ChangeFeedExpired)
- }
-}
-
-// A counter sitting at zero carries no information and would bury the rows
-// that do, so it is left out.
-func TestRuntimeViewDropsZeroCounters(t *testing.T) {
- reg := prometheus.NewRegistry()
- errs := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_storage_errors_total", Help: "t"}, []string{"operation"})
- reg.MustRegister(errs)
-
- // Touching a label creates the series at zero.
- errs.WithLabelValues("get")
- errs.WithLabelValues("put").Add(2)
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if len(v.StorageErrors) != 1 {
- t.Fatalf("expected only the non-zero row, got %+v", v.StorageErrors)
- }
- if v.StorageErrors[0].Label != "put" {
- t.Errorf("kept row = %q, want %q", v.StorageErrors[0].Label, "put")
- }
-}
-
-// Artifact and metadata lookups are separate caches, so the view reports each
-// on its own rather than pooling them into one hit rate.
-func TestRuntimeViewSplitsCacheKinds(t *testing.T) {
- reg := prometheus.NewRegistry()
- hits := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_cache_hits_total", Help: "t"}, []string{"ecosystem", "kind"})
- misses := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_cache_misses_total", Help: "t"}, []string{"ecosystem", "kind"})
- served := prometheus.NewCounterVec(
- prometheus.CounterOpts{Name: "proxy_metadata_cache_served_bytes_total", Help: "t"}, []string{"ecosystem"})
- reg.MustRegister(hits, misses, served)
-
- hits.WithLabelValues("npm", metrics.CacheKindArtifact).Add(80)
- misses.WithLabelValues("npm", metrics.CacheKindArtifact).Add(20)
- hits.WithLabelValues("pypi", metrics.CacheKindMetadata).Add(20)
- hits.WithLabelValues("npm", metrics.CacheKindMetadata).Add(10)
- misses.WithLabelValues("pypi", metrics.CacheKindMetadata).Add(10)
- served.WithLabelValues("pypi").Add(2048)
-
- snap, err := metrics.GatherFrom(reg)
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- want := map[string]struct {
- got CacheLookups
- want CacheLookups
- }{
- "artifact": {v.ArtifactCache, CacheLookups{Hits: "80", Misses: "20", HitRatio: "80.0", HasTraffic: true}},
- "metadata": {v.MetadataCache, CacheLookups{Hits: "30", Misses: "10", HitRatio: "75.0", HasTraffic: true}},
- }
- for kind, c := range want {
- if c.got != c.want {
- t.Errorf("%s lookups = %+v, want %+v", kind, c.got, c.want)
- }
- }
- if v.MetadataServed != "2.0 KB" {
- t.Errorf("MetadataServed = %q, want %q", v.MetadataServed, "2.0 KB")
- }
-}
-
-// An empty registry must still render, reporting nothing rather than dividing
-// by zero or showing a hit rate of NaN.
-func TestRuntimeViewEmptyRegistry(t *testing.T) {
- snap, err := metrics.GatherFrom(prometheus.NewRegistry())
- if err != nil {
- t.Fatalf("GatherFrom: %v", err)
- }
- v := runtimeView(snap, false)
-
- if !v.Available {
- t.Error("an empty registry is still a valid snapshot")
- }
- for name, c := range map[string]CacheLookups{"artifact": v.ArtifactCache, "metadata": v.MetadataCache} {
- if c.HasTraffic {
- t.Errorf("%s: no traffic recorded, so HasTraffic must be false", name)
- }
- if c.HitRatio != "" {
- t.Errorf("%s: HitRatio = %q, want empty when nothing was recorded", name, c.HitRatio)
- }
- }
- if v.Requests != "0" {
- t.Errorf("Requests = %q, want %q", v.Requests, "0")
- }
- if v.RequestMean != "-" {
- t.Errorf("RequestMean = %q, want a dash", v.RequestMean)
- }
- if v.ScanningOn {
- t.Error("scanning must read as off with no scan metrics")
- }
-}
diff --git a/internal/server/analytics_test.go b/internal/server/analytics_test.go
deleted file mode 100644
index 540408b..0000000
--- a/internal/server/analytics_test.go
+++ /dev/null
@@ -1,370 +0,0 @@
-package server
-
-import (
- "database/sql"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-func TestFormatCount(t *testing.T) {
- tests := []struct {
- in int64
- want string
- }{
- {0, "0"},
- {7, "7"},
- {100, "100"},
- {1000, "1,000"},
- {12004, "12,004"},
- {1000000, "1,000,000"},
- {-4200, "-4,200"},
- }
- for _, tc := range tests {
- if got := formatCount(tc.in); got != tc.want {
- t.Errorf("formatCount(%d) = %q, want %q", tc.in, got, tc.want)
- }
- }
-}
-
-func TestPercentOf(t *testing.T) {
- tests := []struct {
- part, whole int64
- want string
- }{
- {1, 4, "25.0"},
- {2, 3, "66.7"},
- {0, 100, "0.0"},
- {5, 5, "100.0"},
- // A zero total must not divide by zero.
- {0, 0, "0.0"},
- {7, 0, "0.0"},
- // A negligible share still rounds to a plain number here; only
- // formatPercent turns it into "<0.1".
- {1, 1000000, "0.0"},
- }
- for _, tc := range tests {
- if got := percentOf(tc.part, tc.whole); got != tc.want {
- t.Errorf("percentOf(%d, %d) = %q, want %q", tc.part, tc.whole, got, tc.want)
- }
- }
-}
-
-func TestFormatPercent(t *testing.T) {
- tests := []struct {
- part, whole int64
- want string
- }{
- {1, 4, "25.0"},
- {0, 100, "0.0"},
- {0, 0, "0.0"},
- // A real but tiny share must not be reported as zero.
- {1, 1000000, "<0.1"},
- {7_800_000, 18_000_000_000, "<0.1"},
- }
- for _, tc := range tests {
- if got := formatPercent(tc.part, tc.whole); got != tc.want {
- t.Errorf("formatPercent(%d, %d) = %q, want %q", tc.part, tc.whole, got, tc.want)
- }
- }
-}
-
-func TestFormatRatio(t *testing.T) {
- if got := formatRatio(3500, 1000); got != "3.5x" {
- t.Errorf("formatRatio(3500, 1000) = %q, want %q", got, "3.5x")
- }
- // Nothing cached means no meaningful multiplier, not "+Infx".
- if got := formatRatio(3500, 0); got != "" {
- t.Errorf("formatRatio(3500, 0) = %q, want empty", got)
- }
-}
-
-func TestAvgArtifactSize(t *testing.T) {
- if got := avgArtifactSize(1000, 4); got != "250 B" {
- t.Errorf("avgArtifactSize(1000, 4) = %q, want %q", got, "250 B")
- }
- if got := avgArtifactSize(0, 0); got != "0 B" {
- t.Errorf("avgArtifactSize(0, 0) = %q, want %q", got, "0 B")
- }
-}
-
-func TestAnalyticsView(t *testing.T) {
- stats := []database.EcosystemStats{
- {Ecosystem: "npm", Packages: 2, Versions: 2, Artifacts: 2, CacheSize: 1500, Downloads: 4, DownloadedBytes: 3000},
- {Ecosystem: "cargo", Packages: 1, Versions: 1, Artifacts: 1, CacheSize: 200, Downloads: 5, DownloadedBytes: 1000},
- {Ecosystem: "gem", Packages: 3, Versions: 4},
- }
-
- totals, rows := analyticsView(stats)
-
- if totals.DownloadedBytes != 4000 {
- t.Errorf("DownloadedBytes = %d, want 4000", totals.DownloadedBytes)
- }
- if totals.Downloads != "9" {
- t.Errorf("Downloads = %q, want %q", totals.Downloads, "9")
- }
- if totals.CachedArtifacts != "3" {
- t.Errorf("CachedArtifacts = %q, want %q", totals.CachedArtifacts, "3")
- }
- if totals.Packages != "6" {
- t.Errorf("Packages = %q, want %q", totals.Packages, "6")
- }
- if totals.Versions != "7" {
- t.Errorf("Versions = %q, want %q", totals.Versions, "7")
- }
- if totals.Ecosystems != 3 {
- t.Errorf("Ecosystems = %d, want 3", totals.Ecosystems)
- }
- // gem has served nothing, so it is known but not active.
- if totals.ActiveEcosystems != 2 {
- t.Errorf("ActiveEcosystems = %d, want 2", totals.ActiveEcosystems)
- }
- // 4000 bytes served from 1700 bytes stored.
- if totals.Amplification != "2.4x" {
- t.Errorf("Amplification = %q, want %q", totals.Amplification, "2.4x")
- }
-
- if len(rows) != 3 {
- t.Fatalf("expected 3 rows, got %d", len(rows))
- }
-
- // Shares are of the grand total.
- if rows[0].SharePct != "75.0" {
- t.Errorf("npm share = %q, want 75.0", rows[0].SharePct)
- }
- if rows[1].SharePct != "25.0" {
- t.Errorf("cargo share = %q, want 25.0", rows[1].SharePct)
- }
- // gem served nothing, so it contributes no share but still gets a row.
- if rows[2].SharePct != "0.0" || rows[2].Ecosystem != "gem" {
- t.Errorf("gem row = %+v, want a 0.0%% share", rows[2])
- }
- if rows[0].AvgArtifactSize != "750 B" {
- t.Errorf("npm AvgArtifactSize = %q, want %q", rows[0].AvgArtifactSize, "750 B")
- }
-}
-
-// With nothing cached at all the view must stay renderable rather than dividing
-// by a zero total.
-func TestAnalyticsViewEmpty(t *testing.T) {
- totals, rows := analyticsView(nil)
-
- if len(rows) != 0 {
- t.Errorf("expected no rows, got %+v", rows)
- }
- if totals.DownloadedBytes != 0 {
- t.Errorf("DownloadedBytes = %d, want 0", totals.DownloadedBytes)
- }
- if totals.Downloaded != "0 B" {
- t.Errorf("Downloaded = %q, want %q", totals.Downloaded, "0 B")
- }
- if totals.Amplification != "" {
- t.Errorf("Amplification = %q, want empty", totals.Amplification)
- }
-}
-
-func TestAnalyticsPageRendersTotals(t *testing.T) {
- tpl := &Templates{}
- w := httptest.NewRecorder()
-
- totals, rows := analyticsView([]database.EcosystemStats{
- {Ecosystem: "npm", Packages: 1, Versions: 1, Artifacts: 1, CacheSize: 1000, Downloads: 3, DownloadedBytes: 3000},
- })
- data := AnalyticsData{Totals: totals, Ecosystems: rows}
- data.Donut = donutView([]database.EcosystemStats{
- {Ecosystem: "npm", Packages: 1, Versions: 1, Artifacts: 1, CacheSize: 1000, Downloads: 3, DownloadedBytes: 3000},
- }, totals.DownloadedBytes, totals.Downloaded)
-
- if err := tpl.Render(w, "analytics", data); err != nil {
- t.Fatalf("Render: %v", err)
- }
-
- body := w.Body.String()
- if strings.Contains(body, "ZgotmplZ") {
- t.Error("a template value was sanitized away; check the donut dash geometry")
- }
- for _, want := range []string{
- "accumulated download size",
- "2.9 KB", // the total, stated in the middle of the ring
- "donut-slot-0", // the single slice takes the first categorical slot
- "3.0x", // 3000 bytes served from 1000 stored
- "/ui/analytics", // nav link renders on the page itself
- } {
- if !strings.Contains(body, want) {
- t.Errorf("rendered page missing %q", want)
- }
- }
-}
-
-// A retained snapshot must be labelled as one. The cache deliberately serves
-// the last good rows when the query fails, so without this the page presents
-// arbitrarily old figures as current and the only trace is a log line.
-func TestAnalyticsPageFlagsStaleFigures(t *testing.T) {
- tpl := &Templates{}
- w := httptest.NewRecorder()
-
- totals, rows := analyticsView([]database.EcosystemStats{
- {Ecosystem: "npm", Artifacts: 1, CacheSize: 1000, Downloads: 3, DownloadedBytes: 3000},
- })
- data := AnalyticsData{
- Totals: totals,
- Ecosystems: rows,
- StatsFailed: true,
- StatsStale: true,
- StatsAge: "2 hours ago",
- }
-
- if err := tpl.Render(w, "analytics", data); err != nil {
- t.Fatalf("Render: %v", err)
- }
-
- body := w.Body.String()
- for _, want := range []string{"Figures may be out of date", "2 hours ago"} {
- if !strings.Contains(body, want) {
- t.Errorf("rendered page missing %q", want)
- }
- }
- // The figures themselves still render — stale beats absent.
- if !strings.Contains(body, "2.9 KB") {
- t.Error("the retained snapshot was not rendered alongside the warning")
- }
-}
-
-// A failure with nothing retained keeps the existing unavailable message and
-// must not also claim the figures below are stale, since there are none.
-func TestAnalyticsPageStaleBannerNeedsASnapshot(t *testing.T) {
- tpl := &Templates{}
- w := httptest.NewRecorder()
-
- if err := tpl.Render(w, "analytics", AnalyticsData{StatsFailed: true}); err != nil {
- t.Fatalf("Render: %v", err)
- }
-
- body := w.Body.String()
- if strings.Contains(body, "Figures may be out of date") {
- t.Error("stale banner rendered with no retained snapshot")
- }
- if !strings.Contains(body, "the database query failed") {
- t.Error("expected the unavailable message when the query failed with no snapshot")
- }
-}
-
-func TestAnalyticsPageRendersWithoutTraffic(t *testing.T) {
- tpl := &Templates{}
- w := httptest.NewRecorder()
-
- if err := tpl.Render(w, "analytics", AnalyticsData{}); err != nil {
- t.Fatalf("Render: %v", err)
- }
- if body := w.Body.String(); !strings.Contains(body, "Nothing has been served from cache yet") {
- t.Error("expected the empty-state message when no traffic has been recorded")
- }
-}
-
-func TestEcosystemMetricsMapping(t *testing.T) {
- got := ecosystemMetrics([]database.EcosystemStats{
- {Ecosystem: "npm", Packages: 1, Versions: 2, Artifacts: 3, CacheSize: 4, Downloads: 5, DownloadedBytes: 6},
- })
- if len(got) != 1 {
- t.Fatalf("expected 1 snapshot, got %d", len(got))
- }
- m := got[0]
- if m.Ecosystem != "npm" || m.Packages != 1 || m.Versions != 2 || m.Artifacts != 3 ||
- m.CacheSize != 4 || m.Downloads != 5 || m.DownloadedBytes != 6 {
- t.Errorf("fields did not map across: %+v", m)
- }
-}
-
-// The metadata cache is stored apart from artifacts, so the page reports it in
-// its own card rather than leaving it out of every figure.
-func TestAnalyticsPageShowsMetadataCache(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- ts.server.cfg.CacheMetadata = true
-
- for _, e := range []database.MetadataCacheEntry{
- {Ecosystem: "pypi", Name: "requests", StoragePath: "_metadata/pypi/requests", Size: sql.NullInt64{Int64: 3 << 10, Valid: true}},
- {Ecosystem: "pypi", Name: "urllib3", StoragePath: "_metadata/pypi/urllib3", Size: sql.NullInt64{Int64: 1 << 10, Valid: true}},
- } {
- if err := ts.db.UpsertMetadataCache(&e); err != nil {
- t.Fatalf("UpsertMetadataCache: %v", err)
- }
- }
-
- body := ts.getOK(t, "/ui/analytics")
- for _, want := range []string{"Metadata cache", "Cached documents", "4.0 KB", "100.0%"} {
- if !strings.Contains(body, want) {
- t.Errorf("rendered page missing %q", want)
- }
- }
- if strings.Contains(body, "Metadata caching is off") {
- t.Error("the off-state note rendered with cache_metadata on")
- }
-}
-
-func TestAnalyticsPageNotesMetadataCachingOff(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- body := ts.getOK(t, "/ui/analytics")
- for _, want := range []string{"Metadata cache", "Metadata caching is off", "No metadata cached yet"} {
- if !strings.Contains(body, want) {
- t.Errorf("rendered page missing %q", want)
- }
- }
-}
-
-func TestMetadataCacheView(t *testing.T) {
- v := metadataCacheView([]database.MetadataCacheStats{
- {Ecosystem: "npm", Entries: 3, Size: 3000},
- {Ecosystem: "pypi", Entries: 1, Size: 1000},
- }, true)
-
- if !v.Enabled || v.Entries != "4" || v.Size != "3.9 KB" {
- t.Errorf("totals = %+v, want enabled, 4 entries, 3.9 KB", v)
- }
- if len(v.Rows) != 2 || v.Rows[0].SharePct != "75.0" || v.Rows[1].SharePct != "25.0" {
- t.Errorf("rows = %+v, want npm 75.0%% then pypi 25.0%%", v.Rows)
- }
-
- empty := metadataCacheView(nil, false)
- if empty.Enabled || len(empty.Rows) != 0 || empty.Entries != "0" || empty.Size != "0 B" {
- t.Errorf("empty view = %+v", empty)
- }
-}
-
-// The page reads the metadata card from the snapshot the gauges publish, so a
-// page load issues no query of its own and the two always agree.
-func TestAnalyticsPageMetadataCardUsesSnapshot(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- ts.server.cfg.CacheMetadata = true
-
- seed := func(name string, size int64) {
- t.Helper()
- if err := ts.db.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: "pypi", Name: name, StoragePath: "_metadata/pypi/" + name,
- Size: sql.NullInt64{Int64: size, Valid: true},
- }); err != nil {
- t.Fatalf("UpsertMetadataCache: %v", err)
- }
- }
-
- seed("requests", 1<<10)
- ts.server.updateMetadataCacheStats()
- if !strings.Contains(ts.getOK(t, "/ui/analytics"), "1.0 KB") {
- t.Fatal("page did not show the snapshot the metrics tick read")
- }
-
- seed("urllib3", 2<<10)
- if body := ts.getOK(t, "/ui/analytics"); !strings.Contains(body, "1.0 KB") || strings.Contains(body, "3.0 KB") {
- t.Error("a page load re-queried the database instead of serving the snapshot")
- }
-
- ts.server.updateMetadataCacheStats()
- if !strings.Contains(ts.getOK(t, "/ui/analytics"), "3.0 KB") {
- t.Error("page did not pick up the refreshed snapshot")
- }
-}
diff --git a/internal/server/api.go b/internal/server/api.go
index 07080aa..a3ed9d8 100644
--- a/internal/server/api.go
+++ b/internal/server/api.go
@@ -1,12 +1,9 @@
package server
import (
- "context"
"encoding/json"
- "errors"
"net/http"
"strings"
- "time"
shared "github.com/git-pkgs/enrichment"
"github.com/git-pkgs/proxy/internal/database"
@@ -15,28 +12,11 @@ import (
"github.com/go-chi/chi/v5"
)
-const (
- maxBodySize = 1 << 20 // 1 MB
- licenseCategoryUnknown = "unknown"
- defaultSortBy = "hits"
-)
-
// APIHandler provides REST endpoints for package enrichment data.
type APIHandler struct {
- enrichment enricher
+ enrichment *enrichment.Service
ecosystems *shared.EcosystemsClient
db DBSearcher
- // cooldown is nil with cooldown off.
- cooldown *cooldownView
-}
-
-// enricher is the part of enrichment.Service the API uses.
-type enricher interface {
- EnrichPackage(ctx context.Context, ecosystem, name string) (*enrichment.PackageInfo, error)
- EnrichFull(ctx context.Context, ecosystem, name, version string) (*enrichment.EnrichmentResult, error)
- CheckVulnerabilities(ctx context.Context, ecosystem, name, version string) ([]enrichment.VulnInfo, error)
- GetLatestVersion(ctx context.Context, ecosystem, name string) (string, error)
- BulkEnrichPackages(ctx context.Context, packages []struct{ Ecosystem, Name string }) map[string]*enrichment.PackageInfo
}
// DBSearcher defines the interface for database search operations.
@@ -60,12 +40,6 @@ func NewAPIHandler(svc *enrichment.Service, db DBSearcher) *APIHandler {
return h
}
-func (s *Server) newAPIHandler(svc *enrichment.Service) *APIHandler {
- h := NewAPIHandler(svc, s.db)
- h.cooldown = s.cooldown
- return h
-}
-
// PackageResponse contains enriched package metadata.
type PackageResponse struct {
Ecosystem string `json:"ecosystem"`
@@ -77,9 +51,6 @@ type PackageResponse struct {
Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
- // Cooldown is the cooldown that applies to the package, in the
- // configuration's syntax ("3d", "0").
- Cooldown string `json:"cooldown,omitempty"`
}
// VersionResponse contains enriched version metadata.
@@ -89,15 +60,11 @@ type VersionResponse struct {
Version string `json:"version"`
License string `json:"license,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
- // AvailableAt is when a version held back by cooldown leaves it.
- AvailableAt string `json:"available_at,omitempty"`
Integrity string `json:"integrity,omitempty"`
Yanked bool `json:"yanked"`
IsOutdated bool `json:"is_outdated"`
}
-const apiTimeFormat = "2006-01-02T15:04:05Z"
-
// VulnResponse contains vulnerability information.
type VulnResponse struct {
ID string `json:"id"`
@@ -162,71 +129,33 @@ type BulkResponse struct {
Packages map[string]*PackageResponse `json:"packages"`
}
-// HandlePackagePath dispatches /api/package/{ecosystem}/* to the appropriate handler.
-// Resolves namespaced package names (Composer vendor/name, npm @scope/name) from the path.
-func (h *APIHandler) HandlePackagePath(w http.ResponseWriter, r *http.Request) {
+// HandleGetPackage handles GET /api/package/{ecosystem}/{name}
+func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- segments, err := packagePathSegments(r)
- if err != nil {
- badRequest(w, err.Error())
+ name := chi.URLParam(r, "name")
+
+ if ecosystem == "" || name == "" {
+ http.Error(w, "ecosystem and name are required", http.StatusBadRequest)
return
}
- if ecosystem == "" || len(segments) == 0 {
- badRequest(w, "ecosystem and name are required")
- return
- }
-
- // For the API, we don't have a DB to resolve names, so we use a heuristic:
- // the last segment that looks like a version (contains a digit) is the version,
- // everything before it is the name. If no version-like segment, it's all name.
- //
- // With 1 segment: package lookup (name only)
- // With 2+ segments: last segment is version, rest is name
- // Exception: if this is a namespaced ecosystem and we have exactly 2 segments,
- // it could be vendor/name with no version. The enrichment service handles
- // both cases (it will try to look up the package either way).
- if len(segments) == 1 {
- h.getPackage(w, r, ecosystem, segments[0])
- return
- }
-
- // Try the full path as a package name first via enrichment.
- // If it resolves, this is a package-only lookup.
- fullName := strings.Join(segments, "/")
- info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, fullName)
- if err == nil && info != nil {
- resp := &PackageResponse{
- Ecosystem: info.Ecosystem,
- Name: info.Name,
- LatestVersion: info.LatestVersion,
- License: info.License,
- LicenseCategory: string(shared.CategorizeLicense(info.License)),
- Description: info.Description,
- Homepage: info.Homepage,
- Repository: info.Repository,
- RegistryURL: info.RegistryURL,
- Cooldown: h.packageCooldown(ecosystem, fullName),
+ // Handle scoped npm packages (e.g., @scope/name)
+ if strings.HasPrefix(name, "@") {
+ // The path is split, so we need to get the rest
+ rest := chi.URLParam(r, "rest")
+ if rest != "" {
+ name = name + "/" + rest
}
- writeJSON(w, resp)
- return
}
- // Otherwise, last segment is the version.
- name := strings.Join(segments[:len(segments)-1], "/")
- version := segments[len(segments)-1]
- h.getVersion(w, r, ecosystem, name, version)
-}
-
-func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosystem, name string) {
info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name)
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to enrich package")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if info == nil {
- notFound(w, "package not found")
+ http.Error(w, "package not found", http.StatusNotFound)
return
}
@@ -235,25 +164,30 @@ func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosyste
Name: info.Name,
LatestVersion: info.LatestVersion,
License: info.License,
- LicenseCategory: string(shared.CategorizeLicense(info.License)),
+ LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)),
Description: info.Description,
Homepage: info.Homepage,
Repository: info.Repository,
RegistryURL: info.RegistryURL,
- Cooldown: h.packageCooldown(ecosystem, name),
}
writeJSON(w, resp)
}
-func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
- result, err := h.enrichment.EnrichFull(r.Context(), ecosystem, name, version)
- if errors.Is(err, shared.ErrUnsupportedVulnerabilityIdentity) {
- badRequest(w, "package identity is not supported for vulnerability lookups")
+// HandleGetVersion handles GET /api/package/{ecosystem}/{name}/{version}
+func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ if ecosystem == "" || name == "" || version == "" {
+ http.Error(w, "ecosystem, name, and version are required", http.StatusBadRequest)
return
}
+
+ result, err := h.enrichment.EnrichFull(r.Context(), ecosystem, name, version)
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to enrich version")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -268,12 +202,11 @@ func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosyste
Name: result.Package.Name,
LatestVersion: result.Package.LatestVersion,
License: result.Package.License,
- LicenseCategory: string(shared.CategorizeLicense(result.Package.License)),
+ LicenseCategory: string(h.enrichment.CategorizeLicense(result.Package.License)),
Description: result.Package.Description,
Homepage: result.Package.Homepage,
Repository: result.Package.Repository,
RegistryURL: result.Package.RegistryURL,
- Cooldown: h.packageCooldown(ecosystem, name),
}
}
@@ -287,11 +220,8 @@ func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosyste
Yanked: result.Version.Yanked,
IsOutdated: result.IsOutdated,
}
- if published := result.Version.PublishedAt; !published.IsZero() {
- resp.Version.PublishedAt = published.UTC().Format(apiTimeFormat)
- if at := h.cooldown.availableAt(ecosystem, name, published, time.Now()); !at.IsZero() {
- resp.Version.AvailableAt = at.UTC().Format(apiTimeFormat)
- }
+ if !result.Version.PublishedAt.IsZero() {
+ resp.Version.PublishedAt = result.Version.PublishedAt.Format("2006-01-02T15:04:05Z")
}
}
@@ -309,56 +239,25 @@ func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosyste
writeJSON(w, resp)
}
-// packageCooldown returns the cooldown for a package in the configuration's
-// syntax, or "" with cooldown off or not applied to the ecosystem.
-func (h *APIHandler) packageCooldown(ecosystem, name string) string {
- c := h.cooldown.forPackage(ecosystem, name)
- switch {
- case c == nil:
- return ""
- case c.Duration == cooldownOff:
- return "0"
- }
- return c.Duration
-}
-
-// HandleVulnsPath dispatches /api/vulns/{ecosystem}/* to the vulns handler.
-// Supports both {name} and {name}/{version} paths with namespaced package names.
-func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) {
+// HandleGetVulns handles GET /api/vulns/{ecosystem}/{name}
+func (h *APIHandler) HandleGetVulns(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- segments, err := packagePathSegments(r)
- if err != nil {
- badRequest(w, err.Error())
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ if ecosystem == "" || name == "" {
+ http.Error(w, "ecosystem and name are required", http.StatusBadRequest)
return
}
- if ecosystem == "" || len(segments) == 0 {
- badRequest(w, "ecosystem and name are required")
- return
- }
-
- // Last segment could be a version. Try full path as name first,
- // then split off the last segment as version.
- name := strings.Join(segments, "/")
- version := "0"
-
- if len(segments) > 1 {
- // Try enrichment with the full path as name.
- // If it doesn't resolve, assume last segment is version.
- info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name)
- if err != nil || info == nil {
- name = strings.Join(segments[:len(segments)-1], "/")
- version = segments[len(segments)-1]
- }
+ // If no version specified, use "0" to get all vulnerabilities
+ if version == "" {
+ version = "0"
}
vulns, err := h.enrichment.CheckVulnerabilities(r.Context(), ecosystem, name, version)
- if errors.Is(err, shared.ErrUnsupportedVulnerabilityIdentity) {
- badRequest(w, "package identity is not supported for vulnerability lookups")
- return
- }
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to check vulnerabilities")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -384,25 +283,15 @@ func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) {
}
// HandleOutdated handles POST /api/outdated
-// @Summary Check outdated packages
-// @Tags api
-// @Accept json
-// @Produce json
-// @Param request body OutdatedRequest true "Packages to check"
-// @Success 200 {object} OutdatedResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/outdated [post]
func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req OutdatedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
+ http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.Packages) == 0 {
- badRequest(w, "packages list is required")
+ http.Error(w, "packages list is required", http.StatusBadRequest)
return
}
@@ -420,7 +309,7 @@ func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) {
latest, err := h.enrichment.GetLatestVersion(r.Context(), pkg.Ecosystem, pkg.Name)
if err == nil && latest != "" {
result.LatestVersion = latest
- result.IsOutdated = shared.IsOutdated(pkg.Version, latest)
+ result.IsOutdated = h.enrichment.IsOutdated(pkg.Version, latest)
}
resp.Results = append(resp.Results, result)
@@ -430,25 +319,15 @@ func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) {
}
// HandleBulkLookup handles POST /api/bulk
-// @Summary Bulk package lookup by PURL
-// @Tags api
-// @Accept json
-// @Produce json
-// @Param request body BulkRequest true "PURLs"
-// @Success 200 {object} BulkResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/bulk [post]
func (h *APIHandler) HandleBulkLookup(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req BulkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
+ http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.PURLs) == 0 {
- badRequest(w, "purls list is required")
+ http.Error(w, "purls list is required", http.StatusBadRequest)
return
}
@@ -467,7 +346,7 @@ func (h *APIHandler) HandleBulkLookup(w http.ResponseWriter, r *http.Request) {
Name: info.Name,
LatestVersion: info.LatestVersion,
License: info.License,
- LicenseCategory: string(shared.CategorizeLicense(info.License)),
+ LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)),
Description: info.Description,
Homepage: info.Homepage,
Repository: info.Repository,
@@ -498,7 +377,7 @@ func (h *APIHandler) HandleBulkLookup(w http.ResponseWriter, r *http.Request) {
Name: info.Name,
LatestVersion: info.LatestVersion,
License: info.License,
- LicenseCategory: string(shared.CategorizeLicense(info.License)),
+ LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)),
Description: info.Description,
Homepage: info.Homepage,
Repository: info.Repository,
@@ -530,21 +409,12 @@ type SearchPackageResult struct {
}
// HandleSearch handles GET /api/search
-// @Summary Search cached packages
-// @Tags api
-// @Produce json
-// @Param q query string true "Query"
-// @Param ecosystem query string false "Ecosystem"
-// @Success 200 {object} SearchResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/search [get]
func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
ecosystem := r.URL.Query().Get("ecosystem")
if query == "" {
- badRequest(w, "query parameter 'q' is required")
+ http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
@@ -554,7 +424,7 @@ func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
// Search in database
results, err := h.db.SearchPackages(query, ecosystem, limit, (page-1)*limit)
if err != nil {
- internalError(w, "search failed")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -598,7 +468,7 @@ func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
- internalError(w, "failed to encode response")
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
@@ -627,32 +497,23 @@ type PackageListResult struct {
}
// HandlePackagesList handles GET /api/packages
-// @Summary List cached packages
-// @Tags api
-// @Produce json
-// @Param ecosystem query string false "Ecosystem"
-// @Param sort query string false "Sort" Enums(hits,name,size,cached_at,ecosystem,vulns)
-// @Success 200 {object} PackagesListResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/packages [get]
func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem")
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
- sortBy = defaultSortBy
+ sortBy = "hits"
}
validSorts := map[string]bool{
- defaultSortBy: true,
- "name": true,
- "size": true,
- "cached_at": true,
- "ecosystem": true,
- "vulns": true,
+ "hits": true,
+ "name": true,
+ "size": true,
+ "cached_at": true,
+ "ecosystem": true,
+ "vulns": true,
}
if !validSorts[sortBy] {
- badRequest(w, "invalid sort parameter")
+ http.Error(w, "invalid sort parameter", http.StatusBadRequest)
return
}
@@ -661,7 +522,7 @@ func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request)
packages, err := h.db.ListCachedPackages(ecosystem, sortBy, limit, (page-1)*limit)
if err != nil {
- internalError(w, "failed to list packages")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -686,10 +547,12 @@ func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request)
latestVersion = pkg.LatestVersion.String
}
license := ""
- licenseCategory := licenseCategoryUnknown
+ licenseCategory := "unknown"
if pkg.License.Valid {
license = pkg.License.String
- licenseCategory = string(shared.CategorizeLicense(license))
+ if h.enrichment != nil {
+ licenseCategory = string(h.enrichment.CategorizeLicense(license))
+ }
}
cachedAt := ""
if pkg.CachedAt.Valid {
diff --git a/internal/server/api_cooldown_test.go b/internal/server/api_cooldown_test.go
deleted file mode 100644
index 4e3e3c7..0000000
--- a/internal/server/api_cooldown_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package server
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/enrichment"
- "github.com/go-chi/chi/v5"
-)
-
-func TestAPIVersionAvailableAt(t *testing.T) {
- now := time.Now().UTC().Truncate(time.Second)
- recent, old := now.Add(-time.Hour), now.Add(-96*time.Hour)
- h := &APIHandler{
- enrichment: fakeEnricher{published: map[string]time.Time{"1.0.0": old, "2.0.0": recent}},
- cooldown: testCooldownView(t, config.CooldownConfig{Default: "3d"}),
- }
- r := chi.NewRouter()
- r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath)
-
- tests := []struct{ version, availableAt string }{
- {"2.0.0", recent.Add(72 * time.Hour).Format(apiTimeFormat)},
- {"1.0.0", ""},
- }
- for _, tt := range tests {
- t.Run(tt.version, func(t *testing.T) {
- w := httptest.NewRecorder()
- r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/package/npm/leftpad/"+tt.version, nil))
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, body = %s", w.Code, w.Body)
- }
- var resp EnrichmentResponse
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if resp.Package == nil || resp.Package.Cooldown != "3d" {
- t.Errorf("package = %+v, want cooldown 3d", resp.Package)
- }
- if resp.Version == nil || resp.Version.AvailableAt != tt.availableAt {
- t.Errorf("version = %+v, want available_at %q", resp.Version, tt.availableAt)
- }
- })
- }
-}
-
-// fakeEnricher answers version lookups with the given publish times and
-// knows no packages by name alone.
-type fakeEnricher struct{ published map[string]time.Time }
-
-func (fakeEnricher) EnrichPackage(context.Context, string, string) (*enrichment.PackageInfo, error) {
- return nil, nil
-}
-
-func (f fakeEnricher) EnrichFull(_ context.Context, ecosystem, name, version string) (*enrichment.EnrichmentResult, error) {
- return &enrichment.EnrichmentResult{
- Package: &enrichment.PackageInfo{Ecosystem: ecosystem, Name: name},
- Version: &enrichment.VersionInfo{Number: version, PublishedAt: f.published[version]},
- }, nil
-}
-
-func (fakeEnricher) CheckVulnerabilities(context.Context, string, string, string) ([]enrichment.VulnInfo, error) {
- return nil, nil
-}
-
-func (fakeEnricher) GetLatestVersion(context.Context, string, string) (string, error) {
- return "", nil
-}
-
-func (fakeEnricher) BulkEnrichPackages(context.Context, []struct{ Ecosystem, Name string }) map[string]*enrichment.PackageInfo {
- return nil
-}
diff --git a/internal/server/api_test.go b/internal/server/api_test.go
index 0494b2f..832f31a 100644
--- a/internal/server/api_test.go
+++ b/internal/server/api_test.go
@@ -9,16 +9,12 @@ import (
"net/http/httptest"
"os"
"path/filepath"
- "strings"
"testing"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
- "github.com/go-chi/chi/v5"
)
-const testEcosystemNPM = "npm"
-
func TestNewAPIHandler(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
@@ -32,66 +28,55 @@ func TestNewAPIHandler(t *testing.T) {
}
}
-func TestHandlePackagePath_MissingParams(t *testing.T) {
+func TestHandleGetPackage_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath)
-
req := httptest.NewRequest("GET", "/api/package//", nil)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
+
w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
+ h.HandleGetPackage(w, req)
- if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
- t.Errorf("expected status 400 or 404, got %d", w.Code)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
-func TestHandlePackagePath_InvalidName(t *testing.T) {
+func TestHandleGetVersion_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath)
+ req := httptest.NewRequest("GET", "/api/package///", nil)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
+ req.SetPathValue("version", "")
- tests := []struct {
- name string
- path string
- }{
- {"null byte", "/api/package/npm/lodash%00"},
- {"too long", "/api/package/npm/" + strings.Repeat("a", maxPackagePathLen+1)},
- }
+ w := httptest.NewRecorder()
+ h.HandleGetVersion(w, req)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest("GET", tt.path, nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status 400, got %d", w.Code)
- }
- })
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
-func TestHandleVulnsPath_MissingParams(t *testing.T) {
+func TestHandleGetVulns_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/vulns/{ecosystem}/*", h.HandleVulnsPath)
-
req := httptest.NewRequest("GET", "/api/vulns//", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
- if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
- t.Errorf("expected status 400 or 404, got %d", w.Code)
+ w := httptest.NewRecorder()
+ h.HandleGetVulns(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
@@ -109,25 +94,6 @@ func TestHandleOutdated_EmptyBody(t *testing.T) {
}
}
-func TestHandleOutdated_OversizedBody(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
- h := NewAPIHandler(svc, nil)
-
- // Send a body larger than 1 MB
- body := make([]byte, 2<<20)
- for i := range body {
- body[i] = 'x'
- }
- req := httptest.NewRequest("POST", "/api/outdated", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleOutdated(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status %d for oversized body, got %d", http.StatusBadRequest, w.Code)
- }
-}
-
func TestHandleOutdated_InvalidJSON(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
@@ -192,7 +158,7 @@ func TestWriteJSON(t *testing.T) {
func TestPackageResponseJSON(t *testing.T) {
resp := &PackageResponse{
- Ecosystem: testEcosystemNPM,
+ Ecosystem: "npm",
Name: "lodash",
LatestVersion: "4.17.21",
License: "MIT",
@@ -213,7 +179,7 @@ func TestPackageResponseJSON(t *testing.T) {
t.Fatalf("failed to unmarshal: %v", err)
}
- if decoded.Ecosystem != testEcosystemNPM {
+ if decoded.Ecosystem != "npm" {
t.Errorf("expected ecosystem npm, got %s", decoded.Ecosystem)
}
if decoded.Name != "lodash" {
@@ -324,7 +290,7 @@ func TestHandleSearch_WithNullValues(t *testing.T) {
pkg := &database.Package{
PURL: "pkg:npm/api-test",
- Ecosystem: testEcosystemNPM,
+ Ecosystem: "npm",
Name: "api-test",
}
if err := db.UpsertPackage(pkg); err != nil {
@@ -383,109 +349,3 @@ func TestHandleSearch_WithNullValues(t *testing.T) {
t.Errorf("expected 3 hits, got %d", result.Hits)
}
}
-
-func TestHandlePackagesListAPI(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
-
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "test.db")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // Seed two packages
- for _, name := range []string{"api-list-one", "api-list-two"} {
- pkg := &database.Package{
- PURL: "pkg:npm/" + name,
- Ecosystem: testEcosystemNPM,
- Name: name,
- }
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatalf("UpsertPackage failed: %v", err)
- }
- ver := &database.Version{
- PURL: "pkg:npm/" + name + "@1.0.0",
- PackagePURL: pkg.PURL,
- }
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatalf("UpsertVersion failed: %v", err)
- }
- art := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: name + "-1.0.0.tgz",
- UpstreamURL: "https://registry.npmjs.org/" + name + "/-/" + name + "-1.0.0.tgz",
- StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
- }
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatalf("UpsertArtifact failed: %v", err)
- }
- }
-
- h := NewAPIHandler(svc, db)
-
- r := chi.NewRouter()
- r.Get("/api/packages", h.HandlePackagesList)
-
- req := httptest.NewRequest("GET", "/api/packages", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- var resp PackagesListResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
-
- if len(resp.Results) < 2 {
- t.Fatalf("expected at least 2 results, got %d", len(resp.Results))
- }
-
- if resp.SortBy != defaultSortBy {
- t.Errorf("expected default sort by hits, got %q", resp.SortBy)
- }
-
- found := false
- for _, pkg := range resp.Results {
- if pkg.Name == "api-list-one" || pkg.Name == "api-list-two" {
- found = true
- break
- }
- }
- if !found {
- t.Error("expected seeded packages in results")
- }
-}
-
-func TestHandlePackagesListAPI_InvalidSort(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
-
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "test.db")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- h := NewAPIHandler(svc, db)
-
- r := chi.NewRouter()
- r.Get("/api/packages", h.HandlePackagesList)
-
- req := httptest.NewRequest("GET", "/api/packages?sort=invalid", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status 400 for invalid sort, got %d", w.Code)
- }
-}
diff --git a/internal/server/apk_snapshots.go b/internal/server/apk_snapshots.go
deleted file mode 100644
index ee8e3c9..0000000
--- a/internal/server/apk_snapshots.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package server
-
-import (
- "context"
- "time"
-)
-
-// apkSnapshotPruneInterval is how often snapshots kept past
-// upstream.apk_snapshots are deleted.
-const apkSnapshotPruneInterval = time.Hour
-
-// startAPKSnapshotPruning deletes APK index snapshots that were replaced more
-// than upstream.apk_snapshots ago, once at startup and then every hour. Their
-// blobs go through the pending-delete queue like evicted cache entries.
-func (s *Server) startAPKSnapshotPruning(ctx context.Context) {
- keep := s.cfg.ParseAPKSnapshots()
- if keep <= 0 {
- return
- }
- if !s.cfg.CacheMetadata {
- s.logger.Warn("upstream.apk_snapshots needs cache_metadata, snapshots disabled")
- return
- }
-
- ticker := time.NewTicker(apkSnapshotPruneInterval)
- defer ticker.Stop()
- for {
- s.pruneAPKSnapshots(ctx, keep, time.Now())
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- }
- }
-}
-
-func (s *Server) pruneAPKSnapshots(ctx context.Context, keep time.Duration, now time.Time) {
- s.runExclusive(ctx, "apk-snapshot-prune", func() {
- n, err := s.db.PruneMetadataSnapshots("alpine", now.Add(-keep))
- if err != nil {
- s.logger.Warn("apk snapshots: prune failed", "error", err)
- return
- }
- if n > 0 {
- s.logger.Info("apk snapshots: pruned", "deleted", n)
- }
- })
-}
diff --git a/internal/server/apk_snapshots_test.go b/internal/server/apk_snapshots_test.go
deleted file mode 100644
index 191e073..0000000
--- a/internal/server/apk_snapshots_test.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package server
-
-import (
- "context"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
- "github.com/go-chi/chi/v5"
-)
-
-// TestAPKSnapshotsConfigured checks that upstream.apk_snapshots enables the
-// dated APK paths, with the digest the file storage records for the cached
-// index matching the copy kept as a snapshot.
-func TestAPKSnapshotsConfigured(t *testing.T) {
- const index = "signed index"
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = io.WriteString(w, index)
- }))
- defer upstream.Close()
-
- db, err := database.Create(t.TempDir() + "/test.db")
- if err != nil {
- t.Fatalf("creating database: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
- store, err := storage.OpenBucket(context.Background(), "file://"+t.TempDir())
- if err != nil {
- t.Fatalf("opening storage: %v", err)
- }
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- proxy := handler.NewProxy(db, store, fetch.NewFetcher(), fetch.NewResolver(), logger)
- proxy.HTTPClient = upstream.Client()
- proxy.CacheMetadata = true
-
- cfg := config.Default()
- cfg.BaseURL = "http://proxy.test"
- cfg.Upstream.APK = map[string]string{"alpine": upstream.URL}
- cfg.Upstream.APKSnapshots = "30d"
- s := &Server{cfg: cfg, logger: logger}
- r := chi.NewRouter()
- s.mountProtocolHandlers(r, proxy)
-
- today := time.Now().UTC().Format("2006-01-02")
- for _, target := range []string{
- "/apk/alpine/v3.22/main/x86_64/APKINDEX.tar.gz",
- "/apk/alpine@" + today + "/v3.22/main/x86_64/APKINDEX.tar.gz",
- } {
- w := httptest.NewRecorder()
- r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
- if w.Code != http.StatusOK || w.Body.String() != index {
- t.Fatalf("%s: status = %d, body = %q, want 200 %q", target, w.Code, w.Body.String(), index)
- }
- }
-}
diff --git a/internal/server/breakers.go b/internal/server/breakers.go
deleted file mode 100644
index 383e0db..0000000
--- a/internal/server/breakers.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package server
-
-import (
- "log/slog"
- "sync"
-
- "github.com/git-pkgs/proxy/internal/metrics"
-)
-
-// Gauge values for proxy_circuit_breaker_state. The fetcher reports only open
-// or closed, so half-open (1) is never published.
-const (
- breakerGaugeClosed = 0
- breakerGaugeOpen = 2
-)
-
-const (
- breakerStateOpen = "open"
- breakerStateClosed = "closed"
-)
-
-// breakerStateSource reports circuit breaker state per upstream registry, keyed
-// by the identifier the fetcher derives from the fetch URL, with values
-// breakerStateOpen or breakerStateClosed. Implemented by
-// fetch.CircuitBreakerFetcher.
-type breakerStateSource interface {
- GetBreakerState() map[string]string
-}
-
-// breakerMonitor mirrors the artifact fetcher's per-registry circuit breaker
-// state into Prometheus metrics, the health report, and the log.
-//
-// Breaker state lives only in the fetcher's memory. While a breaker is open
-// every artifact fetch it covers that misses the cache fails without reaching
-// the upstream, bar the one probe per backoff interval the breaker admits to
-// test recovery. That looks identical to an upstream outage from the outside:
-// metadata still serves (it does not go through the fetcher), other registries
-// still serve, and /health reports the database and storage as fine. Publishing
-// the state makes that distinguishable.
-type breakerMonitor struct {
- source breakerStateSource
- logger *slog.Logger
-
- // mu serializes snapshots. It guards seen, which holds one entry per
- // registry that has tripped at least once in this process — the only
- // registries published as metrics — and keeps each state read paired with
- // the updates it produces.
- mu sync.Mutex
- seen map[string]string
-}
-
-func newBreakerMonitor(source breakerStateSource, logger *slog.Logger) *breakerMonitor {
- if logger == nil {
- logger = slog.Default()
- }
- return &breakerMonitor{
- source: source,
- logger: logger,
- seen: map[string]string{},
- }
-}
-
-// snapshot returns the current state of every breaker the fetcher has created,
-// keyed by registry identifier, and mirrors it into the breaker metrics as a
-// side effect. It returns nil for a nil monitor so callers that build a Server
-// without a fetcher (tests) need no special case.
-//
-// The keys pass through unaltered into Prometheus labels and the /health body,
-// neither of which is authenticated, so they are only as safe to publish as the
-// fetcher makes them. It keys by the fetch URL's host, and where it cannot take
-// a host from that URL — a signed composer dist.url that fails to parse, say —
-// by an opaque keyed digest of the URL rather than the URL itself, so a
-// credential carried in one does not reach either endpoint
-// (github.com/git-pkgs/registries v0.9.0 and later).
-//
-// Only registries that have tripped at least once are published as metrics.
-// The fetcher creates a breaker per identifier it fetches under, and for some
-// ecosystems that identifier comes from upstream metadata rather than
-// configuration (composer takes it from a package's dist.url, helm from the
-// chart URLs in index.yaml), so publishing every one would let upstream content
-// grow the series count for the life of the process — the more so for URLs with
-// no host, which get an identifier apiece rather than sharing one. An
-// identifier that has never tripped carries no information a series could
-// convey; once it trips it keeps reporting, including the 0 that marks its
-// recovery. /health is a per-request response rather than a persistent series,
-// so it reports every breaker.
-//
-// Trips are counted on the closed→open transitions observed between calls,
-// because the fetcher exposes current state rather than trip events: a breaker
-// that opens and recovers entirely between two calls is not counted.
-func (m *breakerMonitor) snapshot() map[string]string {
- if m == nil || m.source == nil {
- return nil
- }
-
- m.mu.Lock()
- defer m.mu.Unlock()
-
- // Read under the lock. Two concurrent snapshots — a /health request and a
- // /metrics scrape landing during a transition — can otherwise apply their
- // reads to seen in the opposite order, counting one trip twice, logging a
- // close for a breaker that is still open, and leaving the gauge at 0 until
- // the next call.
- states := m.source.GetBreakerState()
-
- for registry, state := range states {
- previous, published := m.seen[registry]
-
- switch {
- case state == breakerStateOpen && previous != breakerStateOpen:
- metrics.RecordCircuitBreakerTrip(registry)
- m.logger.Error("circuit breaker open, artifact fetches for this registry "+
- "fail without contacting it", "registry", registry)
- case state == breakerStateClosed && previous == breakerStateOpen:
- m.logger.Info("circuit breaker closed", "registry", registry)
- case state == breakerStateClosed && !published:
- // Never tripped: nothing to publish.
- continue
- }
-
- gauge := breakerGaugeClosed
- if state == breakerStateOpen {
- gauge = breakerGaugeOpen
- }
- metrics.UpdateCircuitBreakerState(registry, gauge)
- m.seen[registry] = state
- }
-
- return states
-}
diff --git a/internal/server/breakers_test.go b/internal/server/breakers_test.go
deleted file mode 100644
index 6cfb511..0000000
--- a/internal/server/breakers_test.go
+++ /dev/null
@@ -1,391 +0,0 @@
-package server
-
-import (
- "context"
- "encoding/json"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
- "github.com/git-pkgs/proxy/internal/metrics"
- registryclient "github.com/git-pkgs/registries/client"
- "github.com/git-pkgs/registries/fetch"
- "github.com/prometheus/client_golang/prometheus"
- dto "github.com/prometheus/client_model/go"
-)
-
-// cbThreshold in registries/fetch: failures inside the breaker's rolling window
-// needed to trip it. The library counts the window, not a consecutive run, so
-// these fetches only have to land close together, which they do.
-const breakerTripFailures = 5
-
-// fakeBreakerSource reports breaker state without a real fetcher, so tests can
-// drive transitions that would otherwise need to wait out a 30s backoff.
-type fakeBreakerSource struct {
- states map[string]string
-}
-
-func (f *fakeBreakerSource) GetBreakerState() map[string]string {
- states := make(map[string]string, len(f.states))
- for registry, state := range f.states {
- states[registry] = state
- }
- return states
-}
-
-// newTrippedMonitor returns a monitor over a real circuit-breaker fetcher whose
-// breaker for the test server's host has been tripped by repeated 5xx
-// responses, plus that host.
-func newTrippedMonitor(t *testing.T) (*breakerMonitor, string) {
- t.Helper()
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusBadGateway)
- }))
- t.Cleanup(upstream.Close)
-
- monitor, fetcher, host := newMonitorFor(t, upstream)
- for range breakerTripFailures {
- if _, err := fetcher.Fetch(context.Background(), upstream.URL+"/artifact.tgz"); err == nil {
- t.Fatal("fetch against a 502 upstream should fail")
- }
- }
- return monitor, host
-}
-
-// newMonitorFor builds a monitor over a circuit-breaker fetcher that talks to
-// srv through its own client, bypassing the SSRF dial gate that would otherwise
-// refuse the loopback address.
-func newMonitorFor(t *testing.T, srv *httptest.Server) (
- monitor *breakerMonitor, fetcher *fetch.CircuitBreakerFetcher, host string,
-) {
- t.Helper()
-
- base := fetch.NewFetcher(
- fetch.WithHTTPClient(srv.Client()),
- fetch.WithMaxRetries(0),
- )
- t.Cleanup(func() { _ = base.Close() })
-
- fetcher = fetch.NewCircuitBreakerFetcher(base)
- return newBreakerMonitor(fetcher, slog.New(slog.DiscardHandler)),
- fetcher,
- strings.TrimPrefix(srv.URL, "http://")
-}
-
-// metricValue returns the value of the series carrying registry=want, and
-// whether such a series exists at all. It collects rather than calling
-// WithLabelValues, which would create the series it is looking for.
-func metricValue(t *testing.T, collector prometheus.Collector, want string) (value float64, found bool) {
- t.Helper()
-
- ch := make(chan prometheus.Metric, 64)
- go func() {
- collector.Collect(ch)
- close(ch)
- }()
-
- for metric := range ch {
- var parsed dto.Metric
- if err := metric.Write(&parsed); err != nil {
- t.Fatalf("writing metric: %v", err)
- }
- for _, label := range parsed.GetLabel() {
- if label.GetName() != "registry" || label.GetValue() != want {
- continue
- }
- if gauge := parsed.GetGauge(); gauge != nil {
- return gauge.GetValue(), true
- }
- return parsed.GetCounter().GetValue(), true
- }
- }
- return 0, false
-}
-
-// resetSeries drops any series for host left behind by an earlier test, since
-// httptest ports can be reused within a process and the metrics registry is
-// global. Absolute trip counts stay meaningful after it.
-func resetSeries(host string) {
- metrics.CircuitBreakerState.DeleteLabelValues(host)
- metrics.CircuitBreakerTrips.DeleteLabelValues(host)
-}
-
-func TestBreakerMonitor_OpenBreakerReportedAndCounted(t *testing.T) {
- monitor, host := newTrippedMonitor(t)
- resetSeries(host)
-
- if state := monitor.snapshot()[host]; state != breakerStateOpen {
- t.Fatalf("state for %s = %q, want open", host, state)
- }
-
- gauge, found := metricValue(t, metrics.CircuitBreakerState, host)
- if !found {
- t.Fatalf("no state gauge published for %s", host)
- }
- if gauge != breakerGaugeOpen {
- t.Errorf("state gauge = %v, want %v", gauge, breakerGaugeOpen)
- }
- if trips, _ := metricValue(t, metrics.CircuitBreakerTrips, host); trips != 1 {
- t.Errorf("trips = %v, want 1", trips)
- }
-
- // A breaker that stays open is one trip, not one per scrape.
- monitor.snapshot()
- monitor.snapshot()
- if trips, _ := metricValue(t, metrics.CircuitBreakerTrips, host); trips != 1 {
- t.Errorf("trips after further snapshots = %v, want 1", trips)
- }
-}
-
-// A breaker per fetched host is created even for hosts that come from upstream
-// metadata (composer dist.url, helm chart URLs), so publishing a series for
-// every one of them would let upstream content grow the series count without
-// bound. Only registries that have actually tripped are published.
-func TestBreakerMonitor_HealthyRegistryPublishesNoSeries(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte("artifact"))
- }))
- defer upstream.Close()
-
- monitor, fetcher, host := newMonitorFor(t, upstream)
- resetSeries(host)
-
- artifact, err := fetcher.Fetch(context.Background(), upstream.URL+"/artifact.tgz")
- if err != nil {
- t.Fatalf("fetch: %v", err)
- }
- _, _ = io.Copy(io.Discard, artifact.Body)
- _ = artifact.Body.Close()
-
- // The breaker exists and is reported to /health...
- if state := monitor.snapshot()[host]; state != breakerStateClosed {
- t.Fatalf("state for %s = %q, want closed", host, state)
- }
- // ...but carries no metric series.
- if _, found := metricValue(t, metrics.CircuitBreakerState, host); found {
- t.Errorf("state gauge published for %s, want none until it trips", host)
- }
- if _, found := metricValue(t, metrics.CircuitBreakerTrips, host); found {
- t.Errorf("trip counter published for %s, want none until it trips", host)
- }
-}
-
-// Once a registry has tripped it keeps reporting, so recovery is visible as a
-// transition to 0 rather than as a series that disappears.
-func TestBreakerMonitor_RecoveryReportedAfterTrip(t *testing.T) {
- const host = "recovering.example.com"
-
- resetSeries(host)
-
- source := &fakeBreakerSource{states: map[string]string{host: breakerStateOpen}}
- monitor := newBreakerMonitor(source, slog.New(slog.DiscardHandler))
-
- monitor.snapshot()
- if gauge, _ := metricValue(t, metrics.CircuitBreakerState, host); gauge != breakerGaugeOpen {
- t.Fatalf("state gauge = %v, want %v", gauge, breakerGaugeOpen)
- }
-
- source.states[host] = breakerStateClosed
- if state := monitor.snapshot()[host]; state != breakerStateClosed {
- t.Fatalf("state for %s = %q, want closed", host, state)
- }
- gauge, found := metricValue(t, metrics.CircuitBreakerState, host)
- if !found {
- t.Fatal("state gauge disappeared after recovery, want 0")
- }
- if gauge != breakerGaugeClosed {
- t.Errorf("state gauge = %v, want %v", gauge, breakerGaugeClosed)
- }
- if trips, _ := metricValue(t, metrics.CircuitBreakerTrips, host); trips != 1 {
- t.Errorf("trips = %v, want 1", trips)
- }
-
- // Tripping again after recovery counts a second trip.
- source.states[host] = breakerStateOpen
- monitor.snapshot()
- if trips, _ := metricValue(t, metrics.CircuitBreakerTrips, host); trips != 2 {
- t.Errorf("trips after re-trip = %v, want 2", trips)
- }
-}
-
-func TestBreakerMonitor_NilSafe(t *testing.T) {
- var nilMonitor *breakerMonitor
- if states := nilMonitor.snapshot(); states != nil {
- t.Errorf("nil monitor snapshot = %v, want nil", states)
- }
- if states := newBreakerMonitor(nil, nil).snapshot(); states != nil {
- t.Errorf("snapshot without a state source = %v, want nil", states)
- }
-}
-
-func TestHealthEndpoint_ReportsOpenBreaker(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- monitor, host := newTrippedMonitor(t)
- ts.server.breakers = monitor
-
- req := httptest.NewRequest("GET", "/health", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- // An unreachable upstream is not this proxy being unhealthy: the breaker
- // state is reported, but the probe still passes.
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
-
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp.Status != "ok" {
- t.Errorf("status = %q, want ok", resp.Status)
- }
- if got := resp.CircuitBreakers[host]; got != breakerStateOpen {
- t.Errorf("circuit_breakers[%s] = %q, want open", host, got)
- }
-}
-
-func TestHealthEndpoint_OmitsBreakersWhenNoneExist(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest("GET", "/health", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if strings.Contains(w.Body.String(), "circuit_breakers") {
- t.Errorf("body should omit circuit_breakers when no breaker exists: %s", w.Body.String())
- }
-}
-
-// blockingBreakerSource holds each state read open until the test releases it,
-// and reports every entry, so a read that escaped the monitor lock announces
-// itself. inFlight is a second net: it catches an overlap anywhere in the test,
-// not just while the release channel is held.
-type blockingBreakerSource struct {
- states map[string]string
- entered chan struct{}
- release chan struct{}
- inFlight atomic.Int32
- overlap atomic.Bool
-}
-
-func (b *blockingBreakerSource) GetBreakerState() map[string]string {
- if b.inFlight.Add(1) > 1 {
- b.overlap.Store(true)
- }
- defer b.inFlight.Add(-1)
-
- b.entered <- struct{}{}
- <-b.release
-
- states := make(map[string]string, len(b.states))
- for registry, state := range b.states {
- states[registry] = state
- }
- return states
-}
-
-// /health requests and /metrics scrapes call snapshot concurrently. The state
-// read has to happen under the same lock as the seen updates it feeds: read
-// outside it, two snapshots straddling a transition can apply their reads to
-// seen in the opposite order and count one trip twice.
-func TestBreakerMonitor_SnapshotSerializesStateReads(t *testing.T) {
- const host = "serialized.example.com"
-
- resetSeries(host)
-
- source := &blockingBreakerSource{
- states: map[string]string{host: breakerStateOpen},
- entered: make(chan struct{}, 2),
- release: make(chan struct{}),
- }
- monitor := newBreakerMonitor(source, slog.New(slog.DiscardHandler))
-
- first := make(chan map[string]string, 1)
- go func() { first <- monitor.snapshot() }()
-
- // The first snapshot is inside GetBreakerState now, holding the lock.
- <-source.entered
-
- second := make(chan map[string]string, 1)
- go func() { second <- monitor.snapshot() }()
-
- // Nothing can release the first read, so the second cannot get past the
- // lock: an unlocked read would have queued an entry on the channel.
- select {
- case <-source.entered:
- t.Fatal("second snapshot read breaker state while the first still held the lock")
- case <-time.After(100 * time.Millisecond):
- }
-
- close(source.release)
-
- if state := (<-first)[host]; state != breakerStateOpen {
- t.Errorf("first snapshot state for %s = %q, want open", host, state)
- }
- // Only reached once the first snapshot has returned the lock.
- <-source.entered
- if state := (<-second)[host]; state != breakerStateOpen {
- t.Errorf("second snapshot state for %s = %q, want open", host, state)
- }
-
- if source.overlap.Load() {
- t.Error("two snapshots read breaker state concurrently")
- }
- // The same open breaker seen twice is one trip, which is what the paired
- // read and update buys.
- if trips, _ := metricValue(t, metrics.CircuitBreakerTrips, host); trips != 1 {
- t.Errorf("trips = %v, want 1", trips)
- }
-}
-
-func TestHealthEndpoint_ReportsUpstreamHolds(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- gate := registryclient.NewRateLimitGate(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Header: http.Header{"Retry-After": []string{"600"}},
- Body: http.NoBody,
- Request: req,
- }, nil
- }))
- req, _ := http.NewRequest(http.MethodGet, "https://repo1.maven.org/maven2/x", nil)
- if resp, err := gate.RoundTrip(req); err == nil {
- _ = resp.Body.Close()
- }
- ts.server.rateLimits = upstreamhttp.NewSharedRateLimitGate(gate, nil, nil)
-
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
-
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- until, err := time.Parse(time.RFC3339, resp.UpstreamHolds["repo1.maven.org"])
- if err != nil {
- t.Fatalf("upstream_holds = %v, want repo1.maven.org with an RFC 3339 deadline", resp.UpstreamHolds)
- }
- if remaining := time.Until(until); remaining < 9*time.Minute || remaining > 10*time.Minute {
- t.Errorf("hold ends in %s, want about 10m", remaining)
- }
-}
-
-type roundTripperFunc func(*http.Request) (*http.Response, error)
-
-func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
diff --git a/internal/server/browse.go b/internal/server/browse.go
index 1184f2e..21f973a 100644
--- a/internal/server/browse.go
+++ b/internal/server/browse.go
@@ -1,159 +1,29 @@
package server
import (
- "bufio"
"encoding/json"
- "errors"
"fmt"
"io"
- "io/fs"
"net/http"
"path"
"strings"
- "sync"
"github.com/git-pkgs/archives"
"github.com/git-pkgs/archives/diff"
- "github.com/git-pkgs/magic"
"github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
+ "github.com/git-pkgs/purl"
"github.com/go-chi/chi/v5"
)
-const (
- contentTypePlainText = "text/plain; charset=utf-8"
- browseSniffSize = 512
- // browsePrefetchSize is how much of a browsed file is read before the
- // response starts. Read errors within it still produce an error status.
- browsePrefetchSize = 64 << 10
-)
-
-// maxBrowseArchiveSize caps the compressed artifact size the browse and diff
-// endpoints will read. Artifacts larger than this are rejected to prevent
-// memory exhaustion from a single request.
-const maxBrowseArchiveSize = 512 << 20 // 512 MB
-
-// maxConcurrentCompares caps how many version diffs run at once. Each one
-// buffers both archives whole, up to maxBrowseArchiveSize apiece, so without
-// a cap parallel requests could exhaust memory.
-const maxConcurrentCompares = 2
-
-// compareRetryAfter is the Retry-After, in seconds, sent with a diff refused
-// because maxConcurrentCompares are already running.
-const compareRetryAfter = "5"
-
-// compareLimiter counts running version diffs. The zero value is ready to use.
-type compareLimiter struct {
- mu sync.Mutex
- active int
-}
-
-// tryAcquire takes a slot if fewer than maxConcurrentCompares are taken.
-func (l *compareLimiter) tryAcquire() bool {
- l.mu.Lock()
- defer l.mu.Unlock()
- if l.active >= maxConcurrentCompares {
- return false
- }
- l.active++
- return true
-}
-
-func (l *compareLimiter) release() {
- l.mu.Lock()
- defer l.mu.Unlock()
- l.active--
-}
-
-// firstBrowsableArtifact returns the first cached artifact that can be opened as
-// an archive, or nil if the version has none.
-//
-// A version's artifact list is not all archives: a PEP 658 core-metadata sidecar
-// resolves to the same name and version as the distribution it describes, so it
-// is cached under that version too. Sidecars are plain text, and because '-'
-// sorts before '.' one can even precede the real distribution in the
-// filename-ordered list, so selecting blindly would hand openArchive a file it
-// cannot parse.
-func firstBrowsableArtifact(artifacts []database.Artifact) *database.Artifact {
- for i := range artifacts {
- if artifacts[i].StoragePath.Valid && !isMetadataSidecar(artifacts[i].Filename) {
- return &artifacts[i]
- }
- }
-
- return nil
-}
-
-// isMetadataSidecar reports whether filename is a core-metadata sidecar rather
-// than a distribution archive.
-func isMetadataSidecar(filename string) bool {
- return strings.HasSuffix(filename, handler.PyPIMetadataSuffix)
-}
-
-// detectSingleRootDir returns the single top-level directory name if all files
-// in the archive live under one common directory (e.g. GitHub zipballs use
-// "repo-hash/"). Returns "" if there's no single root or the archive is flat.
-func detectSingleRootDir(reader archives.Reader) string {
- files, err := reader.List()
- if err != nil {
+// getStripPrefix returns the path prefix to strip for a given ecosystem.
+// npm packages wrap content in a "package/" directory.
+func getStripPrefix(ecosystem string) string {
+ switch ecosystem {
+ case "npm":
+ return "package/"
+ default:
return ""
}
-
- return archives.CommonRoot(files)
-}
-
-// openArchive opens a cached artifact as a random-access archive reader,
-// auto-detecting and stripping a single top-level directory prefix (like
-// GitHub zipballs). For npm, the hardcoded "package/" prefix takes precedence.
-// The whole artifact is buffered, so only the version diff uses it; listing
-// and file reads use the streaming browser instead.
-func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations
- data, err := readBrowseInput(content, maxBrowseArchiveSize)
- if err != nil {
- return nil, err
- }
-
- if ecosystem == "npm" {
- return archives.OpenBytesWithPrefix(filename, data, npmPackagePrefix)
- }
-
- probe, err := archives.OpenBytes(filename, data)
- if err != nil {
- return nil, err
- }
- prefix := detectSingleRootDir(probe)
- _ = probe.Close()
-
- return archives.OpenBytesWithPrefix(filename, data, prefix)
-}
-
-// browseLimitsOrDefault returns the stream limits for listing and file reads.
-func (s *Server) browseLimitsOrDefault() archives.StreamOptions {
- if s.browseLimits != (archives.StreamOptions{}) {
- return s.browseLimits
- }
- return defaultBrowseLimits
-}
-
-// openBrowseArchive prepares a cached artifact for streaming. content is the
-// already open storage reader; later passes reopen the artifact.
-func (s *Server) openBrowseArchive(r *http.Request, artifact *database.Artifact, content io.Reader, ecosystem string) (*archives.Browser, error) {
- storagePath := artifact.StoragePath.String
- reopen := func() (io.ReadCloser, error) {
- return s.storage.Open(r.Context(), storagePath)
- }
- return newBrowseArchive(artifact.Filename, ecosystem, content, reopen, s.browseLimitsOrDefault())
-}
-
-// browseArchiveError reports a failure to read an archive for browsing.
-func (s *Server) browseArchiveError(w http.ResponseWriter, err error, filename string) {
- if isBrowseLimitError(err) {
- s.logger.Warn("archive exceeds browse limits", "error", err, "filename", filename)
- internalError(w, "archive exceeds browse limits")
- return
- }
- s.logger.Error("failed to open archive", "error", err, "filename", filename)
- internalError(w, "failed to open archive")
}
// BrowseListResponse contains the file listing for a directory in an archives.
@@ -173,122 +43,36 @@ type BrowseFileInfo struct {
// handleBrowseList returns a list of files in a directory within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}?path=/some/dir
-// @Summary List files inside a cached artifact
-// @Description Lists files from the first cached artifact for a package version.
-// @Tags browse
-// @Produce json
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param version path string true "Version"
-// @Param path query string false "Directory path inside the archive"
-// @Success 200 {object} BrowseListResponse
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /ui/api/browse/{ecosystem}/{name}/{version} [get]
-// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler.
-// It resolves namespaced package names by consulting the database.
-//
-// Supported paths:
-//
-// {name}/{version} -> browse list
-// {name}/{version}/file/{path} -> browse file
-func (s *Server) handleBrowsePath(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- segments, err := packagePathSegments(r)
- if err != nil {
- badRequest(w, err.Error())
- return
- }
-
- if ecosystem == "" || len(segments) < 2 {
- badRequest(w, "ecosystem, name, and version required")
- return
- }
-
- // Check for /file/ in the path for browse file requests.
- fileIdx := -1
- for i, seg := range segments {
- if seg == "file" && i > 0 {
- fileIdx = i
- break
- }
- }
-
- if fileIdx >= 0 {
- // Everything before "file" is name+version, everything after is the file path.
- nameVersionSegments := segments[:fileIdx]
- filePath := strings.Join(segments[fileIdx+1:], "/")
-
- name, rest := resolvePackageName(s.db, ecosystem, nameVersionSegments)
- if name == "" && len(nameVersionSegments) >= 2 {
- name = strings.Join(nameVersionSegments[:len(nameVersionSegments)-1], "/")
- rest = nameVersionSegments[len(nameVersionSegments)-1:]
- }
- if len(rest) != 1 {
- notFound(w, "not found")
- return
- }
- s.browseFile(w, r, ecosystem, name, rest[0], filePath)
- return
- }
-
- // No /file/ segment: this is a browse list.
- name, rest := resolvePackageName(s.db, ecosystem, segments)
- if name == "" && len(segments) >= 2 {
- name = strings.Join(segments[:len(segments)-1], "/")
- rest = segments[len(segments)-1:]
- }
- if len(rest) != 1 {
- notFound(w, "not found")
- return
- }
- s.browseList(w, r, ecosystem, name, rest[0])
-}
-
-// handleComparePath dispatches /api/compare/{ecosystem}/* to the compare handler.
-// Supported paths: {name}/{fromVersion}/{toVersion}
-func (s *Server) handleComparePath(w http.ResponseWriter, r *http.Request) {
- ecosystem := chi.URLParam(r, "ecosystem")
- segments, err := packagePathSegments(r)
- if err != nil {
- badRequest(w, err.Error())
- return
- }
-
- if ecosystem == "" || len(segments) < 3 {
- badRequest(w, "ecosystem, name, fromVersion, and toVersion required")
- return
- }
-
- // The last two segments are fromVersion and toVersion.
- // Everything before that is the package name.
- name := strings.Join(segments[:len(segments)-2], "/")
- fromVersion := segments[len(segments)-2]
- toVersion := segments[len(segments)-1]
-
- s.compareDiff(w, r, ecosystem, name, fromVersion, toVersion)
-}
-
-func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
dirPath := r.URL.Query().Get("path")
// Get the artifact for this version
- versionPURL := s.cachedVersionPURL(ecosystem, name, version)
+ versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
- notFound(w, "version not found")
+ http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
- notFound(w, "no artifacts cached")
+ http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
- cachedArtifact := firstBrowsableArtifact(artifacts)
+ // Find the first cached artifact
+ var cachedArtifact *database.Artifact
+ for i := range artifacts {
+ if artifacts[i].StoragePath.Valid {
+ cachedArtifact = &artifacts[i]
+ break
+ }
+ }
if cachedArtifact == nil {
- notFound(w, "artifact not cached")
+ http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
@@ -296,21 +80,26 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
- internalError(w, "failed to read artifact")
+ http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
- archive, err := s.openBrowseArchive(r, cachedArtifact, artifactReader, ecosystem)
+ // Open archive with appropriate prefix stripping
+ stripPrefix := getStripPrefix(ecosystem)
+ archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
- s.browseArchiveError(w, err, cachedArtifact.Filename)
+ s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
+ http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
+ defer func() { _ = archiveReader.Close() }()
- // List files in the directory, with the root prefix stripped
- files, err := archive.ListDir(dirPath)
+ // List files in the directory
+ files, err := archiveReader.ListDir(dirPath)
if err != nil {
- s.browseArchiveError(w, err, cachedArtifact.Filename)
+ s.logger.Error("failed to list directory", "error", err, "path", dirPath)
+ http.Error(w, "failed to list directory", http.StatusInternalServerError)
return
}
@@ -336,42 +125,42 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
// handleBrowseFile returns the contents of a specific file within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}/file/{filepath...}
-// @Summary Fetch a file inside a cached artifact
-// @Description Streams a single file from the cached artifact. The file path may contain slashes.
-// @Tags browse
-// @Produce application/octet-stream
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param version path string true "Version"
-// @Param filepath path string true "File path inside the archive"
-// @Success 200 {file} file
-// @Failure 400 {object} ErrorResponse
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get]
-func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) {
+func (s *Server) handleBrowseFile(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ // Get the wildcard path
+ filePath := chi.URLParam(r, "*")
if filePath == "" {
- badRequest(w, "file path required")
+ http.Error(w, "file path required", http.StatusBadRequest)
return
}
// Get the artifact for this version
- versionPURL := s.cachedVersionPURL(ecosystem, name, version)
+ versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
- notFound(w, "version not found")
+ http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
- notFound(w, "no artifacts cached")
+ http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
- cachedArtifact := firstBrowsableArtifact(artifacts)
+ // Find the first cached artifact
+ var cachedArtifact *database.Artifact
+ for i := range artifacts {
+ if artifacts[i].StoragePath.Valid {
+ cachedArtifact = &artifacts[i]
+ break
+ }
+ }
if cachedArtifact == nil {
- notFound(w, "artifact not cached")
+ http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
@@ -379,190 +168,122 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
- internalError(w, "failed to read artifact")
+ http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
- archive, err := s.openBrowseArchive(r, cachedArtifact, artifactReader, ecosystem)
+ // Open archive with appropriate prefix stripping
+ stripPrefix := getStripPrefix(ecosystem)
+ archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
- s.browseArchiveError(w, err, cachedArtifact.Filename)
+ s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
+ http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
+ defer func() { _ = archiveReader.Close() }()
- // Find the file and stream it straight from the archive
- fileReader, err := archive.Extract(filePath)
- switch {
- case errors.Is(err, fs.ErrNotExist), errors.Is(err, archives.ErrIsDir):
- notFound(w, "file not found")
- return
- case err != nil:
- s.browseArchiveError(w, err, cachedArtifact.Filename)
+ // Extract the file
+ fileReader, err := archiveReader.Extract(filePath)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ http.Error(w, "file not found", http.StatusNotFound)
+ return
+ }
+ s.logger.Error("failed to extract file", "error", err, "path", filePath)
+ http.Error(w, "failed to extract file", http.StatusInternalServerError)
return
}
defer func() { _ = fileReader.Close() }()
- s.writeBrowseFile(w, fileReader, filePath, cachedArtifact.Filename)
-}
-
-// writeBrowseFile sends a file streamed from an archive. The start of the file
-// is read before any headers, so a truncated or over-limit entry that fails
-// there still gets an error status. A failure after the response has started
-// aborts it, so the client sees an incomplete download rather than a
-// successful one.
-func (s *Server) writeBrowseFile(w http.ResponseWriter, file io.Reader, filePath, artifactName string) {
- source := &trackedReader{reader: file}
- content := bufio.NewReaderSize(source, browsePrefetchSize)
- head, err := content.Peek(browsePrefetchSize)
- if err != nil && !errors.Is(err, io.EOF) {
- s.browseReadError(w, err, filePath, artifactName)
- return
- }
-
- contentType, knownPath := detectContentTypeFromPath(filePath)
- if !knownPath {
- contentType = detectContentTypeFromPrefix(head[:min(len(head), browseSniffSize)])
- }
+ // Set content type based on file extension
+ contentType := detectContentType(filePath)
w.Header().Set("Content-Type", contentType)
- w.Header().Set("Content-Security-Policy", "sandbox; "+frameAncestorsNone)
- w.Header().Set("X-Content-Type-Options", "nosniff")
+ // Set filename for download
_, filename := path.Split(filePath)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
- written, err := io.Copy(w, content)
- if err == nil || source.err == nil {
- // A write error without a read error means the client went away.
- return
- }
- s.logger.Error("failed to stream file from archive", "error", source.err,
- "path", filePath, "filename", artifactName, "bytes", written)
- // Headers are already committed: finishing normally would turn a truncated
- // file into a seemingly successful download. Let net/http close the HTTP/1
- // connection or reset the HTTP/2 stream instead.
- panic(http.ErrAbortHandler)
+ // Stream the file
+ _, _ = io.Copy(w, fileReader)
}
-// browseReadError reports a failure reading a file before its response starts.
-func (s *Server) browseReadError(w http.ResponseWriter, err error, filePath, artifactName string) {
- if isBrowseLimitError(err) {
- s.logger.Warn("archive exceeds browse limits", "error", err, "path", filePath, "filename", artifactName)
- internalError(w, "archive exceeds browse limits")
- return
- }
- s.logger.Error("failed to read file from archive", "error", err, "path", filePath, "filename", artifactName)
- internalError(w, "failed to read file")
-}
-
-// trackedReader records the first read error other than io.EOF, so a failed
-// copy can be told apart from a failed write to the client.
-type trackedReader struct {
- reader io.Reader
- err error
-}
-
-func (r *trackedReader) Read(p []byte) (int, error) {
- n, err := r.reader.Read(p)
- if err != nil && !errors.Is(err, io.EOF) && r.err == nil {
- r.err = err
- }
- return n, err
-}
-
-func detectContentTypeFromPath(filename string) (string, bool) {
+// detectContentType returns an appropriate content type based on file extension.
+func detectContentType(filename string) string {
ext := strings.ToLower(path.Ext(filename))
switch ext {
// Text formats
case ".txt", ".md", ".markdown":
- return contentTypePlainText, true
- case ".html", ".htm", ".xhtml":
- return contentTypePlainText, true
+ return "text/plain; charset=utf-8"
+ case ".html", ".htm":
+ return "text/html; charset=utf-8"
case ".css":
- return "text/css; charset=utf-8", true
+ return "text/css; charset=utf-8"
case ".js", ".mjs":
- return "application/javascript; charset=utf-8", true
+ return "application/javascript; charset=utf-8"
case ".json":
- return "application/json; charset=utf-8", true
+ return "application/json; charset=utf-8"
case ".xml":
- return "application/xml; charset=utf-8", true
+ return "application/xml; charset=utf-8"
case ".yaml", ".yml":
- return "text/yaml; charset=utf-8", true
+ return "text/yaml; charset=utf-8"
case ".toml":
- return "text/toml; charset=utf-8", true
+ return "text/toml; charset=utf-8"
// Programming languages
case ".go":
- return "text/x-go; charset=utf-8", true
+ return "text/x-go; charset=utf-8"
case ".rs":
- return "text/x-rust; charset=utf-8", true
+ return "text/x-rust; charset=utf-8"
case ".py":
- return "text/x-python; charset=utf-8", true
+ return "text/x-python; charset=utf-8"
case ".rb":
- return "text/x-ruby; charset=utf-8", true
+ return "text/x-ruby; charset=utf-8"
case ".java":
- return "text/x-java; charset=utf-8", true
+ return "text/x-java; charset=utf-8"
case ".c", ".h":
- return "text/x-c; charset=utf-8", true
+ return "text/x-c; charset=utf-8"
case ".cpp", ".cc", ".cxx", ".hpp":
- return "text/x-c++; charset=utf-8", true
+ return "text/x-c++; charset=utf-8"
case ".ts":
- return "text/typescript; charset=utf-8", true
+ return "text/typescript; charset=utf-8"
case ".tsx":
- return "text/tsx; charset=utf-8", true
+ return "text/tsx; charset=utf-8"
case ".jsx":
- return "text/jsx; charset=utf-8", true
+ return "text/jsx; charset=utf-8"
case ".php":
- return "text/x-php; charset=utf-8", true
+ return "text/x-php; charset=utf-8"
// Config files
case ".conf", ".config", ".ini":
- return contentTypePlainText, true
+ return "text/plain; charset=utf-8"
case ".sh", ".bash":
- return "text/x-shellscript; charset=utf-8", true
+ return "text/x-shellscript; charset=utf-8"
case ".dockerfile":
- return "text/x-dockerfile; charset=utf-8", true
+ return "text/x-dockerfile; charset=utf-8"
// Images
case ".png":
- return "image/png", true
+ return "image/png"
case ".jpg", ".jpeg":
- return "image/jpeg", true
+ return "image/jpeg"
case ".gif":
- return "image/gif", true
+ return "image/gif"
case ".svg":
- return contentTypePlainText, true
+ return "image/svg+xml"
case ".ico":
- return "image/x-icon", true
+ return "image/x-icon"
// Archives
case ".zip", ".tar", ".gz", ".bz2", ".xz":
- return "application/octet-stream", true
+ return "application/octet-stream"
default:
+ // Try to detect if it looks like text
if isLikelyText(filename) {
- return contentTypePlainText, true
+ return "text/plain; charset=utf-8"
}
- return "", false
- }
-}
-
-func detectContentTypeFromPrefix(prefix []byte) string {
- result := magic.DetectPrefix(prefix)
- if result.Kind == magic.KindText {
- return contentTypePlainText
- }
-
- switch result.Format {
- case "png":
- return "image/png"
- case "jpeg":
- return "image/jpeg"
- case "gif":
- return "image/gif"
- case "pdf":
- return "application/pdf"
- default:
return "application/octet-stream"
}
}
@@ -591,73 +312,80 @@ func isLikelyText(filename string) bool {
}
// BrowseSourceData contains data for the browse source page.
-//
-// Version is the decoded version, for display. EscapedVersion is the same value
-// escaped as a single URL path segment and is what the links and the browse API
-// calls must use; see database.Version.EscapedVersion.
type BrowseSourceData struct {
- Layout
- Ecosystem string
- PackageName string
- Version string
- EscapedVersion string
+ Ecosystem string
+ PackageName string
+ Version string
}
-// handleBrowseSource is now showBrowseSource in server.go, dispatched via handlePackagePath.
+// handleBrowseSource renders the source code browser UI.
+// GET /package/{ecosystem}/{name}/{version}/browse
+func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ data := BrowseSourceData{
+ Ecosystem: ecosystem,
+ PackageName: name,
+ Version: version,
+ }
+
+ if err := s.templates.Render(w, "browse_source", data); err != nil {
+ s.logger.Error("failed to render browse source page", "error", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+}
// handleCompareDiff compares two versions and returns a diff.
// GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}
-// @Summary Compare two cached versions
-// @Description Returns a structured diff for two cached versions.
-// @Tags browse
-// @Produce json
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param fromVersion path string true "From version"
-// @Param toVersion path string true "To version"
-// @Success 200 {object} map[string]any
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Failure 503 {object} ErrorResponse
-// @Router /ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
-func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
+func (s *Server) handleCompareDiff(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ fromVersion := chi.URLParam(r, "fromVersion")
+ toVersion := chi.URLParam(r, "toVersion")
+
// Get artifacts for both versions
- fromPURL := s.cachedVersionPURL(ecosystem, name, fromVersion)
- toPURL := s.cachedVersionPURL(ecosystem, name, toVersion)
+ fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
+ toPURL := purl.MakePURLString(ecosystem, name, toVersion)
fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL)
if err != nil || len(fromArtifacts) == 0 {
- notFound(w, "from version not found or not cached")
+ http.Error(w, "from version not found or not cached", http.StatusNotFound)
return
}
toArtifacts, err := s.db.GetArtifactsByVersionPURL(toPURL)
if err != nil || len(toArtifacts) == 0 {
- notFound(w, "to version not found or not cached")
+ http.Error(w, "to version not found or not cached", http.StatusNotFound)
return
}
// Find cached artifacts
- fromArtifact := firstBrowsableArtifact(fromArtifacts)
- toArtifact := firstBrowsableArtifact(toArtifacts)
+ var fromArtifact, toArtifact *database.Artifact
+ for i := range fromArtifacts {
+ if fromArtifacts[i].StoragePath.Valid {
+ fromArtifact = &fromArtifacts[i]
+ break
+ }
+ }
+ for i := range toArtifacts {
+ if toArtifacts[i].StoragePath.Valid {
+ toArtifact = &toArtifacts[i]
+ break
+ }
+ }
if fromArtifact == nil || toArtifact == nil {
- notFound(w, "one or both versions not cached")
+ http.Error(w, "one or both versions not cached", http.StatusNotFound)
return
}
- if !s.compares.tryAcquire() {
- w.Header().Set("Retry-After", compareRetryAfter)
- writeError(w, http.StatusServiceUnavailable, ErrCodeBusy, "too many comparisons in progress, try again shortly")
- return
- }
- defer s.compares.release()
-
// Open both archives
fromReader, err := s.storage.Open(r.Context(), fromArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open from artifact", "error", err)
- internalError(w, "failed to read from version")
+ http.Error(w, "failed to read from version", http.StatusInternalServerError)
return
}
defer func() { _ = fromReader.Close() }()
@@ -665,23 +393,25 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
toReader, err := s.storage.Open(r.Context(), toArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open to artifact", "error", err)
- internalError(w, "failed to read to version")
+ http.Error(w, "failed to read to version", http.StatusInternalServerError)
return
}
defer func() { _ = toReader.Close() }()
- fromArchive, err := openArchive(fromArtifact.Filename, fromReader, ecosystem)
+ stripPrefix := getStripPrefix(ecosystem)
+
+ fromArchive, err := archives.OpenWithPrefix(fromArtifact.Filename, fromReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open from archive", "error", err)
- internalError(w, "failed to open from archive")
+ http.Error(w, "failed to open from archive", http.StatusInternalServerError)
return
}
defer func() { _ = fromArchive.Close() }()
- toArchive, err := openArchive(toArtifact.Filename, toReader, ecosystem)
+ toArchive, err := archives.OpenWithPrefix(toArtifact.Filename, toReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open to archive", "error", err)
- internalError(w, "failed to open to archive")
+ http.Error(w, "failed to open to archive", http.StatusInternalServerError)
return
}
defer func() { _ = toArchive.Close() }()
@@ -690,7 +420,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
result, err := diff.Compare(fromArchive, toArchive)
if err != nil {
s.logger.Error("failed to generate diff", "error", err)
- internalError(w, "failed to generate diff")
+ http.Error(w, "failed to generate diff", http.StatusInternalServerError)
return
}
@@ -699,17 +429,40 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
}
// ComparePageData contains data for the version comparison page.
-//
-// FromVersion and ToVersion are decoded, for display; the Escaped variants are
-// the path-segment form used to build the compare API URL.
type ComparePageData struct {
- Layout
- Ecosystem string
- PackageName string
- FromVersion string
- ToVersion string
- EscapedFromVersion string
- EscapedToVersion string
+ Ecosystem string
+ PackageName string
+ FromVersion string
+ ToVersion string
}
-// handleComparePage is now showComparePage in server.go, dispatched via handlePackagePath.
+// handleComparePage renders the version comparison UI.
+// GET /package/{ecosystem}/{name}/compare/{versions}
+// where {versions} is in format "fromVersion...toVersion"
+func (s *Server) handleComparePage(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ versions := chi.URLParam(r, "versions")
+
+ // Parse versions (format: "1.0.0...2.0.0")
+ parts := strings.Split(versions, "...")
+ if len(parts) != 2 {
+ http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest)
+ return
+ }
+
+ fromVersion := parts[0]
+ toVersion := parts[1]
+
+ data := ComparePageData{
+ Ecosystem: ecosystem,
+ PackageName: name,
+ FromVersion: fromVersion,
+ ToVersion: toVersion,
+ }
+
+ if err := s.templates.Render(w, "compare_versions", data); err != nil {
+ s.logger.Error("failed to render compare page", "error", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+}
diff --git a/internal/server/browse_archive.go b/internal/server/browse_archive.go
deleted file mode 100644
index 4545ed7..0000000
--- a/internal/server/browse_archive.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package server
-
-import (
- "errors"
- "fmt"
- "io"
-
- "github.com/git-pkgs/archives"
-)
-
-// Limits applied when listing or reading a single file. Bodies are streamed
-// rather than retained, so these bound decompression work per request; they
-// match the limits the buffered archive reader enforces.
-const (
- maxBrowseExpandedSize = 512 << 20 // 512 MB across all entries
- maxBrowseEntrySize = 512 << 20 // 512 MB for any one entry
- maxBrowseEntries = 100_000
-)
-
-// npmPackagePrefix is the directory npm wraps every tarball's contents in.
-const npmPackagePrefix = "package/"
-
-var defaultBrowseLimits = archives.StreamOptions{
- MaxInputBytes: maxBrowseArchiveSize,
- MaxEntryBytes: maxBrowseEntrySize,
- MaxExpandedBytes: maxBrowseExpandedSize,
- MaxEntries: maxBrowseEntries,
-}
-
-var errArtifactTooLarge = errors.New("artifact too large for browsing")
-
-// isBrowseLimitError reports whether err comes from one of the browse size or
-// entry-count limits rather than a malformed archive.
-func isBrowseLimitError(err error) bool {
- return errors.Is(err, errArtifactTooLarge) ||
- errors.Is(err, archives.ErrInputLimit) ||
- errors.Is(err, archives.ErrEntrySizeLimit) ||
- errors.Is(err, archives.ErrDecompressLimit) ||
- errors.Is(err, archives.ErrEntryLimit)
-}
-
-// readBrowseInput reads all of r, failing once it exceeds limit bytes.
-func readBrowseInput(r io.Reader, limit int64) ([]byte, error) {
- data, err := io.ReadAll(io.LimitReader(r, limit+1))
- if err != nil {
- return nil, fmt.Errorf("reading artifact: %w", err)
- }
- if int64(len(data)) > limit {
- return nil, fmt.Errorf("%w (%d bytes)", errArtifactTooLarge, len(data))
- }
- return data, nil
-}
-
-func newBrowseArchive(filename, ecosystem string, content io.Reader, reopen func() (io.ReadCloser, error), limits archives.StreamOptions) (*archives.Browser, error) {
- options := archives.BrowseOptions{Limits: limits, DetectRoot: true}
- if ecosystem == "npm" {
- options.StripPrefix = npmPackagePrefix
- }
- return archives.OpenBrowser(filename, content, reopen, options)
-}
diff --git a/internal/server/browse_archive_test.go b/internal/server/browse_archive_test.go
deleted file mode 100644
index 838802e..0000000
--- a/internal/server/browse_archive_test.go
+++ /dev/null
@@ -1,771 +0,0 @@
-package server
-
-import (
- "archive/tar"
- "archive/zip"
- "bytes"
- "compress/gzip"
- "database/sql"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-
- "github.com/git-pkgs/archives"
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-type archiveEntry struct {
- name string
- body string
- dir bool
-}
-
-func buildTar(t testing.TB, entries []archiveEntry) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- tw := tar.NewWriter(buf)
- for _, e := range entries {
- header := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body)), Typeflag: tar.TypeReg}
- if e.dir {
- header.Typeflag = tar.TypeDir
- header.Mode = 0o755
- header.Size = 0
- }
- if err := tw.WriteHeader(header); err != nil {
- t.Fatalf("writing tar header: %v", err)
- }
- if _, err := io.WriteString(tw, e.body); err != nil {
- t.Fatalf("writing tar body: %v", err)
- }
- }
- if err := tw.Close(); err != nil {
- t.Fatalf("closing tar: %v", err)
- }
- return buf.Bytes()
-}
-
-func gzipBytes(t testing.TB, data []byte) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- if _, err := gw.Write(data); err != nil {
- t.Fatalf("writing gzip: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("closing gzip: %v", err)
- }
- return buf.Bytes()
-}
-
-func buildTarGz(t testing.TB, entries []archiveEntry) []byte {
- t.Helper()
- return gzipBytes(t, buildTar(t, entries))
-}
-
-func buildZip(t testing.TB, entries []archiveEntry) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- zw := zip.NewWriter(buf)
- for _, e := range entries {
- f, err := zw.Create(e.name)
- if err != nil {
- t.Fatalf("creating zip entry: %v", err)
- }
- if _, err := io.WriteString(f, e.body); err != nil {
- t.Fatalf("writing zip entry: %v", err)
- }
- }
- if err := zw.Close(); err != nil {
- t.Fatalf("closing zip: %v", err)
- }
- return buf.Bytes()
-}
-
-// buildGem wraps entries in a gem: an outer tar holding data.tar.gz.
-func buildGem(t testing.TB, entries []archiveEntry) []byte {
- t.Helper()
- return buildTar(t, []archiveEntry{
- {name: "metadata.gz", body: string(gzipBytes(t, []byte("--- {}\n")))},
- {name: "data.tar.gz", body: string(buildTarGz(t, entries))},
- })
-}
-
-// testBrowseVersion is the version every browse fixture is cached under.
-const testBrowseVersion = "1.0.0"
-
-// cacheBrowseArtifact stores data as the cached artifact for ecosystem/name.
-func cacheBrowseArtifact(t *testing.T, ts *testServer, ecosystem, name, filename string, data []byte) {
- t.Helper()
-
- storagePath := fmt.Sprintf("%s-%s-%s", ecosystem, name, filename)
- artifactsDir := filepath.Join(ts.tempDir, "artifacts")
- if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
- t.Fatalf("creating artifacts dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(artifactsDir, storagePath), data, 0o644); err != nil {
- t.Fatalf("writing artifact: %v", err)
- }
-
- pkg := &database.Package{
- PURL: fmt.Sprintf("pkg:%s/%s", ecosystem, name),
- Ecosystem: ecosystem,
- Name: name,
- }
- if err := ts.db.UpsertPackage(pkg); err != nil {
- t.Fatalf("upserting package: %v", err)
- }
- ver := &database.Version{PURL: pkg.PURL + "@" + testBrowseVersion, PackagePURL: pkg.PURL}
- if err := ts.db.UpsertVersion(ver); err != nil {
- t.Fatalf("upserting version: %v", err)
- }
- artifact := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: filename,
- UpstreamURL: "https://example.com/" + filename,
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- }
- if err := ts.db.UpsertArtifact(artifact); err != nil {
- t.Fatalf("upserting artifact: %v", err)
- }
-}
-
-func browseGet(ts *testServer, target string) *httptest.ResponseRecorder {
- req := httptest.NewRequest(http.MethodGet, target, nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
- return w
-}
-
-func browseListURL(ecosystem, name, dir string) string {
- return fmt.Sprintf("/ui/api/browse/%s/%s/%s?path=%s", ecosystem, name, testBrowseVersion, url.QueryEscape(dir))
-}
-
-func browseFileURL(ecosystem, name, file string) string {
- return fmt.Sprintf("/ui/api/browse/%s/%s/%s/file/%s", ecosystem, name, testBrowseVersion, file)
-}
-
-func decodeListing(t *testing.T, w *httptest.ResponseRecorder) BrowseListResponse {
- t.Helper()
- if w.Code != http.StatusOK {
- t.Fatalf("listing status = %d, body %s", w.Code, w.Body.String())
- }
- var resp BrowseListResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding listing: %v", err)
- }
- return resp
-}
-
-func listingPaths(resp BrowseListResponse) []string {
- paths := make([]string, len(resp.Files))
- for i, f := range resp.Files {
- paths[i] = f.Path
- }
- return paths
-}
-
-type browseFixture struct {
- name string
- ecosystem string
- filename string
- data []byte
-}
-
-func browseFixtures(t *testing.T) []browseFixture {
- t.Helper()
-
- rooted := []archiveEntry{
- {name: "repo-abc123/", dir: true},
- {name: "repo-abc123/README.md", body: "# rooted\n"},
- {name: "repo-abc123/src/", dir: true},
- {name: "repo-abc123/src/main.go", body: "package main\n"},
- {name: "repo-abc123/src/util/strings.go", body: "package util\n"},
- {name: "repo-abc123/docs/guide.md", body: "guide\n"},
- }
- npm := []archiveEntry{
- {name: "package/package.json", body: `{"name":"x"}`},
- {name: "package/README.md", body: "# npm\n"},
- {name: "package/src/main.go", body: "package main\n"},
- {name: "package/src/util/strings.go", body: "package util\n"},
- {name: "other/ignored.txt", body: "outside the package prefix\n"},
- }
- flat := []archiveEntry{
- {name: "README.md", body: "# flat\n"},
- {name: "src/main.go", body: "package main\n"},
- {name: "src/util/strings.go", body: "package util\n"},
- {name: "docs/guide.md", body: "guide\n"},
- }
-
- return []browseFixture{
- {"tar.gz with root dir", "cargo", "demo-1.0.0.crate", buildTarGz(t, rooted)},
- {"extensionless tar.gz", "cargo", "artifact", buildTarGz(t, rooted)},
- {"npm package prefix", "npm", "demo-1.0.0.tgz", buildTarGz(t, npm)},
- {"flat tar.gz", "cargo", "demo-1.0.0.tar.gz", buildTarGz(t, flat)},
- {"zip with root dir", "composer", "demo.zip", buildZip(t, rooted)},
- {"extensionless zip", "composer", "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", buildZip(t, rooted)},
- {"flat zip", "composer", "demo.zip", buildZip(t, flat)},
- {"gem", "gem", "demo-1.0.0.gem", buildGem(t, flat)},
- }
-}
-
-func TestBrowseListingStripsPrefix(t *testing.T) {
- for _, fx := range browseFixtures(t) {
- t.Run(fx.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- cacheBrowseArtifact(t, ts, fx.ecosystem, "demo", fx.filename, fx.data)
-
- root := listingPaths(decodeListing(t, browseGet(ts, browseListURL(fx.ecosystem, "demo", ""))))
- for _, p := range root {
- if strings.HasPrefix(p, "repo-abc123/") || strings.HasPrefix(p, "package/") {
- t.Errorf("root listing kept archive prefix: %q", p)
- }
- }
- if !containsPath(root, "README.md") || !containsPath(root, "src/") {
- t.Errorf("root listing = %v, want README.md and src/", root)
- }
-
- src := listingPaths(decodeListing(t, browseGet(ts, browseListURL(fx.ecosystem, "demo", "src"))))
- if !containsPath(src, "src/main.go") || !containsPath(src, "src/util/") {
- t.Errorf("src listing = %v, want src/main.go and src/util/", src)
- }
- })
- }
-}
-
-func containsPath(paths []string, want string) bool {
- for _, p := range paths {
- if p == want {
- return true
- }
- }
- return false
-}
-
-// TestBrowseListingMatchesBufferedReader checks the streamed listing returns
-// exactly what the buffered archive reader returned for the same request.
-func TestBrowseListingMatchesBufferedReader(t *testing.T) {
- dirs := []string{"", "/", "src", "src/", "/src", "src/util", "docs", "missing", "README.md"}
-
- for _, fx := range browseFixtures(t) {
- t.Run(fx.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- cacheBrowseArtifact(t, ts, fx.ecosystem, "demo", fx.filename, fx.data)
-
- buffered, err := openArchive(fx.filename, bytes.NewReader(fx.data), fx.ecosystem)
- if err != nil {
- t.Fatalf("openArchive: %v", err)
- }
- defer func() { _ = buffered.Close() }()
-
- for _, dir := range dirs {
- want, err := buffered.ListDir(dir)
- if err != nil {
- t.Fatalf("buffered ListDir(%q): %v", dir, err)
- }
- got := decodeListing(t, browseGet(ts, browseListURL(fx.ecosystem, "demo", dir)))
- if len(got.Files) != len(want) {
- t.Fatalf("ListDir(%q) = %v, want %d entries", dir, listingPaths(got), len(want))
- }
- for i, f := range got.Files {
- w := want[i]
- if f.Path != w.Path || f.Name != w.Name || f.IsDir != w.IsDir || f.Size != w.Size {
- t.Errorf("ListDir(%q)[%d] = %+v, want %+v", dir, i, f, w)
- }
- }
- }
- })
- }
-}
-
-func TestBrowseFileContents(t *testing.T) {
- for _, fx := range browseFixtures(t) {
- t.Run(fx.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- cacheBrowseArtifact(t, ts, fx.ecosystem, "demo", fx.filename, fx.data)
-
- buffered, err := openArchive(fx.filename, bytes.NewReader(fx.data), fx.ecosystem)
- if err != nil {
- t.Fatalf("openArchive: %v", err)
- }
- defer func() { _ = buffered.Close() }()
-
- for _, file := range []string{"README.md", "src/main.go", "src/util/strings.go"} {
- rc, err := buffered.Extract(file)
- if err != nil {
- t.Fatalf("buffered Extract(%q): %v", file, err)
- }
- want, _ := io.ReadAll(rc)
- _ = rc.Close()
-
- w := browseGet(ts, browseFileURL(fx.ecosystem, "demo", file))
- if w.Code != http.StatusOK {
- t.Fatalf("file %s status = %d, body %s", file, w.Code, w.Body.String())
- }
- if w.Body.String() != string(want) {
- t.Errorf("file %s = %q, want %q", file, w.Body.String(), want)
- }
- wantType, _ := detectContentTypeFromPath(file)
- if got := w.Header().Get("Content-Type"); got != wantType {
- t.Errorf("file %s Content-Type = %q, want %q", file, got, wantType)
- }
- if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, filepath.Base(file)) {
- t.Errorf("file %s Content-Disposition = %q", file, got)
- }
- }
-
- w := browseGet(ts, browseFileURL(fx.ecosystem, "demo", "missing.txt"))
- if w.Code != http.StatusNotFound {
- t.Errorf("missing file status = %d, want 404", w.Code)
- }
- })
- }
-}
-
-func TestBrowseFileDirectoryNotFound(t *testing.T) {
- for _, format := range []string{"tar.gz", "zip"} {
- for _, explicit := range []bool{false, true} {
- t.Run(fmt.Sprintf("%s/explicit=%t", format, explicit), func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- var logs bytes.Buffer
- ts.server.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelError}))
-
- entries := []archiveEntry{{name: "pkg/src/main.go", body: "package main\n"}}
- if explicit {
- entries = append([]archiveEntry{{name: "pkg/src/", dir: true}}, entries...)
- }
- data := buildTarGz(t, entries)
- if format == "zip" {
- data = buildZip(t, entries)
- }
- cacheBrowseArtifact(t, ts, "cargo", "demo", "demo."+format, data)
-
- for _, dir := range []string{"src", "src/"} {
- response := browseGet(ts, browseFileURL("cargo", "demo", dir))
- if response.Code != http.StatusNotFound {
- t.Errorf("directory %q returned %d, want 404: %s", dir, response.Code, response.Body.String())
- }
- }
- if logs.Len() != 0 {
- t.Errorf("directory request logged an error: %s", logs.String())
- }
- })
- }
- }
-}
-
-func TestBrowseFileFirstDuplicateWins(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- data := buildTarGz(t, []archiveEntry{
- {name: "package/index.js", body: "first"},
- {name: "package/index.js", body: "second"},
- })
- cacheBrowseArtifact(t, ts, "npm", "dup", "dup-1.0.0.tgz", data)
-
- w := browseGet(ts, browseFileURL("npm", "dup", "index.js"))
- if w.Code != http.StatusOK || w.Body.String() != "first" {
- t.Fatalf("got %d %q, want 200 \"first\"", w.Code, w.Body.String())
- }
-}
-
-func TestBrowseSingleRootFile(t *testing.T) {
- for _, format := range []string{"tar.gz", "zip"} {
- t.Run(format, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- entries := []archiveEntry{{name: "README.md", body: "single file\n"}}
- data := buildTarGz(t, entries)
- if format == "zip" {
- data = buildZip(t, entries)
- }
- filename := "single." + format
- cacheBrowseArtifact(t, ts, "cargo", "single", filename, data)
- paths := listingPaths(decodeListing(t, browseGet(ts, browseListURL("cargo", "single", ""))))
- if len(paths) != 1 || paths[0] != "README.md" {
- t.Fatalf("listing = %v, want README.md", paths)
- }
- response := browseGet(ts, browseFileURL("cargo", "single", "README.md"))
- if response.Code != http.StatusOK || response.Body.String() != "single file\n" {
- t.Fatalf("file = %d %q", response.Code, response.Body.String())
- }
- r, err := openArchive(filename, bytes.NewReader(data), "cargo")
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = r.Close() }()
- files, err := r.List()
- if err != nil || len(files) != 1 || files[0].Path != "README.md" {
- t.Fatalf("diff reader listing = %+v, %v", files, err)
- }
- })
- }
-}
-
-// TestBrowseFileEscapedPath requests files the way the source browser does,
-// with each path segment percent-encoded, so names holding URL syntax resolve.
-func TestBrowseFileEscapedPath(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- names := []string{"src/a#b.txt", "src/what?.txt", "src/100%.txt", "dir with space/c.txt"}
- entries := make([]archiveEntry, len(names))
- for i, name := range names {
- entries[i] = archiveEntry{name: "package/" + name, body: name}
- }
- cacheBrowseArtifact(t, ts, "npm", "escaped", "escaped-1.0.0.tgz", buildTarGz(t, entries))
-
- for _, name := range names {
- segments := strings.Split(name, "/")
- for i, seg := range segments {
- segments[i] = url.PathEscape(seg)
- }
- w := browseGet(ts, browseFileURL("npm", "escaped", strings.Join(segments, "/")))
- if w.Code != http.StatusOK || w.Body.String() != name {
- t.Errorf("file %q: got %d %q, want 200 %q", name, w.Code, w.Body.String(), name)
- }
- }
-}
-
-func TestBrowseLimits(t *testing.T) {
- entries := []archiveEntry{
- {name: "package/a.txt", body: strings.Repeat("a", 100)},
- {name: "package/b.txt", body: strings.Repeat("b", 100)},
- {name: "package/c.txt", body: strings.Repeat("c", 100)},
- }
- tarGz := buildTarGz(t, entries)
- zipped := buildZip(t, entries)
-
- tests := []struct {
- name string
- filename string
- data []byte
- limits archives.StreamOptions
- file string
- }{
- {"entry count", "limit.tgz", tarGz, archives.StreamOptions{MaxEntries: 2}, "c.txt"},
- {"entry size", "limit.tgz", tarGz, archives.StreamOptions{MaxEntryBytes: 50}, "a.txt"},
- {"expanded size", "limit.tgz", tarGz, archives.StreamOptions{MaxExpandedBytes: 250}, "c.txt"},
- {"input size", "limit.tgz", tarGz, archives.StreamOptions{MaxInputBytes: int64(len(tarGz) / 2)}, "c.txt"},
- {"zip entry count", "limit.zip", zipped, archives.StreamOptions{MaxEntries: 2}, "c.txt"},
- {"zip input size", "limit.zip", zipped, archives.StreamOptions{MaxInputBytes: int64(len(zipped) / 2)}, "a.txt"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- ts.server.browseLimits = tt.limits
- cacheBrowseArtifact(t, ts, "npm", "limited", tt.filename, tt.data)
-
- for _, target := range []string{
- browseListURL("npm", "limited", ""),
- browseFileURL("npm", "limited", tt.file),
- } {
- w := browseGet(ts, target)
- if w.Code != http.StatusInternalServerError {
- t.Fatalf("%s status = %d, want 500 (body %s)", target, w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "archive exceeds browse limits") {
- t.Errorf("%s body = %s, want limit error", target, w.Body.String())
- }
- }
- })
- }
-}
-
-// largeBrowseArchive is a gzip tarball with many unrelated entries and one
-// small file. The padding compresses well, so the fixture is small on disk
-// while its expanded size dwarfs what a streamed request should allocate.
-func largeBrowseArchive(t testing.TB, entries, entrySize int) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
- padding := make([]byte, entrySize)
- write := func(name string, body []byte) {
- if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
- t.Fatalf("writing tar header: %v", err)
- }
- if _, err := tw.Write(body); err != nil {
- t.Fatalf("writing tar body: %v", err)
- }
- }
- write("repo-large/README.md", []byte("small file\n"))
- for i := range entries {
- write(fmt.Sprintf("repo-large/vendor/blob%05d.bin", i), padding)
- }
- if err := tw.Close(); err != nil {
- t.Fatalf("closing tar: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("closing gzip: %v", err)
- }
- return buf.Bytes()
-}
-
-// allocatedBytes returns the bytes allocated while fn runs. Total allocation
-// bounds the peak heap growth fn can cause.
-func allocatedBytes(fn func()) uint64 {
- runtime.GC()
- var before, after runtime.MemStats
- runtime.ReadMemStats(&before)
- fn()
- runtime.ReadMemStats(&after)
- return after.TotalAlloc - before.TotalAlloc
-}
-
-func TestBrowseLargeArchiveMemory(t *testing.T) {
- const (
- entries = 1024
- entrySize = 32 << 10
- expanded = entries * entrySize // 32 MiB
- )
- data := largeBrowseArchive(t, entries, entrySize)
-
- ts := newTestServer(t)
- defer ts.close()
- cacheBrowseArtifact(t, ts, "cargo", "large", "large-1.0.0.crate", data)
-
- buffered := allocatedBytes(func() {
- r, err := openArchive("large-1.0.0.crate", bytes.NewReader(data), "cargo")
- if err != nil {
- t.Fatalf("openArchive: %v", err)
- }
- _ = r.Close()
- })
-
- var listing BrowseListResponse
- list := allocatedBytes(func() {
- listing = decodeListing(t, browseGet(ts, browseListURL("cargo", "large", "")))
- })
- if !containsPath(listingPaths(listing), "README.md") || !containsPath(listingPaths(listing), "vendor/") {
- t.Fatalf("listing = %v, want README.md and vendor/", listingPaths(listing))
- }
-
- var w *httptest.ResponseRecorder
- file := allocatedBytes(func() {
- w = browseGet(ts, browseFileURL("cargo", "large", "README.md"))
- })
- if w.Code != http.StatusOK || w.Body.String() != "small file\n" {
- t.Fatalf("file = %d %q, want 200 \"small file\\n\"", w.Code, w.Body.String())
- }
-
- t.Logf("compressed %d bytes, expanded %d bytes", len(data), expanded)
- t.Logf("allocated: buffered open %d, streamed listing %d, streamed file %d", buffered, list, file)
-
- if buffered < expanded {
- t.Fatalf("buffered open allocated %d bytes, expected at least the expanded %d", buffered, expanded)
- }
- limit := uint64(expanded / 4)
- if list > limit {
- t.Errorf("streamed listing allocated %d bytes, want under %d", list, limit)
- }
- if file > limit {
- t.Errorf("streamed file read allocated %d bytes, want under %d", file, limit)
- }
-}
-
-func BenchmarkBrowseLargeArchive(b *testing.B) {
- data := largeBrowseArchive(b, 1024, 32<<10)
- artifactPath := filepath.Join(b.TempDir(), "large.crate")
- if err := os.WriteFile(artifactPath, data, 0o644); err != nil {
- b.Fatal(err)
- }
-
- b.Run("buffered-list", func(b *testing.B) { benchBufferedBrowse(b, data, false) })
- b.Run("streamed-list", func(b *testing.B) { benchStreamedBrowse(b, artifactPath, false) })
- b.Run("buffered-file", func(b *testing.B) { benchBufferedBrowse(b, data, true) })
- b.Run("streamed-file", func(b *testing.B) { benchStreamedBrowse(b, artifactPath, true) })
-}
-
-// benchBufferedBrowse lists the root, or reads README.md, through openArchive.
-func benchBufferedBrowse(b *testing.B, data []byte, readFile bool) {
- b.ReportAllocs()
- for b.Loop() {
- r, err := openArchive("large.crate", bytes.NewReader(data), "cargo")
- if err != nil {
- b.Fatal(err)
- }
- if readFile {
- drainBrowseFile(b, r.Extract)
- } else if _, err := r.ListDir(""); err != nil {
- b.Fatal(err)
- }
- _ = r.Close()
- }
-}
-
-// benchStreamedBrowse lists the root, or reads README.md, through browseArchive.
-func benchStreamedBrowse(b *testing.B, artifactPath string, readFile bool) {
- reopen := func() (io.ReadCloser, error) { return os.Open(artifactPath) }
- b.ReportAllocs()
- for b.Loop() {
- content, err := reopen()
- if err != nil {
- b.Fatal(err)
- }
- a, err := newBrowseArchive("large.crate", "cargo", content, reopen, defaultBrowseLimits)
- if err != nil {
- b.Fatal(err)
- }
- if readFile {
- drainBrowseFile(b, a.Extract)
- } else if _, err := a.ListDir(""); err != nil {
- b.Fatal(err)
- }
- _ = content.Close()
- }
-}
-
-func drainBrowseFile(b *testing.B, extract func(string) (io.ReadCloser, error)) {
- b.Helper()
- rc, err := extract("README.md")
- if err != nil {
- b.Fatal(err)
- }
- _, _ = io.Copy(io.Discard, rc)
- _ = rc.Close()
-}
-
-// truncatedTar is a TAR whose only entry declares size bytes but carries just
-// body, ending without the rest of the entry or the end-of-archive marker.
-func truncatedTar(t testing.TB, name string, size int64, body []byte) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- tw := tar.NewWriter(buf)
- if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: size}); err != nil {
- t.Fatalf("writing tar header: %v", err)
- }
- if _, err := tw.Write(body); err != nil {
- t.Fatalf("writing tar body: %v", err)
- }
- // Deliberately not closed: tw.Close would fail on the short entry.
- return buf.Bytes()
-}
-
-func TestBrowseFileReadErrorBeforeResponse(t *testing.T) {
- complete := buildTar(t, []archiveEntry{{name: "package/data.txt", body: strings.Repeat("x", 4096)}})
-
- tests := []struct {
- name string
- filename string
- data []byte
- limits archives.StreamOptions
- wantBody string
- }{
- {
- name: "truncated entry body",
- filename: "short-1.0.0.tgz",
- data: gzipBytes(t, truncatedTar(t, "package/data.txt", 4096, []byte("abc"))),
- wantBody: "failed to read file",
- },
- {
- name: "input limit inside entry",
- filename: "short-1.0.0.tar",
- data: complete,
- limits: archives.StreamOptions{MaxInputBytes: 1024},
- wantBody: "archive exceeds browse limits",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- ts.server.browseLimits = tt.limits
- cacheBrowseArtifact(t, ts, "npm", "short", tt.filename, tt.data)
-
- w := browseGet(ts, browseFileURL("npm", "short", "data.txt"))
- if w.Code != http.StatusInternalServerError {
- t.Fatalf("status = %d, want 500 (body %q)", w.Code, w.Body.String())
- }
- if strings.Contains(w.Body.String(), "abc") || strings.Contains(w.Body.String(), "xxx") {
- t.Errorf("error response leaked partial file content: %q", w.Body.String())
- }
- if !strings.Contains(w.Body.String(), tt.wantBody) {
- t.Errorf("body = %q, want %q", w.Body.String(), tt.wantBody)
- }
- })
- }
-}
-
-func TestBrowseFileReadErrorAfterResponseStarts(t *testing.T) {
- const size = 4 * browsePrefetchSize
- body := bytes.Repeat([]byte("0123456789abcdef"), size/16)
- complete := buildTar(t, []archiveEntry{{name: "package/big.txt", body: string(body)}})
-
- tests := []struct {
- name string
- filename string
- data []byte
- limits archives.StreamOptions
- wantErr bool
- }{
- {
- name: "complete file",
- filename: "big-1.0.0.tar",
- data: complete,
- },
- {
- name: "truncated entry body",
- filename: "big-1.0.0.tgz",
- data: gzipBytes(t, truncatedTar(t, "package/big.txt", size, body[:size/2])),
- wantErr: true,
- },
- {
- name: "input limit inside entry",
- filename: "big-1.0.0.tar",
- data: complete,
- limits: archives.StreamOptions{MaxInputBytes: size / 2},
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- ts.server.browseLimits = tt.limits
- cacheBrowseArtifact(t, ts, "npm", "big", tt.filename, tt.data)
-
- srv := httptest.NewServer(ts.handler)
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + browseFileURL("npm", "big", "big.txt"))
- if err != nil {
- if tt.wantErr {
- return
- }
- t.Fatalf("GET: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- got, readErr := io.ReadAll(resp.Body)
-
- if !tt.wantErr {
- if resp.StatusCode != http.StatusOK || readErr != nil || !bytes.Equal(got, body) {
- t.Fatalf("status %d, read error %v, %d of %d bytes", resp.StatusCode, readErr, len(got), len(body))
- }
- return
- }
- if readErr == nil {
- t.Fatalf("status %d with %d of %d bytes and no read error; want an aborted response",
- resp.StatusCode, len(got), len(body))
- }
- if len(got) >= len(body) {
- t.Errorf("read %d bytes, want fewer than %d", len(got), len(body))
- }
- })
- }
-}
diff --git a/internal/server/browse_bench_test.go b/internal/server/browse_bench_test.go
deleted file mode 100644
index 840bc75..0000000
--- a/internal/server/browse_bench_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package server
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "fmt"
- "math/rand"
- "testing"
-)
-
-func createBenchTarGz(prefix string, fileCount, fileSize int) []byte {
- rnd := rand.New(rand.NewSource(1)) //nolint:gosec
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
-
- payload := make([]byte, fileSize)
- for i := range fileCount {
- rnd.Read(payload)
- _ = tw.WriteHeader(&tar.Header{
- Name: fmt.Sprintf("%sfile%04d.dat", prefix, i),
- Size: int64(fileSize),
- Mode: 0644,
- })
- _, _ = tw.Write(payload)
- }
- _ = tw.Close()
- _ = gw.Close()
- return buf.Bytes()
-}
-
-func BenchmarkOpenArchive(b *testing.B) {
- cases := []struct {
- name string
- ecosystem string
- filename string
- data []byte
- }{
- {"npm", "npm", "pkg.tgz", createBenchTarGz("package/", 64, 16*1024)},
- {"go", "go", "v1.2.3.tar.gz", createBenchTarGz("repo-abc123/", 64, 16*1024)},
- }
-
- for _, tc := range cases {
- b.Run(tc.name, func(b *testing.B) {
- b.SetBytes(int64(len(tc.data)))
- b.ReportAllocs()
- for b.Loop() {
- r, err := openArchive(tc.filename, bytes.NewReader(tc.data), tc.ecosystem)
- if err != nil {
- b.Fatal(err)
- }
- _ = r.Close()
- }
- })
- }
-}
-
-func BenchmarkDetectContentType(b *testing.B) {
- cases := []struct {
- name string
- filename string
- prefix []byte
- knownPath bool
- }{
- {"known-path", "README.md", nil, true},
- {"text-prefix", "artifact", bytes.Repeat([]byte("a"), browseSniffSize), false},
- {"png-prefix", "artifact", append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, browseSniffSize-8)...), false},
- }
-
- for _, tc := range cases {
- b.Run(tc.name, func(b *testing.B) {
- b.ReportAllocs()
- var contentType string
- if tc.knownPath {
- for b.Loop() {
- contentType, _ = detectContentTypeFromPath(tc.filename)
- }
- } else {
- for b.Loop() {
- contentType = detectContentTypeFromPrefix(tc.prefix)
- }
- }
- if contentType == "" {
- b.Fatal("empty content type")
- }
- })
- }
-}
diff --git a/internal/server/browse_test.go b/internal/server/browse_test.go
index 34c864c..ed7af57 100644
--- a/internal/server/browse_test.go
+++ b/internal/server/browse_test.go
@@ -2,7 +2,6 @@ package server
import (
"archive/tar"
- "archive/zip"
"bytes"
"compress/gzip"
"database/sql"
@@ -17,8 +16,6 @@ import (
"github.com/git-pkgs/proxy/internal/database"
)
-const testArchiveName = "test.tar.gz"
-
func TestHandleBrowseList(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
@@ -29,12 +26,12 @@ func TestHandleBrowseList(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
// Storage path relative to artifacts directory
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -65,7 +62,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// Test listing root directory
- req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0", nil)
+ req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -83,7 +80,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// Test listing subdirectory
- req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0?path=lib", nil)
+ req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0?path=lib", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -102,12 +99,12 @@ func TestHandleBrowseFile(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
// Storage path relative to artifacts directory
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -137,47 +134,29 @@ func TestHandleBrowseFile(t *testing.T) {
t.Fatalf("failed to upsert artifact: %v", err)
}
- files := []struct {
- path string
- content string
- contentType string
- }{
- {"README.md", "# Test Package\n", contentTypePlainText},
- {"notes.data", "short text\n", contentTypePlainText},
- {"logo", "\x89PNG\r\n\x1a\nimage data", "image/png"},
- {"page", "", contentTypePlainText},
- {"misleading.txt", "\x89PNG\r\n\x1a\nimage data", contentTypePlainText},
- }
- for _, file := range files {
- t.Run(file.path, func(t *testing.T) {
- req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/"+file.path, nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
+ // Test fetching a file
+ req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/README.md", nil)
+ w := httptest.NewRecorder()
+ ts.handler.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
- }
- if w.Body.String() != file.content {
- t.Errorf("unexpected file content: %q", w.Body.String())
- }
- if got := w.Header().Get("Content-Type"); got != file.contentType {
- t.Errorf("Content-Type = %q, want %q", got, file.contentType)
- }
- if got, want := w.Header().Get("Content-Security-Policy"), "sandbox; frame-ancestors 'none'"; got != want {
- t.Errorf("Content-Security-Policy = %q, want %q", got, want)
- }
- if got := w.Header().Get("X-Frame-Options"); got != "DENY" {
- t.Errorf("X-Frame-Options = %q, want DENY", got)
- }
- if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
- t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
- }
- })
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body != "# Test Package\n" {
+ t.Errorf("unexpected file content: %q", body)
+ }
+
+ // Check content type
+ contentType := w.Header().Get("Content-Type")
+ if contentType != "text/plain; charset=utf-8" {
+ t.Errorf("expected text/plain content type, got %q", contentType)
}
// Test fetching non-existent file
- req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
- w := httptest.NewRecorder()
+ req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
+ w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -185,70 +164,37 @@ func TestHandleBrowseFile(t *testing.T) {
}
}
-func TestBrowseContentTypePolicy(t *testing.T) {
+func TestDetectContentType(t *testing.T) {
tests := []struct {
- name string
- filename string
- prefix []byte
- expectedCT string
+ filename string
+ expectedCT string
}{
- {"text extension", "file.txt", nil, contentTypePlainText},
- {"markdown extension", "file.md", nil, contentTypePlainText},
- {"JSON extension", "file.json", nil, "application/json; charset=utf-8"},
- {"JavaScript extension", "file.js", nil, "application/javascript; charset=utf-8"},
- {"Go extension", "file.go", nil, "text/x-go; charset=utf-8"},
- {"Python extension", "file.py", nil, "text/x-python; charset=utf-8"},
- {"Rust extension", "file.rs", nil, "text/x-rust; charset=utf-8"},
- {"HTML extension", "file.html", nil, contentTypePlainText},
- {"HTM extension", "file.htm", nil, contentTypePlainText},
- {"XHTML extension", "file.xhtml", nil, contentTypePlainText},
- {"SVG extension", "file.svg", nil, contentTypePlainText},
- {"PNG extension", "file.png", nil, "image/png"},
- {"JPEG extension", "file.jpg", nil, "image/jpeg"},
- {"README", "README", nil, contentTypePlainText},
- {"LICENSE", "LICENSE", nil, contentTypePlainText},
- {"Makefile", "Makefile", nil, contentTypePlainText},
- {"gitignore", ".gitignore", nil, contentTypePlainText},
- {"unknown empty", "file.bin", nil, "application/octet-stream"},
- {"extensionless PNG", "asset", []byte("\x89PNG\r\n\x1a\n"), "image/png"},
- {"extensionless JPEG", "asset", []byte("\xff\xd8\xff"), "image/jpeg"},
- {"extensionless GIF", "asset", []byte("GIF89a"), "image/gif"},
- {"extensionless PDF", "asset", []byte("%PDF-1.7"), "application/pdf"},
- {"extensionless text", "asset", []byte("plain text\n"), contentTypePlainText},
- {"extensionless HTML", "asset", []byte(""), contentTypePlainText},
- {"extensionless XML", "asset", []byte(""), contentTypePlainText},
- {"extensionless SVG", "asset", []byte(""), contentTypePlainText},
- {"extensionless ZIP", "asset", []byte("PK\x03\x04"), "application/octet-stream"},
- {"extensionless binary", "asset", []byte{0, 1, 2}, "application/octet-stream"},
- {"known path wins", "file.txt", []byte("\x89PNG\r\n\x1a\n"), contentTypePlainText},
+ {"file.txt", "text/plain; charset=utf-8"},
+ {"file.md", "text/plain; charset=utf-8"},
+ {"file.json", "application/json; charset=utf-8"},
+ {"file.js", "application/javascript; charset=utf-8"},
+ {"file.go", "text/x-go; charset=utf-8"},
+ {"file.py", "text/x-python; charset=utf-8"},
+ {"file.rs", "text/x-rust; charset=utf-8"},
+ {"file.png", "image/png"},
+ {"file.jpg", "image/jpeg"},
+ {"README", "text/plain; charset=utf-8"},
+ {"LICENSE", "text/plain; charset=utf-8"},
+ {"Makefile", "text/plain; charset=utf-8"},
+ {".gitignore", "text/plain; charset=utf-8"},
+ {"file.bin", "application/octet-stream"},
}
for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, knownPath := detectContentTypeFromPath(tt.filename)
- if !knownPath {
- got = detectContentTypeFromPrefix(tt.prefix)
- }
+ t.Run(tt.filename, func(t *testing.T) {
+ got := detectContentType(tt.filename)
if got != tt.expectedCT {
- t.Errorf("content type for %q with prefix %q = %q, want %q", tt.filename, tt.prefix, got, tt.expectedCT)
+ t.Errorf("detectContentType(%q) = %q, want %q", tt.filename, got, tt.expectedCT)
}
})
}
}
-func TestOpenArchiveSizeLimit(t *testing.T) {
- huge := bytes.Repeat([]byte("x"), int(maxBrowseArchiveSize)+1)
- for _, eco := range []string{"npm", "go"} {
- _, err := openArchive("test.tar.gz", bytes.NewReader(huge), eco)
- if err == nil {
- t.Fatalf("%s: expected error for oversized archive, got nil", eco)
- }
- if !strings.Contains(err.Error(), "too large") {
- t.Fatalf("%s: expected 'too large' error, got: %v", eco, err)
- }
- }
-}
-
func TestIsLikelyText(t *testing.T) {
tests := []struct {
filename string
@@ -289,10 +235,6 @@ func createTestArchive(t *testing.T) []byte {
"package/lib/index.js": "module.exports = {};",
"package/lib/helper.js": "module.exports.help = () => {};",
"package/test/index.test.js": "// tests",
- "package/notes.data": "short text\n",
- "package/logo": "\x89PNG\r\n\x1a\nimage data",
- "package/page": "",
- "package/misleading.txt": "\x89PNG\r\n\x1a\nimage data",
}
for path, content := range files {
@@ -352,7 +294,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) {
}
// Try to browse
- req := httptest.NewRequest("GET", "/ui/api/browse/npm/not-cached/1.0.0", nil)
+ req := httptest.NewRequest("GET", "/api/browse/npm/not-cached/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -371,11 +313,11 @@ func TestHandleBrowseSourcePage(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -406,7 +348,7 @@ func TestHandleBrowseSourcePage(t *testing.T) {
}
// Test the browse source page loads
- req := httptest.NewRequest("GET", "/ui/package/npm/test-browse/1.0.0/browse", nil)
+ req := httptest.NewRequest("GET", "/package/npm/test-browse/1.0.0/browse", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -433,23 +375,6 @@ func TestHandleBrowseSourcePage(t *testing.T) {
}
}
- if !strings.Contains(body, "proxy test-version (test-commit)") {
- t.Error("browse source footer should contain proxy build information, not the package version")
- }
-
- // Check that the escapeHTML function is present for XSS protection
- if !strings.Contains(body, "function escapeHTML(str)") {
- t.Error("browse source page missing escapeHTML function for XSS protection")
- }
-
- // Check that onclick handlers use escapeHTML
- if strings.Contains(body, "onclick=\"loadFileTree('${file.path}')") {
- t.Error("browse source page has unescaped file.path in onclick handler")
- }
- if strings.Contains(body, "onclick=\"loadFile('${file.path}')") {
- t.Error("browse source page has unescaped file.path in onclick handler")
- }
-
// Check that ecosystem, package name, and version are set in JavaScript
if !strings.Contains(body, "const ecosystem = 'npm'") {
t.Error("browse source page missing ecosystem variable")
@@ -457,10 +382,8 @@ func TestHandleBrowseSourcePage(t *testing.T) {
if !strings.Contains(body, "const packageName = 'test-browse'") {
t.Error("browse source page missing packageName variable")
}
- // The version reaches the browse API as one path segment, so the page holds
- // its escaped form.
- if !strings.Contains(body, "const versionPath = '1.0.0'") {
- t.Error("browse source page missing versionPath variable")
+ if !strings.Contains(body, "const version = '1.0.0'") {
+ t.Error("browse source page missing version variable")
}
// Verify content type
@@ -474,47 +397,6 @@ func TestHandleCompareDiff(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
- seedCompareVersions(t, ts)
-
- // Test the compare endpoint
- req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Parse response
- var result map[string]interface{}
- if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
-
- // Check that we have files
- files, ok := result["files"].([]interface{})
- if !ok {
- t.Fatal("response should have files array")
- }
-
- if len(files) == 0 {
- t.Error("should have detected file changes")
- }
-
- // Check counts exist
- if _, ok := result["files_changed"]; !ok {
- t.Error("response should have files_changed")
- }
- if _, ok := result["files_added"]; !ok {
- t.Error("response should have files_added")
- }
-}
-
-// seedCompareVersions caches two versions of pkg:npm/test-compare with
-// different archives for the compare endpoint.
-func seedCompareVersions(t *testing.T, ts *testServer) {
- t.Helper()
-
// Create two test archives with different content
archive1Data := createArchiveWithContent(t, map[string]string{
"README.md": "# Version 1\n",
@@ -584,52 +466,38 @@ func seedCompareVersions(t *testing.T, ts *testServer) {
if err := ts.db.UpsertArtifact(artifact2); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
-}
-func compareTestVersions(ts *testServer) *httptest.ResponseRecorder {
- req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
+ // Test the compare endpoint
+ req := httptest.NewRequest("GET", "/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
- return w
-}
-// Each comparison buffers both archives in memory, so only a few may run at
-// once; the rest are refused instead of piling up until the process runs
-// out of memory.
-func TestCompareDiffRefusedWhileComparisonsAreRunning(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- seedCompareVersions(t, ts)
-
- for range maxConcurrentCompares {
- if !ts.server.compares.tryAcquire() {
- t.Fatal("could not take a free comparison slot")
- }
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
- w := compareTestVersions(ts)
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("status with all slots taken = %d, want %d", w.Code, http.StatusServiceUnavailable)
- }
- if w.Header().Get("Retry-After") == "" {
- t.Error("refused comparison has no Retry-After header")
+ // Parse response
+ var result map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
}
- ts.server.compares.release()
- if w := compareTestVersions(ts); w.Code != http.StatusOK {
- t.Fatalf("status after a slot was freed = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
+ // Check that we have files
+ files, ok := result["files"].([]interface{})
+ if !ok {
+ t.Fatal("response should have files array")
}
-}
-func TestCompareDiffReleasesItsSlot(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- seedCompareVersions(t, ts)
+ if len(files) == 0 {
+ t.Error("should have detected file changes")
+ }
- for i := range maxConcurrentCompares + 1 {
- if w := compareTestVersions(ts); w.Code != http.StatusOK {
- t.Fatalf("comparison %d: status = %d, want %d: %s", i, w.Code, http.StatusOK, w.Body.String())
- }
+ // Check counts exist
+ if _, ok := result["files_changed"]; !ok {
+ t.Error("response should have files_changed")
+ }
+ if _, ok := result["files_added"]; !ok {
+ t.Error("response should have files_added")
}
}
@@ -671,7 +539,7 @@ func TestHandleComparePage(t *testing.T) {
defer ts.close()
// Test valid format with ... separator
- req := httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0...2.0.0", nil)
+ req := httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0...2.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -681,17 +549,16 @@ func TestHandleComparePage(t *testing.T) {
body := w.Body.String()
- // Check that versions are set correctly in JavaScript. The compare API takes
- // each version as a path segment, so the page holds their escaped forms.
- if !strings.Contains(body, "const fromVersionPath = '1.0.0'") {
- t.Error("page should set fromVersionPath")
+ // Check that versions are set correctly in JavaScript
+ if !strings.Contains(body, "const fromVersion = '1.0.0'") {
+ t.Error("page should set fromVersion")
}
- if !strings.Contains(body, "const toVersionPath = '2.0.0'") {
- t.Error("page should set toVersionPath")
+ if !strings.Contains(body, "const toVersion = '2.0.0'") {
+ t.Error("page should set toVersion")
}
// Test invalid format (missing separator)
- req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/invalid", nil)
+ req = httptest.NewRequest("GET", "/package/npm/test/compare/invalid", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -700,7 +567,7 @@ func TestHandleComparePage(t *testing.T) {
}
// Test with only one dot (should fail)
- req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0.2.0.0", nil)
+ req = httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0.2.0.0", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@@ -708,256 +575,3 @@ func TestHandleComparePage(t *testing.T) {
t.Errorf("expected status 400 for invalid separator, got %d", w.Code)
}
}
-
-func TestOpenArchiveDetectsExtensionlessTarGz(t *testing.T) {
- reader, err := openArchive("artifact", bytes.NewReader(createTestArchive(t)), "npm")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- if len(files) == 0 {
- t.Fatal("expected files in extensionless archive")
- }
-}
-
-func TestOpenArchiveStripsSingleRootDir(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "repo-abc123/README.md": "hello",
- "repo-abc123/src/main.go": "package main",
- "repo-abc123/go.mod": "module test",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "repo-abc123/") {
- t.Errorf("file %q still has root prefix after stripping", f.Path)
- }
- }
-}
-
-func TestOpenArchiveMultipleRootDirs(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "src/main.go": "package main",
- "docs/README.md": "hello",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- paths := make(map[string]bool)
- for _, f := range files {
- paths[f.Path] = true
- }
- if !paths["src/main.go"] {
- t.Error("expected src/main.go to remain unchanged")
- }
- if !paths["docs/README.md"] {
- t.Error("expected docs/README.md to remain unchanged")
- }
-}
-
-func TestOpenArchiveFlatNoSubdirs(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "README.md": "hello",
- "main.go": "package main",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- paths := make(map[string]bool)
- for _, f := range files {
- paths[f.Path] = true
- }
- if !paths["README.md"] {
- t.Error("expected README.md at root")
- }
-}
-
-func TestOpenArchiveNpmUsesPackagePrefix(t *testing.T) {
- data := createTarGzArchive(t, map[string]string{
- "package/README.md": "hello",
- "package/index.js": "module.exports = {}",
- })
- reader, err := openArchive("pkg.tgz", bytes.NewReader(data), "npm")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "package/") {
- t.Errorf("file %q still has package/ prefix", f.Path)
- }
- }
-}
-
-func TestOpenArchiveExtensionlessFilename(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "repo-hash/README.md": "hello",
- })
- reader, err := openArchive("d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- if len(files) == 0 {
- t.Fatal("expected files in archive")
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "repo-hash/") {
- t.Errorf("file %q still has root prefix", f.Path)
- }
- }
-}
-
-func createZipArchive(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- w := zip.NewWriter(buf)
-
- for name, content := range files {
- f, err := w.Create(name)
- if err != nil {
- t.Fatalf("failed to create zip entry: %v", err)
- }
- if _, err := f.Write([]byte(content)); err != nil {
- t.Fatalf("failed to write zip content: %v", err)
- }
- }
-
- if err := w.Close(); err != nil {
- t.Fatalf("failed to close zip writer: %v", err)
- }
- return buf.Bytes()
-}
-
-func createTarGzArchive(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
-
- for name, content := range files {
- header := &tar.Header{
- Name: name,
- Size: int64(len(content)),
- Mode: 0644,
- }
- if err := tw.WriteHeader(header); err != nil {
- t.Fatalf("failed to write tar header: %v", err)
- }
- if _, err := tw.Write([]byte(content)); err != nil {
- t.Fatalf("failed to write tar content: %v", err)
- }
- }
-
- if err := tw.Close(); err != nil {
- t.Fatalf("failed to close tar writer: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("failed to close gzip writer: %v", err)
- }
- return buf.Bytes()
-}
-
-// TestFirstBrowsableArtifact guards artifact selection against PEP 658
-// core-metadata sidecars. A sidecar resolves to the same version as the
-// distribution it describes, so it is cached under that version, but it is plain
-// text and openArchive cannot parse it.
-func TestFirstBrowsableArtifact(t *testing.T) {
- cached := func(filename string) database.Artifact {
- return database.Artifact{
- Filename: filename,
- StoragePath: sql.NullString{String: "pypi/" + filename, Valid: true},
- }
- }
- uncached := func(filename string) database.Artifact {
- return database.Artifact{Filename: filename}
- }
-
- tests := []struct {
- name string
- artifacts []database.Artifact
- want string
- }{
- {"no artifacts", nil, ""},
- {
- "sidecar only is not browsable",
- []database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata")},
- "",
- },
- {
- // '-' (0x2D) sorts before '.' (0x2E), so the sidecar precedes the
- // sdist in the filename-ordered list the query returns.
- "sidecar sorting ahead of the sdist is skipped",
- []database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata"), cached("foo-1.0.tar.gz")},
- "foo-1.0.tar.gz",
- },
- {
- "sidecar skipped in favour of its own wheel",
- []database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata"), cached("foo-1.0-py3-none-any.whl")},
- "foo-1.0-py3-none-any.whl",
- },
- {
- "uncached archive is still not selected",
- []database.Artifact{cached("foo-1.0.tar.gz.metadata"), uncached("foo-1.0.tar.gz")},
- "",
- },
- {"plain sdist", []database.Artifact{cached("foo-1.0.tar.gz")}, "foo-1.0.tar.gz"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := firstBrowsableArtifact(tt.artifacts)
-
- if tt.want == "" {
- if got != nil {
- t.Fatalf("firstBrowsableArtifact() = %q, want nil", got.Filename)
- }
- return
- }
-
- if got == nil {
- t.Fatalf("firstBrowsableArtifact() = nil, want %q", tt.want)
- }
- if got.Filename != tt.want {
- t.Errorf("firstBrowsableArtifact() = %q, want %q", got.Filename, tt.want)
- }
- })
- }
-}
diff --git a/internal/server/cache_api.go b/internal/server/cache_api.go
deleted file mode 100644
index 771d333..0000000
--- a/internal/server/cache_api.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package server
-
-import (
- "net/http"
- "strings"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/go-chi/chi/v5"
-)
-
-// CacheDeleteResponse lists the cached files removed for a version.
-type CacheDeleteResponse struct {
- PURL string `json:"purl"`
- Deleted []string `json:"deleted"`
-}
-
-// handleCacheDelete handles DELETE /api/cache/{ecosystem}/{name}/{version}
-// @Summary Delete cached artifacts
-// @Description Removes the cached files of name@version, or only the one named by filename, so the next request fetches them from the upstream again. Pushed archives are not touched; delete those through /api/hosted.
-// @Tags cache
-// @Produce json
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name as shown in the UI, e.g. com.google.guava:guava for Maven"
-// @Param version path string true "Version"
-// @Param filename query string false "Delete only this file"
-// @Success 200 {object} CacheDeleteResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 404 {object} ErrorResponse
-// @Failure 409 {object} ErrorResponse
-// @Router /api/cache/{ecosystem}/{name}/{version} [delete]
-func (s *Server) handleCacheDelete(w http.ResponseWriter, r *http.Request) {
- segments, err := packagePathSegments(r)
- if err != nil {
- badRequest(w, err.Error())
- return
- }
- if len(segments) < minVersionPathSegments {
- badRequest(w, "path must be /api/cache/{ecosystem}/{name}/{version}")
- return
- }
- last := len(segments) - 1
- ecosystem, name, version := chi.URLParam(r, "ecosystem"), strings.Join(segments[:last], "/"), segments[last]
- filename := r.URL.Query().Get("filename")
-
- versionPURL := s.cachedVersionPURL(ecosystem, name, version)
- if versionPURL == "" {
- notFound(w, "package not found")
- return
- }
- artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
- if err != nil {
- s.logger.Error("failed to list artifacts", "purl", versionPURL, "error", err)
- internalError(w, "failed to list artifacts")
- return
- }
-
- deleted := []string{}
- for _, art := range artifacts {
- if filename != "" && art.Filename != filename {
- continue
- }
- if !art.StoragePath.Valid {
- continue
- }
- if art.Source == database.SourceLocal {
- if filename != "" {
- writeError(w, http.StatusConflict, ErrCodeConflict, "pushed archives are deleted through /api/hosted")
- return
- }
- continue
- }
- if err := s.db.DiscardArtifact(versionPURL, art.Filename, art.StoragePath.String); err != nil {
- s.logger.Error("failed to delete cached artifact", "purl", versionPURL, "filename", art.Filename, "error", err)
- internalError(w, "failed to delete cached artifact")
- return
- }
- deleted = append(deleted, art.Filename)
- }
- if len(deleted) == 0 {
- notFound(w, "no cached artifact matched")
- return
- }
-
- s.logger.Info("deleted cached artifacts", "purl", versionPURL, "files", deleted)
- writeJSON(w, CacheDeleteResponse{PURL: versionPURL, Deleted: deleted})
-}
diff --git a/internal/server/cache_api_test.go b/internal/server/cache_api_test.go
deleted file mode 100644
index 8e66755..0000000
--- a/internal/server/cache_api_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package server
-
-import (
- "database/sql"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "slices"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/go-chi/chi/v5"
-)
-
-const (
- cacheTestPackage = "pkg:maven/com.example/lib"
- cacheTestVersion = cacheTestPackage + "@1.0"
-)
-
-// seedCacheDeleteVersion caches lib-1.0.jar and its .sha1 for
-// com.example:lib 1.0, plus a pushed lib-1.0-local.jar when pushed is set.
-func seedCacheDeleteVersion(t *testing.T, db *database.DB, pushed bool) {
- t.Helper()
- if err := db.UpsertPackage(&database.Package{PURL: cacheTestPackage, Ecosystem: "maven", Name: "com.example:lib"}); err != nil {
- t.Fatalf("UpsertPackage: %v", err)
- }
- if err := db.UpsertVersion(&database.Version{PURL: cacheTestVersion, PackagePURL: cacheTestPackage}); err != nil {
- t.Fatalf("UpsertVersion: %v", err)
- }
- files := map[string]string{"lib-1.0.jar": database.SourceUpstream, "lib-1.0.jar.sha1": database.SourceUpstream}
- if pushed {
- files["lib-1.0-local.jar"] = database.SourceLocal
- }
- for filename, source := range files {
- if err := db.UpsertArtifact(&database.Artifact{
- VersionPURL: cacheTestVersion,
- Filename: filename,
- UpstreamURL: "https://repo1.maven.org/maven2/com/example/lib/1.0/" + filename,
- StoragePath: sql.NullString{String: "objects/" + filename, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- Source: source,
- }); err != nil {
- t.Fatalf("UpsertArtifact %s: %v", filename, err)
- }
- }
-}
-
-func cacheDelete(ts *testServer, target string) *httptest.ResponseRecorder {
- r := chi.NewRouter()
- r.Delete("/api/cache/{ecosystem}/*", ts.server.handleCacheDelete)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, target, nil))
- return w
-}
-
-func cachedFiles(t *testing.T, db *database.DB) []string {
- t.Helper()
- artifacts, err := db.GetArtifactsByVersionPURL(cacheTestVersion)
- if err != nil {
- t.Fatalf("GetArtifactsByVersionPURL: %v", err)
- }
- var cached []string
- for _, art := range artifacts {
- if art.StoragePath.Valid {
- cached = append(cached, art.Filename)
- }
- }
- slices.Sort(cached)
- return cached
-}
-
-func queuedDeletes(t *testing.T, db *database.DB) []string {
- t.Helper()
- paths, err := db.GetDuePendingDeletes(time.Now().Add(time.Hour), 100)
- if err != nil {
- t.Fatalf("GetDuePendingDeletes: %v", err)
- }
- slices.Sort(paths)
- return paths
-}
-
-func TestCacheDelete_OneFile(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- seedCacheDeleteVersion(t, ts.db, false)
-
- w := cacheDelete(ts, "/api/cache/maven/com.example:lib/1.0?filename=lib-1.0.jar")
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- var resp CacheDeleteResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp.PURL != cacheTestVersion || !slices.Equal(resp.Deleted, []string{"lib-1.0.jar"}) {
- t.Errorf("response = %+v, want lib-1.0.jar deleted from %s", resp, cacheTestVersion)
- }
- if got := cachedFiles(t, ts.db); !slices.Equal(got, []string{"lib-1.0.jar.sha1"}) {
- t.Errorf("still cached = %v, want only the .sha1", got)
- }
- if got := queuedDeletes(t, ts.db); !slices.Equal(got, []string{"objects/lib-1.0.jar"}) {
- t.Errorf("queued for deletion = %v, want the jar's object", got)
- }
-}
-
-func TestCacheDelete_WholeVersionKeepsPushedFiles(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- seedCacheDeleteVersion(t, ts.db, true)
-
- w := cacheDelete(ts, "/api/cache/maven/com.example:lib/1.0")
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
- }
- if got := cachedFiles(t, ts.db); !slices.Equal(got, []string{"lib-1.0-local.jar"}) {
- t.Errorf("still cached = %v, want only the pushed jar", got)
- }
- if got := queuedDeletes(t, ts.db); !slices.Equal(got, []string{"objects/lib-1.0.jar", "objects/lib-1.0.jar.sha1"}) {
- t.Errorf("queued for deletion = %v, want both upstream objects", got)
- }
-}
-
-func TestCacheDelete_Refusals(t *testing.T) {
- tests := []struct {
- name string
- target string
- want int
- }{
- {"pushed file", "/api/cache/maven/com.example:lib/1.0?filename=lib-1.0-local.jar", http.StatusConflict},
- {"unknown file", "/api/cache/maven/com.example:lib/1.0?filename=other.jar", http.StatusNotFound},
- {"unknown version", "/api/cache/maven/com.example:lib/2.0", http.StatusNotFound},
- {"unknown package", "/api/cache/maven/com.example:other/1.0", http.StatusNotFound},
- {"no version", "/api/cache/maven/lib", http.StatusBadRequest},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
- seedCacheDeleteVersion(t, ts.db, true)
-
- w := cacheDelete(ts, tt.target)
- if w.Code != tt.want {
- t.Fatalf("status = %d, want %d; body: %s", w.Code, tt.want, w.Body.String())
- }
- if got := cachedFiles(t, ts.db); len(got) != 3 {
- t.Errorf("still cached = %v, want all three files untouched", got)
- }
- })
- }
-}
-
-func TestVersionPage_CacheDeleteButtons(t *testing.T) {
- for _, enabled := range []bool{false, true} {
- ts := newTestServer(t)
- seedCacheDeleteVersion(t, ts.db, true)
- ts.server.cfg.CacheDelete = enabled
-
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ui/package/maven/com.example:lib/1.0", nil))
- ts.close()
- if w.Code != http.StatusOK {
- t.Fatalf("enabled=%v: status = %d, want 200", enabled, w.Code)
- }
- body := w.Body.String()
- // One button per upstream file plus the version-wide one; the pushed
- // jar gets none.
- wantButtons := 0
- if enabled {
- wantButtons = 3
- }
- if got := strings.Count(body, `class="cache-delete `); got != wantButtons {
- t.Errorf("enabled=%v: %d delete buttons, want %d", enabled, got, wantButtons)
- }
- if enabled && !strings.Contains(body, `data-cache-url="/ui/api/cache/maven/com.example:lib/1.0"`) {
- t.Errorf("page has no data-cache-url for the version")
- }
- if got := strings.Contains(body, `
-
+
{{block "head" .}}{{end}}
-
+
{{template "header" .}}
-
+
{{block "content" .}}{{end}}
diff --git a/internal/server/templates/layout/footer.html b/internal/server/templates/layout/footer.html
index 33daddf..2c963f0 100644
--- a/internal/server/templates/layout/footer.html
+++ b/internal/server/templates/layout/footer.html
@@ -5,37 +5,34 @@
About
- git-pkgs proxy is a caching proxy for package registries supporting 17+ ecosystems.
+ git-pkgs proxy is a caching proxy for package registries supporting 16+ ecosystems.
- Accumulated download volume and cache composition across
- {{.Totals.Ecosystems}} ecosystem{{if ne .Totals.Ecosystems 1}}s{{end}}.
-
-
-
-{{if .StatsStale}}
-
- Figures may be out of date.
- The database query is failing, so the download and cache numbers below are
- the last snapshot that could be read{{if .StatsAge}}, from {{.StatsAge}}{{end}}.
- Check the proxy logs.
-
- The per-ecosystem query failed, so this table is unavailable. Check the proxy logs.
-
- {{else}}
-
No packages cached yet
- {{end}}
-
-
-
-
-
-
Metadata cache
-
- Cached registry metadata, kept apart from artifacts: the download, cache size and
- artifact figures above do not include it.
- {{if not .Metadata.Enabled}}Metadata caching is off; set cache_metadata to enable it.{{end}}
-
-
- {{if .Metadata.Rows}}
-
-
-
Cached documents
-
{{.Metadata.Entries}}
-
-
-
Metadata size
-
{{.Metadata.Size}}
-
-
-
-
-
-
-
Ecosystem
-
Size
-
Share
-
Documents
-
-
-
- {{range .Metadata.Rows}}
-
-
{{template "ecosystem_badge" .Ecosystem}}
-
{{.Size}}
-
{{.SharePct}}%
-
{{.Entries}}
-
- {{end}}
-
-
-
- {{else if .Metadata.Failed}}
-
- The metadata cache query failed, so these figures are unavailable. Check the proxy logs.
-
- Every figure on this page is also exported for Prometheus at
- /metrics.
- This page shows current state only — nothing here is a time series. For history,
- trends and alerting, scrape /metrics and use the
- Grafana dashboard shipped in deploy/grafana/.
-