From de0efc9500eb0a642e5b6c2ecce3307f10bc54e0 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:36:54 +0200 Subject: [PATCH 01/15] Proxy the OCI referrers API in the container handler GET /v2/{name}/referrers/{digest} used to fall through to a plain 404, so clients only ever saw referrers stored under the sha256- tag schema. Registries that keep SBOMs and signatures behind the referrers API looked empty through the proxy. The index is now fetched from the upstream that the repository routes to and cached like the tag list: fresh within the metadata TTL, revalidated with If-None-Match, and served stale when the upstream is down. artifactType is not forwarded, so one cached row covers every filter and clients filter the full list themselves, which they do when OCI-Filters-Applied is absent. An upstream 404 is passed through untouched, so clients still fall back to the tag schema for registries like GHCR. When the upstream cannot answer and nothing is cached, the proxy keeps answering 404 as before rather than failing the request. Closes #144 --- README.md | 10 + internal/handler/container.go | 3 + internal/handler/container_referrers.go | 243 +++++++++++++ internal/handler/container_referrers_test.go | 360 +++++++++++++++++++ 4 files changed, 616 insertions(+) create mode 100644 internal/handler/container_referrers.go create mode 100644 internal/handler/container_referrers_test.go diff --git a/README.md b/README.md index 083f18b..fc4a7ab 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,16 @@ 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}`), which tools such as +`oras discover`, `notation verify` and `cosign` use. 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_referrers.go b/internal/handler/container_referrers.go new file mode 100644 index 0000000..ac10baf --- /dev/null +++ b/internal/handler/container_referrers.go @@ -0,0 +1,243 @@ +package handler + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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})$`) +) + +type cachedContainerReferrers struct { + body []byte + contentType string + etag string + link string + size int64 + fetchedAt time.Time +} + +// 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.registryForName(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. + query := r.URL.Query() + query.Del("artifactType") + cacheKey := h.containerReferrersCacheKey(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.containerReferrersFresh(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 := &cachedContainerReferrers{ + 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 *cachedContainerReferrers, 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) containerReferrersCacheKey(registryURL, name, digest, query string) string { + identity := strings.Join([]string{registryURL, name, digest, query}, "\x00") + sum := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(sum[:]) +} + +func (h *ContainerHandler) containerReferrersFresh(referrers *cachedContainerReferrers) bool { + return h.proxy.MetadataTTL > 0 && !referrers.fetchedAt.IsZero() && time.Since(referrers.fetchedAt) < h.proxy.MetadataTTL +} + +func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerReferrers, error) { + if h.proxy.DB == nil || h.proxy.Storage == nil { + return nil, nil + } + entry, err := h.proxy.DB.GetMetadataCache(containerReferrersCacheEcosystem, cacheKey) + if err != nil || entry == nil { + return nil, err + } + reader, err := h.proxy.Storage.Open(ctx, entry.StoragePath) + if err != nil { + return nil, nil + } + defer func() { _ = reader.Close() }() + body, err := h.proxy.ReadMetadata(reader) + if err != nil { + return nil, err + } + + referrers := &cachedContainerReferrers{body: body, contentType: containerReferrersMediaType, size: int64(len(body))} + if entry.ContentType.Valid { + referrers.contentType = entry.ContentType.String + } + if entry.ETag.Valid { + referrers.etag = entry.ETag.String + } + if entry.Link.Valid { + referrers.link = entry.Link.String + } + if entry.Size.Valid { + referrers.size = entry.Size.Int64 + } + if entry.FetchedAt.Valid { + referrers.fetchedAt = entry.FetchedAt.Time + } + return referrers, nil +} + +func (h *ContainerHandler) storeContainerReferrers(ctx context.Context, cacheKey string, referrers *cachedContainerReferrers) 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 *cachedContainerReferrers, 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); 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..ac90d49 --- /dev/null +++ b/internal/handler/container_referrers_test.go @@ -0,0 +1,360 @@ +package handler + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "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 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests++ + if r.Header.Get("If-None-Match") == `"referrers-etag"` { + 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 = 0 + 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) + } + 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) + } + if upstreamRequests != 2 { + t.Errorf("upstream requests = %d, want 2", 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) + if r.URL.Query().Get("last") == "" { + w.Header().Add("Link", `; rel="next"`) + w.Header().Add("Link", `; rel="help"`) + } + _, _ = 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()) + + 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) + } + + 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()) + } + if got := next.Header().Get("Link"); got != "" { + t.Errorf("last page Link = %q, want none", got) + } + 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: "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) + 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) + } +} From 867246ef780710f099925a734ea7390a4fea8919 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:01 +0200 Subject: [PATCH 02/15] Cover absolute upstream links in the referrers link test The second page now answers with an absolute Link pointing back at the upstream host, and the test checks that it comes back rewritten to the proxy. That was missing so far, and it also gives the upstream variable a reason to be declared before the handler closure, which staticcheck complained about. --- internal/handler/container_referrers_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index ac90d49..3020d9d 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -248,9 +248,12 @@ func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { return } w.Header().Set("Content-Type", containerReferrersMediaType) - if r.URL.Query().Get("last") == "" { + 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) })) @@ -280,8 +283,9 @@ func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { if next.Code != http.StatusOK { t.Fatalf("next page status = %d, want 200: %s", next.Code, next.Body.String()) } - if got := next.Header().Get("Link"); got != "" { - t.Errorf("last page Link = %q, want none", got) + 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) From 02ae82b297890b99af72a60d4d4a59a833f65bb3 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:16 +0200 Subject: [PATCH 03/15] Read cached referrers through a small shared loader loadContainerReferrers was a line-for-line copy of loadContainerTags with the types renamed, which dupl flags in CI. The part that opens the cache row and reads the body now lives in loadContainerMetadata next to storeContainerMetadata, and the referrers loader only maps the row onto its struct. The tag and manifest loaders are left as they are. --- internal/handler/container_metadata.go | 22 ++++++++++++++++ internal/handler/container_referrers.go | 35 +++++++------------------ 2 files changed, 31 insertions(+), 26 deletions(-) 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 index ac10baf..d89f9a2 100644 --- a/internal/handler/container_referrers.go +++ b/internal/handler/container_referrers.go @@ -1,6 +1,7 @@ package handler import ( + "cmp" "context" "crypto/sha256" "encoding/hex" @@ -180,39 +181,21 @@ func (h *ContainerHandler) containerReferrersFresh(referrers *cachedContainerRef } func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerReferrers, error) { - if h.proxy.DB == nil || h.proxy.Storage == nil { - return nil, nil - } - entry, err := h.proxy.DB.GetMetadataCache(containerReferrersCacheEcosystem, cacheKey) + entry, body, err := h.loadContainerMetadata(ctx, containerReferrersCacheEcosystem, cacheKey) if err != nil || entry == nil { return nil, err } - reader, err := h.proxy.Storage.Open(ctx, entry.StoragePath) - if err != nil { - return nil, nil - } - defer func() { _ = reader.Close() }() - body, err := h.proxy.ReadMetadata(reader) - if err != nil { - return nil, err - } - - referrers := &cachedContainerReferrers{body: body, contentType: containerReferrersMediaType, size: int64(len(body))} - if entry.ContentType.Valid { - referrers.contentType = entry.ContentType.String - } - if entry.ETag.Valid { - referrers.etag = entry.ETag.String - } - if entry.Link.Valid { - referrers.link = entry.Link.String + referrers := &cachedContainerReferrers{ + 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 } - if entry.FetchedAt.Valid { - referrers.fetchedAt = entry.FetchedAt.Time - } return referrers, nil } From 047686432444059453377b9037089eb2fe2a28d1 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:22 +0200 Subject: [PATCH 04/15] Make the manifest routing test notice which handler answered For foo/manifests/referrers/ the manifest handler and the referrers handler send the very same upstream path, so the test passed even with the referrers case moved in front of the manifest case. The fake upstream now rejects requests that only accept an image index, which is what the referrers handler sends. --- internal/handler/container_referrers_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 3020d9d..2a85756 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -337,6 +337,12 @@ func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { 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) })) From d54320d34785e30acf13cb441c4f642c5f16fe1e Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:33 +0200 Subject: [PATCH 05/15] Check that a cached referrers row links back to whoever reads it A repository can be reached as upstream// and, when the default registry is the same host, as plain . Both end up in the same cache row, so the pagination Link has to be rewritten when the row is served, not when it is stored. The link test now reads the row through both paths; with a store-time rewrite the second path would get the first one's link. --- internal/handler/container_referrers_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 2a85756..9f10601 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -262,7 +262,9 @@ func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() proxy.MetadataTTL = time.Hour - h := NewContainerHandler(proxy, "http://proxy.example.test", map[string]string{"test": upstream.URL}) + // 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() @@ -277,6 +279,20 @@ func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { 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)) From e89abcc39b1aee2b9b7d2a1a7e75f988e9c8c84a Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:43 +0200 Subject: [PATCH 06/15] Make the referrers ETag test prove the revalidation Without If-None-Match the fake upstream just sends the full index again, so the old assertions held either way. The test now counts the 304s the upstream sent and expects exactly one. --- internal/handler/container_referrers_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 9f10601..9a346a1 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -99,9 +99,11 @@ func TestContainerHandler_ReferrersCachesIndexForEveryFilter(t *testing.T) { 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 } @@ -125,8 +127,8 @@ func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { if got := second.Header().Get("Warning"); got != "" { t.Errorf("Warning = %q, want none after 304", got) } - if upstreamRequests != 2 { - t.Errorf("upstream requests = %d, want 2", upstreamRequests) + if upstreamRequests != 2 || notModified != 1 { + t.Errorf("upstream requests = %d with %d answered 304, want 2 with 1", upstreamRequests, notModified) } } From 7efe45f2ae9307ddf8aeaa3087f11b9a50e76c43 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:45:45 +0200 Subject: [PATCH 07/15] Reword which tools use the referrers API in the README The cosign mention was too broad. cosign reaches the referrers API for its newer signature bundles, while the old .sig tags keep going through the normal manifest path. Also rewraps the paragraph, one line had run past the others. --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fc4a7ab..ee6f68b 100644 --- a/README.md +++ b/README.md @@ -448,14 +448,15 @@ 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}`), which tools such as -`oras discover`, `notation verify` and `cosign` use. 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. +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) From 605704230b044f218dabaedeae70f91263dc5be9 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:52:52 +0200 Subject: [PATCH 08/15] Check that a 304 restarts the referrers cache TTL The ETag test now backdates the cached row instead of running with a zero TTL. After the upstream answers 304, a third request has to come from the cache. Before, the test would also have passed if the 304 branch forgot to bump fetchedAt and every request kept revalidating. --- internal/handler/container_referrers_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 9a346a1..aaa151d 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -114,12 +114,23 @@ func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) - proxy.MetadataTTL = 0 + 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.containerReferrersCacheKey(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()) @@ -127,6 +138,11 @@ func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { 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) } From 9eded620d465bfd7a4f8b72ea045ee391a252813 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:53:01 +0200 Subject: [PATCH 09/15] Test the referrers Content-Type fallback None of the fake upstreams left out Content-Type, so the default to the image index type was never exercised. oras-go rejects anything else, so it is worth pinning, both for the first fetch and for the cached copy. --- internal/handler/container_referrers_test.go | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index aaa151d..8a849ba 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -148,6 +148,35 @@ func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { } } +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 From aee7c1518e4eef00abb738ee9d79338c7475728b Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 21:53:09 +0200 Subject: [PATCH 10/15] Test referrers digests with extra characters around them The invalid-request table only had digests that were too short or used an unknown algorithm, so it would not notice if the digest pattern lost its anchors. Two cases now put junk before and after an otherwise valid sha256 digest. --- internal/handler/container_referrers_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 8a849ba..6166c6b 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -376,6 +376,8 @@ func TestContainerHandler_ReferrersRejectsInvalidRequests(t *testing.T) { }{ {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"}, } From a9c648f6cdded1e1f594c0caf98ac0e9216879ae Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 22:01:19 +0200 Subject: [PATCH 11/15] Reuse the manifest cache key for referrers The referrers handler had its own key function, but it did exactly what containerManifestCacheKey already does with four strings. Referrers rows sit in their own oci-referrers ecosystem anyway, so they can't collide with manifests. --- internal/handler/container_referrers.go | 12 +++--------- internal/handler/container_referrers_test.go | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/internal/handler/container_referrers.go b/internal/handler/container_referrers.go index d89f9a2..0802117 100644 --- a/internal/handler/container_referrers.go +++ b/internal/handler/container_referrers.go @@ -3,8 +3,6 @@ package handler import ( "cmp" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -76,7 +74,9 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request // response, clients filter the index themselves, as the spec requires. query := r.URL.Query() query.Del("artifactType") - cacheKey := h.containerReferrersCacheKey(registryURL, name, digest, query.Encode()) + // 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) @@ -170,12 +170,6 @@ func (h *ContainerHandler) serveStaleReferrersOrFallback(w http.ResponseWriter, h.containerError(w, http.StatusNotFound, "UNSUPPORTED", "referrers unavailable from upstream") } -func (h *ContainerHandler) containerReferrersCacheKey(registryURL, name, digest, query string) string { - identity := strings.Join([]string{registryURL, name, digest, query}, "\x00") - sum := sha256.Sum256([]byte(identity)) - return hex.EncodeToString(sum[:]) -} - func (h *ContainerHandler) containerReferrersFresh(referrers *cachedContainerReferrers) bool { return h.proxy.MetadataTTL > 0 && !referrers.fetchedAt.IsZero() && time.Since(referrers.fetchedAt) < h.proxy.MetadataTTL } diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 6166c6b..2eb5dde 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -121,7 +121,7 @@ func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { t.Fatalf("first status = %d, want 200", first.Code) } // Age the row past the TTL so the next request has to revalidate. - cacheKey := h.containerReferrersCacheKey(upstream.URL, "library/nginx", testReferrersSubject, "") + 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) From f4c7a30a4a22f58fe63101806f1a8a25e05f3a78 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 4 Oct 2026 22:01:32 +0200 Subject: [PATCH 12/15] Drop the separate struct for cached referrers A cached referrers response carries the same fields as a cached tag list, and the freshness check was a straight copy too. So the referrers code just uses cachedContainerTags and containerTagsFresh now. Loading, storing and writing stay on their own, since the ecosystem and the Link handling are different. --- internal/handler/container_referrers.go | 27 +++++++------------------ 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/internal/handler/container_referrers.go b/internal/handler/container_referrers.go index 0802117..e768579 100644 --- a/internal/handler/container_referrers.go +++ b/internal/handler/container_referrers.go @@ -25,15 +25,6 @@ var ( referrersDigestPattern = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`) ) -type cachedContainerReferrers struct { - body []byte - contentType string - etag string - link string - size int64 - fetchedAt time.Time -} - // parseReferrersPath extracts repository name and subject digest from a // referrers path. func (h *ContainerHandler) parseReferrersPath(path string) (name, digest string) { @@ -82,7 +73,7 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request h.proxy.Logger.Warn("failed to read cached container referrers", "error", err) cached = nil } - if cached != nil && h.containerReferrersFresh(cached) { + if cached != nil && h.containerTagsFresh(cached) { h.writeContainerReferrers(w, r, registryURL, cached, false) return } @@ -137,7 +128,7 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request h.serveStaleReferrersOrFallback(w, r, registryURL, cached, errors.New("upstream referrers response is not JSON")) return } - referrers := &cachedContainerReferrers{ + referrers := &cachedContainerTags{ body: body, contentType: resp.Header.Get(headerContentType), etag: resp.Header.Get(headerETag), @@ -160,7 +151,7 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request // 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 *cachedContainerReferrers, err error) { +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) @@ -170,16 +161,12 @@ func (h *ContainerHandler) serveStaleReferrersOrFallback(w http.ResponseWriter, h.containerError(w, http.StatusNotFound, "UNSUPPORTED", "referrers unavailable from upstream") } -func (h *ContainerHandler) containerReferrersFresh(referrers *cachedContainerReferrers) bool { - return h.proxy.MetadataTTL > 0 && !referrers.fetchedAt.IsZero() && time.Since(referrers.fetchedAt) < h.proxy.MetadataTTL -} - -func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerReferrers, error) { +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 := &cachedContainerReferrers{ + referrers := &cachedContainerTags{ body: body, contentType: cmp.Or(entry.ContentType.String, containerReferrersMediaType), etag: entry.ETag.String, @@ -193,7 +180,7 @@ func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey return referrers, nil } -func (h *ContainerHandler) storeContainerReferrers(ctx context.Context, cacheKey string, referrers *cachedContainerReferrers) error { +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 { @@ -203,7 +190,7 @@ func (h *ContainerHandler) storeContainerReferrers(ctx context.Context, cacheKey return nil } -func (h *ContainerHandler) writeContainerReferrers(w http.ResponseWriter, r *http.Request, registryURL string, referrers *cachedContainerReferrers, stale bool) { +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 != "" { From 77eb8b9f6eb90f70d73d6e2455e19cfac021cb87 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sat, 10 Oct 2026 09:46:50 +0200 Subject: [PATCH 13/15] Route referrers requests by ns like the other OCI endpoints Now that containerd ns support is on main, referrers requests have to go through registryForRequest too, otherwise a _default mirror would look every subject up on Docker Hub. ns only picks the registry, so it is dropped from the forwarded query and the cache key, and the next-page Link keeps it, the same way tag lists do. This also follows the new rewriteContainerTagsLink signature, which takes the namespace. --- internal/handler/container_referrers.go | 7 +++- internal/handler/container_referrers_test.go | 44 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/handler/container_referrers.go b/internal/handler/container_referrers.go index e768579..d04e503 100644 --- a/internal/handler/container_referrers.go +++ b/internal/handler/container_referrers.go @@ -49,7 +49,7 @@ func (h *ContainerHandler) handleReferrers(w http.ResponseWriter, r *http.Reques return } - registryURL, upstreamName, _, ok := h.registryForName(name) + registryURL, upstreamName, _, ok := h.registryForRequest(r, name) if !ok { h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry") return @@ -63,8 +63,11 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request // 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()) @@ -196,7 +199,7 @@ func (h *ContainerHandler) writeContainerReferrers(w http.ResponseWriter, r *htt if referrers.etag != "" { w.Header().Set(headerETag, referrers.etag) } - if link := h.rewriteContainerTagsLink(referrers.link, registryURL, r.URL.Path); link != "" { + if link := h.rewriteContainerTagsLink(referrers.link, registryURL, r.URL.Path, r.URL.Query().Get(namespaceQueryParam)); link != "" { w.Header().Set("Link", link) } if stale { diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 2eb5dde..5379330 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -433,3 +434,46 @@ func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { 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) + } +} From 789f4994602023aa706849d39025ff96f35240e7 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 11 Oct 2026 19:35:59 +0200 Subject: [PATCH 14/15] Without a cached index, referrers now pass upstream errors through and answer 502 when the upstream can't be reached or sends garbage, so clients no longer read an outage as a registry without the referrers API. Stale cache is still served when there is one. --- internal/handler/container_referrers.go | 39 ++++++++++------ internal/handler/container_referrers_test.go | 48 +++++++++----------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/internal/handler/container_referrers.go b/internal/handler/container_referrers.go index d04e503..d03e859 100644 --- a/internal/handler/container_referrers.go +++ b/internal/handler/container_referrers.go @@ -97,7 +97,7 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request resp, err := h.proxy.HTTPClient.Do(req) if err != nil { - h.serveStaleReferrersOrFallback(w, r, registryURL, cached, err) + h.serveStaleReferrersOrError(w, r, registryURL, cached, err) return } defer func() { _ = resp.Body.Close() }() @@ -111,24 +111,25 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request return } if resp.StatusCode != http.StatusOK { - if shouldServeStaleManifest(resp.StatusCode) { - h.serveStaleReferrersOrFallback(w, r, registryURL, cached, + if shouldServeStaleManifest(resp.StatusCode) && cached != nil { + h.serveStaleReferrersOrError(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) + // Upstream errors reach the client as sent. Only a 404 tells it the + // registry has no referrers API, so it falls back to the sha256- + // tag schema served as manifests. + h.proxy.relayResponse(w, r, resp, copyContainerReferrersHeaders) return } body, err := h.proxy.ReadMetadata(resp.Body) if err != nil { - h.serveStaleReferrersOrFallback(w, r, registryURL, cached, fmt.Errorf("reading referrers: %w", err)) + h.serveStaleReferrersOrError(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")) + h.serveStaleReferrersOrError(w, r, registryURL, cached, errors.New("upstream referrers response is not JSON")) return } referrers := &cachedContainerTags{ @@ -150,18 +151,26 @@ func (h *ContainerHandler) serveReferrers(w http.ResponseWriter, r *http.Request 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) { +// serveStaleReferrersOrError serves a cached index when the upstream cannot +// answer. Without one it returns 502 rather than 404, so clients do not take +// a failed fetch as a registry without the referrers API. +func (h *ContainerHandler) serveStaleReferrersOrError(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") + h.proxy.Logger.Error("failed to fetch container referrers", "error", err) + h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") +} + +// copyContainerReferrersHeaders also keeps Retry-After, since uncached 429 +// and 5xx answers are relayed here instead of turning into a 502. +func copyContainerReferrersHeaders(destination, source http.Header) { + copyContainerTagsHeaders(destination, source) + if value := source.Get("Retry-After"); value != "" { + destination.Set("Retry-After", value) + } } func (h *ContainerHandler) loadContainerReferrers(ctx context.Context, cacheKey string) (*cachedContainerTags, error) { diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index 5379330..b97d886 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -1,7 +1,6 @@ package handler import ( - "encoding/json" "io" "net/http" "net/http/httptest" @@ -223,18 +222,22 @@ func TestContainerHandler_ReferrersRelaysUpstreamErrorsWithoutCaching(t *testing } } -func TestContainerHandler_ReferrersFallsBackToTagSchemaWithoutCache(t *testing.T) { +func TestContainerHandler_ReferrersReportsUpstreamFailureWithoutCache(t *testing.T) { tests := []struct { - name string - handler http.HandlerFunc + name string + handler http.HandlerFunc + wantStatus int + wantRetryAfter string }{ - {name: "upstream 503", handler: func(w http.ResponseWriter, _ *http.Request) { + {name: "upstream 503", wantStatus: http.StatusServiceUnavailable, wantRetryAfter: "120", handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "120") http.Error(w, "unavailable", http.StatusServiceUnavailable) }}, - {name: "upstream 429", handler: func(w http.ResponseWriter, _ *http.Request) { + {name: "upstream 429", wantStatus: http.StatusTooManyRequests, wantRetryAfter: "60", handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "60") http.Error(w, "slow down", http.StatusTooManyRequests) }}, - {name: "upstream not JSON", handler: func(w http.ResponseWriter, _ *http.Request) { + {name: "upstream not JSON", wantStatus: http.StatusBadGateway, handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html") _, _ = io.WriteString(w, "captive portal") }}, @@ -252,7 +255,13 @@ func TestContainerHandler_ReferrersFallsBackToTagSchemaWithoutCache(t *testing.T proxy.MetadataTTL = time.Hour target := "/library/nginx/referrers/" + testReferrersSubject for range 2 { - assertReferrersFallback(t, serveReferrersRequest(h, http.MethodGet, target)) + got := serveReferrersRequest(h, http.MethodGet, target) + if got.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d: %s", got.Code, tt.wantStatus, got.Body.String()) + } + if got.Header().Get("Retry-After") != tt.wantRetryAfter { + t.Errorf("Retry-After = %q, want %q", got.Header().Get("Retry-After"), tt.wantRetryAfter) + } } if upstreamRequests != 2 { t.Errorf("upstream requests = %d, want 2 (failure must not be cached)", upstreamRequests) @@ -264,28 +273,13 @@ func TestContainerHandler_ReferrersFallsBackToTagSchemaWithoutCache(t *testing.T upstream := httptest.NewServer(http.NotFoundHandler()) h, _ := newReferrersTestHandler(t, upstream) upstream.Close() - assertReferrersFallback(t, serveReferrersRequest(h, http.MethodGet, "/library/nginx/referrers/"+testReferrersSubject)) + got := serveReferrersRequest(h, http.MethodGet, "/library/nginx/referrers/"+testReferrersSubject) + if got.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 so clients keep using the referrers API: %s", got.Code, got.Body.String()) + } }) } -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 From 8b38b3e6683dc39b5ab674dd6463f77aa00b034c Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 11 Oct 2026 19:36:08 +0200 Subject: [PATCH 15/15] The tag list and manifest routes now only match paths that end in their endpoint, so a repository with tags/list or manifests in its name can still reach the referrers endpoint. --- internal/handler/container.go | 6 ++-- internal/handler/container_referrers_test.go | 33 ++++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/internal/handler/container.go b/internal/handler/container.go index 5580e84..202400d 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -324,10 +324,10 @@ func (h *ContainerHandler) Routes() http.Handler { case strings.HasSuffix(path, "/blobs/"+r.URL.Query().Get("digest")) || strings.Contains(path, "/blobs/sha256:"): // Blob download: GET /v2/{name}/blobs/{digest} h.handleBlobDownload(w, r, path) - case strings.Contains(path, "/manifests/"): + case manifestPathPattern.MatchString(path): // Manifest: GET /v2/{name}/manifests/{reference} h.handleManifest(w, r, path) - case strings.Contains(path, "/tags/list"): + case tagsListPathPattern.MatchString(path): // Tags list: GET /v2/{name}/tags/list h.handleTagsList(w, r, path) case referrersPathPattern.MatchString(path): @@ -659,7 +659,7 @@ func (h *ContainerHandler) parseBlobPath(path string) (name, digest string) { } // manifestPathPattern matches manifest paths: {name}/manifests/{reference} -var manifestPathPattern = regexp.MustCompile(`^(.+)/manifests/(.+)$`) +var manifestPathPattern = regexp.MustCompile(`^(.+)/manifests/([^/]+)$`) // parseManifestPath extracts repository name and reference from a manifest path. func (h *ContainerHandler) parseManifestPath(path string) (name, reference string) { diff --git a/internal/handler/container_referrers_test.go b/internal/handler/container_referrers_test.go index b97d886..75f1cf9 100644 --- a/internal/handler/container_referrers_test.go +++ b/internal/handler/container_referrers_test.go @@ -412,7 +412,7 @@ func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { for _, path := range []string{ // A repository may contain a "referrers" component; manifest paths // must keep reaching the manifest handler. - "/foo/manifests/referrers/" + testReferrersSubject, + "/foo/referrers/app/manifests/latest", // Clients without a referrers API read the tag schema as a manifest. "/library/nginx/manifests/" + tagSchema, } { @@ -421,7 +421,7 @@ func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { } } want := []string{ - "/v2/foo/manifests/referrers/" + testReferrersSubject, + "/v2/foo/referrers/app/manifests/latest", "/v2/library/nginx/manifests/" + tagSchema, } if strings.Join(upstreamPaths, "\n") != strings.Join(want, "\n") { @@ -429,6 +429,35 @@ func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { } } +func TestContainerHandler_ReferrersRouteAllowsEndpointNamesInRepository(t *testing.T) { + var upstreamPaths []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamPaths = append(upstreamPaths, r.URL.Path) + // The manifest handler would forward the same path, so only answer + // the referrers handler, which asks for the index type alone. + if r.Header.Get("Accept") != containerReferrersMediaType { + http.Error(w, "reached the manifest handler", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", containerReferrersMediaType) + _, _ = io.WriteString(w, testReferrersIndex) + })) + defer upstream.Close() + + h, _ := newReferrersTestHandler(t, upstream) + for _, name := range []string{"owner/tags/list", "owner/manifests/app"} { + upstreamPaths = nil + got := serveReferrersRequest(h, http.MethodGet, "/"+name+"/referrers/"+testReferrersSubject) + if got.Code != http.StatusOK { + t.Fatalf("%s: status = %d, want 200: %s", name, got.Code, got.Body.String()) + } + want := "/v2/" + name + "/referrers/" + testReferrersSubject + if len(upstreamPaths) != 1 || upstreamPaths[0] != want { + t.Errorf("%s: upstream paths = %q, want [%q]", name, upstreamPaths, want) + } + } +} + func TestContainerHandler_ReferrersFollowsNamespace(t *testing.T) { var upstreamQueries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {