diff --git a/README.md b/README.md index dae91cb..17d0cda 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..2ab066b --- /dev/null +++ b/policy.go @@ -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 +} diff --git a/policy_test.go b/policy_test.go new file mode 100644 index 0000000..3bdc144 --- /dev/null +++ b/policy_test.go @@ -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) + } + }) + } +}