From be15a0f826333d8eba32f9a34b7fcaed67d150c0 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 13 Jul 2026 17:11:04 -0700 Subject: [PATCH 1/2] Fix cooldown overrides for scoped package PURLs --- config.example.yaml | 3 ++- docs/configuration.md | 2 ++ internal/config/config.go | 31 +++++++++++++++++++++++++++++++ internal/config/config_test.go | 28 ++++++++++++++++++++++++++++ internal/server/server.go | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index d1ed9a1..1176f5b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -168,7 +168,8 @@ cooldown: # npm: "7d" # cargo: "0" - # Per-package overrides (keyed by PURL) + # Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes + # may use either @scope or the canonical %40scope form. # packages: # "pkg:npm/lodash": "0" # "pkg:npm/@babel/core": "14d" diff --git a/docs/configuration.md b/docs/configuration.md index dcdec24..b103253 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,6 +234,8 @@ cooldown: Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. +Package PURL keys are normalized automatically. For npm scopes, both the readable form (`pkg:npm/@babel/core`) and canonical form (`pkg:npm/%40babel/core`) are accepted. If both forms configure the same package, the canonical entry wins. + Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages. Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata. diff --git a/internal/config/config.go b/internal/config/config.go index 2d275c7..ec510d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,10 +54,12 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" "time" + "github.com/git-pkgs/purl" "gopkg.in/yaml.v3" ) @@ -137,9 +139,38 @@ type CooldownConfig struct { Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"` // Packages overrides the cooldown for specific packages (keyed by PURL). + // Valid PURL keys are normalized to canonical form before use. Packages map[string]string `json:"packages" yaml:"packages"` } +// NormalizedPackages returns a copy of the package overrides with valid PURL +// keys in canonical form. An explicitly canonical key wins over an equivalent +// noncanonical key, and invalid keys are preserved unchanged. +func (c *CooldownConfig) NormalizedPackages() map[string]string { + if c == nil || c.Packages == nil { + return nil + } + + keys := make([]string, 0, len(c.Packages)) + for key := range c.Packages { + keys = append(keys, key) + } + sort.Strings(keys) + + normalized := make(map[string]string, len(c.Packages)) + for _, key := range keys { + canonical := key + if parsed, err := purl.Parse(key); err == nil { + canonical = parsed.String() + } + if _, exists := normalized[canonical]; exists && key != canonical { + continue + } + normalized[canonical] = c.Packages[key] + } + return normalized +} + // StorageConfig configures artifact storage. type StorageConfig struct { // URL is the storage backend URL. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9c34023..decc18f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -363,6 +363,34 @@ cooldown: if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" { t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d") } + if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" { + t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d") + } +} + +func TestCooldownConfigNormalizedPackages(t *testing.T) { + rawScoped := "pkg:npm/@typescript/typescript-darwin-arm64" + canonicalScoped := "pkg:npm/%40typescript/typescript-darwin-arm64" + cfg := CooldownConfig{Packages: map[string]string{ + rawScoped: "2d", + canonicalScoped: "3d", + "not-a-purl": "4d", + }} + + got := cfg.NormalizedPackages() + + if got[canonicalScoped] != "3d" { + t.Errorf("canonical scoped package duration = %q, want %q", got[canonicalScoped], "3d") + } + if _, exists := got[rawScoped]; exists { + t.Errorf("raw scoped package key %q was not canonicalized", rawScoped) + } + if got["not-a-purl"] != "4d" { + t.Errorf("invalid PURL duration = %q, want preserved value %q", got["not-a-purl"], "4d") + } + if cfg.Packages[rawScoped] != "2d" { + t.Error("NormalizedPackages mutated the source map") + } } func TestLoadCooldownFromEnv(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 5aac4db..74c82c7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -163,7 +163,7 @@ func (s *Server) Start() error { cd := &cooldown.Config{ Default: s.cfg.Cooldown.Default, Ecosystems: s.cfg.Cooldown.Ecosystems, - Packages: s.cfg.Cooldown.Packages, + Packages: s.cfg.Cooldown.NormalizedPackages(), } proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout() From cdbd1a93697b1b97a50d68c7d67c89063be86ef0 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 17 Jul 2026 18:11:47 -0700 Subject: [PATCH 2/2] Canonicalize cooldown lookup PURLs to match config NormalizedPackages runs config keys through purl.Parse().String(), which applies per-type rules like lowercasing pypi names. The handler side was building lookup keys with MakePURLString, which does not, so a config entry for pkg:pypi/Django would be normalized to pkg:pypi/django and never match the runtime key pkg:pypi/Django. Route both sides through the same canonical form: a new canonicalPackagePURL helper calls Normalize() on the constructed PURL before stringifying, and all cooldown IsAllowed call sites use it. --- docs/configuration.md | 2 +- internal/handler/cargo.go | 4 +--- internal/handler/composer.go | 4 +--- internal/handler/conda.go | 4 +--- internal/handler/gem.go | 4 +--- internal/handler/handler.go | 8 ++++++++ internal/handler/handler_test.go | 31 +++++++++++++++++++++++++++++++ internal/handler/hex.go | 3 +-- internal/handler/npm.go | 4 +--- internal/handler/nuget.go | 4 +--- internal/handler/pub.go | 4 +--- internal/handler/pypi.go | 6 ++---- 12 files changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b103253..8998ac2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,7 +234,7 @@ cooldown: Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. -Package PURL keys are normalized automatically. For npm scopes, both the readable form (`pkg:npm/@babel/core`) and canonical form (`pkg:npm/%40babel/core`) are accepted. If both forms configure the same package, the canonical entry wins. +Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins. Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages. diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index 5d7810c..bf424e2 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -143,7 +141,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr continue } - cratePURL := purl.MakePURLString("cargo", crate.Name, "") + cratePURL := canonicalPackagePURL("cargo", crate.Name) if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering cargo version", diff --git a/internal/handler/composer.go b/internal/handler/composer.go index bc3bc1d..23deba5 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -10,8 +10,6 @@ import ( "path" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -216,7 +214,7 @@ func deepCopyValue(v any) any { // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // for a single package's version list. func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { - packagePURL := purl.MakePURLString("composer", packageName, "") + packagePURL := canonicalPackagePURL("composer", packageName) filtered := versionList[:0] for _, v := range versionList { diff --git a/internal/handler/conda.go b/internal/handler/conda.go index cfa20c8..a8632e8 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -6,8 +6,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -218,7 +216,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) { continue } - packagePURL := purl.MakePURLString("conda", name, "") + packagePURL := canonicalPackagePURL("conda", name) if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) { version, _ := entryMap["version"].(string) diff --git a/internal/handler/gem.go b/internal/handler/gem.go index 9ec57e3..a3714d6 100644 --- a/internal/handler/gem.go +++ b/internal/handler/gem.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -266,7 +264,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := purl.MakePURLString("gem", name, "") + packagePURL := canonicalPackagePURL("gem", name) filtered := make(map[string]bool) for _, v := range versions { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d06ca83..202426d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -48,6 +48,14 @@ func hasDotDotSegment(path string) bool { const defaultHTTPTimeout = 30 * time.Second +// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown +// lookups match keys produced by config.CooldownConfig.NormalizedPackages. +func canonicalPackagePURL(ecosystem, name string) string { + p := purl.MakePURL(ecosystem, name, "") + _ = p.Normalize() + return p.String() +} + const contentTypeJSON = "application/json" const headerAcceptEncoding = "Accept-Encoding" diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index bbcab72..9a2b329 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/storage" "github.com/git-pkgs/registries/fetch" @@ -1010,3 +1011,33 @@ func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) { t.Errorf("Warning should be empty for fresh response, got %q", got) } } + +// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key +// agrees with config.CooldownConfig.NormalizedPackages for the same package, +// so a configured override is actually found regardless of how the user wrote it. +func TestCanonicalPackagePURLMatchesConfig(t *testing.T) { + tests := []struct { + ecosystem string + requestName string + configKey string + }{ + {"npm", "@babel/core", "pkg:npm/@babel/core"}, + {"npm", "@babel/core", "pkg:npm/%40babel/core"}, + {"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"}, + {"pypi", "Django", "pkg:pypi/Django"}, + {"pypi", "django", "pkg:pypi/Django"}, + {"composer", "symfony/console", "pkg:composer/Symfony/Console"}, + {"cargo", "serde", "pkg:cargo/serde"}, + } + for _, tt := range tests { + t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) { + cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}} + normalized := cfg.NormalizedPackages() + + lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName) + if _, ok := normalized[lookup]; !ok { + t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized) + } + }) + } +} diff --git a/internal/handler/hex.go b/internal/handler/hex.go index 0f0c72e..6ebc176 100644 --- a/internal/handler/hex.go +++ b/internal/handler/hex.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/git-pkgs/purl" "google.golang.org/protobuf/encoding/protowire" ) @@ -237,7 +236,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := purl.MakePURLString("hex", name, "") + packagePURL := canonicalPackagePURL("hex", name) filtered := make(map[string]bool) for _, release := range pkg.Releases { diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 0585eda..06f539e 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -9,8 +9,6 @@ import ( "sort" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -134,7 +132,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma return } - packagePURL := purl.MakePURLString("npm", packageName, "") + packagePURL := canonicalPackagePURL("npm", packageName) for version := range versions { publishedStr, ok := timeMap[version].(string) diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 40b8b5f..3e6373a 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -271,7 +269,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) { } } - packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "") + packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id)) if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering nuget version", diff --git a/internal/handler/pub.go b/internal/handler/pub.go index 60bbbad..2971ddf 100644 --- a/internal/handler/pub.go +++ b/internal/handler/pub.go @@ -7,8 +7,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -127,7 +125,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) { return body, nil } - packagePURL := purl.MakePURLString("pub", name, "") + packagePURL := canonicalPackagePURL("pub", name) filtered := h.filterAndRewriteVersions(name, packagePURL, versions) metadata["versions"] = filtered diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index 3021d2b..0cf39fb 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -12,8 +12,6 @@ import ( "regexp" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -131,7 +129,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st return nil } - packagePURL := purl.MakePURLString("pypi", name, "") + packagePURL := canonicalPackagePURL("pypi", name) filtered := make(map[string]bool) for version, files := range releases { @@ -262,7 +260,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) { packageName, _ := extractPyPIName(metadata) packagePURL := "" if packageName != "" { - packagePURL = purl.MakePURLString("pypi", packageName, "") + packagePURL = canonicalPackagePURL("pypi", packageName) } h.filterAndRewriteReleases(metadata, packageName, packagePURL)