diff --git a/README.md b/README.md index 083f18b..ee6f68b 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,17 @@ Or pull images directly: docker pull localhost:8080/library/nginx:latest ``` +SBOMs, signatures and attestations attached to an image are found through the +OCI 1.1 referrers API (`GET /v2/{name}/referrers/{digest}`). `oras discover` +and `notation verify` use it, and so does cosign for its newer signature +bundles. The proxy forwards these requests and caches the returned index with +the same TTL as other metadata, so they keep working offline. It does not +filter by `artifactType`; it returns the full list and the client filters it, +as the spec allows. Registries without the referrers API (GHCR, for example) +answer 404, and clients then look up the `sha256-` tag instead, which +the proxy serves like any other manifest. The proxy is pull-only, so attaching +new artifacts through it is not supported. + #### containerd (Kubernetes, k3s, nerdctl) containerd mirrors send the original registry host in an `ns` query diff --git a/internal/handler/container.go b/internal/handler/container.go index 63afd68..5580e84 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -330,6 +330,9 @@ func (h *ContainerHandler) Routes() http.Handler { case strings.Contains(path, "/tags/list"): // Tags list: GET /v2/{name}/tags/list h.handleTagsList(w, r, path) + case referrersPathPattern.MatchString(path): + // Referrers: GET /v2/{name}/referrers/{digest} + h.handleReferrers(w, r, path) default: http.Error(w, "not found", http.StatusNotFound) } diff --git a/internal/handler/container_metadata.go b/internal/handler/container_metadata.go index edc78ad..1168ebd 100644 --- a/internal/handler/container_metadata.go +++ b/internal/handler/container_metadata.go @@ -37,3 +37,25 @@ func (h *ContainerHandler) storeContainerMetadata(ctx context.Context, ecosystem } return size, nil } + +// loadContainerMetadata returns a cached metadata row and its body, or nil +// when nothing usable is cached. +func (h *ContainerHandler) loadContainerMetadata(ctx context.Context, ecosystem, cacheKey string) (*database.MetadataCacheEntry, []byte, error) { + if h.proxy.DB == nil || h.proxy.Storage == nil { + return nil, nil, nil + } + entry, err := h.proxy.DB.GetMetadataCache(ecosystem, cacheKey) + if err != nil || entry == nil { + return nil, nil, err + } + reader, err := h.proxy.Storage.Open(ctx, entry.StoragePath) + if err != nil { + return nil, nil, nil + } + defer func() { _ = reader.Close() }() + body, err := h.proxy.ReadMetadata(reader) + if err != nil { + return nil, nil, err + } + return entry, body, nil +} diff --git a/internal/handler/container_referrers.go b/internal/handler/container_referrers.go new file mode 100644 index 0000000..d04e503 --- /dev/null +++ b/internal/handler/container_referrers.go @@ -0,0 +1,210 @@ +package handler + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + containerReferrersCacheEcosystem = "oci-referrers" + containerReferrersMediaType = "application/vnd.oci.image.index.v1+json" + referrersMatchCount = 3 // full match + name + digest +) + +var ( + // referrersPathPattern matches referrers paths: {name}/referrers/{digest} + referrersPathPattern = regexp.MustCompile(`^(.+)/referrers/([^/]+)$`) + referrersDigestPattern = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`) +) + +// parseReferrersPath extracts repository name and subject digest from a +// referrers path. +func (h *ContainerHandler) parseReferrersPath(path string) (name, digest string) { + matches := referrersPathPattern.FindStringSubmatch(path) + if len(matches) != referrersMatchCount { + return "", "" + } + return matches[1], matches[2] +} + +// handleReferrers serves the OCI 1.1 referrers API. +// Path format: {name}/referrers/{digest} +func (h *ContainerHandler) handleReferrers(w http.ResponseWriter, r *http.Request, path string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + name, digest := h.parseReferrersPath(path) + if name == "" || !referrersDigestPattern.MatchString(digest) { + h.containerError(w, http.StatusBadRequest, "DIGEST_INVALID", "invalid referrers digest") + return + } + + registryURL, upstreamName, _, ok := h.registryForRequest(r, name) + if !ok { + h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") + return + } + + h.proxy.Logger.Info("container referrers request", "name", upstreamName, "digest", digest) + h.serveReferrers(w, r, registryURL, upstreamName, digest) +} + +func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request, registryURL, name, digest string) { + // artifactType is never forwarded, so the upstream returns the full index + // and one row serves every filter. Without OCI-Filters-Applied in the + // response, clients filter the index themselves, as the spec requires. + // ns only picks the registry, so it is neither forwarded nor part of the + // cache identity. + query := r.URL.Query() + query.Del("artifactType") + query.Del(namespaceQueryParam) + // Same identity shape as manifests (registry, name, reference, variant); + // the oci-referrers ecosystem keeps the rows apart. + cacheKey := h.containerManifestCacheKey(registryURL, name, digest, query.Encode()) + cached, err := h.loadContainerReferrers(r.Context(), cacheKey) + if err != nil { + h.proxy.Logger.Warn("failed to read cached container referrers", "error", err) + cached = nil + } + if cached != nil && h.containerTagsFresh(cached) { + h.writeContainerReferrers(w, r, registryURL, cached, false) + return + } + + upstreamURL := fmt.Sprintf("%s/v2/%s/referrers/%s", registryURL, name, digest) + if encoded := query.Encode(); encoded != "" { + upstreamURL += "?" + encoded + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) + if err != nil { + h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request") + return + } + req.Header.Set("Accept", containerReferrersMediaType) + if cached != nil && cached.etag != "" { + req.Header.Set("If-None-Match", cached.etag) + } + + resp, err := h.proxy.HTTPClient.Do(req) + if err != nil { + h.serveStaleReferrersOrFallback(w, r, registryURL, cached, err) + return + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotModified && cached != nil { + cached.fetchedAt = time.Now() + if err := h.storeContainerReferrers(r.Context(), cacheKey, cached); err != nil { + h.proxy.Logger.Warn("failed to refresh cached container referrers", "error", err) + } + h.writeContainerReferrers(w, r, registryURL, cached, false) + return + } + if resp.StatusCode != http.StatusOK { + if shouldServeStaleManifest(resp.StatusCode) { + h.serveStaleReferrersOrFallback(w, r, registryURL, cached, + fmt.Errorf("upstream returned status %d", resp.StatusCode)) + return + } + // A 404 tells the client the registry has no referrers API, so it + // falls back to the sha256- tag schema served as manifests. + h.proxy.relayResponse(w, r, resp, copyContainerTagsHeaders) + return + } + + body, err := h.proxy.ReadMetadata(resp.Body) + if err != nil { + h.serveStaleReferrersOrFallback(w, r, registryURL, cached, fmt.Errorf("reading referrers: %w", err)) + return + } + if !json.Valid(body) { + h.serveStaleReferrersOrFallback(w, r, registryURL, cached, errors.New("upstream referrers response is not JSON")) + return + } + referrers := &cachedContainerTags{ + body: body, + contentType: resp.Header.Get(headerContentType), + etag: resp.Header.Get(headerETag), + // Stored as sent upstream and rewritten when served, so a row shared + // by several client paths to the same registry links back correctly. + link: strings.Join(resp.Header.Values("Link"), ", "), + size: int64(len(body)), + fetchedAt: time.Now(), + } + if referrers.contentType == "" { + referrers.contentType = containerReferrersMediaType + } + if err := h.storeContainerReferrers(r.Context(), cacheKey, referrers); err != nil { + h.proxy.Logger.Warn("failed to cache container referrers", "error", err) + } + h.writeContainerReferrers(w, r, registryURL, referrers, false) +} + +// serveStaleReferrersOrFallback serves a cached index when the upstream cannot +// answer. Without one it returns 404, the signal clients already got before +// the proxy served this endpoint: they fall back to the tag schema, whose +// manifests may well be cached. +func (h *ContainerHandler) serveStaleReferrersOrFallback(w http.ResponseWriter, r *http.Request, registryURL string, cached *cachedContainerTags, err error) { + if cached != nil { + h.proxy.Logger.Warn("upstream referrers fetch failed, serving stale cache", "error", err) + h.writeContainerReferrers(w, r, registryURL, cached, true) + return + } + h.proxy.Logger.Warn("upstream referrers fetch failed, answering without referrers API", "error", err) + h.containerError(w, http.StatusNotFound, "UNSUPPORTED", "referrers unavailable from upstream") +} + +func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerTags, error) { + entry, body, err := h.loadContainerMetadata(ctx, containerReferrersCacheEcosystem, cacheKey) + if err != nil || entry == nil { + return nil, err + } + referrers := &cachedContainerTags{ + body: body, + contentType: cmp.Or(entry.ContentType.String, containerReferrersMediaType), + etag: entry.ETag.String, + link: entry.Link.String, + size: int64(len(body)), + fetchedAt: entry.FetchedAt.Time, + } + if entry.Size.Valid { + referrers.size = entry.Size.Int64 + } + return referrers, nil +} + +func (h *ContainerHandler) storeContainerReferrers(ctx context.Context, cacheKey string, referrers *cachedContainerTags) error { + size, err := h.storeContainerMetadata(ctx, containerReferrersCacheEcosystem, cacheKey, referrers.body, + referrers.etag, referrers.link, referrers.contentType, "", time.Time{}, referrers.fetchedAt) + if err != nil { + return fmt.Errorf("storing referrers: %w", err) + } + referrers.size = size + return nil +} + +func (h *ContainerHandler) writeContainerReferrers(w http.ResponseWriter, r *http.Request, registryURL string, referrers *cachedContainerTags, stale bool) { + w.Header().Set(headerContentType, referrers.contentType) + w.Header().Set(headerContentLength, strconv.FormatInt(referrers.size, 10)) + if referrers.etag != "" { + w.Header().Set(headerETag, referrers.etag) + } + if link := h.rewriteContainerTagsLink(referrers.link, registryURL, r.URL.Path, r.URL.Query().Get(namespaceQueryParam)); link != "" { + w.Header().Set("Link", link) + } + if stale { + w.Header().Set("Warning", containerStaleWarning) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(referrers.body) +} diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go new file mode 100644 index 0000000..5379330 --- /dev/null +++ b/internal/handler/container_referrers_test.go @@ -0,0 +1,479 @@ +package handler + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const testReferrersIndex = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` + + `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","size":10,"artifactType":"application/spdx+json"},` + + `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","size":10,"artifactType":"application/vnd.cncf.notary.signature"}]}` + +var testReferrersSubject = "sha256:" + sha256Hex("subject manifest") + +func newReferrersTestHandler(t *testing.T, upstream *httptest.Server) (*ContainerHandler, *Proxy) { + t.Helper() + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + return &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://proxy.example.test"}, proxy +} + +func serveReferrersRequest(h *ContainerHandler, method, target string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + h.Routes().ServeHTTP(recorder, httptest.NewRequest(method, target, nil)) + return recorder +} + +func TestContainerHandler_ReferrersCachesIndexForEveryFilter(t *testing.T) { + upstreamAvailable := true + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests++ + if r.URL.Path != "/v2/library/nginx/referrers/"+testReferrersSubject { + http.NotFound(w, r) + return + } + if r.URL.RawQuery != "" { + t.Errorf("upstream query = %q, want artifactType stripped", r.URL.RawQuery) + } + if got := r.Header.Get("Accept"); got != containerReferrersMediaType { + t.Errorf("upstream Accept = %q, want %q", got, containerReferrersMediaType) + } + if !upstreamAvailable { + http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", containerReferrersMediaType) + w.Header().Set("OCI-Filters-Applied", "artifactType") + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + h, proxy := newReferrersTestHandler(t, upstream) + proxy.MetadataTTL = time.Hour + base := "/library/nginx/referrers/" + testReferrersSubject + + first := serveReferrersRequest(h, http.MethodGet, base+"?artifactType=application/spdx%2Bjson") + if first.Code != http.StatusOK { + t.Fatalf("first status = %d, want 200: %s", first.Code, first.Body.String()) + } + if first.Body.String() != testReferrersIndex { + t.Errorf("first body = %q, want upstream index", first.Body.String()) + } + if got := first.Header().Get("Content-Type"); got != containerReferrersMediaType { + t.Errorf("first Content-Type = %q, want %q", got, containerReferrersMediaType) + } + if got := first.Header().Get("OCI-Filters-Applied"); got != "" { + t.Errorf("OCI-Filters-Applied = %q, want absent so clients filter", got) + } + + other := serveReferrersRequest(h, http.MethodGet, base+"?artifactType=application/vnd.cncf.notary.signature") + if other.Code != http.StatusOK || other.Body.String() != testReferrersIndex { + t.Fatalf("other filter = %d %q, want cached full index", other.Code, other.Body.String()) + } + if upstreamRequests != 1 { + t.Fatalf("upstream requests after fresh hit = %d, want 1", upstreamRequests) + } + + proxy.MetadataTTL = 0 + upstreamAvailable = false + stale := serveReferrersRequest(h, http.MethodGet, base) + if stale.Code != http.StatusOK || stale.Body.String() != testReferrersIndex { + t.Fatalf("stale = %d %q, want cached index", stale.Code, stale.Body.String()) + } + if got := stale.Header().Get("Content-Type"); got != containerReferrersMediaType { + t.Errorf("stale Content-Type = %q, want %q", got, containerReferrersMediaType) + } + if got := stale.Header().Get("Warning"); got != containerStaleWarning { + t.Errorf("stale Warning = %q, want stale warning", got) + } + if upstreamRequests != 2 { + t.Errorf("upstream requests after stale fallback = %d, want 2", upstreamRequests) + } +} + +func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { + upstreamRequests := 0 + notModified := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests++ + if r.Header.Get("If-None-Match") == `"referrers-etag"` { + notModified++ + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", containerReferrersMediaType) + w.Header().Set("ETag", `"referrers-etag"`) + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + h, proxy := newReferrersTestHandler(t, upstream) + proxy.MetadataTTL = time.Hour + target := "/library/nginx/referrers/" + testReferrersSubject + + if first := serveReferrersRequest(h, http.MethodGet, target); first.Code != http.StatusOK { + t.Fatalf("first status = %d, want 200", first.Code) + } + // Age the row past the TTL so the next request has to revalidate. + cacheKey := h.containerManifestCacheKey(upstream.URL, "library/nginx", testReferrersSubject, "") + entry, err := proxy.DB.GetMetadataCache(containerReferrersCacheEcosystem, cacheKey) + if err != nil || entry == nil { + t.Fatalf("cached row = %v, %v, want one", entry, err) + } + entry.FetchedAt.Time = time.Now().Add(-2 * time.Hour) + if err := proxy.DB.UpsertMetadataCache(entry); err != nil { + t.Fatalf("ageing cached row: %v", err) + } + + second := serveReferrersRequest(h, http.MethodGet, target) + if second.Code != http.StatusOK || second.Body.String() != testReferrersIndex { + t.Fatalf("revalidated = %d %q, want cached index", second.Code, second.Body.String()) + } + if got := second.Header().Get("Warning"); got != "" { + t.Errorf("Warning = %q, want none after 304", got) + } + + // The 304 starts the TTL again, so this one stays in the cache. + if third := serveReferrersRequest(h, http.MethodGet, target); third.Code != http.StatusOK { + t.Fatalf("third status = %d, want 200", third.Code) + } + if upstreamRequests != 2 || notModified != 1 { + t.Errorf("upstream requests = %d with %d answered 304, want 2 with 1", upstreamRequests, notModified) + } +} + +func TestContainerHandler_ReferrersDefaultsMissingContentType(t *testing.T) { + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamRequests++ + // A nil value stops net/http from sniffing a Content-Type. + w.Header()["Content-Type"] = nil + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + h, proxy := newReferrersTestHandler(t, upstream) + proxy.MetadataTTL = time.Hour + target := "/library/nginx/referrers/" + testReferrersSubject + + // oras-go only accepts the exact image index type, both fresh and cached. + for _, label := range []string{"fetched", "cached"} { + got := serveReferrersRequest(h, http.MethodGet, target) + if got.Code != http.StatusOK { + t.Fatalf("%s status = %d, want 200", label, got.Code) + } + if ct := got.Header().Get("Content-Type"); ct != containerReferrersMediaType { + t.Errorf("%s Content-Type = %q, want %q", label, ct, containerReferrersMediaType) + } + } + if upstreamRequests != 1 { + t.Errorf("upstream requests = %d, want 1", upstreamRequests) + } +} + +func TestContainerHandler_ReferrersRelaysUpstreamErrorsWithoutCaching(t *testing.T) { + tests := []struct { + name string + status int + header string + value string + wantError string + }{ + {name: "no referrers API", status: http.StatusNotFound, header: "Content-Type", value: "application/json", wantError: "NOT_FOUND"}, + {name: "repository unknown", status: http.StatusNotFound, header: "Content-Type", value: "application/json", wantError: "NAME_UNKNOWN"}, + {name: "auth challenge", status: http.StatusUnauthorized, header: "WWW-Authenticate", value: `Bearer realm="https://auth.example.test/token"`, wantError: "UNAUTHORIZED"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamRequests++ + w.Header().Set(tt.header, tt.value) + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, `{"errors":[{"code":"`+tt.wantError+`"}]}`) + })) + defer upstream.Close() + + h, proxy := newReferrersTestHandler(t, upstream) + proxy.MetadataTTL = time.Hour + target := "/library/nginx/referrers/" + testReferrersSubject + for range 2 { + got := serveReferrersRequest(h, http.MethodGet, target) + if got.Code != tt.status { + t.Fatalf("status = %d, want %d", got.Code, tt.status) + } + if !strings.Contains(got.Body.String(), tt.wantError) { + t.Errorf("body = %q, want upstream error %s", got.Body.String(), tt.wantError) + } + if got.Header().Get(tt.header) != tt.value { + t.Errorf("%s = %q, want %q", tt.header, got.Header().Get(tt.header), tt.value) + } + } + if upstreamRequests != 2 { + t.Errorf("upstream requests = %d, want 2 (error must not be cached)", upstreamRequests) + } + }) + } +} + +func TestContainerHandler_ReferrersFallsBackToTagSchemaWithoutCache(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + {name: "upstream 503", handler: func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + }}, + {name: "upstream 429", handler: func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "slow down", http.StatusTooManyRequests) + }}, + {name: "upstream not JSON", handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, "captive portal") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests++ + tt.handler(w, r) + })) + defer upstream.Close() + + h, proxy := newReferrersTestHandler(t, upstream) + proxy.MetadataTTL = time.Hour + target := "/library/nginx/referrers/" + testReferrersSubject + for range 2 { + assertReferrersFallback(t, serveReferrersRequest(h, http.MethodGet, target)) + } + if upstreamRequests != 2 { + t.Errorf("upstream requests = %d, want 2 (failure must not be cached)", upstreamRequests) + } + }) + } + + t.Run("upstream unreachable", func(t *testing.T) { + upstream := httptest.NewServer(http.NotFoundHandler()) + h, _ := newReferrersTestHandler(t, upstream) + upstream.Close() + assertReferrersFallback(t, serveReferrersRequest(h, http.MethodGet, "/library/nginx/referrers/"+testReferrersSubject)) + }) +} + +func assertReferrersFallback(t *testing.T, got *httptest.ResponseRecorder) { + t.Helper() + if got.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 so clients use the tag schema: %s", got.Code, got.Body.String()) + } + var body struct { + Errors []struct { + Code string `json:"code"` + } `json:"errors"` + } + if err := json.Unmarshal(got.Body.Bytes(), &body); err != nil || len(body.Errors) != 1 { + t.Fatalf("body = %q, want one OCI error", got.Body.String()) + } + if body.Errors[0].Code == "NAME_UNKNOWN" { + t.Errorf("error code = NAME_UNKNOWN, which stops clients from falling back") + } +} + +func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { + upstreamRequests := 0 + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests++ + if r.URL.Path != "/v2/owner/img/referrers/"+testReferrersSubject { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", containerReferrersMediaType) + switch r.URL.Query().Get("last") { + case "": + w.Header().Add("Link", `; rel="next"`) + w.Header().Add("Link", `; rel="help"`) + case "abc": + w.Header().Set("Link", `<`+upstream.URL+`/v2/owner/img/referrers/`+testReferrersSubject+`?last=def>; rel="next"`) + } + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + proxy.MetadataTTL = time.Hour + // The default registry is the same upstream, so upstream/test/owner/img + // and owner/img share one cache row. + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"test": upstream.URL}) + routes := http.StripPrefix("/v2", h.Routes()) + + first := httptest.NewRecorder() + routes.ServeHTTP(first, httptest.NewRequest(http.MethodGet, + "/v2/upstream/test/owner/img/referrers/"+testReferrersSubject+"?artifactType=application/spdx%2Bjson", nil)) + if first.Code != http.StatusOK { + t.Fatalf("first page status = %d, want 200: %s", first.Code, first.Body.String()) + } + wantLink := `; rel="next", ; rel="help"` + if got := first.Header().Get("Link"); got != wantLink { + t.Fatalf("Link = %q, want %q", got, wantLink) + } + + shared := httptest.NewRecorder() + routes.ServeHTTP(shared, httptest.NewRequest(http.MethodGet, "/v2/owner/img/referrers/"+testReferrersSubject, nil)) + if shared.Code != http.StatusOK { + t.Fatalf("shared row status = %d, want 200: %s", shared.Code, shared.Body.String()) + } + wantSharedLink := `; rel="next", ; rel="help"` + if got := shared.Header().Get("Link"); got != wantSharedLink { + t.Errorf("shared row Link = %q, want it rewritten for the second path %q", got, wantSharedLink) + } + if upstreamRequests != 1 { + t.Fatalf("upstream requests after shared row hit = %d, want 1", upstreamRequests) + } + + nextURL := strings.TrimPrefix(strings.SplitN(first.Header().Get("Link"), ">", 2)[0], "<") + next := httptest.NewRecorder() + routes.ServeHTTP(next, httptest.NewRequest(http.MethodGet, nextURL, nil)) + if next.Code != http.StatusOK { + t.Fatalf("next page status = %d, want 200: %s", next.Code, next.Body.String()) + } + wantNextLink := `; rel="next"` + if got := next.Header().Get("Link"); got != wantNextLink { + t.Errorf("next page Link = %q, want absolute upstream link rewritten to %q", got, wantNextLink) + } + if upstreamRequests != 2 { + t.Errorf("upstream requests = %d, want 2 (pages are separate rows)", upstreamRequests) + } +} + +func TestContainerHandler_ReferrersRejectsInvalidRequests(t *testing.T) { + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamRequests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL) + + tests := []struct { + name string + method string + path string + wantStatus int + wantCode string + }{ + {name: "post", method: http.MethodPost, path: "/library/nginx/referrers/" + testReferrersSubject, wantStatus: http.StatusMethodNotAllowed}, + {name: "short digest", method: http.MethodGet, path: "/library/nginx/referrers/sha256:abc", wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, + {name: "trailing characters", method: http.MethodGet, path: "/library/nginx/referrers/" + testReferrersSubject + "x", wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, + {name: "leading characters", method: http.MethodGet, path: "/library/nginx/referrers/x" + testReferrersSubject, wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, + {name: "unknown algorithm", method: http.MethodGet, path: "/library/nginx/referrers/md5:" + strings.Repeat("a", 32), wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, + {name: "unknown named upstream", method: http.MethodGet, path: "/upstream/missing/owner/img/referrers/" + testReferrersSubject, wantStatus: http.StatusNotFound, wantCode: "NAME_UNKNOWN"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serveReferrersRequest(h, tt.method, tt.path) + if got.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d: %s", got.Code, tt.wantStatus, got.Body.String()) + } + if tt.wantCode != "" && !strings.Contains(got.Body.String(), tt.wantCode) { + t.Errorf("body = %q, want %s", got.Body.String(), tt.wantCode) + } + }) + } + if upstreamRequests != 0 { + t.Errorf("upstream requests = %d, want 0", upstreamRequests) + } +} + +func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { + tagSchema := "sha256-" + strings.TrimPrefix(testReferrersSubject, "sha256:") + var upstreamPaths []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamPaths = append(upstreamPaths, r.URL.Path) + // Both handlers build the same upstream path here, so tell them apart + // by Accept: only the referrers handler asks for the index type alone. + if r.Header.Get("Accept") == containerReferrersMediaType { + http.Error(w, "reached the referrers handler", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", containerReferrersMediaType) + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + h, _ := newReferrersTestHandler(t, upstream) + for _, path := range []string{ + // A repository may contain a "referrers" component; manifest paths + // must keep reaching the manifest handler. + "/foo/manifests/referrers/" + testReferrersSubject, + // Clients without a referrers API read the tag schema as a manifest. + "/library/nginx/manifests/" + tagSchema, + } { + if got := serveReferrersRequest(h, http.MethodGet, path); got.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200: %s", path, got.Code, got.Body.String()) + } + } + want := []string{ + "/v2/foo/manifests/referrers/" + testReferrersSubject, + "/v2/library/nginx/manifests/" + tagSchema, + } + if strings.Join(upstreamPaths, "\n") != strings.Join(want, "\n") { + t.Errorf("upstream paths = %q, want %q", upstreamPaths, want) + } +} + +func TestContainerHandler_ReferrersFollowsNamespace(t *testing.T) { + var upstreamQueries []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/owner/img/referrers/"+testReferrersSubject { + http.NotFound(w, r) + return + } + upstreamQueries = append(upstreamQueries, r.URL.RawQuery) + w.Header().Set("Content-Type", containerReferrersMediaType) + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + proxy.MetadataTTL = time.Hour + h := NewContainerHandler(proxy, "http://proxy.example.test", map[string]string{"test": upstream.URL}) + routes := http.StripPrefix("/v2", h.Routes()) + host := strings.TrimPrefix(upstream.URL, "http://") + target := "/v2/owner/img/referrers/" + testReferrersSubject + + withNS := httptest.NewRecorder() + routes.ServeHTTP(withNS, httptest.NewRequest(http.MethodGet, target+"?ns="+url.QueryEscape(host), nil)) + if withNS.Code != http.StatusOK { + t.Fatalf("ns request status = %d, want 200: %s", withNS.Code, withNS.Body.String()) + } + wantLink := `; rel="next"` + if got := withNS.Header().Get("Link"); got != wantLink { + t.Errorf("Link = %q, want next page kept on the ns route %q", got, wantLink) + } + + // The prefix route reaches the same registry and shares the row. + prefixed := httptest.NewRecorder() + routes.ServeHTTP(prefixed, httptest.NewRequest(http.MethodGet, "/v2/upstream/test/owner/img/referrers/"+testReferrersSubject, nil)) + if prefixed.Code != http.StatusOK { + t.Fatalf("prefix request status = %d, want 200: %s", prefixed.Code, prefixed.Body.String()) + } + if len(upstreamQueries) != 1 || upstreamQueries[0] != "" { + t.Errorf("upstream queries = %q, want one request without ns", upstreamQueries) + } +}