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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 55 additions & 5 deletions cooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions cooldown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down