Skip to content

Commit 0a3b9e5

Browse files
authored
Merge pull request #201 from git-pkgs/fix-197-cooldown-scoped-purls
Normalize cooldown package PURL overrides
2 parents 785c989 + cdbd1a9 commit 0a3b9e5

16 files changed

Lines changed: 113 additions & 29 deletions

File tree

config.example.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ cooldown:
168168
# npm: "7d"
169169
# cargo: "0"
170170

171-
# Per-package overrides (keyed by PURL)
171+
# Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes
172+
# may use either @scope or the canonical %40scope form.
172173
# packages:
173174
# "pkg:npm/lodash": "0"
174175
# "pkg:npm/@babel/core": "14d"

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ cooldown:
234234

235235
Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable.
236236

237+
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.
238+
237239
Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.
238240

239241
Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata.

internal/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ import (
5454
"net/url"
5555
"os"
5656
"path/filepath"
57+
"sort"
5758
"strconv"
5859
"strings"
5960
"time"
6061

62+
"github.com/git-pkgs/purl"
6163
"gopkg.in/yaml.v3"
6264
)
6365

@@ -137,9 +139,38 @@ type CooldownConfig struct {
137139
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
138140

139141
// Packages overrides the cooldown for specific packages (keyed by PURL).
142+
// Valid PURL keys are normalized to canonical form before use.
140143
Packages map[string]string `json:"packages" yaml:"packages"`
141144
}
142145

146+
// NormalizedPackages returns a copy of the package overrides with valid PURL
147+
// keys in canonical form. An explicitly canonical key wins over an equivalent
148+
// noncanonical key, and invalid keys are preserved unchanged.
149+
func (c *CooldownConfig) NormalizedPackages() map[string]string {
150+
if c == nil || c.Packages == nil {
151+
return nil
152+
}
153+
154+
keys := make([]string, 0, len(c.Packages))
155+
for key := range c.Packages {
156+
keys = append(keys, key)
157+
}
158+
sort.Strings(keys)
159+
160+
normalized := make(map[string]string, len(c.Packages))
161+
for _, key := range keys {
162+
canonical := key
163+
if parsed, err := purl.Parse(key); err == nil {
164+
canonical = parsed.String()
165+
}
166+
if _, exists := normalized[canonical]; exists && key != canonical {
167+
continue
168+
}
169+
normalized[canonical] = c.Packages[key]
170+
}
171+
return normalized
172+
}
173+
143174
// StorageConfig configures artifact storage.
144175
type StorageConfig struct {
145176
// URL is the storage backend URL.

internal/config/config_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,34 @@ cooldown:
363363
if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" {
364364
t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d")
365365
}
366+
if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" {
367+
t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d")
368+
}
369+
}
370+
371+
func TestCooldownConfigNormalizedPackages(t *testing.T) {
372+
rawScoped := "pkg:npm/@typescript/typescript-darwin-arm64"
373+
canonicalScoped := "pkg:npm/%40typescript/typescript-darwin-arm64"
374+
cfg := CooldownConfig{Packages: map[string]string{
375+
rawScoped: "2d",
376+
canonicalScoped: "3d",
377+
"not-a-purl": "4d",
378+
}}
379+
380+
got := cfg.NormalizedPackages()
381+
382+
if got[canonicalScoped] != "3d" {
383+
t.Errorf("canonical scoped package duration = %q, want %q", got[canonicalScoped], "3d")
384+
}
385+
if _, exists := got[rawScoped]; exists {
386+
t.Errorf("raw scoped package key %q was not canonicalized", rawScoped)
387+
}
388+
if got["not-a-purl"] != "4d" {
389+
t.Errorf("invalid PURL duration = %q, want preserved value %q", got["not-a-purl"], "4d")
390+
}
391+
if cfg.Packages[rawScoped] != "2d" {
392+
t.Error("NormalizedPackages mutated the source map")
393+
}
366394
}
367395

368396
func TestLoadCooldownFromEnv(t *testing.T) {

internal/handler/cargo.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import (
88
"net/http"
99
"strings"
1010
"time"
11-
12-
"github.com/git-pkgs/purl"
1311
)
1412

1513
const (
@@ -143,7 +141,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr
143141
continue
144142
}
145143

146-
cratePURL := purl.MakePURLString("cargo", crate.Name, "")
144+
cratePURL := canonicalPackagePURL("cargo", crate.Name)
147145

148146
if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
149147
h.proxy.Logger.Info("cooldown: filtering cargo version",

internal/handler/composer.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import (
1010
"path"
1111
"strings"
1212
"time"
13-
14-
"github.com/git-pkgs/purl"
1513
)
1614

1715
const (
@@ -216,7 +214,7 @@ func deepCopyValue(v any) any {
216214
// filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs
217215
// for a single package's version list.
218216
func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any {
219-
packagePURL := purl.MakePURLString("composer", packageName, "")
217+
packagePURL := canonicalPackagePURL("composer", packageName)
220218

221219
filtered := versionList[:0]
222220
for _, v := range versionList {

internal/handler/conda.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"net/http"
77
"strings"
88
"time"
9-
10-
"github.com/git-pkgs/purl"
119
)
1210

1311
const (
@@ -218,7 +216,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
218216
continue
219217
}
220218

221-
packagePURL := purl.MakePURLString("conda", name, "")
219+
packagePURL := canonicalPackagePURL("conda", name)
222220

223221
if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
224222
version, _ := entryMap["version"].(string)

internal/handler/gem.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import (
88
"net/http"
99
"strings"
1010
"time"
11-
12-
"github.com/git-pkgs/purl"
1311
)
1412

1513
const (
@@ -266,7 +264,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
266264
return nil, err
267265
}
268266

269-
packagePURL := purl.MakePURLString("gem", name, "")
267+
packagePURL := canonicalPackagePURL("gem", name)
270268
filtered := make(map[string]bool)
271269

272270
for _, v := range versions {

internal/handler/handler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ func hasDotDotSegment(path string) bool {
4848

4949
const defaultHTTPTimeout = 30 * time.Second
5050

51+
// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
52+
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
53+
func canonicalPackagePURL(ecosystem, name string) string {
54+
p := purl.MakePURL(ecosystem, name, "")
55+
_ = p.Normalize()
56+
return p.String()
57+
}
58+
5159
const contentTypeJSON = "application/json"
5260

5361
const headerAcceptEncoding = "Accept-Encoding"

internal/handler/handler_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/git-pkgs/proxy/internal/config"
1718
"github.com/git-pkgs/proxy/internal/database"
1819
"github.com/git-pkgs/proxy/internal/storage"
1920
"github.com/git-pkgs/registries/fetch"
@@ -1010,3 +1011,33 @@ func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
10101011
t.Errorf("Warning should be empty for fresh response, got %q", got)
10111012
}
10121013
}
1014+
1015+
// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key
1016+
// agrees with config.CooldownConfig.NormalizedPackages for the same package,
1017+
// so a configured override is actually found regardless of how the user wrote it.
1018+
func TestCanonicalPackagePURLMatchesConfig(t *testing.T) {
1019+
tests := []struct {
1020+
ecosystem string
1021+
requestName string
1022+
configKey string
1023+
}{
1024+
{"npm", "@babel/core", "pkg:npm/@babel/core"},
1025+
{"npm", "@babel/core", "pkg:npm/%40babel/core"},
1026+
{"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"},
1027+
{"pypi", "Django", "pkg:pypi/Django"},
1028+
{"pypi", "django", "pkg:pypi/Django"},
1029+
{"composer", "symfony/console", "pkg:composer/Symfony/Console"},
1030+
{"cargo", "serde", "pkg:cargo/serde"},
1031+
}
1032+
for _, tt := range tests {
1033+
t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) {
1034+
cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}}
1035+
normalized := cfg.NormalizedPackages()
1036+
1037+
lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName)
1038+
if _, ok := normalized[lookup]; !ok {
1039+
t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized)
1040+
}
1041+
})
1042+
}
1043+
}

0 commit comments

Comments
 (0)