diff --git a/e2e/head_test.go b/e2e/head_test.go index 487a909..e222551 100644 --- a/e2e/head_test.go +++ b/e2e/head_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -112,3 +113,70 @@ func TestProxyHeadGitHubRPMRepodata(t *testing.T) { } assertHeadMatchesGet(t, url) } + +func TestProxyHeadNpmUncachedPackument(t *testing.T) { + const abbreviated = "application/vnd.npm.install-v1+json" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{"name": "left-pad", "versions": map[string]any{"1.3.0": map[string]any{}}} + if r.Header.Get("Accept") != abbreviated { + doc["readme"] = strings.Repeat("full packument ", 200) + } + json.NewEncoder(w).Encode(doc) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-npm", "package_type": "npm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-npm") + + url := apiURL("/api/v1/remote/head-npm/left-pad") + do := func(method string) (*http.Response, []byte) { + req, _ := http.NewRequest(method, url, nil) + req.Header.Set("Accept", abbreviated) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, b + } + head, _ := do(http.MethodHead) + get, body := do(http.MethodGet) + if head.StatusCode != http.StatusOK || get.StatusCode != http.StatusOK { + t.Fatalf("HEAD %d, GET %d", head.StatusCode, get.StatusCode) + } + if head.ContentLength != int64(len(body)) { + t.Errorf("uncached HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) + } +} + +func TestProxyHeadNonRewritingUsesUpstreamHead(t *testing.T) { + methods := make(chan string, 4) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods <- r.Method + w.Header().Set("Content-Type", "text/xml") + w.Write([]byte("")) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-rpm", "package_type": "rpm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-rpm") + + resp, err := http.Head(apiURL("/api/v1/remote/head-rpm/repodata/repomd.xml")) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Artifact-Source") != "remote" { + t.Fatalf("HEAD %d source %q", resp.StatusCode, resp.Header.Get("X-Artifact-Source")) + } + close(methods) + var seen []string + for m := range methods { + seen = append(seen, m) + } + if len(seen) != 1 || seen[0] != http.MethodHead { + t.Errorf("upstream saw %v, want a single HEAD", seen) + } +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 9015105..a53e8e6 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -142,6 +142,12 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote io.Copy(w, body) } +func rewritesMutable(prov provider.Provider, path string) bool { + _, sr := prov.(provider.ServeRewriter) + _, rr := prov.(provider.ResponseRewriter) + return (sr || rr) && prov.Classify(path) == provider.Mutable +} + // handleProxyHead answers HEAD like GET without a body. Responses a provider // synthesizes or rewrites per request go through the GET path so their headers // match; everything else is answered from store metadata or an upstream HEAD. @@ -154,7 +160,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { if h.serveRemote(w, r, remote, prov, path) { return } - if _, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { + if rewritesMutable(prov, path) { h.serveFetch(w, r, remote, prov, path) return } diff --git a/internal/api/v1/routes_test.go b/internal/api/v1/routes_test.go index 4493a5c..e29d8cf 100644 --- a/internal/api/v1/routes_test.go +++ b/internal/api/v1/routes_test.go @@ -5,6 +5,11 @@ import ( "testing" "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/npm" + "git.unkin.net/unkin/artifactapi/internal/provider/nuget" + "git.unkin.net/unkin/artifactapi/internal/provider/rpm" ) func TestRoutesRemoteHead(t *testing.T) { @@ -15,3 +20,22 @@ func TestRoutesRemoteHead(t *testing.T) { } } } + +func TestRewritesMutable(t *testing.T) { + tests := []struct { + name string + prov provider.Provider + path string + want bool + }{ + {"npm packument", &npm.Provider{}, "left-pad", true}, + {"npm tarball", &npm.Provider{}, "left-pad/-/left-pad-1.3.0.tgz", false}, + {"nuget index", &nuget.Provider{}, "index.json", true}, + {"rpm repodata", &rpm.Provider{}, "repodata/repomd.xml", false}, + } + for _, tc := range tests { + if got := rewritesMutable(tc.prov, tc.path); got != tc.want { + t.Errorf("%s: rewritesMutable = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/provider/helm/helm.go b/internal/provider/helm/helm.go index d37765e..07d81a6 100644 --- a/internal/provider/helm/helm.go +++ b/internal/provider/helm/helm.go @@ -39,6 +39,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") } +func (p *Provider) RewritesResponse() {} + func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { if proxyBaseURL == "" { return nil, nil diff --git a/internal/provider/npm/npm.go b/internal/provider/npm/npm.go index 38cf32d..8bc098d 100644 --- a/internal/provider/npm/npm.go +++ b/internal/provider/npm/npm.go @@ -37,6 +37,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") } +func (p *Provider) RewritesResponse() {} + func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { if proxyBaseURL == "" || !json.Valid(body) { return nil, nil diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4c005f3..ca21e8c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -82,6 +82,12 @@ type ServeRewriter interface { RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte } +// ResponseRewriter marks providers whose RewriteResponse changes mutable +// bodies, so the served length is only known after a fetch. +type ResponseRewriter interface { + RewritesResponse() +} + // RemoteMetadataStore is the persistence surface a RemoteServer needs to cache // and read the metadata it derives per upstream asset. *database.DB satisfies it. type RemoteMetadataStore interface { diff --git a/internal/provider/puppet/puppet.go b/internal/provider/puppet/puppet.go index 142a431..03bf1d8 100644 --- a/internal/provider/puppet/puppet.go +++ b/internal/provider/puppet/puppet.go @@ -39,6 +39,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") } +func (p *Provider) RewritesResponse() {} + func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { if proxyBaseURL == "" { return nil, nil diff --git a/internal/provider/pypi/pypi.go b/internal/provider/pypi/pypi.go index e87e9c1..373e584 100644 --- a/internal/provider/pypi/pypi.go +++ b/internal/provider/pypi/pypi.go @@ -52,6 +52,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") } +func (p *Provider) RewritesResponse() {} + func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { if proxyBaseURL == "" { return nil, nil diff --git a/internal/provider/terraform/terraform.go b/internal/provider/terraform/terraform.go index f29c33d..022d094 100644 --- a/internal/provider/terraform/terraform.go +++ b/internal/provider/terraform/terraform.go @@ -73,6 +73,8 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/v1/providers/" + strings.TrimLeft(path, "/") } +func (p *Provider) RewritesResponse() {} + func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { if remote.ReleasesRemote == "" { return nil, nil