From 52f1e054d39b65f48f9bd06d19f59ab98af75db7 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 22 Aug 2026 12:25:13 +0530 Subject: [PATCH 1/2] fix(oci): cache tag lists and normalize manifest variants --- docs/architecture.md | 2 +- docs/configuration.md | 2 +- internal/handler/container.go | 25 +--- internal/handler/container_manifest.go | 80 +++++++++- internal/handler/container_tags.go | 196 +++++++++++++++++++++++++ internal/handler/container_test.go | 137 +++++++++++++++++ 6 files changed, 415 insertions(+), 27 deletions(-) create mode 100644 internal/handler/container_tags.go diff --git a/docs/architecture.md b/docs/architecture.md index 6d9bfda..16d8578 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -353,7 +353,7 @@ Eviction can be implemented as: - Fresh data - new versions visible immediately - Metadata is small, upstream fetch is fast - Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table -- OCI manifests are the exception: they are cached automatically so previously fetched images remain pullable when the registry or token service is unavailable +- OCI manifests and tag lists are exceptions: they are cached automatically so previously fetched images remain pullable and tag resolution works when the registry or token service is unavailable **Why stream artifacts?** - Memory efficient - don't load large files into RAM diff --git a/docs/configuration.md b/docs/configuration.md index 3b8b935..c249812 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -292,7 +292,7 @@ Note: Hex cooldown requires disabling registry signature verification since the By default the proxy fetches metadata fresh from upstream on every request. Enable `cache_metadata` to store metadata responses in the database and storage backend for offline fallback. When upstream is unreachable, the proxy serves the last cached copy. ETag-based revalidation avoids re-downloading unchanged metadata. -OCI manifests are always cached because cached image blobs cannot be pulled without their manifests. Digest-addressed manifests are immutable and served directly from cache. Tag-addressed manifests follow `metadata_ttl`, revalidate when stale, and fall back to the last cached response when the registry is unavailable. +OCI manifests and tag lists are always cached because cached image blobs cannot be pulled without their manifests and offline clients may need tag resolution. Digest-addressed manifests are immutable and served directly from cache. Tag-addressed manifests and tag lists follow `metadata_ttl`, revalidate when stale, and fall back to the last cached response when the registry is unavailable. ```yaml cache_metadata: true diff --git a/internal/handler/container.go b/internal/handler/container.go index 74819dd..face4e0 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "regexp" "strings" @@ -182,7 +181,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request h.serveManifest(w, r, registryURL, upstreamName, reference) } -// handleTagsList proxies tag list requests to upstream. +// handleTagsList caches tag list responses for offline OCI pulls. func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -201,27 +200,7 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request return } - upstreamURL := fmt.Sprintf("%s/v2/%s/tags/list", registryURL, upstreamName) - if r.URL.RawQuery != "" { - upstreamURL += "?" + r.URL.RawQuery - } - - 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 - } - - resp, err := h.proxy.HTTPClient.Do(req) - if err != nil { - h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") - return - } - defer func() { _ = resp.Body.Close() }() - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) + h.serveTagsList(w, r, registryURL, upstreamName) } // proxyBlobHead handles HEAD requests for blobs. diff --git a/internal/handler/container_manifest.go b/internal/handler/container_manifest.go index cf058ba..7ef3d1d 100644 --- a/internal/handler/container_manifest.go +++ b/internal/handler/container_manifest.go @@ -8,8 +8,10 @@ import ( "encoding/hex" "fmt" "io" + "mime" "net/http" "regexp" + "sort" "strconv" "strings" "time" @@ -35,12 +37,16 @@ type cachedContainerManifest struct { func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, registryURL, name, reference string) { accept := containerManifestAccept(r) - cacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept) + cacheAccept := normalizeContainerManifestAccept(accept) + cacheKey := h.containerManifestCacheKey(registryURL, name, reference, cacheAccept) cached, err := h.loadContainerManifest(r.Context(), cacheKey) if err != nil { h.proxy.Logger.Warn("failed to read cached container manifest", "error", err) cached = nil } + if cached != nil && !containerManifestAccepts(accept, cached.contentType) { + cached = nil + } immutable := manifestDigestReferencePattern.MatchString(reference) if cached != nil && (immutable || h.containerManifestFresh(cached)) { @@ -111,7 +117,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, h.proxy.Logger.Warn("failed to cache container manifest", "error", err) } if manifest.contentDigest != reference && manifestDigestReferencePattern.MatchString(manifest.contentDigest) { - digestKey := h.containerManifestCacheKey(registryURL, name, manifest.contentDigest, accept) + digestKey := h.containerManifestCacheKey(registryURL, name, manifest.contentDigest, cacheAccept) if err := h.storeContainerManifest(r.Context(), digestKey, manifest); err != nil { h.proxy.Logger.Warn("failed to cache container manifest by digest", "error", err) } @@ -233,6 +239,76 @@ func containerManifestAccept(r *http.Request) string { }, ", ") } +func normalizeContainerManifestAccept(accept string) string { + mediaTypes := make([]string, 0) + for _, value := range strings.Split(accept, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + mediaTypes = append(mediaTypes, strings.ToLower(value)) + continue + } + paramKeys := make([]string, 0, len(params)) + for key := range params { + paramKeys = append(paramKeys, key) + } + sort.Strings(paramKeys) + canonical := strings.ToLower(mediaType) + for _, key := range paramKeys { + value := params[key] + if strings.EqualFold(key, "q") { + if quality, err := strconv.ParseFloat(value, 64); err == nil { + value = strconv.FormatFloat(quality, 'g', -1, 64) + } + } + canonical += ";" + strings.ToLower(key) + "=" + value + } + mediaTypes = append(mediaTypes, canonical) + } + sort.Strings(mediaTypes) + return strings.Join(mediaTypes, ",") +} + +func containerManifestAccepts(accept, contentType string) bool { + contentType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + contentType = strings.ToLower(contentType) + contentMajor, contentMinor, found := strings.Cut(contentType, "/") + if !found { + return false + } + + for _, value := range strings.Split(accept, ",") { + mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(value)) + if err != nil || containerAcceptQuality(params) == 0 { + continue + } + mediaType = strings.ToLower(mediaType) + major, minor, found := strings.Cut(mediaType, "/") + if found && (major == "*" || major == contentMajor) && (minor == "*" || minor == contentMinor) { + return true + } + } + return false +} + +func containerAcceptQuality(params map[string]string) float64 { + value, ok := params["q"] + if !ok { + return 1 + } + quality, err := strconv.ParseFloat(value, 64) + if err != nil || quality < 0 || quality > 1 { + return 0 + } + return quality +} + func copyContainerManifestHeaders(destination, source http.Header) { for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "WWW-Authenticate"} { if value := source.Get(header); value != "" { diff --git a/internal/handler/container_tags.go b/internal/handler/container_tags.go new file mode 100644 index 0000000..bba8524 --- /dev/null +++ b/internal/handler/container_tags.go @@ -0,0 +1,196 @@ +package handler + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/git-pkgs/proxy/internal/database" +) + +const containerTagsCacheEcosystem = "oci-tags" + +type cachedContainerTags struct { + body []byte + contentType string + etag string + size int64 + fetchedAt time.Time +} + +func (h *ContainerHandler) serveTagsList(w http.ResponseWriter, r *http.Request, registryURL, name string) { + cacheKey := h.containerTagsCacheKey(registryURL, name, r.URL.Query()) + cached, err := h.loadContainerTags(r.Context(), cacheKey) + if err != nil { + h.proxy.Logger.Warn("failed to read cached container tag list", "error", err) + cached = nil + } + if cached != nil && h.containerTagsFresh(cached) { + writeContainerTags(w, cached, false) + return + } + + upstreamURL := fmt.Sprintf("%s/v2/%s/tags/list", registryURL, name) + if query := r.URL.Query().Encode(); query != "" { + upstreamURL += "?" + query + } + 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", "application/json") + if cached != nil && cached.etag != "" { + req.Header.Set("If-None-Match", cached.etag) + } + + resp, err := h.proxy.HTTPClient.Do(req) + if err != nil { + h.serveStaleTagsOrError(w, cached, err) + return + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotModified && cached != nil { + cached.fetchedAt = time.Now() + if err := h.storeContainerTags(r.Context(), cacheKey, cached); err != nil { + h.proxy.Logger.Warn("failed to refresh cached container tag list", "error", err) + } + writeContainerTags(w, cached, false) + return + } + if resp.StatusCode != http.StatusOK { + if cached != nil && shouldServeStaleManifest(resp.StatusCode) { + writeContainerTags(w, cached, true) + return + } + copyContainerTagsHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return + } + + body, err := h.proxy.ReadMetadata(resp.Body) + if err != nil { + h.serveStaleTagsOrError(w, cached, fmt.Errorf("reading tag list: %w", err)) + return + } + tags := &cachedContainerTags{ + body: body, + contentType: resp.Header.Get("Content-Type"), + etag: resp.Header.Get("ETag"), + size: int64(len(body)), + fetchedAt: time.Now(), + } + if tags.contentType == "" { + tags.contentType = contentTypeJSON + } + if err := h.storeContainerTags(r.Context(), cacheKey, tags); err != nil { + h.proxy.Logger.Warn("failed to cache container tag list", "error", err) + } + writeContainerTags(w, tags, false) +} + +func (h *ContainerHandler) serveStaleTagsOrError(w http.ResponseWriter, cached *cachedContainerTags, err error) { + if cached != nil { + h.proxy.Logger.Warn("upstream tag list fetch failed, serving stale cache", "error", err) + writeContainerTags(w, cached, true) + return + } + h.proxy.Logger.Error("failed to fetch container tag list", "error", err) + h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") +} + +func (h *ContainerHandler) containerTagsCacheKey(registryURL, name string, query url.Values) string { + identity := registryURL + "\x00" + name + "\x00" + query.Encode() + sum := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(sum[:]) +} + +func (h *ContainerHandler) containerTagsFresh(tags *cachedContainerTags) bool { + return h.proxy.MetadataTTL > 0 && !tags.fetchedAt.IsZero() && time.Since(tags.fetchedAt) < h.proxy.MetadataTTL +} + +func (h *ContainerHandler) loadContainerTags(ctx context.Context, cacheKey string) (*cachedContainerTags, error) { + if h.proxy.DB == nil || h.proxy.Storage == nil { + return nil, nil + } + entry, err := h.proxy.DB.GetMetadataCache(containerTagsCacheEcosystem, 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 + } + + tags := &cachedContainerTags{body: body, contentType: contentTypeJSON, size: int64(len(body))} + if entry.ContentType.Valid { + tags.contentType = entry.ContentType.String + } + if entry.ETag.Valid { + tags.etag = entry.ETag.String + } + if entry.Size.Valid { + tags.size = entry.Size.Int64 + } + if entry.FetchedAt.Valid { + tags.fetchedAt = entry.FetchedAt.Time + } + return tags, nil +} + +func (h *ContainerHandler) storeContainerTags(ctx context.Context, cacheKey string, tags *cachedContainerTags) error { + if h.proxy.DB == nil || h.proxy.Storage == nil { + return nil + } + storagePath := metadataStoragePath(containerTagsCacheEcosystem, cacheKey) + size, _, err := h.proxy.Storage.Store(ctx, storagePath, bytes.NewReader(tags.body)) + if err != nil { + return fmt.Errorf("storing tag list: %w", err) + } + tags.size = size + return h.proxy.DB.UpsertMetadataCache(&database.MetadataCacheEntry{ + Ecosystem: containerTagsCacheEcosystem, + Name: cacheKey, + StoragePath: storagePath, + ETag: sql.NullString{String: tags.etag, Valid: tags.etag != ""}, + ContentType: sql.NullString{String: tags.contentType, Valid: tags.contentType != ""}, + Size: sql.NullInt64{Int64: size, Valid: true}, + FetchedAt: sql.NullTime{Time: tags.fetchedAt, Valid: !tags.fetchedAt.IsZero()}, + }) +} + +func writeContainerTags(w http.ResponseWriter, tags *cachedContainerTags, stale bool) { + w.Header().Set("Content-Type", tags.contentType) + w.Header().Set("Content-Length", strconv.FormatInt(tags.size, 10)) + if tags.etag != "" { + w.Header().Set("ETag", tags.etag) + } + if stale { + w.Header().Set("Warning", containerStaleWarning) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tags.body) +} + +func copyContainerTagsHeaders(destination, source http.Header) { + for _, header := range []string{"Content-Type", "Content-Length", "ETag", "WWW-Authenticate"} { + if value := source.Get(header); value != "" { + destination.Set(header, value) + } + } +} diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index 04f00a7..2daadbe 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -134,6 +134,57 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) { } } +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_NamedOCIRegistryServesHelmArtifacts(t *testing.T) { digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" 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 + `"}]}` @@ -609,6 +660,92 @@ func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testi } } +func TestContainerHandler_ManifestVariantCacheNormalizesCompatibleAccept(t *testing.T) { + digest := "sha256:abababababababababababababababababababababababababababababababab" + manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` + 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", "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"} + + firstRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil) + firstRequest.Header.Set("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.oci.image.manifest.v1+json") + 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_ManifestVariantCacheDoesNotServeUnacceptedContentType(t *testing.T) { + digest := "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + 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) + _, _ = 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"} + + warmRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil) + warmRequest.Header.Set("Accept", "application/vnd.oci.image.index.v1+json") + 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.manifest.v1+json") + 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) { digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` From 1bae32c2ceae3498878d9e051a1dc05c2416e73b Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 22 Aug 2026 14:46:58 +0530 Subject: [PATCH 2/2] fix(oci): refine manifest cache variants --- internal/handler/container_manifest.go | 19 +++++++++++++------ internal/handler/container_test.go | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/handler/container_manifest.go b/internal/handler/container_manifest.go index 7ef3d1d..d209542 100644 --- a/internal/handler/container_manifest.go +++ b/internal/handler/container_manifest.go @@ -44,7 +44,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, h.proxy.Logger.Warn("failed to read cached container manifest", "error", err) cached = nil } - if cached != nil && !containerManifestAccepts(accept, cached.contentType) { + if cached != nil && cached.contentType != "" && !containerManifestAccepts(accept, cached.contentType) { cached = nil } @@ -240,7 +240,7 @@ func containerManifestAccept(r *http.Request) string { } func normalizeContainerManifestAccept(accept string) string { - mediaTypes := make([]string, 0) + mediaTypes := make(map[string]struct{}) for _, value := range strings.Split(accept, ",") { value = strings.TrimSpace(value) if value == "" { @@ -248,7 +248,7 @@ func normalizeContainerManifestAccept(accept string) string { } mediaType, params, err := mime.ParseMediaType(value) if err != nil { - mediaTypes = append(mediaTypes, strings.ToLower(value)) + mediaTypes[strings.ToLower(value)] = struct{}{} continue } paramKeys := make([]string, 0, len(params)) @@ -261,15 +261,22 @@ func normalizeContainerManifestAccept(accept string) string { value := params[key] if strings.EqualFold(key, "q") { if quality, err := strconv.ParseFloat(value, 64); err == nil { + if quality == 1 { + continue + } value = strconv.FormatFloat(quality, 'g', -1, 64) } } canonical += ";" + strings.ToLower(key) + "=" + value } - mediaTypes = append(mediaTypes, canonical) + mediaTypes[canonical] = struct{}{} } - sort.Strings(mediaTypes) - return strings.Join(mediaTypes, ",") + canonicalMediaTypes := make([]string, 0, len(mediaTypes)) + for mediaType := range mediaTypes { + canonicalMediaTypes = append(canonicalMediaTypes, mediaType) + } + sort.Strings(canonicalMediaTypes) + return strings.Join(canonicalMediaTypes, ",") } func containerManifestAccepts(accept, contentType string) bool { diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index 2daadbe..8336c11 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -671,7 +671,7 @@ func TestContainerHandler_ManifestVariantCacheNormalizesCompatibleAccept(t *test http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) return } - w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json") + w.Header().Set("Content-Type", "") w.Header().Set("Docker-Content-Digest", digest) _, _ = io.WriteString(w, manifest) })) @@ -683,7 +683,7 @@ func TestContainerHandler_ManifestVariantCacheNormalizesCompatibleAccept(t *test 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,application/vnd.oci.image.manifest.v1+json") + 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 {