diff --git a/README.md b/README.md index ff24c4b..e9ea282 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,14 @@ cfg := &cooldown.Config{ if cfg.IsAllowed("npm", "pkg:npm/lodash", publishedAt) { // version cleared the window; use it } + +decision := cfg.Evaluate("npm", "pkg:npm/lodash", publishedAt, evaluatedAt) +if !decision.Allowed { + fmt.Printf("available at %s (%s)\n", decision.AvailableAt, decision.Reason) +} ``` -`Config.For(ecosystem, purl)` returns the effective duration; useful when surfacing the policy to a UI. `Config.Enabled()` reports whether any cooldown is configured (cheap check before walking a large version set). +`Config.Evaluate` accepts an explicit evaluation time and returns the selected cooldown, availability time, and a typed reason. Versions without a publication time remain allowed and return `ReasonUnknownPublicationTime`. `Config.IsAllowed` is the current-time shorthand. `Config.For(ecosystem, purl)` returns the effective duration; useful when surfacing the policy to a UI. `Config.Enabled()` reports whether any cooldown is configured (cheap check before walking a large version set). Duration strings accept Go's standard formats (`48h`, `30m`, `1h30m`) plus a `d` suffix for days (`3d`). `0` disables the window. diff --git a/cooldown.go b/cooldown.go index f37a2b9..9f7af6d 100644 --- a/cooldown.go +++ b/cooldown.go @@ -9,6 +9,29 @@ import ( const hoursPerDay = 24 +// Reason explains why a package version is allowed or blocked. +type Reason string + +const ( + // ReasonDisabled means the selected cooldown duration is zero. + ReasonDisabled Reason = "disabled" + // ReasonElapsed means the package version has passed its cooldown period. + ReasonElapsed Reason = "elapsed" + // ReasonWaiting means the package version is still in its cooldown period. + ReasonWaiting Reason = "waiting" + // ReasonUnknownPublicationTime means the package version has no publication + // time and is allowed by the default permissive policy. + ReasonUnknownPublicationTime Reason = "unknown-publication-time" +) + +// Decision describes the result of evaluating a package version. +type Decision struct { + Allowed bool + Cooldown time.Duration + AvailableAt time.Time + Reason Reason +} + // Config holds cooldown settings for version filtering. // Cooldown hides package versions published too recently, giving the community // time to spot malicious releases before they're pulled into projects. @@ -70,14 +93,41 @@ func (c *Config) For(ecosystem, packagePURL string) time.Duration { // IsAllowed returns true if a version with the given publish time has passed // the cooldown period for this ecosystem/package. func (c *Config) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { - d := c.For(ecosystem, packagePURL) - if d == 0 { - return true + return c.Evaluate(ecosystem, packagePURL, publishedAt, time.Now()).Allowed +} + +// Evaluate returns the cooldown decision for a version at evaluatedAt. +// AvailableAt is zero when the publication time is unknown. Otherwise, it is +// the publication time plus the selected cooldown, including when the cooldown +// is disabled. +func (c *Config) Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) Decision { + cooldown := c.For(ecosystem, packagePURL) + decision := Decision{ + Cooldown: cooldown, } + + if !publishedAt.IsZero() { + decision.AvailableAt = publishedAt.Add(cooldown) + } + if publishedAt.IsZero() { - return true + decision.Allowed = true + decision.Reason = ReasonUnknownPublicationTime + return decision } - return time.Since(publishedAt) >= d + if cooldown == 0 { + decision.Allowed = true + decision.Reason = ReasonDisabled + return decision + } + if !evaluatedAt.Before(decision.AvailableAt) { + decision.Allowed = true + decision.Reason = ReasonElapsed + return decision + } + + decision.Reason = ReasonWaiting + return decision } // Enabled returns true if any cooldown is configured. diff --git a/cooldown_test.go b/cooldown_test.go index c366077..ecc11e7 100644 --- a/cooldown_test.go +++ b/cooldown_test.go @@ -109,6 +109,92 @@ func TestConfigIsAllowed(t *testing.T) { } } +func TestConfigEvaluate(t *testing.T) { + evaluatedAt := time.Date(2000, time.January, 15, 12, 0, 0, 0, time.UTC) + c := &Config{ + Default: "3d", + Packages: map[string]string{ + "pkg:npm/lodash": "0", + }, + } + + tests := []struct { + name string + packagePURL string + publishedAt time.Time + want Decision + }{ + { + name: "disabled", + packagePURL: "pkg:npm/lodash", + publishedAt: evaluatedAt.Add(-time.Minute), + want: Decision{ + Allowed: true, + AvailableAt: evaluatedAt.Add(-time.Minute), + Reason: ReasonDisabled, + }, + }, + { + name: "disabled with unknown publication time", + packagePURL: "pkg:npm/lodash", + want: Decision{ + Allowed: true, + Reason: ReasonUnknownPublicationTime, + }, + }, + { + name: "unknown publication time", + packagePURL: "pkg:npm/express", + want: Decision{ + Allowed: true, + Cooldown: 3 * 24 * time.Hour, + Reason: ReasonUnknownPublicationTime, + }, + }, + { + name: "elapsed", + packagePURL: "pkg:npm/express", + publishedAt: evaluatedAt.Add(-4 * 24 * time.Hour), + want: Decision{ + Allowed: true, + Cooldown: 3 * 24 * time.Hour, + AvailableAt: evaluatedAt.Add(-24 * time.Hour), + Reason: ReasonElapsed, + }, + }, + { + name: "waiting", + packagePURL: "pkg:npm/express", + publishedAt: evaluatedAt.Add(-24 * time.Hour), + want: Decision{ + Cooldown: 3 * 24 * time.Hour, + AvailableAt: evaluatedAt.Add(2 * 24 * time.Hour), + Reason: ReasonWaiting, + }, + }, + { + name: "exactly at boundary", + packagePURL: "pkg:npm/express", + publishedAt: evaluatedAt.Add(-3 * 24 * time.Hour), + want: Decision{ + Allowed: true, + Cooldown: 3 * 24 * time.Hour, + AvailableAt: evaluatedAt, + Reason: ReasonElapsed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.Evaluate("npm", tt.packagePURL, tt.publishedAt, evaluatedAt) + if got != tt.want { + t.Errorf("Evaluate() = %#v, want %#v", got, tt.want) + } + }) + } +} + func TestConfigEnabled(t *testing.T) { tests := []struct { name string