Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/retention/retention_test.go
simon chrzanowski 1979fb098e
Accept Swift source repository PURLs in the denylist and retention rules (#641)
* Accept Swift source repository PURLs in the denylist and retention rules

Closes #613. pkg:swift/github.com/apple/swift-nio, as SBOM tools and GHSA
write Swift packages, is resolved to its registry identity through the
upstream's /identifiers:

- Denylist entries are resolved at startup, and the proxy refuses to start
  when one can't be, since a skipped entry would let the version through.
- Retention keys are accepted at validation and kept pending. The
  retention loop resolves them before each sweep, and Swift isn't swept
  while any is pending, so a rule that keeps a package is never skipped.

The mirror's source URL helper moves to the handler as SwiftSourceURL.

* Resolve Swift source denylist entries in proxy mirror too

serve resolved source-form Swift denylist entries through the upstream,
but proxy mirror built its denylist directly, so such an entry never
matched a mirror download. Both now use server.ConfigureDenylist, and
neither runs when an entry can't be resolved.

* Apply Swift source policies to every listed identity

A Swift registry may list several identities for one source repository,
but a denylist entry or retention rule naming the repository only
covered the first. Denying pkg:swift/github.com/acme/widget@1.0.0 let
acme.widget through when the registry listed other.widget first, and a
never-evict rule cleared the cached apple.swift-nio archive when
other.swift-nio came first.

Both now cover every identity the registry lists. A retention rule that
conflicts with another rule for any of them stays pending, with none
applied. Mirroring a source PURL still downloads the first identity.
2026-10-31 11:34:05 +00:00

244 lines
8.1 KiB
Go

package retention
import (
"slices"
"testing"
"time"
"github.com/git-pkgs/purl"
)
func TestSpecDBEcosystems(t *testing.T) {
tests := []struct {
key string
want []string
}{
{"npm", []string{"npm"}},
{"gem", []string{"gem", "rubygems"}},
{"composer", []string{"composer", "packagist"}},
{"alpine", []string{"alpine", "apk"}},
{"golang", []string{"golang"}},
{"deb", []string{"deb"}},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got := Spec{Key: tt.key}.dbEcosystems()
if !slices.Equal(got, tt.want) {
t.Errorf("dbEcosystems() = %v, want %v", got, tt.want)
}
})
}
}
func TestRegistryRegister(t *testing.T) {
reg := NewRegistry()
reg.Register(Spec{Key: "gem"})
reg.Register(Spec{Key: "npm"})
if got := reg.Keys(); !slices.Equal(got, []string{"gem", "npm"}) {
t.Errorf("Keys() = %v, want [gem npm]", got)
}
for _, value := range []string{"gem", "rubygems"} {
if key, ok := reg.KeyForDBEcosystem(value); !ok || key != "gem" {
t.Errorf("KeyForDBEcosystem(%q) = %q, %v, want gem, true", value, key, ok)
}
}
if _, ok := reg.KeyForDBEcosystem("pypi"); ok {
t.Error("KeyForDBEcosystem(pypi) found a key for an unregistered ecosystem")
}
if _, ok := reg.Lookup("pypi"); ok {
t.Error("Lookup(pypi) found an unregistered ecosystem")
}
}
func TestRegistryRegisterPanics(t *testing.T) {
tests := []struct {
name string
specs []Spec
}{
{"unknown key", []Spec{{Key: "not-an-ecosystem"}}},
{"duplicate key", []Spec{{Key: "npm"}, {Key: "npm"}}},
{"overlapping ecosystem value", []Spec{{Key: "gem"}, {Key: "npm", ExtraDBEcosystems: []string{"rubygems"}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := NewRegistry()
defer func() {
if recover() == nil {
t.Error("Register did not panic")
}
}()
for _, s := range tt.specs {
reg.Register(s)
}
})
}
}
func mustParse(t *testing.T, s string) *purl.PURL {
t.Helper()
p, err := purl.Parse(s)
if err != nil {
t.Fatalf("parsing %q: %v", s, err)
}
return p
}
func TestDefaultCanonical(t *testing.T) {
canonical := DefaultCanonical("npm")
got, ok := canonical(mustParse(t, "pkg:npm/%40babel/core"))
if !ok || got != "pkg:npm/%40babel/core" {
t.Errorf("scoped package = %q, %v, want pkg:npm/%%40babel/core, true", got, ok)
}
if got, ok := canonical(mustParse(t, "pkg:pypi/requests")); ok {
t.Errorf("other type accepted as %q", got)
}
if _, ok := canonical(nil); ok {
t.Error("nil PURL accepted")
}
// alpine stores pkg:apk/alpine/{name} and deb stores pkg:deb/{name}, so
// building their keys back from the full name gives a different package.
// The default refuses them rather than produce keys that never match.
for _, tt := range []struct{ key, purl string }{
{"alpine", "pkg:apk/alpine/curl"},
{"alpine", "pkg:apk/curl"},
{"deb", "pkg:deb/debian/curl"},
} {
if got, ok := DefaultCanonical(tt.key)(mustParse(t, tt.purl)); ok {
t.Errorf("DefaultCanonical(%q) accepted %s as %q", tt.key, tt.purl, got)
}
}
}
func TestRegistryCanonical(t *testing.T) {
reg := NewRegistry()
reg.Register(Spec{Key: "julia"})
reg.Register(Spec{Key: "npm", CanonicalPackage: DefaultCanonical("npm")})
if got, ok := reg.Canonical(mustParse(t, "pkg:npm/lodash")); !ok || got != "pkg:npm/lodash" {
t.Errorf("Canonical(npm) = %q, %v, want pkg:npm/lodash, true", got, ok)
}
if got, ok := reg.Canonical(mustParse(t, "pkg:cargo/serde")); ok {
t.Errorf("Canonical(cargo) = %q for an unregistered ecosystem", got)
}
}
func TestRulesFor(t *testing.T) {
rules := Rules{
Default: 30 * 24 * time.Hour,
Ecosystems: map[string]time.Duration{"npm": 14 * 24 * time.Hour, "maven": 0},
Packages: map[string]time.Duration{"pkg:npm/lodash": 0, "pkg:maven/a/b": 90 * 24 * time.Hour},
}
tests := []struct {
key, pkg string
want time.Duration
}{
{"npm", "pkg:npm/lodash", 0},
{"npm", "pkg:npm/left-pad", 14 * 24 * time.Hour},
{"maven", "pkg:maven/a/b", 90 * 24 * time.Hour},
{"maven", "pkg:maven/c/d", 0},
{"cargo", "pkg:cargo/serde", 30 * 24 * time.Hour},
}
for _, tt := range tests {
if got := rules.For(tt.key, tt.pkg); got != tt.want {
t.Errorf("For(%q, %q) = %v, want %v", tt.key, tt.pkg, got, tt.want)
}
}
}
func TestGenericRulesRequireExplicitConfiguration(t *testing.T) {
day := 24 * time.Hour
const pkg = "pkg:generic/jqlang%2Fjq"
tests := []struct {
name string
ecosystems map[string]time.Duration
packages map[string]time.Duration
want time.Duration
}{
{"global default ignored", nil, nil, 0},
{"ecosystem rule", map[string]time.Duration{"generic": 7 * day}, nil, 7 * day},
{"ecosystem disabled", map[string]time.Duration{"generic": 0}, nil, 0},
{"package only", nil, map[string]time.Duration{pkg: 14 * day}, 14 * day},
{"other package ignored", nil, map[string]time.Duration{"pkg:generic/other%2Frepo": day}, 0},
{"package overrides ecosystem", map[string]time.Duration{"generic": 7 * day}, map[string]time.Duration{pkg: 14 * day}, 14 * day},
{"package disables eviction", map[string]time.Duration{"generic": 7 * day}, map[string]time.Duration{pkg: 0}, 0},
{"package enables eviction", map[string]time.Duration{"generic": 0}, map[string]time.Duration{pkg: day}, day},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rules := Rules{Default: 30 * day, Ecosystems: tt.ecosystems, Packages: tt.packages}
if got := rules.For("generic", pkg); got != tt.want {
t.Errorf("generic retention = %v, want %v", got, tt.want)
}
if got := rules.For("npm", "pkg:npm/lodash"); got != 30*day {
t.Errorf("npm default retention = %v, want 30d", got)
}
})
}
}
func TestRulesMinPositive(t *testing.T) {
if got := (Rules{}).MinPositive(); got != 0 {
t.Errorf("empty rules MinPositive() = %v, want 0", got)
}
rules := Rules{
Default: 0,
Ecosystems: map[string]time.Duration{"npm": 14 * time.Hour, "maven": 0},
Packages: map[string]time.Duration{"pkg:npm/lodash": 2 * time.Hour},
}
if got := rules.MinPositive(); got != 2*time.Hour {
t.Errorf("MinPositive() = %v, want 2h", got)
}
}
func TestRulesResolveSources(t *testing.T) {
rules := Rules{
Packages: map[string]time.Duration{"pkg:x/kept": 0},
Sources: map[string]map[string]time.Duration{"swift": {
"pkg:swift/github.com/a/one": 0,
"pkg:swift/github.com/a/two": time.Hour,
}},
}
if got := rules.MinPositive(); got != time.Hour {
t.Errorf("MinPositive = %v, want the pending rule's hour", got)
}
if err := rules.Resolve("swift", "pkg:swift/github.com/a/two", []string{"pkg:x/kept"}); err == nil {
t.Error("a rule resolving to a package another rule sets differently was accepted")
}
if !rules.Pending("swift") {
t.Fatal("conflicting rule no longer pending")
}
if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/kept"}); err != nil {
t.Errorf("same duration for the same package: %v", err)
}
if err := rules.Resolve("swift", "pkg:swift/github.com/a/two", []string{"pkg:x/two"}); err != nil {
t.Fatal(err)
}
if rules.Pending("swift") || rules.Packages["pkg:x/two"] != time.Hour {
t.Errorf("after resolving: sources %v, packages %v", rules.Sources, rules.Packages)
}
}
// A source that resolves to several stored packages sets the rule for all
// of them, or, on a conflict with any, for none.
func TestRulesResolveSeveralPackages(t *testing.T) {
rules := Rules{
Packages: map[string]time.Duration{"pkg:x/b": time.Hour},
Sources: map[string]map[string]time.Duration{"swift": {"pkg:swift/github.com/a/one": 0}},
}
if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/a", "pkg:x/b"}); err == nil {
t.Error("a rule conflicting for one of its packages was accepted")
}
if _, set := rules.Packages["pkg:x/a"]; set || !rules.Pending("swift") {
t.Fatalf("conflicting rule partly applied: sources %v, packages %v", rules.Sources, rules.Packages)
}
delete(rules.Packages, "pkg:x/b")
if err := rules.Resolve("swift", "pkg:swift/github.com/a/one", []string{"pkg:x/a", "pkg:x/b"}); err != nil {
t.Fatal(err)
}
if rules.Pending("swift") || len(rules.Packages) != 2 {
t.Errorf("after resolving: sources %v, packages %v", rules.Sources, rules.Packages)
}
}