From b68c83e140ed13a6bdefdf0133de30e4b4b9ae96 Mon Sep 17 00:00:00 2001 From: Sivamuthu Kumar Date: Sat, 8 Aug 2026 11:08:55 -0400 Subject: [PATCH] cooldown: add package pattern overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config.example.yaml | 5 ++ docs/configuration.md | 7 ++- internal/config/config.go | 4 ++ internal/config/config_test.go | 5 ++ internal/cooldownpolicy/policy.go | 87 ++++++++++++++++++++++++++ internal/cooldownpolicy/policy_test.go | 64 +++++++++++++++++++ internal/handler/handler.go | 10 ++- internal/server/server.go | 21 ++++--- 8 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 internal/cooldownpolicy/policy.go create mode 100644 internal/cooldownpolicy/policy_test.go diff --git a/config.example.yaml b/config.example.yaml index 7cf7bb2..5f284cd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -176,3 +176,8 @@ cooldown: # packages: # "pkg:npm/lodash": "0" # "pkg:npm/@babel/core": "14d" + + # Per-package glob overrides (matched against canonical PURLs). Exact + # package entries take precedence over matching patterns. + # package_patterns: + # "pkg:npm/@example/*": "0" diff --git a/docs/configuration.md b/docs/configuration.md index 8998ac2..e098d43 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,6 +224,8 @@ cooldown: packages: "pkg:npm/lodash": "0" "pkg:npm/@babel/core": "14d" + package_patterns: + "pkg:npm/@example/*": "0" ``` | Config | Environment | Description | @@ -231,12 +233,15 @@ cooldown: | `cooldown.default` | `PROXY_COOLDOWN_DEFAULT` | Global default cooldown | | `cooldown.ecosystems` | - | Per-ecosystem overrides | | `cooldown.packages` | - | Per-package overrides (keyed by PURL) | +| `cooldown.package_patterns` | - | Per-package glob overrides (keyed by PURL glob) | Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. 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. +`package_patterns` uses Go path globs against versionless PURLs. For example, `"pkg:npm/@example/*"` matches every package under the `@example` npm scope. Scoped npm patterns accept `@` and normalize it internally. Exact `packages` entries take precedence over patterns. When multiple patterns match, the most specific pattern wins; ties use lexical order. + +Resolution order: exact package override, then package pattern, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted package families. 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 3dabc62..4022177 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -141,6 +141,10 @@ type CooldownConfig struct { // 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"` + + // PackagePatterns overrides the cooldown for packages whose PURLs match a glob. + // Exact package overrides take precedence over matching patterns. + PackagePatterns map[string]string `json:"package_patterns" yaml:"package_patterns"` } // NormalizedPackages returns a copy of the package overrides with valid PURL diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ef6ac90..d5e15f6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -345,6 +345,8 @@ cooldown: packages: "pkg:npm/lodash": "0" "pkg:npm/@babel/core": "14d" + package_patterns: + "pkg:npm/@example/*": "0" ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("writing config file: %v", err) @@ -373,6 +375,9 @@ cooldown: if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" { t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d") } + if cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"] != "0" { + t.Errorf("Cooldown.PackagePatterns[example] = %q, want %q", cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"], "0") + } } func TestCooldownConfigNormalizedPackages(t *testing.T) { diff --git a/internal/cooldownpolicy/policy.go b/internal/cooldownpolicy/policy.go new file mode 100644 index 0000000..5c58d0f --- /dev/null +++ b/internal/cooldownpolicy/policy.go @@ -0,0 +1,87 @@ +// Package cooldownpolicy applies package-pattern overrides to cooldown checks. +package cooldownpolicy + +import ( + "fmt" + "path" + "sort" + "strings" + "time" + + "github.com/git-pkgs/cooldown" +) + +// Policy applies exact PURL overrides before package-pattern overrides. +type Policy struct { + base *cooldown.Config + patterns []pattern +} + +type pattern struct { + glob string + duration time.Duration +} + +// New creates a Policy using the supplied exact and pattern overrides. +func New(base *cooldown.Config, packagePatterns map[string]string) (*Policy, error) { + if base == nil { + base = &cooldown.Config{} + } + + patterns := make([]pattern, 0, len(packagePatterns)) + for glob, value := range packagePatterns { + canonicalGlob := strings.ReplaceAll(glob, "@", "%40") + if _, err := path.Match(canonicalGlob, ""); err != nil { + return nil, fmt.Errorf("invalid cooldown package pattern %q: %w", glob, err) + } + duration, err := cooldown.ParseDuration(value) + if err != nil { + return nil, fmt.Errorf("invalid cooldown duration for package pattern %q: %w", glob, err) + } + patterns = append(patterns, pattern{glob: canonicalGlob, duration: duration}) + } + sort.Slice(patterns, func(i, j int) bool { + left, right := literalLength(patterns[i].glob), literalLength(patterns[j].glob) + if left != right { + return left > right + } + return patterns[i].glob < patterns[j].glob + }) + + return &Policy{base: base, patterns: patterns}, nil +} + +func literalLength(glob string) int { + return len(glob) - strings.Count(glob, "*") - strings.Count(glob, "?") +} + +// IsAllowed reports whether a version published at publishedAt has completed its +// cooldown. Exact package overrides take precedence over package patterns. +func (p *Policy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { + if _, exact := p.base.Packages[packagePURL]; exact { + return p.base.IsAllowed(ecosystem, packagePURL, publishedAt) + } + + for _, candidate := range p.patterns { + matched, _ := path.Match(candidate.glob, packagePURL) + if !matched { + continue + } + return candidate.duration == 0 || publishedAt.IsZero() || time.Since(publishedAt) >= candidate.duration + } + + return p.base.IsAllowed(ecosystem, packagePURL, publishedAt) +} + +// Enabled reports whether any configured cooldown can filter a package version. +func (p *Policy) Enabled() bool { + if p.base.Enabled() { + return true + } + for _, candidate := range p.patterns { + if candidate.duration > 0 { + return true + } + } + return false +} diff --git a/internal/cooldownpolicy/policy_test.go b/internal/cooldownpolicy/policy_test.go new file mode 100644 index 0000000..071ad07 --- /dev/null +++ b/internal/cooldownpolicy/policy_test.go @@ -0,0 +1,64 @@ +package cooldownpolicy + +import ( + "testing" + "time" + + "github.com/git-pkgs/cooldown" +) + +func TestPatternOverride(t *testing.T) { + policy, err := New(&cooldown.Config{ + Default: "7d", + Ecosystems: map[string]string{"npm": "7d"}, + }, map[string]string{ + "pkg:npm/@example/*": "0", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if !policy.IsAllowed("npm", "pkg:npm/%40example/widget", time.Now()) { + t.Fatal("matching package pattern should disable cooldown") + } + if policy.IsAllowed("npm", "pkg:npm/public-package", time.Now()) { + t.Fatal("non-matching package should use ecosystem cooldown") + } +} + +func TestExactOverrideTakesPrecedenceOverPattern(t *testing.T) { + purl := "pkg:npm/%40example/widget" + policy, err := New(&cooldown.Config{ + Default: "7d", + Packages: map[string]string{purl: "2d"}, + }, map[string]string{ + "pkg:npm/@example/*": "0", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if policy.IsAllowed("npm", purl, time.Now()) { + t.Fatal("exact package override should take precedence over pattern") + } +} + +func TestMoreSpecificPatternTakesPrecedence(t *testing.T) { + policy, err := New(&cooldown.Config{Default: "7d"}, map[string]string{ + "pkg:npm/@example/*": "0", + "pkg:npm/@example/critical": "2d", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if policy.IsAllowed("npm", "pkg:npm/%40example/critical", time.Now()) { + t.Fatal("more specific pattern should take precedence") + } +} + +func TestNewRejectsInvalidPattern(t *testing.T) { + if _, err := New(&cooldown.Config{}, map[string]string{"pkg:npm/[": "0"}); err == nil { + t.Fatal("New should reject an invalid package pattern") + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fc78dbf..fa1c844 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -15,7 +15,6 @@ import ( "strings" "time" - "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/metrics" "github.com/git-pkgs/proxy/internal/storage" @@ -91,7 +90,7 @@ type Proxy struct { Fetcher fetch.FetcherInterface Resolver *fetch.Resolver Logger *slog.Logger - Cooldown *cooldown.Config + Cooldown CooldownPolicy CacheMetadata bool MetadataTTL time.Duration MetadataMaxSize int64 @@ -107,6 +106,13 @@ type Proxy struct { AuthForURL func(string) (headerName, headerValue string) } +// CooldownPolicy decides whether a published package version has completed its +// configured cooldown. +type CooldownPolicy interface { + IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool + Enabled() bool +} + // NewProxy creates a new Proxy with the given dependencies. func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInterface, resolver *fetch.Resolver, logger *slog.Logger) *Proxy { if logger == nil { diff --git a/internal/server/server.go b/internal/server/server.go index 856fe2d..0fdd24e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -58,9 +58,10 @@ import ( "strings" "time" + "github.com/git-pkgs/cooldown" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" "github.com/git-pkgs/proxy/internal/config" - "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/proxy/internal/cooldownpolicy" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/enrichment" "github.com/git-pkgs/proxy/internal/handler" @@ -84,12 +85,12 @@ const ( // Server is the main proxy server. type Server struct { - cfg *config.Config - db *database.DB - storage storage.Storage - logger *slog.Logger - http *http.Server - templates *Templates + cfg *config.Config + db *database.DB + storage storage.Storage + logger *slog.Logger + http *http.Server + templates *Templates cancel context.CancelFunc healthCache *healthCache } @@ -168,7 +169,11 @@ func (s *Server) Start() error { proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout() proxy.AuthForURL = s.authForURL - proxy.Cooldown = cd + cooldownPolicy, err := cooldownpolicy.New(cd, s.cfg.Cooldown.PackagePatterns) + if err != nil { + return fmt.Errorf("configuring cooldown policy: %w", err) + } + proxy.Cooldown = cooldownPolicy proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize()