From 0b63f02529bb9e1fc7aacac65f0d0df05d946837 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 13 Sep 2026 14:44:45 +0530 Subject: [PATCH 1/3] fix(nuget): enforce cooldown across metadata and downloads --- docs/configuration.md | 8 + internal/handler/nuget.go | 161 ++---------- internal/handler/nuget_cooldown.go | 328 ++++++++++++++++++++++++ internal/handler/nuget_cooldown_test.go | 286 +++++++++++++++++++++ 4 files changed, 646 insertions(+), 137 deletions(-) create mode 100644 internal/handler/nuget_cooldown.go create mode 100644 internal/handler/nuget_cooldown_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 6d27d7f8..bd947c8f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -401,6 +401,14 @@ Resolution order: package override, then ecosystem override, then global default Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata. +For NuGet, cooldown filters flat-container version lists and registration metadata, +including separately fetched registration pages. Pinned package downloads are +checked before serving either upstream or cached files. Registration metadata is +cached unfiltered and evaluated against the current policy on each request. Missing +or invalid publication timestamps retain the permissive behavior used by metadata +filtering; metadata fetch or JSON parsing failures return an error when no usable +cached metadata is available. + Note: Hex cooldown requires disabling registry signature verification since the proxy re-encodes the protobuf payload without the original signature. Set `HEX_NO_VERIFY_REPO_ORIGIN=1` or configure your repo with `no_verify: true`. ## Artifact Scanning diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 7df62408..3216f23d 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "strings" - "time" ) const ( @@ -51,10 +50,12 @@ func (h *NuGetHandler) Routes() http.Handler { // Package content (downloads) mux.HandleFunc("GET /v3-flatcontainer/{id}/{version}/{filename}", h.handleDownload) - mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.proxyUpstream) + mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.handleVersionList) // Registration (package metadata) - use prefix matching since {version}.json isn't allowed - mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.handleRegistration) + for _, prefix := range nugetRegistrationPrefixes { + mux.HandleFunc("GET "+prefix, h.handleRegistration) + } // Search mux.HandleFunc("GET /query", h.proxyUpstream) @@ -84,6 +85,10 @@ func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request rewritten, err := h.rewriteServiceIndex(body) if err != nil { + if h.cooldownEnabled() { + h.nugetMetadataError(w, err) + return + } h.proxy.Logger.Warn("failed to rewrite service index, proxying original", "error", err) w.Header().Set(headerContentType, "application/json") _, _ = w.Write(body) @@ -131,6 +136,10 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string { switch serviceType { case "PackageBaseAddress/3.0.0": return h.proxyURL + "/nuget/v3-flatcontainer/" + case "RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc": + return h.proxyURL + "/nuget/v3/registration5-semver1/" + case "RegistrationsBaseUrl/3.4.0": + return h.proxyURL + "/nuget/v3/registration5-gz-semver1/" case "RegistrationsBaseUrl/3.6.0", "RegistrationsBaseUrl/Versioned": return h.proxyURL + "/nuget/v3/registration5-gz-semver2/" case "SearchQueryService", "SearchQueryService/3.0.0-rc", "SearchQueryService/3.5.0": @@ -142,140 +151,6 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string { } } -// handleRegistration proxies NuGet registration pages, applying cooldown filtering. -func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) { - if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { - h.proxyUpstream(w, r) - return - } - - upstreamURL := h.buildUpstreamURL(r) - - h.proxy.Logger.Debug("fetching registration for cooldown filtering", "url", upstreamURL) - - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) - if err != nil { - http.Error(w, "failed to create request", http.StatusInternalServerError) - return - } - req.Header.Set(headerAcceptEncoding, "gzip") - - resp, err := h.proxy.HTTPClient.Do(req) - if err != nil { - h.proxy.Logger.Error("upstream request failed", "error", err) - http.Error(w, "upstream request failed", http.StatusBadGateway) - return - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - for k, vv := range resp.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) - return - } - - body, err := h.proxy.ReadMetadata(resp.Body) - if err != nil { - http.Error(w, "failed to read response", http.StatusInternalServerError) - return - } - - filtered, err := h.applyCooldownFiltering(body) - if err != nil { - h.proxy.Logger.Warn("failed to filter registration, proxying original", "error", err) - w.Header().Set(headerContentType, "application/json") - _, _ = w.Write(body) - return - } - - w.Header().Set(headerContentType, "application/json") - _, _ = w.Write(filtered) -} - -// applyCooldownFiltering filters versions from NuGet registration pages -// that are too recently published. -func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) { - if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { - return body, nil - } - - var registration map[string]any - if err := json.Unmarshal(body, ®istration); err != nil { - return nil, err - } - - pages, ok := registration["items"].([]any) - if !ok { - return body, nil - } - - for _, page := range pages { - pageMap, ok := page.(map[string]any) - if !ok { - continue - } - - items, ok := pageMap["items"].([]any) - if !ok { - continue - } - - filtered := items[:0] - for _, item := range items { - itemMap, ok := item.(map[string]any) - if !ok { - continue - } - - catalogEntry, ok := itemMap["catalogEntry"].(map[string]any) - if !ok { - filtered = append(filtered, item) - continue - } - - version, _ := catalogEntry["version"].(string) - id, _ := catalogEntry["id"].(string) - publishedStr, _ := catalogEntry["published"].(string) - - if publishedStr == "" { - filtered = append(filtered, item) - continue - } - - publishedAt, err := time.Parse(time.RFC3339, publishedStr) - if err != nil { - // NuGet uses a slightly non-standard format, try parsing with fractional seconds - publishedAt, err = time.Parse("2006-01-02T15:04:05.999-07:00", publishedStr) - if err != nil { - filtered = append(filtered, item) - continue - } - } - - packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id)) - - if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) { - h.proxy.Logger.Info("cooldown: filtering nuget version", - "package", id, "version", version, - "published", publishedStr) - continue - } - - filtered = append(filtered, item) - } - - pageMap["items"] = filtered - pageMap["count"] = len(filtered) - } - - return json.Marshal(registration) -} - // handleDownload serves a package file, fetching and caching from upstream if needed. func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") @@ -287,6 +162,18 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) { return } + if h.cooldownEnabled() { + allowed, err := h.nugetDownloadAllowed(r.Context(), id, version) + if err != nil { + h.nugetMetadataError(w, err) + return + } + if !allowed { + JSONError(w, http.StatusNotFound, "version not found") + return + } + } + // Only cache .nupkg files if !strings.HasSuffix(filename, ".nupkg") { h.proxyUpstream(w, r) diff --git a/internal/handler/nuget_cooldown.go b/internal/handler/nuget_cooldown.go new file mode 100644 index 00000000..2b92c8cd --- /dev/null +++ b/internal/handler/nuget_cooldown.go @@ -0,0 +1,328 @@ +package handler + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +var nugetRegistrationPrefixes = []string{ + "/v3/registration5-semver1/", + "/v3/registration5-gz-semver1/", + "/v3/registration5-gz-semver2/", +} + +const nugetRegistrationPath = "/v3/registration5-gz-semver2/" + +func (h *NuGetHandler) cooldownEnabled() bool { + return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() +} + +// Cache upstream documents, not filtered results, so policy changes and elapsed +// time take effect even while metadata is fresh. Include the upstream in the key. +func (h *NuGetHandler) nugetMetadata(ctx context.Context, path string) (map[string]any, error) { + target := h.upstreamURL + path + key := fmt.Sprintf("_cooldown/%x", sha256.Sum256([]byte(target))) + body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "nuget", key, target) + if err != nil { + return nil, err + } + // Normally the HTTP transport decodes gzip. Also support compressed cached + // bytes and clients with transparent decompression disabled, with the same + // metadata limit applied to the decompressed document. + if bytes.HasPrefix(body, []byte{0x1f, 0x8b}) { + reader, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + body, err = h.proxy.ReadMetadata(reader) + if err != nil { + return nil, err + } + } + var document map[string]any + if err := json.Unmarshal(body, &document); err != nil { + return nil, fmt.Errorf("parsing NuGet metadata: %w", err) + } + if document == nil { + return nil, fmt.Errorf("empty NuGet metadata") + } + return document, nil +} + +func (h *NuGetHandler) nugetMetadataError(w http.ResponseWriter, err error) { + if errors.Is(err, ErrUpstreamNotFound) { + JSONError(w, http.StatusNotFound, "package metadata not found") + return + } + h.proxy.Logger.Warn("failed to process NuGet metadata", "error", err) + JSONError(w, http.StatusBadGateway, "failed to process package metadata") +} + +func (h *NuGetHandler) handleVersionList(w http.ResponseWriter, r *http.Request) { + if !h.cooldownEnabled() { + h.proxyUpstream(w, r) + return + } + id := strings.ToLower(r.PathValue("id")) + document, err := h.nugetMetadata(r.Context(), "/v3-flatcontainer/"+url.PathEscape(id)+"/index.json") + if err != nil { + h.nugetMetadataError(w, err) + return + } + registrationPath := nugetRegistrationPath + url.PathEscape(id) + "/index.json" + registration, err := h.nugetMetadata(r.Context(), registrationPath) + if err == nil { + err = h.expandNuGetPages(r.Context(), registration, registrationPath) + } + if err != nil { + h.nugetMetadataError(w, err) + return + } + blocked := make(map[string]bool) + h.collectNuGetBlockedVersions(registration, id, blocked) + versions, ok := document["versions"].([]any) + if !ok { + h.nugetMetadataError(w, fmt.Errorf("missing NuGet versions")) + return + } + filtered := make([]any, 0, len(versions)) + for _, value := range versions { + version, ok := value.(string) + if !ok { + h.nugetMetadataError(w, fmt.Errorf("invalid NuGet version")) + return + } + if !blocked[nugetVersionKey(version)] { + filtered = append(filtered, value) + } + } + document["versions"] = filtered + w.Header().Set(headerContentType, contentTypeJSON) + _ = json.NewEncoder(w).Encode(document) +} + +func nugetVersionKey(version string) string { + version, _, _ = strings.Cut(version, "+") + return strings.ToLower(version) +} + +func (h *NuGetHandler) nugetDownloadAllowed(ctx context.Context, id, version string) (bool, error) { + if h.proxy.Cooldown.For("nuget", canonicalPackagePURL("nuget", strings.ToLower(id))) <= 0 { + return true, nil + } + path := nugetRegistrationPath + url.PathEscape(strings.ToLower(id)) + "/" + url.PathEscape(nugetVersionKey(version)) + ".json" + leaf, err := h.nugetMetadata(ctx, path) + if err != nil { + return false, err + } + return h.nugetLeafAllowed(leaf, id), nil +} + +// A standalone leaf has published at its root; leaves embedded in pages carry +// it in catalogEntry. Missing/invalid timestamps retain the existing permissive +// behavior, but fetch and JSON errors must not bypass the policy. +func (h *NuGetHandler) nugetLeafAllowed(leaf map[string]any, id string) bool { + if !h.cooldownEnabled() { + return true + } + entry := nugetCatalogEntry(leaf) + if id == "" { + id, _ = entry["id"].(string) + } + published, _ := entry["published"].(string) + when, err := time.Parse(time.RFC3339, published) + if err != nil { + return true + } + return h.proxy.Cooldown.IsAllowed("nuget", canonicalPackagePURL("nuget", strings.ToLower(id)), when) +} + +func nugetCatalogEntry(leaf map[string]any) map[string]any { + if entry, ok := leaf["catalogEntry"].(map[string]any); ok { + return entry + } + return leaf +} + +func (h *NuGetHandler) collectNuGetBlockedVersions(document map[string]any, id string, blocked map[string]bool) { + entry := nugetCatalogEntry(document) + if version, ok := entry["version"].(string); ok && !h.nugetLeafAllowed(document, id) { + blocked[nugetVersionKey(version)] = true + } + items, _ := document["items"].([]any) + for _, item := range items { + if child, ok := item.(map[string]any); ok { + h.collectNuGetBlockedVersions(child, id, blocked) + } + } +} + +func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) { + if !h.cooldownEnabled() { + h.proxyUpstream(w, r) + return + } + document, err := h.nugetMetadata(r.Context(), r.URL.Path) + if err == nil { + err = h.expandNuGetPages(r.Context(), document, r.URL.Path) + } + if err != nil { + h.nugetMetadataError(w, err) + return + } + id := nugetRegistrationID(r.URL.Path) + _, hasItems := document["items"] + if !h.filterNuGetRegistration(document, id) && !hasItems { + JSONError(w, http.StatusNotFound, "version not found") + return + } + h.rewriteNuGetRegistrationLinks(document) + w.Header().Set(headerContentType, contentTypeJSON) + _ = json.NewEncoder(w).Encode(document) +} + +func nugetRegistrationID(path string) string { + for _, prefix := range nugetRegistrationPrefixes { + if rest, ok := strings.CutPrefix(path, prefix); ok { + id, _, _ := strings.Cut(rest, "/") + return id + } + } + return "" +} + +// Only expand index pages, never recursively follow arbitrary upstream links. +// Pin requests to this configured upstream and the current package's page path. +func (h *NuGetHandler) expandNuGetPages(ctx context.Context, document map[string]any, path string) error { + if !strings.HasSuffix(path, "/index.json") { + return nil + } + items, ok := document["items"].([]any) + if !ok { + return fmt.Errorf("missing registration pages") + } + base, err := url.Parse(h.upstreamURL + path) + if err != nil { + return err + } + pagePrefix := strings.TrimSuffix(base.Path, "index.json") + "page/" + for _, item := range items { + page, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("invalid registration page") + } + if _, ok := page["items"].([]any); ok { + continue + } + link, _ := page["@id"].(string) + target, err := base.Parse(link) + if err != nil || target.Scheme != base.Scheme || target.Host != base.Host || + !strings.HasPrefix(target.Path, pagePrefix) || containsPathTraversal(target.Path) || target.RawQuery != "" || target.Fragment != "" { + return fmt.Errorf("invalid registration page URL: %q", link) + } + upstream, _ := url.Parse(h.upstreamURL) + pageDocument, err := h.nugetMetadata(ctx, strings.TrimPrefix(target.Path, upstream.Path)) + if err != nil { + return err + } + leaves, ok := pageDocument["items"].([]any) + if !ok { + return fmt.Errorf("missing registration leaves") + } + page["items"] = leaves + } + return nil +} + +func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) { + if !h.cooldownEnabled() { + return body, nil + } + var document map[string]any + if err := json.Unmarshal(body, &document); err != nil { + return nil, err + } + h.filterNuGetRegistration(document, "") + return json.Marshal(document) +} + +func (h *NuGetHandler) filterNuGetRegistration(document map[string]any, id string) bool { + items, ok := document["items"].([]any) + if !ok { + return h.nugetLeafAllowed(document, id) + } + filtered := make([]any, 0, len(items)) + for _, item := range items { + child, ok := item.(map[string]any) + if ok && h.filterNuGetRegistration(child, id) { + filtered = append(filtered, child) + } + } + document["items"] = filtered + document["count"] = len(filtered) + // Page bounds describe the retained leaves, not versions hidden by cooldown. + if _, isPage := document["lower"]; isPage && len(filtered) > 0 { + first, _ := filtered[0].(map[string]any) + last, _ := filtered[len(filtered)-1].(map[string]any) + document["lower"] = nugetCatalogEntry(first)["version"] + document["upper"] = nugetCatalogEntry(last)["version"] + } + return len(filtered) > 0 +} + +func (h *NuGetHandler) rewriteNuGetRegistrationLinks(value any) { + switch node := value.(type) { + case map[string]any: + for key, child := range node { + if link, ok := child.(string); ok { + switch key { + case "@id", "parent", "registration", "packageContent": + node[key] = h.nugetProxyLink(link) + } + } else { + h.rewriteNuGetRegistrationLinks(child) + } + } + case []any: + for _, child := range node { + h.rewriteNuGetRegistrationLinks(child) + } + } +} + +func (h *NuGetHandler) nugetProxyLink(link string) string { + u, err := url.Parse(link) + if err != nil { + return link + } + upstream, err := url.Parse(h.upstreamURL) + if err != nil { + return link + } + path := u.Path + if u.Host == upstream.Host { + path = strings.TrimPrefix(path, upstream.Path) + } + for _, prefix := range append([]string{"/v3-flatcontainer/"}, nugetRegistrationPrefixes...) { + if strings.HasPrefix(path, prefix) { + proxy, err := url.Parse(h.proxyURL + "/nuget" + path) + if err != nil { + return link + } + proxy.RawQuery = u.RawQuery + proxy.Fragment = u.Fragment + return proxy.String() + } + } + return link +} diff --git a/internal/handler/nuget_cooldown_test.go b/internal/handler/nuget_cooldown_test.go new file mode 100644 index 00000000..983793de --- /dev/null +++ b/internal/handler/nuget_cooldown_test.go @@ -0,0 +1,286 @@ +package handler + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/registries/fetch" +) + +func TestNuGetCooldownRoutes(t *testing.T) { + for _, disableCompression := range []bool{false, true} { + t.Run(map[bool]string{false: "transport gzip", true: "explicit gzip"}[disableCompression], func(t *testing.T) { + proxy, db, store, fetcher := setupTestProxy(t) + proxy.Cooldown = &cooldown.Config{Default: "14d"} + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached package") + seedPackage(t, db, store, "nuget", "testpkg", "1.0.0", "testpkg.1.0.0.nupkg", "old package") + metadataRequests := 0 + upstream := newNuGetCooldownUpstream(t, &metadataRequests) + defer upstream.Close() + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DisableCompression = disableCompression + defer transport.CloseIdleConnections() + proxy.HTTPClient = &http.Client{Transport: transport} + h := NewNuGetHandlerWithUpstreams(proxy, "http://proxy.test", upstream.URL, upstream.URL) + routes := http.StripPrefix("/nuget", h.Routes()) + get := func(path string, status int) *httptest.ResponseRecorder { + t.Helper() + return nugetGet(t, routes, path, status) + } + list := get("/nuget/v3-flatcontainer/testpkg/index.json", http.StatusOK) + if got := strings.TrimSpace(list.Body.String()); got != `{"versions":["1.0.0"]}` { + t.Fatalf("filtered list = %s", got) + } + index := get("/nuget"+nugetRegistrationPath+"testpkg/index.json", http.StatusOK) + if strings.Contains(index.Body.String(), `"version":"2.0.0"`) || strings.Contains(index.Body.String(), upstream.URL) { + t.Fatalf("registration leaks blocked leaf or upstream link: %s", index.Body.String()) + } + if index.Header().Get("Content-Encoding") != "" || !json.Valid(index.Body.Bytes()) { + t.Fatal("registration must be decoded JSON") + } + var doc struct { + Items []struct { + ID string `json:"@id"` + Count int + Lower, Upper string + Items []struct { + ID string `json:"@id"` + PackageContent string + } + } + } + if err := json.Unmarshal(index.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if len(doc.Items) != 1 || doc.Items[0].Count != 1 || doc.Items[0].Upper != "1.0.0" { + t.Fatalf("incorrect page: %+v", doc) + } + get(doc.Items[0].ID, http.StatusOK) + get(doc.Items[0].Items[0].ID, http.StatusOK) + get(doc.Items[0].Items[0].PackageContent, http.StatusOK) + get("/nuget"+nugetRegistrationPath+"testpkg/2.0.0.json", http.StatusNotFound) + get("/nuget/v3-flatcontainer/TestPkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusNotFound) + get("/nuget/v3-flatcontainer/testpkg/2.0.0/testpkg.nuspec", http.StatusNotFound) + if fetcher.fetchCalled { + t.Fatal("blocked or cached downloads must not fetch artifacts") + } + + // Reevaluate fresh, unfiltered metadata under a changed package policy. + requestsBefore := metadataRequests + proxy.Cooldown = &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "1d"}} + list = get("/nuget/v3-flatcontainer/testpkg/index.json", http.StatusOK) + if !strings.Contains(list.Body.String(), "2.0.0") { + t.Fatal("fresh metadata retained the previous policy") + } + get("/nuget/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusOK) + if metadataRequests != requestsBefore { + t.Fatal("fresh metadata should be reused") + } + }) + } +} + +func nugetGet(t *testing.T, routes http.Handler, path string, status int) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != status { + t.Fatalf("GET %s: status %d, want %d: %s", path, w.Code, status, w.Body.String()) + } + return w +} + +func TestNuGetCooldownColdDownload(t *testing.T) { + for _, tt := range []struct { + name, published string + policy *cooldown.Config + want int + }{ + {"recent", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Default: "14d"}, http.StatusNotFound}, + {"missing timestamp", "", &cooldown.Config{Default: "14d"}, http.StatusOK}, + {"package exemption", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "0"}}, http.StatusOK}, + {"ecosystem override", time.Now().Add(-time.Hour).Format(time.RFC3339), &cooldown.Config{Ecosystems: map[string]string{"nuget": "14d"}}, http.StatusNotFound}, + } { + t.Run(tt.name, func(t *testing.T) { + p, _, _, fetcher := setupTestProxy(t) + p.Cooldown = tt.policy + fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/octet-stream"} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"published": tt.published}) + })) + defer upstream.Close() + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", tt.want) + if fetcher.fetchCalled != (tt.want == http.StatusOK) { + t.Errorf("artifact fetch called = %v", fetcher.fetchCalled) + } + }) + } +} + +func TestNuGetRegistrationServiceAliases(t *testing.T) { + h := NewNuGetHandler(nugetTestProxy(), "http://proxy.test") + for _, tt := range []struct{ service, path string }{ + {"RegistrationsBaseUrl", "/v3/registration5-semver1/"}, + {"RegistrationsBaseUrl/3.0.0-beta", "/v3/registration5-semver1/"}, + {"RegistrationsBaseUrl/3.0.0-rc", "/v3/registration5-semver1/"}, + {"RegistrationsBaseUrl/3.4.0", "/v3/registration5-gz-semver1/"}, + {"RegistrationsBaseUrl/3.6.0", nugetRegistrationPath}, + {"RegistrationsBaseUrl/Versioned", nugetRegistrationPath}, + } { + t.Run(tt.service, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tt.path+"testpkg/index.json" { + t.Errorf("wrong hive: %s", r.URL.Path) + } + _, _ = io.WriteString(w, `{"count":0,"items":[]}`) + })) + defer upstream.Close() + h.upstreamURL = upstream.URL + h.proxy.Cooldown = &cooldown.Config{Default: "14d"} + body := []byte(`{"resources":[{"@id":"` + upstream.URL + tt.path + `","@type":"` + tt.service + `"}]}`) + out, err := h.rewriteServiceIndex(body) + if err != nil { + t.Fatal(err) + } + var doc struct { + Resources []struct { + ID string `json:"@id"` + } + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + http.StripPrefix("/nuget", h.Routes()).ServeHTTP(w, httptest.NewRequest(http.MethodGet, doc.Resources[0].ID+"testpkg/index.json", nil)) + if w.Code != http.StatusOK { + t.Fatalf("alias route status = %d: %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestNuGetCooldownMetadataErrors(t *testing.T) { + for _, tt := range []struct { + name, body string + status int + }{ + {"upstream failure", "unavailable", http.StatusServiceUnavailable}, + {"invalid JSON", "broken JSON", http.StatusOK}, + {"null", "null", http.StatusOK}, + } { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + })) + defer upstream.Close() + p := nugetTestProxy() + p.Cooldown = &cooldown.Config{Default: "14d"} + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + for _, path := range []string{"/v3-flatcontainer/testpkg/index.json", "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", nugetRegistrationPath + "testpkg/index.json"} { + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusBadGateway { + t.Errorf("GET %s: %d, want 502", path, w.Code) + } + } + }) + } +} + +func TestNuGetCooldownRejectsUnsafePageLinks(t *testing.T) { + for _, link := range []string{"https://other.example/page.json", "/v3/registration5-gz-semver2/other/page/1/2.json", "page/../index.json", "index.json"} { + t.Run(link, func(t *testing.T) { + requests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"@id": link}}}) + })) + defer upstream.Close() + p := nugetTestProxy() + p.Cooldown = &cooldown.Config{Default: "14d"} + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusBadGateway) + if requests != 1 { + t.Fatalf("unsafe page link was followed (%d requests)", requests) + } + }) + } +} + +func TestNuGetCooldownDecompressedMetadataLimit(t *testing.T) { + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + _, _ = io.WriteString(gz, `{"padding":"`+strings.Repeat("x", 2048)+`"}`) + _ = gz.Close() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + _, _ = w.Write(compressed.Bytes()) + })) + defer upstream.Close() + p := nugetTestProxy() + p.Cooldown = &cooldown.Config{Default: "14d"} + p.MetadataMaxSize = 1024 + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DisableCompression = true + defer transport.CloseIdleConnections() + p.HTTPClient = &http.Client{Transport: transport} + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusBadGateway) +} + +func newNuGetCooldownUpstream(t *testing.T, metadataRequests *int) *httptest.Server { + t.Helper() + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + (*metadataRequests)++ + base := upstream.URL + nugetRegistrationPath + "testpkg/" + leaf := func(version string, age time.Duration) map[string]any { + return map[string]any{ + "@id": base + version + ".json", + "packageContent": upstream.URL + "/v3-flatcontainer/testpkg/" + version + "/testpkg." + version + ".nupkg", + "catalogEntry": map[string]any{"id": "TestPkg", "version": version, "published": time.Now().Add(-age).Format(time.RFC3339)}, + } + } + page := map[string]any{"@id": base + "page/1.0.0/2.0.0.json", "lower": "1.0.0", "upper": "2.0.0", "count": 2, + "parent": base + "index.json", "items": []any{leaf("1.0.0", 30*24*time.Hour), leaf("2.0.0", 2*24*time.Hour)}} + var body any + switch r.URL.Path { + case "/v3-flatcontainer/testpkg/index.json": + body = map[string]any{"versions": []string{"1.0.0", "2.0.0"}} + case nugetRegistrationPath + "testpkg/index.json": + // This index deliberately does not inline its leaves. + body = map[string]any{"count": 1, "items": []any{map[string]any{ + "@id": page["@id"], "count": 2, "lower": "1.0.0", "upper": "2.0.0", + }}} + case nugetRegistrationPath + "testpkg/page/1.0.0/2.0.0.json": + body = page + case nugetRegistrationPath + "testpkg/1.0.0.json": + body = map[string]any{"published": time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)} + case nugetRegistrationPath + "testpkg/2.0.0.json": + body = map[string]any{"published": time.Now().Add(-2 * 24 * time.Hour).Format(time.RFC3339)} + default: + t.Errorf("unexpected metadata request: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + _ = json.NewEncoder(gz).Encode(body) + _ = gz.Close() + })) + + return upstream +} From 74a9bf11bc037d047728226104b2c07ba53a3b84 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 13 Sep 2026 21:51:32 +0530 Subject: [PATCH 2/3] fix(nuget): support legacy registration and preserve valid metadata cache --- docs/configuration.md | 5 + internal/handler/handler.go | 57 ++++--- internal/handler/nuget_cooldown.go | 90 +++++++++--- internal/handler/nuget_cooldown_test.go | 40 +++++ internal/handler/nuget_review_test.go | 188 ++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 40 deletions(-) create mode 100644 internal/handler/nuget_review_test.go diff --git a/docs/configuration.md b/docs/configuration.md index bd947c8f..08043e79 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -409,6 +409,11 @@ or invalid publication timestamps retain the permissive behavior used by metadat filtering; metadata fetch or JSON parsing failures return an error when no usable cached metadata is available. +If the semver2 registration endpoint returns not found, cooldown checks try the +older registration aliases advertised by the source's service index. Invalid JSON +or compressed metadata cannot replace a previously valid cached document; the +proxy falls back to that document and evaluates it against the current policy. + Note: Hex cooldown requires disabling registry signature verification since the proxy re-encodes the protobuf payload without the original signature. Set `HEX_NO_VERIFY_REPO_ORIGIN=1` or configure your repo with `no_verify: true`. ## Artifact Scanning diff --git a/internal/handler/handler.go b/internal/handler/handler.go index a78393d9..b5aab8cb 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -697,7 +697,7 @@ func metadataStoragePath(ecosystem, cacheKey string) string { // cacheKey is typically the package name but can include subpath components. // Optional acceptHeaders specify the Accept header(s) to send; defaults to application/json. func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, acceptHeaders ...string) ([]byte, string, error) { - return p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, false, acceptHeaders...) + return p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, false, nil, acceptHeaders...) } // fetchOrCacheMetadata implements FetchOrCacheMetadata. When verbatim is true @@ -705,7 +705,9 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u // upstream is fetched with Accept-Encoding: identity so signed and hash-pinned // index files are cached exactly as sent. Direct callers that parse or rewrite // the body pass verbatim=false and keep transparent transfer compression. -func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, verbatim bool, acceptHeaders ...string) ([]byte, string, error) { +// validate, when supplied, runs before caching or serving a document. Validation +// failures follow the same stale-cache fallback path as upstream failures. +func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, verbatim bool, validate func([]byte) error, acceptHeaders ...string) ([]byte, string, error) { if containsPathTraversal(cacheKey) { return nil, "", fmt.Errorf("invalid cache key: %q", cacheKey) } @@ -721,18 +723,14 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u // Serve from cache if within TTL (skip upstream entirely) if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid { if time.Since(entry.FetchedAt.Time) < p.MetadataTTL { - cached, readErr := p.Storage.Open(ctx, entry.StoragePath) + data, ct, readErr := p.readCachedMetadata(ctx, entry, validate) if readErr == nil { - defer func() { _ = cached.Close() }() - data, readErr := p.ReadMetadata(cached) - if readErr == nil { - ct := contentTypeJSON - if entry.ContentType.Valid { - ct = entry.ContentType.String - } - metrics.RecordCacheHit(ecosystem) - return data, ct, nil - } + metrics.RecordCacheHit(ecosystem) + return data, ct, nil + } + if validate != nil { + // Do not revalidate an unusable cached body with its ETag. + entry = nil } // Cache file missing/unreadable, fall through to upstream } @@ -750,6 +748,9 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u // 304 but cached file is gone; retry without ETag meta, err = p.fetchUpstreamMetadata(ctx, upstreamURL, nil, accept, verbatim) } + if err == nil && validate != nil { + err = validate(meta.body) + } if err == nil { if p.CacheMetadata { p.cacheMetadataBlob(ctx, ecosystem, cacheKey, storagePath, meta) @@ -765,23 +766,33 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u p.Logger.Warn("upstream metadata fetch failed, checking cache", "ecosystem", ecosystem, "key", cacheKey, "error", err) - cached, readErr := p.Storage.Open(ctx, entry.StoragePath) + data, ct, readErr := p.readCachedMetadata(ctx, entry, validate) if readErr != nil { - return nil, "", fmt.Errorf("upstream failed and cached file missing: %w", err) + return nil, "", fmt.Errorf("upstream failed and cached metadata unusable (%v): %w", readErr, err) } - defer func() { _ = cached.Close() }() - data, readErr := p.ReadMetadata(cached) - if readErr != nil { - return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err) - } + p.Logger.Info("serving metadata from cache", + "ecosystem", ecosystem, "key", cacheKey) + return data, ct, nil +} +func (p *Proxy) readCachedMetadata(ctx context.Context, entry *database.MetadataCacheEntry, validate func([]byte) error) ([]byte, string, error) { + cached, err := p.Storage.Open(ctx, entry.StoragePath) + if err != nil { + return nil, "", err + } + defer func() { _ = cached.Close() }() + data, err := p.ReadMetadata(cached) + if err == nil && validate != nil { + err = validate(data) + } + if err != nil { + return nil, "", err + } ct := contentTypeJSON if entry.ContentType.Valid { ct = entry.ContentType.String } - p.Logger.Info("serving metadata from cache", - "ecosystem", ecosystem, "key", cacheKey) return data, ct, nil } @@ -952,7 +963,7 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL, return } - body, contentType, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, true, acceptHeaders...) + body, contentType, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, true, nil, acceptHeaders...) if err != nil { if errors.Is(err, ErrUpstreamNotFound) { http.Error(w, "not found", http.StatusNotFound) diff --git a/internal/handler/nuget_cooldown.go b/internal/handler/nuget_cooldown.go index 2b92c8cd..00611e92 100644 --- a/internal/handler/nuget_cooldown.go +++ b/internal/handler/nuget_cooldown.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "time" ) @@ -26,15 +27,29 @@ func (h *NuGetHandler) cooldownEnabled() bool { return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() } +func (h *NuGetHandler) nugetCooldownApplies(id string) bool { + return h.cooldownEnabled() && h.proxy.Cooldown.For("nuget", canonicalPackagePURL("nuget", strings.ToLower(id))) > 0 +} + // Cache upstream documents, not filtered results, so policy changes and elapsed // time take effect even while metadata is fresh. Include the upstream in the key. func (h *NuGetHandler) nugetMetadata(ctx context.Context, path string) (map[string]any, error) { target := h.upstreamURL + path key := fmt.Sprintf("_cooldown/%x", sha256.Sum256([]byte(target))) - body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "nuget", key, target) + var document map[string]any + validate := func(body []byte) error { + var err error + document, err = h.decodeNuGetMetadata(body) + return err + } + _, _, err := h.proxy.fetchOrCacheMetadata(ctx, "nuget", key, target, false, validate) if err != nil { return nil, err } + return document, nil +} + +func (h *NuGetHandler) decodeNuGetMetadata(body []byte) (map[string]any, error) { // Normally the HTTP transport decodes gzip. Also support compressed cached // bytes and clients with transparent decompression disabled, with the same // metadata limit applied to the decompressed document. @@ -59,6 +74,43 @@ func (h *NuGetHandler) nugetMetadata(ctx context.Context, path string) (map[stri return document, nil } +// Prefer semver2, but a configured source may advertise only an older hive. +// Retry only advertised aliases on 404; transport/validation errors must not +// silently switch to a hive with less complete metadata. Keep requests on the +// configured upstream, consistent with the service-index route rewriting. +func (h *NuGetHandler) nugetRegistrationMetadata(ctx context.Context, suffix string) (map[string]any, string, error) { + path := nugetRegistrationPath + suffix + document, err := h.nugetMetadata(ctx, path) + if !errors.Is(err, ErrUpstreamNotFound) { + return document, path, err + } + index, indexErr := h.nugetMetadata(ctx, "/v3/index.json") + if indexErr != nil { + return nil, path, indexErr + } + resources, _ := index["resources"].([]any) + seen := map[string]bool{nugetRegistrationPath: true} + for _, resource := range resources { + entry, _ := resource.(map[string]any) + service, _ := entry["@type"].(string) + id, _ := entry["@id"].(string) + if id == "" || !strings.HasPrefix(service, "RegistrationsBaseUrl") { + continue + } + prefix := strings.TrimPrefix(h.rewriteNuGetURL(id, service), h.proxyURL+"/nuget") + if !slices.Contains(nugetRegistrationPrefixes, prefix) || seen[prefix] { + continue + } + seen[prefix] = true + path = prefix + suffix + document, err = h.nugetMetadata(ctx, path) + if !errors.Is(err, ErrUpstreamNotFound) { + return document, path, err + } + } + return nil, path, err +} + func (h *NuGetHandler) nugetMetadataError(w http.ResponseWriter, err error) { if errors.Is(err, ErrUpstreamNotFound) { JSONError(w, http.StatusNotFound, "package metadata not found") @@ -79,17 +131,20 @@ func (h *NuGetHandler) handleVersionList(w http.ResponseWriter, r *http.Request) h.nugetMetadataError(w, err) return } - registrationPath := nugetRegistrationPath + url.PathEscape(id) + "/index.json" - registration, err := h.nugetMetadata(r.Context(), registrationPath) - if err == nil { - err = h.expandNuGetPages(r.Context(), registration, registrationPath) - } - if err != nil { - h.nugetMetadataError(w, err) - return - } blocked := make(map[string]bool) - h.collectNuGetBlockedVersions(registration, id, blocked) + // A globally enabled policy may still exempt this package or ecosystem. + // Keep metadata caching, but do not require publication data in that case. + if h.nugetCooldownApplies(id) { + registration, registrationPath, err := h.nugetRegistrationMetadata(r.Context(), url.PathEscape(id)+"/index.json") + if err == nil { + err = h.expandNuGetPages(r.Context(), registration, registrationPath) + } + if err != nil { + h.nugetMetadataError(w, err) + return + } + h.collectNuGetBlockedVersions(registration, id, blocked) + } versions, ok := document["versions"].([]any) if !ok { h.nugetMetadataError(w, fmt.Errorf("missing NuGet versions")) @@ -117,11 +172,11 @@ func nugetVersionKey(version string) string { } func (h *NuGetHandler) nugetDownloadAllowed(ctx context.Context, id, version string) (bool, error) { - if h.proxy.Cooldown.For("nuget", canonicalPackagePURL("nuget", strings.ToLower(id))) <= 0 { + if !h.nugetCooldownApplies(id) { return true, nil } - path := nugetRegistrationPath + url.PathEscape(strings.ToLower(id)) + "/" + url.PathEscape(nugetVersionKey(version)) + ".json" - leaf, err := h.nugetMetadata(ctx, path) + suffix := url.PathEscape(strings.ToLower(id)) + "/" + url.PathEscape(nugetVersionKey(version)) + ".json" + leaf, _, err := h.nugetRegistrationMetadata(ctx, suffix) if err != nil { return false, err } @@ -172,17 +227,18 @@ func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request h.proxyUpstream(w, r) return } + id := nugetRegistrationID(r.URL.Path) + applyCooldown := h.nugetCooldownApplies(id) document, err := h.nugetMetadata(r.Context(), r.URL.Path) - if err == nil { + if err == nil && applyCooldown { err = h.expandNuGetPages(r.Context(), document, r.URL.Path) } if err != nil { h.nugetMetadataError(w, err) return } - id := nugetRegistrationID(r.URL.Path) _, hasItems := document["items"] - if !h.filterNuGetRegistration(document, id) && !hasItems { + if applyCooldown && !h.filterNuGetRegistration(document, id) && !hasItems { JSONError(w, http.StatusNotFound, "version not found") return } diff --git a/internal/handler/nuget_cooldown_test.go b/internal/handler/nuget_cooldown_test.go index 983793de..cc1a4075 100644 --- a/internal/handler/nuget_cooldown_test.go +++ b/internal/handler/nuget_cooldown_test.go @@ -100,6 +100,46 @@ func nugetGet(t *testing.T, routes http.Handler, path string, status int) *httpt return w } +func TestNuGetMetadataWithoutEffectiveCooldown(t *testing.T) { + for _, tt := range []struct { + name string + policy *cooldown.Config + }{ + {"package exemption", &cooldown.Config{Default: "14d", Packages: map[string]string{"pkg:nuget/testpkg": "0"}}}, + {"ecosystem exemption", &cooldown.Config{Default: "14d", Ecosystems: map[string]string{"nuget": "0"}}}, + {"other ecosystem only", &cooldown.Config{Ecosystems: map[string]string{"npm": "14d"}}}, + {"other package only", &cooldown.Config{Packages: map[string]string{"pkg:nuget/other": "14d"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + const body = `{"versions":["1.0.0","2.0.0"]}` + const pagePath = nugetRegistrationPath + "testpkg/page/1.0.0/2.0.0.json" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v3-flatcontainer/testpkg/index.json": + _, _ = io.WriteString(w, body) + case nugetRegistrationPath + "testpkg/index.json": + _, _ = io.WriteString(w, `{"count":1,"items":[{"@id":"`+pagePath+`","count":2,"lower":"1.0.0","upper":"2.0.0"}]}`) + default: + t.Errorf("unnecessary registration request: %s", r.URL.Path) + http.Error(w, "registration unavailable", http.StatusServiceUnavailable) + } + })) + defer upstream.Close() + p := nugetTestProxy() + p.Cooldown = tt.policy + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + w := nugetGet(t, h.Routes(), "/v3-flatcontainer/TestPkg/index.json", http.StatusOK) + if got := strings.TrimSpace(w.Body.String()); got != body { + t.Fatalf("version list = %s, want %s", got, body) + } + w = nugetGet(t, h.Routes(), nugetRegistrationPath+"testpkg/index.json", http.StatusOK) + if !strings.Contains(w.Body.String(), `"@id":"http://proxy.test/nuget`+pagePath+`"`) { + t.Fatalf("registration page link was not rewritten: %s", w.Body.String()) + } + }) + } +} + func TestNuGetCooldownColdDownload(t *testing.T) { for _, tt := range []struct { name, published string diff --git a/internal/handler/nuget_review_test.go b/internal/handler/nuget_review_test.go new file mode 100644 index 00000000..b218595d --- /dev/null +++ b/internal/handler/nuget_review_test.go @@ -0,0 +1,188 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/git-pkgs/cooldown" +) + +func TestNuGetCooldownLegacyRegistration(t *testing.T) { + for _, service := range []string{"RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc", "RegistrationsBaseUrl/3.4.0"} { + t.Run(service, func(t *testing.T) { + prefix := "/v3/registration5-semver1/" + if service == "RegistrationsBaseUrl/3.4.0" { + prefix = "/v3/registration5-gz-semver1/" + } + upstream := newNuGetLegacyUpstream(t, service, prefix) + defer upstream.Close() + p, db, store, fetcher := setupTestProxy(t) + p.Cooldown = &cooldown.Config{Default: "14d"} + seedPackage(t, db, store, "nuget", "testpkg", "1.0.0", "testpkg.1.0.0.nupkg", "cached old package") + seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached recent package") + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL+"/feed", upstream.URL) + list := nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/index.json", http.StatusOK) + if strings.TrimSpace(list.Body.String()) != `{"versions":["1.0.0"]}` { + t.Fatalf("incorrect version list: %s", list.Body.String()) + } + nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/1.0.0/testpkg.1.0.0.nupkg", http.StatusOK) + nugetGet(t, h.Routes(), "/v3-flatcontainer/testpkg/2.0.0/testpkg.2.0.0.nupkg", http.StatusNotFound) + if fetcher.fetchCalled { + t.Fatal("cached or blocked package must not be fetched") + } + }) + } +} + +func newNuGetLegacyUpstream(t *testing.T, service, prefix string) *httptest.Server { + t.Helper() + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/feed") + base := upstream.URL + "/feed" + prefix + "testpkg/" + published := func(age time.Duration) string { return time.Now().Add(-age).Format(time.RFC3339) } + var body any + switch path { + case "/v3/index.json": + body = map[string]any{"resources": []any{map[string]string{"@id": upstream.URL + "/feed" + prefix, "@type": service}}} + case "/v3-flatcontainer/testpkg/index.json": + body = map[string]any{"versions": []string{"1.0.0", "2.0.0"}} + case prefix + "testpkg/index.json": + body = map[string]any{"items": []any{map[string]any{"@id": base + "page/1.0.0/2.0.0.json"}}} + case prefix + "testpkg/page/1.0.0/2.0.0.json": + body = map[string]any{"items": []any{ + map[string]any{"catalogEntry": map[string]string{"id": "testpkg", "version": "1.0.0", "published": published(30 * 24 * time.Hour)}}, + map[string]any{"catalogEntry": map[string]string{"id": "testpkg", "version": "2.0.0", "published": published(time.Hour)}}, + }} + case prefix + "testpkg/1.0.0.json": + body = map[string]string{"published": published(30 * 24 * time.Hour)} + case prefix + "testpkg/2.0.0.json": + body = map[string]string{"published": published(time.Hour)} + default: + if !strings.HasPrefix(path, nugetRegistrationPath) { + t.Errorf("unexpected request: %s", r.URL.Path) + } + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(body) + })) + return upstream +} + +func TestNuGetRegistrationDoesNotFallbackOnFailure(t *testing.T) { + for _, status := range []int{http.StatusServiceUnavailable, http.StatusUnauthorized, http.StatusOK} { + t.Run(http.StatusText(status), func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != nugetRegistrationPath+"testpkg/index.json" { + t.Errorf("must not switch registration hive on failure: %s", r.URL.Path) + } + w.WriteHeader(status) + _, _ = io.WriteString(w, "invalid metadata") + })) + defer upstream.Close() + h := NewNuGetHandlerWithUpstreams(nugetTestProxy(), "http://proxy.test", upstream.URL, upstream.URL) + if _, _, err := h.nugetRegistrationMetadata(t.Context(), "testpkg/index.json"); err == nil { + t.Fatal("expected metadata error") + } + }) + } +} + +func TestNuGetMetadataPreservesValidCache(t *testing.T) { + for _, invalid := range []string{"broken JSON", "null", "[]", string([]byte{0x1f, 0x8b, 0x00})} { + t.Run(invalid, func(t *testing.T) { + const good = `{"published":"2020-01-01T00:00:00Z"}` + var response atomic.Value + response.Store(good) + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) > 1 && r.Header.Get("If-None-Match") != `"good"` { + t.Errorf("cached ETag was replaced: %s", r.Header.Get("If-None-Match")) + } + body := response.Load().(string) + etag := `"good"` + if body == invalid { + etag = `"bad"` + } + w.Header().Set("ETag", etag) + _, _ = io.WriteString(w, body) + })) + defer upstream.Close() + p, _, _, _ := setupTestProxy(t) + p.CacheMetadata = true + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + check := func(want string) { + t.Helper() + doc, err := h.nugetMetadata(t.Context(), nugetRegistrationPath+"testpkg/1.0.0.json") + if err != nil || doc["published"] != want { + t.Fatalf("metadata = %v, err = %v, want publication %s", doc, err, want) + } + } + check("2020-01-01T00:00:00Z") + response.Store(invalid) + check("2020-01-01T00:00:00Z") // Bad 200 must fall back without overwriting. + p.MetadataTTL = time.Hour + check("2020-01-01T00:00:00Z") // The on-disk cache must still be usable. + if requests.Load() != 2 { + t.Fatalf("requests = %d, want 2", requests.Load()) + } + p.MetadataTTL = 0 + response.Store(`{"published":"2021-01-01T00:00:00Z"}`) + check("2021-01-01T00:00:00Z") // A later valid response replaces the cache. + }) + } +} + +func TestNuGetMetadataInvalidResponseNotCached(t *testing.T) { + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + _, _ = io.WriteString(w, "invalid JSON") + return + } + _, _ = io.WriteString(w, `{"published":"2020-01-01T00:00:00Z"}`) + })) + defer upstream.Close() + p, _, _, _ := setupTestProxy(t) + p.CacheMetadata = true + p.MetadataTTL = time.Hour + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + path := nugetRegistrationPath + "testpkg/1.0.0.json" + if _, err := h.nugetMetadata(t.Context(), path); err == nil { + t.Fatal("invalid response without a usable cache must fail") + } + if _, err := h.nugetMetadata(t.Context(), path); err != nil { + t.Fatalf("invalid response was cached: %v", err) + } + if requests.Load() != 2 { + t.Fatalf("requests = %d, want 2", requests.Load()) + } +} + +func TestNuGetRegistrationDoesNotGuessUnadvertisedAliases(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case nugetRegistrationPath + "testpkg/index.json": + http.NotFound(w, r) + case "/v3/index.json": + _, _ = io.WriteString(w, `{"resources":[{"@type":"UnrelatedService","@id":"https://other.example/"}]}`) + default: + t.Errorf("unadvertised endpoint requested: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer upstream.Close() + h := NewNuGetHandlerWithUpstreams(nugetTestProxy(), "http://proxy.test", upstream.URL, upstream.URL) + _, _, err := h.nugetRegistrationMetadata(t.Context(), "testpkg/index.json") + if !errors.Is(err, ErrUpstreamNotFound) { + t.Fatalf("error = %v, want metadata not found", err) + } +} From cbd033364c76090c42c0e24ffcb2ada60f668bee Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 13 Sep 2026 22:09:37 +0530 Subject: [PATCH 3/3] test(server): separate OCI timeout from readiness probes --- internal/server/server_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a52a5b55..d6bd20fa 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -237,7 +237,7 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { } }() - client := &http.Client{Timeout: 250 * time.Millisecond} + probeClient := &http.Client{Timeout: 250 * time.Millisecond} deadline := time.Now().Add(5 * time.Second) for { req, err := http.NewRequest(http.MethodGet, cfg.BaseURL+"/pypi/simple/ruff/", nil) @@ -245,7 +245,7 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { t.Fatalf("creating request: %v", err) } req.Header.Set("Accept", "application/vnd.pypi.simple.v1+json") - resp, requestErr := client.Do(req) + resp, requestErr := probeClient.Do(req) if requestErr == nil { body, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() @@ -266,6 +266,9 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { time.Sleep(10 * time.Millisecond) } + // This checks upstream routing, not latency. Allow time for fetching and + // cache I/O under -race on slower CI workers. + client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(cfg.BaseURL + "/v2/library/demo/manifests/latest") if err != nil { t.Fatalf("OCI request failed: %v", err)