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
3 changes: 2 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ cooldown:
# npm: "7d"
# cargo: "0"

# Per-package overrides (keyed by PURL)
# Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes
# may use either @scope or the canonical %40scope form.
# packages:
# "pkg:npm/lodash": "0"
# "pkg:npm/@babel/core": "14d"
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ cooldown:

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.

Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata.
Expand Down
31 changes: 31 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

"github.com/git-pkgs/purl"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -137,9 +139,38 @@ type CooldownConfig struct {
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`

// 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"`
}

// NormalizedPackages returns a copy of the package overrides with valid PURL
// keys in canonical form. An explicitly canonical key wins over an equivalent
// noncanonical key, and invalid keys are preserved unchanged.
func (c *CooldownConfig) NormalizedPackages() map[string]string {
if c == nil || c.Packages == nil {
return nil
}

keys := make([]string, 0, len(c.Packages))
for key := range c.Packages {
keys = append(keys, key)
}
sort.Strings(keys)

normalized := make(map[string]string, len(c.Packages))
for _, key := range keys {
canonical := key
if parsed, err := purl.Parse(key); err == nil {
canonical = parsed.String()
}
if _, exists := normalized[canonical]; exists && key != canonical {
continue
}
normalized[canonical] = c.Packages[key]
}
return normalized
}

// StorageConfig configures artifact storage.
type StorageConfig struct {
// URL is the storage backend URL.
Expand Down
28 changes: 28 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,34 @@ cooldown:
if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" {
t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d")
}
if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" {
t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d")
}
}

func TestCooldownConfigNormalizedPackages(t *testing.T) {
rawScoped := "pkg:npm/@typescript/typescript-darwin-arm64"
canonicalScoped := "pkg:npm/%40typescript/typescript-darwin-arm64"
cfg := CooldownConfig{Packages: map[string]string{
rawScoped: "2d",
canonicalScoped: "3d",
"not-a-purl": "4d",
}}

got := cfg.NormalizedPackages()

if got[canonicalScoped] != "3d" {
t.Errorf("canonical scoped package duration = %q, want %q", got[canonicalScoped], "3d")
}
if _, exists := got[rawScoped]; exists {
t.Errorf("raw scoped package key %q was not canonicalized", rawScoped)
}
if got["not-a-purl"] != "4d" {
t.Errorf("invalid PURL duration = %q, want preserved value %q", got["not-a-purl"], "4d")
}
if cfg.Packages[rawScoped] != "2d" {
t.Error("NormalizedPackages mutated the source map")
}
}

func TestLoadCooldownFromEnv(t *testing.T) {
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/cargo.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -143,7 +141,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr
continue
}

cratePURL := purl.MakePURLString("cargo", crate.Name, "")
cratePURL := canonicalPackagePURL("cargo", crate.Name)

if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering cargo version",
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (
"path"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -216,7 +214,7 @@ func deepCopyValue(v any) any {
// filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs
// for a single package's version list.
func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any {
packagePURL := purl.MakePURLString("composer", packageName, "")
packagePURL := canonicalPackagePURL("composer", packageName)

filtered := versionList[:0]
for _, v := range versionList {
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/conda.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"net/http"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -218,7 +216,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
continue
}

packagePURL := purl.MakePURLString("conda", name, "")
packagePURL := canonicalPackagePURL("conda", name)

if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
version, _ := entryMap["version"].(string)
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/gem.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -266,7 +264,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err
}

packagePURL := purl.MakePURLString("gem", name, "")
packagePURL := canonicalPackagePURL("gem", name)
filtered := make(map[string]bool)

for _, v := range versions {
Expand Down
8 changes: 8 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ func hasDotDotSegment(path string) bool {

const defaultHTTPTimeout = 30 * time.Second

// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
func canonicalPackagePURL(ecosystem, name string) string {
p := purl.MakePURL(ecosystem, name, "")
_ = p.Normalize()
return p.String()
}

const contentTypeJSON = "application/json"

const headerAcceptEncoding = "Accept-Encoding"
Expand Down
31 changes: 31 additions & 0 deletions internal/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"testing"
"time"

"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
Expand Down Expand Up @@ -1010,3 +1011,33 @@ func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
t.Errorf("Warning should be empty for fresh response, got %q", got)
}
}

// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key
// agrees with config.CooldownConfig.NormalizedPackages for the same package,
// so a configured override is actually found regardless of how the user wrote it.
func TestCanonicalPackagePURLMatchesConfig(t *testing.T) {
tests := []struct {
ecosystem string
requestName string
configKey string
}{
{"npm", "@babel/core", "pkg:npm/@babel/core"},
{"npm", "@babel/core", "pkg:npm/%40babel/core"},
{"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"},
{"pypi", "Django", "pkg:pypi/Django"},
{"pypi", "django", "pkg:pypi/Django"},
{"composer", "symfony/console", "pkg:composer/Symfony/Console"},
{"cargo", "serde", "pkg:cargo/serde"},
}
for _, tt := range tests {
t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) {
cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}}
normalized := cfg.NormalizedPackages()

lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName)
if _, ok := normalized[lookup]; !ok {
t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized)
}
})
}
}
3 changes: 1 addition & 2 deletions internal/handler/hex.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strings"
"time"

"github.com/git-pkgs/purl"
"google.golang.org/protobuf/encoding/protowire"
)

Expand Down Expand Up @@ -237,7 +236,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err
}

packagePURL := purl.MakePURLString("hex", name, "")
packagePURL := canonicalPackagePURL("hex", name)
filtered := make(map[string]bool)

for _, release := range pkg.Releases {
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
"sort"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -134,7 +132,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma
return
}

packagePURL := purl.MakePURLString("npm", packageName, "")
packagePURL := canonicalPackagePURL("npm", packageName)

for version := range versions {
publishedStr, ok := timeMap[version].(string)
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/nuget.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -271,7 +269,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
}
}

packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id))

if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering nuget version",
Expand Down
4 changes: 1 addition & 3 deletions internal/handler/pub.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"net/http"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -127,7 +125,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
return body, nil
}

packagePURL := purl.MakePURLString("pub", name, "")
packagePURL := canonicalPackagePURL("pub", name)
filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
metadata["versions"] = filtered

Expand Down
6 changes: 2 additions & 4 deletions internal/handler/pypi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import (
"regexp"
"strings"
"time"

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

const (
Expand Down Expand Up @@ -131,7 +129,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st
return nil
}

packagePURL := purl.MakePURLString("pypi", name, "")
packagePURL := canonicalPackagePURL("pypi", name)
filtered := make(map[string]bool)

for version, files := range releases {
Expand Down Expand Up @@ -262,7 +260,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
packageName, _ := extractPyPIName(metadata)
packagePURL := ""
if packageName != "" {
packagePURL = purl.MakePURLString("pypi", packageName, "")
packagePURL = canonicalPackagePURL("pypi", packageName)
}

h.filterAndRewriteReleases(metadata, packageName, packagePURL)
Expand Down
2 changes: 1 addition & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (s *Server) Start() error {
cd := &cooldown.Config{
Default: s.cfg.Cooldown.Default,
Ecosystems: s.cfg.Cooldown.Ecosystems,
Packages: s.cfg.Cooldown.Packages,
Packages: s.cfg.Cooldown.NormalizedPackages(),
}
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout()
Expand Down