Skip to content
Merged
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ if !decision.Allowed {

Duration strings accept Go's standard formats (`48h`, `30m`, `1h30m`) plus a `d` suffix for days (`3d`). `0` disables the window.

For package-pattern overrides, construct a `Policy` before serving requests:

```go
policy, err := cooldown.NewPolicy(cfg, map[string]string{
"pkg:npm/@example/*": "0",
"pkg:nuget/example.*": "7d",
})
if err != nil {
return err
}
decision := policy.Evaluate("npm", "pkg:npm/%40example/widget", publishedAt, evaluatedAt)
```

`Policy` provides the same `For`, `Evaluate`, `IsAllowed`, and `Enabled` methods as `Config`. Exact package overrides take precedence over patterns, followed by ecosystem overrides and the global default. The matching pattern with the most literal characters wins, with lexical order breaking ties. `*` and `?` do not match `/`; character classes and escapes are rejected.

Patterns accept either `@` or `%40` for npm scopes. Exact keys and lookup PURLs must already use the same canonical encoding. The constructor rejects invalid pattern durations and conflicting normalized patterns; equivalent durations such as `1d` and `24h` are accepted. A nil base config supplies a zero default. Construct the policy once and leave the base config unchanged while using it concurrently.

## Why standalone

Originally lived inside an HTTP proxy that filtered registry responses; spotted as a reusable shape and lifted out. The same predicate is useful for package-manager CLIs, dependency-update bots, security scanners, and anything else that walks a version set with an age constraint.
Expand Down
113 changes: 113 additions & 0 deletions policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cooldown

import (
"fmt"
"path"
"sort"
"strings"
"time"
)

// Policy applies exact PURL overrides before package-pattern overrides.
type Policy struct {
base *Config
patterns []pattern
enabled bool
}

type pattern struct {
glob string
duration time.Duration
config *Config
}

// NewPolicy creates a Policy using the supplied exact and pattern overrides.
// Exact keys and lookup PURLs must use the same canonical encoding.
// Do not modify base after constructing the policy.
func NewPolicy(base *Config, packagePatterns map[string]string) (*Policy, error) {
if base == nil {
base = &Config{}
}

keys := make([]string, 0, len(packagePatterns))
for glob := range packagePatterns {
keys = append(keys, glob)
}
sort.Strings(keys)
patterns := make([]pattern, 0, len(packagePatterns))
seen := make(map[string]pattern)
enabled := base.Enabled()
for _, glob := range keys {
value := packagePatterns[glob]
if strings.ContainsAny(glob, "[\\") {
return nil, fmt.Errorf("invalid cooldown package pattern %q: character classes and escapes are not supported", glob)
}
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 := ParseDuration(value)
if err != nil {
return nil, fmt.Errorf("invalid cooldown duration for package pattern %q: %w", glob, err)
}
if previous, exists := seen[canonicalGlob]; exists {
if previous.duration != duration {
return nil, fmt.Errorf("conflicting cooldown package patterns %q and %q", previous.glob, glob)
}
continue
}
seen[canonicalGlob] = pattern{glob: glob, duration: duration}
config := &Config{Default: value}
enabled = config.Enabled() || enabled
patterns = append(patterns, pattern{glob: canonicalGlob, duration: duration, config: config})
}
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, enabled: enabled}, nil
}

func literalLength(glob string) int {
return len(glob) - strings.Count(glob, "*") - strings.Count(glob, "?")
}

// For returns the duration, with exact overrides taking precedence over patterns.
func (p *Policy) For(ecosystem, packagePURL string) time.Duration {
return p.configFor(packagePURL).For(ecosystem, packagePURL)
}

func (p *Policy) configFor(packagePURL string) *Config {
if _, exact := p.base.Packages[packagePURL]; exact {
return p.base
}

for _, candidate := range p.patterns {
matched, _ := path.Match(candidate.glob, packagePURL)
if !matched {
continue
}
return candidate.config
}

return p.base
}

// IsAllowed reports whether the package version has completed its cooldown.
func (p *Policy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
return p.Evaluate(ecosystem, packagePURL, publishedAt, time.Now()).Allowed
}

// Evaluate returns the cooldown decision at the supplied evaluation time.
func (p *Policy) Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) Decision {
return p.configFor(packagePURL).Evaluate(ecosystem, packagePURL, publishedAt, evaluatedAt)
}

// Enabled reports whether any configured cooldown can filter a package version.
func (p *Policy) Enabled() bool {
return p.enabled
}
250 changes: 250 additions & 0 deletions policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package cooldown_test

import (
"strings"
"sync"
"testing"
"time"

"github.com/git-pkgs/cooldown"
)

func TestPolicyConcurrentReads(t *testing.T) {
policy, err := cooldown.NewPolicy(&cooldown.Config{
Default: "48h",
Ecosystems: map[string]string{"npm": "72h"},
Packages: map[string]string{"pkg:npm/%40example/exact": "0"},
}, map[string]string{"pkg:npm/@example/*": "7d"})
if err != nil {
t.Fatal(err)
}
var readers sync.WaitGroup
for range 10 {
readers.Go(func() {
for _, purl := range []string{"pkg:npm/%40example/exact", "pkg:npm/%40example/widget", "pkg:npm/other"} {
if !policy.Enabled() {
t.Error("policy disabled")
}
decision := policy.Evaluate("npm", purl, time.Time{}, time.Now())
if !decision.Allowed || decision.Cooldown != policy.For("npm", purl) {
t.Errorf("inconsistent decision for %s: %+v", purl, decision)
}
}
})
}
readers.Wait()
}

func TestPatternOverride(t *testing.T) {
policy, err := cooldown.NewPolicy(&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 := cooldown.NewPolicy(&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 := cooldown.NewPolicy(&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) {
for _, glob := range []string{"pkg:npm/[", "pkg:npm/[@a]*", "pkg:npm/[abcdef]*", `pkg:npm/\*`, `pkg:npm/\@example/*`} {
t.Run(glob, func(t *testing.T) {
if _, err := cooldown.NewPolicy(nil, map[string]string{glob: "0"}); err == nil || !strings.Contains(err.Error(), "character classes and escapes are not supported") {
t.Fatalf("unsupported pattern %q: error = %v", glob, err)
}
})
}
}

func TestQuestionMarkPattern(t *testing.T) {
policy, err := cooldown.NewPolicy(&cooldown.Config{Default: "7d"}, map[string]string{"pkg:npm/@example/widget-?": "0"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
purl string
want time.Duration
}{
{"pkg:npm/%40example/widget-a", 0},
{"pkg:npm/%40example/widget-ab", 7 * 24 * time.Hour},
{"pkg:npm/%40example/widget-/", 7 * 24 * time.Hour},
} {
if got := policy.For("npm", tc.purl); got != tc.want {
t.Errorf("For(%q) = %s, want %s", tc.purl, got, tc.want)
}
}
}

func TestNormalizedPatternCollisions(t *testing.T) {
for _, duration := range []string{"0", "24h"} {
t.Run(duration, func(t *testing.T) {
for range 20 {
policy, err := cooldown.NewPolicy(nil, map[string]string{
"pkg:npm/@example/*": "1d",
"pkg:npm/%40example/*": duration,
})
if duration == "0" {
if err == nil || !strings.Contains(err.Error(), `conflicting cooldown package patterns "pkg:npm/%40example/*" and "pkg:npm/@example/*"`) {
t.Fatalf("conflict error = %v", err)
}
continue
}
if err != nil {
t.Fatal(err)
}
if got := policy.For("npm", "pkg:npm/%40example/widget"); got != 24*time.Hour {
t.Fatalf("duration = %s", got)
}
}
})
}
}

func TestForPrecedence(t *testing.T) {
policy, err := cooldown.NewPolicy(&cooldown.Config{
Default: "1h",
Ecosystems: map[string]string{"npm": "2h"},
Packages: map[string]string{"pkg:npm/%40example/exact": "0"},
}, map[string]string{
"pkg:npm/@example/*": "3h",
"pkg:npm/@example/specific*": "4h",
"pkg:npm/@example/ab*": "5h",
"pkg:npm/@example/a*c": "6h",
})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
ecosystem, purl string
want time.Duration
}{
{"npm", "pkg:npm/%40example/exact", 0},
{"npm", "pkg:npm/%40example/widget", 3 * time.Hour},
{"npm", "pkg:npm/%40example/specific-widget", 4 * time.Hour},
{"npm", "pkg:npm/%40example/abc", 6 * time.Hour},
{"npm", "pkg:npm/other", 2 * time.Hour},
{"npm", "pkg:npm/%40example/nested/package", 2 * time.Hour},
{"cargo", "pkg:cargo/serde", time.Hour},
} {
t.Run(tc.purl, func(t *testing.T) {
if got := policy.For(tc.ecosystem, tc.purl); got != tc.want {
t.Errorf("For = %s, want %s", got, tc.want)
}
})
}
}

func TestPatternOnlyPolicy(t *testing.T) {
policy, err := cooldown.NewPolicy(nil, map[string]string{"pkg:nuget/example.*": "24h"})
if err != nil {
t.Fatal(err)
}
if !policy.Enabled() {
t.Fatal("pattern-only policy should be enabled")
}
for _, tc := range []struct {
published time.Time
allowed bool
}{
{time.Now().Add(-time.Hour), false},
{time.Now().Add(-48 * time.Hour), true},
{time.Time{}, true},
} {
if got := policy.IsAllowed("nuget", "pkg:nuget/example.widget", tc.published); got != tc.allowed {
t.Errorf("published=%s: allowed=%t, want %t", tc.published, got, tc.allowed)
}
}
if !policy.IsAllowed("nuget", "pkg:nuget/other", time.Now()) {
t.Error("unmatched package should have no cooldown")
}
}

func TestDisabledPolicy(t *testing.T) {
for _, patterns := range []map[string]string{nil, {"pkg:npm/@example/*": "0"}} {
policy, err := cooldown.NewPolicy(nil, patterns)
if err != nil {
t.Fatal(err)
}
if policy.Enabled() {
t.Error("zero cooldown should be disabled")
}
}
}

func TestNewRejectsInvalidDuration(t *testing.T) {
if _, err := cooldown.NewPolicy(nil, map[string]string{"pkg:npm/*": "invalid"}); err == nil {
t.Fatal("invalid duration accepted")
}
}

func TestEvaluate(t *testing.T) {
policy, err := cooldown.NewPolicy(&cooldown.Config{
Default: "48h",
Ecosystems: map[string]string{"npm": "72h"},
Packages: map[string]string{"pkg:npm/%40example/exact": "0"},
}, map[string]string{"pkg:npm/@example/*": "7d"})
if err != nil {
t.Fatal(err)
}
published := time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC)
eligible := published.Add(7 * 24 * time.Hour)
for _, tc := range []struct {
name, ecosystem, purl string
published, at time.Time
want cooldown.Decision
}{
{"waiting", "npm", "pkg:npm/%40example/widget", published, eligible.Add(-time.Nanosecond), cooldown.Decision{Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonWaiting}},
{"boundary", "npm", "pkg:npm/%40example/widget", published, eligible, cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonElapsed}},
{"elapsed", "npm", "pkg:npm/%40example/widget", published, eligible.Add(time.Hour), cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonElapsed}},
{"unknown publication", "npm", "pkg:npm/%40example/widget", time.Time{}, eligible, cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, Reason: cooldown.ReasonUnknownPublicationTime}},
{"exact exemption", "npm", "pkg:npm/%40example/exact", published, published, cooldown.Decision{Allowed: true, AvailableAt: published, Reason: cooldown.ReasonDisabled}},
{"exemption without publication", "npm", "pkg:npm/%40example/exact", time.Time{}, eligible, cooldown.Decision{Allowed: true, Reason: cooldown.ReasonUnknownPublicationTime}},
{"ecosystem fallback", "npm", "pkg:npm/other", published, published, cooldown.Decision{Cooldown: 72 * time.Hour, AvailableAt: published.Add(72 * time.Hour), Reason: cooldown.ReasonWaiting}},
{"global fallback", "cargo", "pkg:cargo/serde", published, published, cooldown.Decision{Cooldown: 48 * time.Hour, AvailableAt: published.Add(48 * time.Hour), Reason: cooldown.ReasonWaiting}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := policy.Evaluate(tc.ecosystem, tc.purl, tc.published, tc.at); got != tc.want {
t.Errorf("Evaluate = %+v, want %+v", got, tc.want)
}
})
}
}
Loading