Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
pkg-proxy/internal/handler/container_test.go

1431 lines
52 KiB
Go
Raw Permalink Normal View History

package handler
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
)
func TestContainerHandler_parseBlobPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
wantDigest string
}{
{
path: "library/nginx/blobs/sha256:abc123def456",
wantName: "library/nginx",
wantDigest: "sha256:abc123def456",
},
{
path: "myorg/myrepo/blobs/sha256:0123456789abcdef",
wantName: "myorg/myrepo",
wantDigest: "sha256:0123456789abcdef",
},
{
path: "deep/nested/repo/name/blobs/sha256:fedcba9876543210",
wantName: "deep/nested/repo/name",
wantDigest: "sha256:fedcba9876543210",
},
{
path: "invalid/path",
wantName: "",
wantDigest: "",
},
{
path: "repo/blobs/md5:invalid",
wantName: "",
wantDigest: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, digest := h.parseBlobPath(tt.path)
if name != tt.wantName {
t.Errorf("parseBlobPath() name = %q, want %q", name, tt.wantName)
}
if digest != tt.wantDigest {
t.Errorf("parseBlobPath() digest = %q, want %q", digest, tt.wantDigest)
}
})
}
}
func TestContainerHandler_parseManifestPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
wantReference string
}{
{
path: "library/nginx/manifests/latest",
wantName: "library/nginx",
wantReference: "latest",
},
{
path: "myorg/myrepo/manifests/v1.0.0",
wantName: "myorg/myrepo",
wantReference: "v1.0.0",
},
{
path: "repo/manifests/sha256:abc123",
wantName: "repo",
wantReference: "sha256:abc123",
},
{
path: "invalid/path",
wantName: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, ref := h.parseManifestPath(tt.path)
if name != tt.wantName {
t.Errorf("parseManifestPath() name = %q, want %q", name, tt.wantName)
}
if ref != tt.wantReference {
t.Errorf("parseManifestPath() reference = %q, want %q", ref, tt.wantReference)
}
})
}
}
func TestContainerHandler_parseTagsListPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
}{
{
path: "library/nginx/tags/list",
wantName: "library/nginx",
},
{
path: "myorg/myrepo/tags/list",
wantName: "myorg/myrepo",
},
{
path: "invalid/path",
wantName: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name := h.parseTagsListPath(tt.path)
if name != tt.wantName {
t.Errorf("parseTagsListPath() = %q, want %q", name, tt.wantName)
}
})
}
}
func TestContainerHandler_TagsListUsesStaleCacheOnUpstreamFailure(t *testing.T) {
tags := `{"name":"library/nginx","tags":["1.0","latest"]}`
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if r.URL.Path != "/v2/library/nginx/tags/list" {
http.NotFound(w, r)
return
}
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", `"tags-etag"`)
_, _ = io.WriteString(w, tags)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?n=2", nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want 200: %s", first.Code, first.Body.String())
}
if first.Body.String() != tags {
t.Errorf("initial body = %q, want %q", first.Body.String(), tags)
}
upstreamAvailable = false
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?n=2", nil))
if second.Code != http.StatusOK {
t.Fatalf("stale status = %d, want 200: %s", second.Code, second.Body.String())
}
if second.Body.String() != tags {
t.Errorf("stale body = %q, want %q", second.Body.String(), tags)
}
if got := second.Header().Get("Warning"); got != `110 - "Response is Stale"` {
t.Errorf("Warning = %q, want stale warning", got)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
}
func TestContainerHandler_TagsListCachesPaginationLink(t *testing.T) {
tags := `{"name":"library/nginx","tags":["1.0"]}`
upstreamAvailable := true
upstreamRequests := 0
var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Link", `<`+upstream.URL+`/v2/library/nginx/tags/list?last=1.0&n=2>; rel="next"`)
_, _ = io.WriteString(w, tags)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = time.Hour
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://proxy.example.test"}
wantLink := `<http://proxy.example.test/v2/library/nginx/tags/list?last=1.0&n=2>; rel="next"`
warmRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?n=2", nil)
warm := httptest.NewRecorder()
h.Routes().ServeHTTP(warm, warmRequest)
if warm.Code != http.StatusOK {
t.Fatalf("warm status = %d, want 200: %s", warm.Code, warm.Body.String())
}
if got := warm.Header().Get("Link"); got != wantLink {
t.Errorf("warm Link = %q, want %q", got, wantLink)
}
fresh := httptest.NewRecorder()
h.Routes().ServeHTTP(fresh, httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?n=2", nil))
if fresh.Code != http.StatusOK {
t.Fatalf("fresh status = %d, want 200: %s", fresh.Code, fresh.Body.String())
}
if got := fresh.Header().Get("Link"); got != wantLink {
t.Errorf("fresh Link = %q, want %q", got, wantLink)
}
if upstreamRequests != 1 {
t.Fatalf("upstream requests after fresh cache hit = %d, want 1", upstreamRequests)
}
proxy.MetadataTTL = 0
upstreamAvailable = false
stale := httptest.NewRecorder()
h.Routes().ServeHTTP(stale, httptest.NewRequest(http.MethodGet, "/library/nginx/tags/list?n=2", nil))
if stale.Code != http.StatusOK {
t.Fatalf("stale status = %d, want 200: %s", stale.Code, stale.Body.String())
}
if got := stale.Header().Get("Link"); got != wantLink {
t.Errorf("stale Link = %q, want %q", got, wantLink)
}
if got := stale.Header().Get("Warning"); got != `110 - "Response is Stale"` {
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_TagsListRewritesRelativePaginationLinkForNamedRegistry(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/owner/repo/tags/list" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("n") != "1" {
http.Error(w, "unexpected page size", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("last") == "" {
w.Header().Set("Link", `</v2/owner/repo/tags/list?last=1.0&n=1>; rel="next"`)
_, _ = io.WriteString(w, `{"name":"owner/repo","tags":["1.0"]}`)
return
}
if r.URL.Query().Get("last") != "1.0" {
http.Error(w, "unexpected pagination token", http.StatusBadRequest)
return
}
_, _ = io.WriteString(w, `{"name":"owner/repo","tags":["2.0"]}`)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
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/repo/tags/list?n=1", nil))
if first.Code != http.StatusOK {
t.Fatalf("first page status = %d, want 200: %s", first.Code, first.Body.String())
}
const wantLink = `<http://proxy.example.test/v2/upstream/test/owner/repo/tags/list?last=1.0&n=1>; rel="next"`
if got := first.Header().Get("Link"); got != wantLink {
t.Fatalf("first page 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, want := next.Body.String(), `{"name":"owner/repo","tags":["2.0"]}`; got != want {
t.Errorf("next page body = %q, want %q", got, want)
}
}
func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(t *testing.T) {
2026-09-03 16:59:12 +01:00
const blob = "chart archive"
digest := "sha256:" + sha256Hex(blob)
manifest := `{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json"},"layers":[{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"` + digest + `"}]}`
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/owner/demo/manifests/1.0.0":
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
2026-09-03 16:59:12 +01:00
w.Header().Set("Docker-Content-Digest", "sha256:"+sha256Hex(manifest))
_, _ = io.WriteString(w, manifest)
case "/v2/owner/demo/blobs/" + digest:
w.Header().Set("Content-Type", "application/vnd.cncf.helm.chart.content.v1.tar+gzip")
2026-09-03 16:59:12 +01:00
_, _ = io.WriteString(w, blob)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
proxy.Fetcher = fetcher
t.Cleanup(func() { _ = fetcher.Close() })
h := NewContainerHandler(proxy, "http://proxy.example", map[string]string{"ghcr": upstream.URL})
manifestResponse := httptest.NewRecorder()
h.Routes().ServeHTTP(manifestResponse,
httptest.NewRequest(http.MethodGet, "/upstream/ghcr/owner/demo/manifests/1.0.0", nil))
if manifestResponse.Code != http.StatusOK {
t.Fatalf("manifest status = %d, want 200: %s", manifestResponse.Code, manifestResponse.Body.String())
}
if got := manifestResponse.Header().Get("Content-Type"); got != "application/vnd.oci.image.manifest.v1+json" {
t.Errorf("manifest Content-Type = %q", got)
}
blobResponse := httptest.NewRecorder()
h.Routes().ServeHTTP(blobResponse,
httptest.NewRequest(http.MethodGet, "/upstream/ghcr/owner/demo/blobs/"+digest, nil))
if blobResponse.Code != http.StatusOK {
t.Fatalf("blob status = %d, want 200: %s", blobResponse.Code, blobResponse.Body.String())
}
if got := blobResponse.Header().Get("Content-Type"); got != "application/vnd.cncf.helm.chart.content.v1.tar+gzip" {
t.Errorf("blob Content-Type = %q", got)
}
}
2026-09-03 16:59:12 +01:00
func TestContainerHandler_registryURLForUsesLongestRepositoryPrefix(t *testing.T) {
h := &ContainerHandler{registryURL: "https://registry-1.docker.io"}
h.RegisterRegistry("homebrew", "https://example.test")
h.RegisterRegistry("homebrew/core", "https://ghcr.io/")
tests := map[string]string{
"homebrew/core": "https://ghcr.io",
"homebrew/core/jq": "https://ghcr.io",
"homebrew/portable-ruby": "https://example.test",
"homebrew-core/jq": "https://registry-1.docker.io",
"library/homebrew/core/jq": "https://registry-1.docker.io",
}
for name, want := range tests {
if got := h.registryURLFor(name); got != want {
t.Errorf("registryURLFor(%q) = %q, want %q", name, got, want)
}
}
}
func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
2026-09-03 16:59:12 +01:00
blob := "upstream blob"
digest := sha256Digest([]byte(blob))
registryRequests := 0
tokenRequests := 0
var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
tokenRequests++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "discovered-token",
"expires_in": 3600,
})
case "/v2/library/nginx/blobs/" + digest:
registryRequests++
if r.Header.Get("Authorization") != "Bearer discovered-token" {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+upstream.URL+`/token",service="registry.test",scope="repository:library/nginx:pull"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
2026-09-03 16:59:12 +01:00
_, _ = io.WriteString(w, blob)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
authTransport := upstreamhttp.NewTransport(http.DefaultTransport, nil)
client := &http.Client{Transport: authTransport}
artifactFetcher := fetch.NewFetcher(
fetch.WithHTTPClient(client),
fetch.WithMaxRetries(0),
)
t.Cleanup(func() { _ = artifactFetcher.Close() })
proxy.Fetcher = artifactFetcher
proxy.HTTPClient = client
h := &ContainerHandler{
proxy: proxy,
registryURL: upstream.URL,
proxyURL: "http://localhost:8080",
}
for range 2 {
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
2026-09-03 16:59:12 +01:00
if got := w.Body.String(); got != blob {
t.Errorf("body = %q, want %q", got, blob)
}
}
if tokenRequests != 1 {
t.Errorf("token requests = %d, want 1", tokenRequests)
}
if registryRequests != 2 {
t.Errorf("registry requests = %d, want 2", registryRequests)
}
}
2026-09-03 16:59:12 +01:00
func TestContainerHandler_HomebrewBlobDoesNotForwardClientCredentialsToRegistryOrCDN(t *testing.T) {
blob := "homebrew bottle"
digest := sha256Digest([]byte(blob))
var registryAuthorization, cdnAuthorization string
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cdnAuthorization = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/vnd.homebrew.bottle")
_, _ = io.WriteString(w, blob)
}))
defer cdn.Close()
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
registryAuthorization = r.Header.Get("Authorization")
http.Redirect(w, r, cdn.URL+"/bottle", http.StatusTemporaryRedirect)
}))
defer registry.Close()
defaultRequests := 0
defaultRegistry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defaultRequests++
http.NotFound(w, r)
}))
defer defaultRegistry.Close()
proxy, _, _, _ := setupTestProxy(t)
client := registry.Client()
artifactFetcher := fetch.NewFetcher(
fetch.WithHTTPClient(client),
fetch.WithMaxRetries(0),
)
t.Cleanup(func() { _ = artifactFetcher.Close() })
proxy.Fetcher = artifactFetcher
h := &ContainerHandler{proxy: proxy, registryURL: defaultRegistry.URL}
h.RegisterRegistry("homebrew/core", registry.URL)
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil)
req.Header.Set("Authorization", "Bearer client-secret")
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if got := w.Body.String(); got != blob {
t.Errorf("body = %q, want %q", got, blob)
}
if got := w.Header().Get("Content-Type"); got != "application/vnd.homebrew.bottle" {
t.Errorf("Content-Type = %q, want application/vnd.homebrew.bottle", got)
}
if registryAuthorization != "" {
t.Errorf("registry Authorization = %q, want empty", registryAuthorization)
}
if cdnAuthorization != "" {
t.Errorf("CDN Authorization = %q, want empty", cdnAuthorization)
}
if defaultRequests != 0 {
t.Errorf("default registry requests = %d, want 0", defaultRequests)
}
}
func TestContainerHandler_BlobDigestMismatchIsNotCached(t *testing.T) {
proxy, _, store, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("wrong bottle")),
ContentType: "application/octet-stream",
}
digest := sha256Digest([]byte("expected bottle"))
h := &ContainerHandler{proxy: proxy, registryURL: "https://registry.example.test"}
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil))
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
}
if !strings.Contains(w.Body.String(), "DIGEST_INVALID") {
t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String())
}
cached, err := proxy.GetCachedArtifact(t.Context(), "oci", "homebrew/core/jq", digest, digest)
if err != nil {
t.Fatalf("checking cache: %v", err)
}
if cached != nil {
t.Error("digest-mismatched blob was recorded in the cache")
}
if len(store.files) != 0 {
t.Errorf("stored files = %d, want 0", len(store.files))
}
}
func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
blob := "cached image blob"
2026-09-03 16:59:12 +01:00
manifestDigest := sha256Digest([]byte(manifest))
blobDigest := sha256Digest([]byte(blob))
registryAvailable := true
tokenAvailable := true
registryRequests := 0
tokenRequests := 0
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
tokenRequests++
if !tokenAvailable {
http.Error(w, "token service unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "discovered-token",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
registryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
registryRequests++
if !registryAvailable {
http.Error(w, "registry unavailable", http.StatusServiceUnavailable)
return
}
if r.Header.Get("Authorization") != "Bearer discovered-token" {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenServer.URL+`",service="registry.test",scope="repository:library/nginx:pull"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/v2/library/nginx/manifests/latest":
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
2026-09-03 16:59:12 +01:00
w.Header().Set("Docker-Content-Digest", manifestDigest)
_, _ = io.WriteString(w, manifest)
2026-09-03 16:59:12 +01:00
case "/v2/library/nginx/blobs/" + blobDigest:
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, blob)
default:
http.NotFound(w, r)
}
}))
defer registryServer.Close()
warmProxy, db, store, _ := setupTestProxy(t)
warmClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport, nil)}
warmFetcher := fetch.NewFetcher(
fetch.WithHTTPClient(warmClient),
fetch.WithMaxRetries(0),
)
t.Cleanup(func() { _ = warmFetcher.Close() })
warmProxy.Fetcher = warmFetcher
warmProxy.HTTPClient = warmClient
warmProxy.MetadataTTL = time.Hour
warmHandler := (&ContainerHandler{
proxy: warmProxy,
registryURL: registryServer.URL,
proxyURL: "http://localhost:8080",
}).Routes()
for _, request := range []struct {
path string
body string
}{
{path: "/library/nginx/manifests/latest", body: manifest},
2026-09-03 16:59:12 +01:00
{path: "/library/nginx/blobs/" + blobDigest, body: blob},
} {
response := httptest.NewRecorder()
warmHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil))
if response.Code != http.StatusOK {
t.Fatalf("warming %s: status = %d, want %d; body: %s", request.path, response.Code, http.StatusOK, response.Body.String())
}
if got := response.Body.String(); got != request.body {
t.Fatalf("warming %s: body = %q, want %q", request.path, got, request.body)
}
}
warmRegistryRequests := registryRequests
warmTokenRequests := tokenRequests
registryAvailable = false
tokenAvailable = false
offlineClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport, nil)}
offlineFetcher := fetch.NewFetcher(
fetch.WithHTTPClient(offlineClient),
fetch.WithMaxRetries(0),
)
t.Cleanup(func() { _ = offlineFetcher.Close() })
offlineProxy := NewProxy(db, store, offlineFetcher, fetch.NewResolver(), warmProxy.Logger)
offlineProxy.HTTPClient = offlineClient
offlineProxy.MetadataTTL = time.Hour
offlineHandler := (&ContainerHandler{
proxy: offlineProxy,
registryURL: registryServer.URL,
proxyURL: "http://localhost:8080",
}).Routes()
for _, request := range []struct {
2026-09-03 16:59:12 +01:00
name string
path string
body string
digest string
}{
2026-09-03 16:59:12 +01:00
{name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest, digest: manifestDigest},
{name: "digest manifest", path: "/library/nginx/manifests/" + manifestDigest, body: manifest, digest: manifestDigest},
{name: "blob", path: "/library/nginx/blobs/" + blobDigest, body: blob, digest: blobDigest},
} {
t.Run(request.name, func(t *testing.T) {
response := httptest.NewRecorder()
offlineHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if got := response.Body.String(); got != request.body {
t.Errorf("body = %q, want %q", got, request.body)
}
2026-09-03 16:59:12 +01:00
if got := response.Header().Get("Docker-Content-Digest"); got != request.digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, request.digest)
}
})
}
if registryRequests != warmRegistryRequests {
t.Errorf("offline registry requests = %d, want 0", registryRequests-warmRegistryRequests)
}
if tokenRequests != warmTokenRequests {
t.Errorf("offline token requests = %d, want 0", tokenRequests-warmTokenRequests)
}
}
func TestContainerHandler_BlobDownload_CacheHitSkipsAuth(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
store.seekable = true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer upstream.Close()
h := &ContainerHandler{
proxy: proxy,
registryURL: upstream.URL,
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil)
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
req.Header.Set("Range", "bytes=0-5")
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
if w.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusPartialContent, w.Body.String())
}
if got := w.Body.String(); got != "cached" {
t.Errorf("body = %q, want %q", got, "cached")
}
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
if got := w.Header().Get("Content-Range"); got != "bytes 0-5/11" {
t.Errorf("Content-Range = %q, want %q", got, "bytes 0-5/11")
}
if upstreamRequests != 0 {
t.Errorf("upstream requests = %d, want 0", upstreamRequests)
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
}
func TestContainerHandler_BlobHead_CacheHitSkipsUpstreamAndAuth(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer upstream.Close()
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{
proxy: proxy,
registryURL: upstream.URL,
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodHead, "/library/nginx/blobs/"+digest, nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if got := w.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, digest)
}
if got := w.Header().Get("Content-Length"); got != "11" {
t.Errorf("Content-Length = %q, want %q", got, "11")
}
if w.Body.Len() != 0 {
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
}
if upstreamRequests != 0 {
t.Errorf("upstream requests = %d, want 0", upstreamRequests)
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
}
func TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
store.signedURL = "https://storage.example.test/cached-blob?signature=test"
proxy.DirectServe = true
h := &ContainerHandler{
proxy: proxy,
registryURL: "https://registry.example.test",
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodHead, "/library/nginx/blobs/"+digest, nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", w.Code, http.StatusFound)
}
if got := w.Header().Get("Location"); got != store.signedURL {
t.Errorf("Location = %q, want %q", got, store.signedURL)
}
wantETag := `"` + sha256Hex("cached blob") + `"`
if got := w.Header().Get("ETag"); got != wantETag {
t.Errorf("ETag = %q, want %q", got, wantETag)
}
if w.Body.Len() != 0 {
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
}
func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
2026-09-03 16:59:12 +01:00
digest := sha256Digest([]byte(manifest))
lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC)
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Path != "/v2/library/nginx/manifests/"+digest {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("ETag", `"manifest-etag"`)
2026-09-03 16:59:12 +01:00
w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, manifest)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
if first.Body.String() != manifest {
t.Fatalf("initial body = %q, want %q", first.Body.String(), manifest)
}
upstreamAvailable = false
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if second.Code != http.StatusOK {
t.Fatalf("cached status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String())
}
if second.Body.String() != manifest {
t.Errorf("cached body = %q, want %q", second.Body.String(), manifest)
}
if got := second.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("cached Docker-Content-Digest = %q, want %q", got, digest)
}
2026-09-03 16:59:12 +01:00
if got := second.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) {
t.Errorf("cached Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat))
}
conditionalRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil)
conditionalRequest.Header.Set("If-None-Match", `"manifest-etag"`)
conditional := httptest.NewRecorder()
h.Routes().ServeHTTP(conditional, conditionalRequest)
if conditional.Code != http.StatusNotModified {
t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified)
}
if got := conditional.Header().Get("ETag"); got != `"manifest-etag"` {
t.Errorf("conditional ETag = %q, want %q", got, `"manifest-etag"`)
}
if conditional.Body.Len() != 0 {
t.Errorf("conditional body length = %d, want 0", conditional.Body.Len())
}
head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil))
if head.Code != http.StatusOK {
t.Fatalf("cached HEAD status = %d, want %d", head.Code, http.StatusOK)
}
wantLength := strconv.Itoa(len(manifest))
if got := head.Header().Get("Content-Length"); got != wantLength {
t.Errorf("cached HEAD Content-Length = %q, want %q", got, wantLength)
}
if head.Body.Len() != 0 {
t.Errorf("cached HEAD body length = %d, want 0", head.Body.Len())
}
if upstreamRequests != 1 {
t.Errorf("upstream requests = %d, want 1", upstreamRequests)
}
}
2026-09-03 16:59:12 +01:00
func TestContainerHandler_ManifestDigestMismatchIsNotCached(t *testing.T) {
manifest := `{"schemaVersion":2}`
digest := sha256Digest([]byte("different manifest"))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, db, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/"+digest, nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
}
if !strings.Contains(w.Body.String(), "DIGEST_INVALID") {
t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String())
}
cacheKey := h.containerManifestSharedCacheKey(upstream.URL, "homebrew/core/jq", digest, normalizeContainerManifestAccept(containerManifestAccept(req)))
2026-09-03 16:59:12 +01:00
entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey)
if err != nil {
t.Fatalf("checking manifest cache: %v", err)
}
if entry != nil {
t.Error("digest-mismatched manifest was recorded in the cache")
}
}
func TestContainerHandler_ManifestNonSHA256DigestReferenceIsProxied(t *testing.T) {
manifest := `{"schemaVersion":2}`
sha512Reference := "sha512:" + strings.Repeat("a", 128)
sha512Header := "sha512:" + strings.Repeat("b", 128)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", sha512Header)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
for _, reference := range []string{sha512Reference, "latest"} {
t.Run(reference, func(t *testing.T) {
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+reference, nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if got := w.Body.String(); got != manifest {
t.Errorf("body = %q, want %q", got, manifest)
}
if got := w.Header().Get("Docker-Content-Digest"); got != sha512Header {
t.Errorf("Docker-Content-Digest = %q, want %q", got, sha512Header)
}
})
}
}
func TestContainerHandler_ManifestTagWithInvalidDigestIsNotAliased(t *testing.T) {
manifest := `{"schemaVersion":2}`
invalidDigest := sha256Digest([]byte("different manifest"))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", invalidDigest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, db, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL}
req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/latest", nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
}
cacheKey := h.containerManifestSharedCacheKey(upstream.URL, "homebrew/core/jq", invalidDigest, normalizeContainerManifestAccept(containerManifestAccept(req)))
2026-09-03 16:59:12 +01:00
entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey)
if err != nil {
t.Fatalf("checking manifest cache: %v", err)
}
if entry != nil {
t.Error("tag manifest was cached under an unverified digest")
}
}
func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
2026-09-03 16:59:12 +01:00
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
upstreamAvailable = false
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if second.Code != http.StatusOK {
t.Fatalf("stale status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String())
}
if second.Body.String() != manifest {
t.Errorf("stale body = %q, want %q", second.Body.String(), manifest)
}
if got := second.Header().Get("Warning"); got != `110 - "Response is Stale"` {
t.Errorf("Warning = %q, want stale warning", got)
}
if got := second.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("stale Docker-Content-Digest = %q, want %q", got, digest)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
}
func TestContainerHandler_ManifestVariantCacheNormalizesCompatibleAccept(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
2026-09-03 16:59:12 +01:00
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
firstRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
firstRequest.Header.Set("Accept", "application/vnd.oci.image.index.v1+json;q=1, application/vnd.oci.image.manifest.v1+json;q=1, application/vnd.oci.image.index.v1+json;q=1")
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, firstRequest)
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want 200: %s", first.Code, first.Body.String())
}
upstreamAvailable = false
secondRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
secondRequest.Header.Set("Accept", " application/vnd.oci.image.manifest.v1+json , application/vnd.oci.image.index.v1+json ")
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, secondRequest)
if second.Code != http.StatusOK {
t.Fatalf("stale status = %d, want 200: %s", second.Code, second.Body.String())
}
if second.Body.String() != manifest {
t.Errorf("stale body = %q, want %q", second.Body.String(), manifest)
}
if got := second.Header().Get("Warning"); got != `110 - "Response is Stale"` {
t.Errorf("Warning = %q, want stale warning", got)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
}
func TestContainerHandler_ManifestMigratesLegacyAcceptCacheKey(t *testing.T) {
digest := "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
http.Error(w, "upstream should not be called", http.StatusServiceUnavailable)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = time.Hour
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
accept := "application/vnd.oci.image.index.v1+json;q=1, application/vnd.oci.image.manifest.v1+json;q=1"
cacheAccept := normalizeContainerManifestAccept(accept)
legacyCacheKey := h.containerManifestCacheKey(upstream.URL, "library/nginx", "latest", accept)
cacheKey := h.containerManifestSharedCacheKey(upstream.URL, "library/nginx", "latest", cacheAccept)
if legacyCacheKey == cacheKey {
t.Fatal("legacy and shared cache keys are equal")
}
request := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
request.Header.Set("Accept", accept)
legacyManifest := &cachedContainerManifest{
body: []byte(manifest),
contentType: "application/vnd.oci.image.index.v1+json",
contentDigest: digest,
fetchedAt: time.Now(),
}
if err := h.storeContainerManifest(request.Context(), legacyCacheKey, legacyManifest); err != nil {
t.Fatalf("store legacy manifest: %v", err)
}
response := httptest.NewRecorder()
h.Routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", response.Code, response.Body.String())
}
if response.Body.String() != manifest {
t.Errorf("body = %q, want %q", response.Body.String(), manifest)
}
if upstreamRequests != 0 {
t.Errorf("upstream requests = %d, want 0", upstreamRequests)
}
migrated, err := h.loadContainerManifest(request.Context(), cacheKey)
if err != nil {
t.Fatalf("load migrated manifest: %v", err)
}
if migrated == nil {
t.Error("shared cache entry was not created")
}
}
func TestContainerHandler_ManifestDualWritesLegacyAcceptCacheKeys(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
2026-09-03 16:59:12 +01:00
digest := sha256Digest([]byte(manifest))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/library/nginx/manifests/latest" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
accept := "application/vnd.oci.image.manifest.v1+json;q=1, application/vnd.oci.image.manifest.v1+json;q=1"
request := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
request.Header.Set("Accept", accept)
response := httptest.NewRecorder()
h.Routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", response.Code, response.Body.String())
}
for _, reference := range []string{"latest", digest} {
legacyCacheKey := h.containerManifestCacheKey(upstream.URL, "library/nginx", reference, accept)
cached, err := h.loadContainerManifest(request.Context(), legacyCacheKey)
if err != nil {
t.Fatalf("load legacy %s manifest: %v", reference, err)
}
if cached == nil {
t.Errorf("legacy %s cache entry was not written", reference)
}
}
}
func TestContainerHandler_ManifestVariantCacheHonorsSpecificAcceptExclusions(t *testing.T) {
2026-09-03 16:59:12 +01:00
manifest := `{"schemaVersion":2}`
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
2026-09-03 16:59:12 +01:00
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
warmRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
warmRequest.Header.Set("Accept", "application/vnd.oci.image.index.v1+json;q=0, */*;q=1")
warm := httptest.NewRecorder()
h.Routes().ServeHTTP(warm, warmRequest)
if warm.Code != http.StatusOK {
t.Fatalf("warm status = %d, want 200", warm.Code)
}
upstreamAvailable = false
offlineRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
offlineRequest.Header.Set("Accept", "application/vnd.oci.image.index.v1+json;q=0, */*;q=1")
offline := httptest.NewRecorder()
h.Routes().ServeHTTP(offline, offlineRequest)
if offline.Code != http.StatusServiceUnavailable {
t.Errorf("offline status = %d, want 503", offline.Code)
}
}
func TestContainerHandler_ManifestVariantCacheHonorsParameterizedAcceptExclusions(t *testing.T) {
contentType := "application/vnd.oci.image.index.v1+json; charset=utf-8"
upstreamAvailable := true
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", contentType)
_, _ = io.WriteString(w, `{"schemaVersion":2}`)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
accept := "application/vnd.oci.image.index.v1+json;q=1, application/vnd.oci.image.index.v1+json;charset=utf-8;q=0"
warmRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
warmRequest.Header.Set("Accept", accept)
warm := httptest.NewRecorder()
h.Routes().ServeHTTP(warm, warmRequest)
if warm.Code != http.StatusOK {
t.Fatalf("warm status = %d, want 200", warm.Code)
}
upstreamAvailable = false
offlineRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)
offlineRequest.Header.Set("Accept", accept)
offline := httptest.NewRecorder()
h.Routes().ServeHTTP(offline, offlineRequest)
if offline.Code != http.StatusServiceUnavailable {
t.Errorf("offline status = %d, want 503", offline.Code)
}
}
func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
2026-09-03 16:59:12 +01:00
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Path != "/v2/library/nginx/manifests/latest" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("tag status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
upstreamAvailable = false
byDigest := httptest.NewRecorder()
h.Routes().ServeHTTP(byDigest, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if byDigest.Code != http.StatusOK {
t.Fatalf("digest status = %d, want %d; body: %s", byDigest.Code, http.StatusOK, byDigest.Body.String())
}
if byDigest.Body.String() != manifest {
t.Errorf("digest body = %q, want %q", byDigest.Body.String(), manifest)
}
if got := byDigest.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, digest)
}
if upstreamRequests != 1 {
t.Errorf("upstream requests = %d, want 1", upstreamRequests)
}
}
func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
2026-09-03 16:59:12 +01:00
manifest := `{"schemaVersion":2}`
oldDigest := sha256Digest([]byte(manifest))
newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
currentDigest := oldDigest
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", currentDigest)
w.Header().Set("ETag", `"`+currentDigest+`"`)
if r.Method != http.MethodHead {
2026-09-03 16:59:12 +01:00
_, _ = io.WriteString(w, manifest)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d", first.Code, http.StatusOK)
}
currentDigest = newDigest
head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/latest", nil))
if head.Code != http.StatusOK {
t.Fatalf("HEAD status = %d, want %d", head.Code, http.StatusOK)
}
if got := head.Header().Get("Docker-Content-Digest"); got != newDigest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, newDigest)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
}
func TestContainerHandler_Routes_VersionCheck(t *testing.T) {
h := NewContainerHandler(nil, "http://localhost:8080")
handler := h.Routes()
if handler == nil {
t.Fatal("Routes() returned nil")
}
// Test /v2/ version check endpoint
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("version check: got status %d, want %d", w.Code, http.StatusOK)
}
if got := w.Header().Get("Docker-Distribution-Api-Version"); got != "registry/2.0" {
t.Errorf("Docker-Distribution-Api-Version = %q, want %q", got, "registry/2.0")
}
}
func TestOCIRetentionKey(t *testing.T) {
tests := []struct {
key string
want string
ok bool
}{
{"pkg:oci/library/nginx", "pkg:oci/library%2Fnginx", true},
{"pkg:oci/library%2Fnginx", "pkg:oci/library%2Fnginx", true},
{"pkg:oci/nginx", "pkg:oci/nginx", true},
{"pkg:oci/upstream/ghcr/owner/app", "pkg:oci/upstream%2Fghcr%2Fowner%2Fapp", true},
{"pkg:oci/homebrew/core/wget", "pkg:oci/homebrew%2Fcore%2Fwget", true},
{"pkg:oci/upstream/ghcr", "", false},
{"pkg:oci/library%2F%2Fnginx", "", false},
{"pkg:oci/%2Fnginx", "", false},
{"pkg:oci/library/nginx:latest", "", false},
{"pkg:oci/homebrew/core/openssl%403", "", false},
{"pkg:oci/homebrew/core/openssl/3", "pkg:oci/homebrew%2Fcore%2Fopenssl%2F3", true},
{"pkg:npm/library/nginx", "", false},
}
for _, tt := range tests {
p, err := purl.Parse(tt.key)
if err != nil {
t.Fatalf("Parse(%s): %v", tt.key, err)
}
got, ok := ociRetentionKey(p)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("ociRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
}
}
if _, ok := ociRetentionKey(nil); ok {
t.Error("ociRetentionKey(nil) accepted")
}
}
// Docker sends one Accept header per media type. All of them count, both for
// the request upstream and for which cached representation can be served.
func TestContainerHandler_ManifestHonoursSeparateAcceptHeaders(t *testing.T) {
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json"}`
digest := sha256Digest([]byte(manifest))
dockerAccept := []string{
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
}
upstreamAvailable := true
var upstreamAccept string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
upstreamAccept = strings.Join(r.Header.Values("Accept"), ", ")
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
pull := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/library/busybox/manifests/1", nil)
for _, mediaType := range dockerAccept {
req.Header.Add("Accept", mediaType)
}
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
return w
}
if w := pull(); w.Code != http.StatusOK {
t.Fatalf("online status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
for _, mediaType := range dockerAccept {
if !strings.Contains(upstreamAccept, mediaType) {
t.Errorf("upstream Accept = %q, missing %s", upstreamAccept, mediaType)
}
}
upstreamAvailable = false
w := pull()
if w.Code != http.StatusOK {
t.Fatalf("offline status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if w.Body.String() != manifest {
t.Errorf("offline body = %q, want the cached manifest", w.Body.String())
}
}