Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 6 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,19 +224,24 @@ cooldown:
packages:
"pkg:npm/lodash": "0"
"pkg:npm/@babel/core": "14d"
package_patterns:
"pkg:npm/@example/*": "0"
```

| Config | Environment | Description |
|--------|-------------|-------------|
| `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.

Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions internal/cooldownpolicy/policy.go
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions internal/cooldownpolicy/policy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 8 additions & 2 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
21 changes: 13 additions & 8 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down