mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
/v2/_catalog answered a bare request with 200 {"repositories":[]} but 400ed
any n, because buildDistributionConfig leaves Catalog.MaxEntries at 0 and the
library rejects n > max. crane catalog sends n=1000, so it always failed. The
same endpoint both worked and rejected a legal parameter.
Setting MaxEntries was the obvious fix and is the wrong one. This registry has
no global catalog and will not grow one: repositories live in per-user ATProto
PDS namespaces, and buildStorageConfig hands the library a placeholder
inmemory driver, so its enumeration is empty by construction. An empty 200
asserts that this registry contains no repositories, which is false and
silently so. UNSUPPORTED says the true thing, and matches the vocabulary
DomainRoutingMiddleware already uses to refuse /v2/* on the UI domain.
There is no conformance cost: _catalog is not in the OCI distribution spec at
all. It is a Docker Registry HTTP API V2 extension, and the spec places
repository discovery out of scope. Docker Hub and GHCR both refuse it outright
and Quay returns an unconditional empty list; none of them 400s a legal n.
The path had three distinct behaviours, not two, and all three now collapse to
one: GET varied by parameter, HEAD was answered by gorilla's MethodHandler
with a bare 405, and the trailing-slash form 301-redirected because
distribution sets StrictSlash(true). Both path forms are registered, and chi
prefers a static pattern over the /v2/* wildcard regardless of declaration
order (verified against the pinned chi version, and pinned by a test that
fails if a request reaches the distribution stand-in).
Also adds the Allow header that RFC 9110 requires on any 405 — a MUST in both
15.5.6 and 10.2.1, not a SHOULD. The value is empty, which 10.2.1 defines as
"the resource allows no methods": true for the catalog, and true for /v2/* on
the UI domain, so the pre-existing gap in DomainRoutingMiddleware is closed
too. Naming a method there would advertise something that does not work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
package appview
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// catalogRouter wires the production registration, mountCatalogRefusal, over a
|
|
// stand-in for the distribution app, so the test can tell an intercepted request
|
|
// from one that reached the library. Calling the real helper rather than
|
|
// restating its routes keeps the test from drifting away from NewServer.
|
|
func catalogRouter() (chi.Router, *bool) {
|
|
reachedDistribution := false
|
|
r := chi.NewRouter()
|
|
// Registered first on purpose: chi must prefer the static patterns below
|
|
// even when the wildcard is declared ahead of them.
|
|
r.Handle("/v2/*", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
reachedDistribution = true
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"repositories":[]}`))
|
|
}))
|
|
mountCatalogRefusal(r)
|
|
return r, &reachedDistribution
|
|
}
|
|
|
|
// TestCatalogUnsupported pins the contract: /v2/_catalog answers UNSUPPORTED
|
|
// with 405 for every parameter shape, so no query string can change the answer.
|
|
func TestCatalogUnsupported(t *testing.T) {
|
|
paths := []string{
|
|
"/v2/_catalog",
|
|
"/v2/_catalog?n=1",
|
|
"/v2/_catalog?n=1000",
|
|
"/v2/_catalog?n=0",
|
|
"/v2/_catalog?n=-1",
|
|
"/v2/_catalog?n=abc",
|
|
"/v2/_catalog?last=foo",
|
|
"/v2/_catalog?n=1000&last=foo",
|
|
"/v2/_catalog/",
|
|
"/v2/_catalog/?n=1000",
|
|
}
|
|
methods := []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodDelete}
|
|
|
|
for _, path := range paths {
|
|
for _, method := range methods {
|
|
t.Run(method+" "+path, func(t *testing.T) {
|
|
router, reached := catalogRouter()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
|
|
|
if *reached {
|
|
t.Fatalf("request reached the distribution handler, chi routed %q to the wildcard", path)
|
|
}
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
var body struct {
|
|
Errors []struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
|
}
|
|
if len(body.Errors) != 1 {
|
|
t.Fatalf("got %d errors, want 1: %s", len(body.Errors), rec.Body.String())
|
|
}
|
|
if body.Errors[0].Code != "UNSUPPORTED" {
|
|
t.Errorf("code = %q, want UNSUPPORTED", body.Errors[0].Code)
|
|
}
|
|
if body.Errors[0].Message == "" {
|
|
t.Error("message is empty, it should explain why there is no catalog")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCatalogResponseIsIdentical asserts the stronger property: not merely that
|
|
// every form is UNSUPPORTED, but that every form produces byte-identical output,
|
|
// so no parameter is left able to influence the answer.
|
|
func TestCatalogResponseIsIdentical(t *testing.T) {
|
|
paths := []string{
|
|
"/v2/_catalog",
|
|
"/v2/_catalog?n=1",
|
|
"/v2/_catalog?n=1000",
|
|
"/v2/_catalog?n=0",
|
|
"/v2/_catalog?n=-1",
|
|
"/v2/_catalog?n=abc",
|
|
"/v2/_catalog?last=foo",
|
|
"/v2/_catalog/",
|
|
}
|
|
|
|
var wantCode int
|
|
var wantBody string
|
|
for i, path := range paths {
|
|
router, _ := catalogRouter()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if i == 0 {
|
|
wantCode, wantBody = rec.Code, rec.Body.String()
|
|
continue
|
|
}
|
|
if rec.Code != wantCode || rec.Body.String() != wantBody {
|
|
t.Errorf("%s returned %d %q, want %d %q", path, rec.Code, rec.Body.String(), wantCode, wantBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCatalogDoesNotShadowRepositories guards the interception's blast radius:
|
|
// real repository paths must still reach the distribution handler.
|
|
func TestCatalogDoesNotShadowRepositories(t *testing.T) {
|
|
paths := []string{
|
|
"/v2/",
|
|
"/v2/alice.example.com/nginx/tags/list",
|
|
"/v2/alice.example.com/_catalog/manifests/latest",
|
|
"/v2/_catalogue/nginx/tags/list",
|
|
}
|
|
for _, path := range paths {
|
|
t.Run(path, func(t *testing.T) {
|
|
router, reached := catalogRouter()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if !*reached {
|
|
t.Errorf("%s was intercepted, it should reach the distribution handler (got %d %q)", path, rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCatalogSends405Allow pins the Allow header RFC 9110 requires on a 405.
|
|
//
|
|
// 15.5.6 and 10.2.1 both state it as a MUST: "An origin server MUST generate an
|
|
// Allow header field in a 405 (Method Not Allowed) response." UNSUPPORTED maps
|
|
// to 405, so the header has to be there. The value is empty on purpose —
|
|
// 10.2.1: "An empty Allow field value indicates that the resource allows no
|
|
// methods" — which is the truth for a catalog that does not exist.
|
|
//
|
|
// Asserted via the header map rather than the raw wire because Go writes an
|
|
// empty field value as "Allow: " and a client reads it back as present with an
|
|
// empty string, which is the distinction that matters: present-and-empty is
|
|
// compliant, absent is not.
|
|
func TestCatalogSends405Allow(t *testing.T) {
|
|
r, _ := catalogRouter()
|
|
|
|
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost} {
|
|
for _, path := range []string{"/v2/_catalog", "/v2/_catalog/", "/v2/_catalog?n=1000"} {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s %s: status = %d, want %d", method, path, rec.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
values, present := rec.Result().Header["Allow"]
|
|
if !present {
|
|
t.Errorf("%s %s: no Allow header on a 405; RFC 9110 15.5.6 makes it a MUST", method, path)
|
|
continue
|
|
}
|
|
if len(values) != 1 || values[0] != "" {
|
|
t.Errorf("%s %s: Allow = %q, want exactly one empty value (RFC 9110 10.2.1: the resource allows no methods)",
|
|
method, path, values)
|
|
}
|
|
}
|
|
}
|
|
}
|