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.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

272 lines
8.5 KiB
Go

// Package retention holds the per-ecosystem rules for evicting cached
// artifacts that have not been accessed for a configured time.
//
// An ecosystem takes part only once its handler registers a Spec with
// Default, usually from an init function in the handler's own file. Until
// then configuring retention for it is a validation error, and the global
// default does not apply to it.
package retention
import (
"fmt"
"slices"
"sort"
"sync"
"time"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/purl"
)
// KnownKeys lists the ecosystem keys storage.retention.ecosystems accepts.
// They are the literals the handlers pass when they fetch an artifact. A key
// outside this list is a typo; a key in it whose handler has not registered
// yet is not supported by this version.
var KnownKeys = []string{
"alpine", "cargo", "composer", "conan", "conda", "cran", "deb", "freebsd", "gem",
"generic", "golang", "helm", "hex", "julia", "maven", "npm", "nuget", "oci", "pub",
"pypi", "rpm", "swift",
}
// IsKnown reports whether key is one of KnownKeys.
func IsKnown(key string) bool {
return slices.Contains(KnownKeys, key)
}
// Spec describes how one ecosystem takes part in retention.
type Spec struct {
// Key is the ecosystem's config key, one of KnownKeys.
Key string
// ExtraDBEcosystems lists packages.ecosystem values besides the ones
// derived from Key that belong to this ecosystem.
ExtraDBEcosystems []string
// CanonicalPackage turns a package PURL from the config into a
// policy key, reporting false when the
// PURL does not belong to this ecosystem. Nil means the ecosystem accepts
// no package overrides.
CanonicalPackage func(p *purl.PURL) (string, bool)
// SourcePackage reports a package PURL that names the package by where
// its source lives, such as a Swift package by its repository. It has to
// be resolved through the upstream before it can be compared, so the
// config keeps it in Rules.Sources until then. Nil means the ecosystem
// has no such form.
SourcePackage func(p *purl.PURL) bool
}
// dbEcosystems returns every packages.ecosystem value that belongs to the
// spec: the key, its PURL type and the ecosystem name that type maps back
// to, the same spellings git-pkgs matches. The proxy's mirror command and
// git-pkgs store "rubygems" where the gem handler stores "gem", for example.
func (s Spec) dbEcosystems() []string {
purlType := purl.EcosystemToPURLType(s.Key)
values := []string{s.Key, purlType, purl.PURLTypeToEcosystem(purlType)}
values = append(values, s.ExtraDBEcosystems...)
slices.Sort(values)
return slices.Compact(values)
}
// DefaultCanonical returns a CanonicalPackage for ecosystems that store the
// package PURL exactly as purl.MakePURLString builds it from the PURL's full
// name. It accepts a PURL only when building it back that way gives the same
// package, so for an ecosystem that stores a different form, such as one
// with a default namespace, it refuses the key instead of producing one
// that never matches. Such ecosystems need their own CanonicalPackage.
func DefaultCanonical(key string) func(p *purl.PURL) (string, bool) {
purlType := purl.EcosystemToPURLType(key)
return func(p *purl.PURL) (string, bool) {
if p == nil || p.Type != purlType {
return "", false
}
canonical := purl.MakePURLString(key, p.FullName(), "")
if canonical == "" {
return "", false
}
given := purl.New(p.Type, p.Namespace, p.Name, "", nil).String()
return canonical, canonical == given
}
}
// Registry maps ecosystem keys to their specs.
type Registry struct {
mu sync.RWMutex
specs map[string]Spec
byDB map[string]string
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{specs: map[string]Spec{}, byDB: map[string]string{}}
}
// Default is the registry the handlers register with.
var Default = NewRegistry()
// Register adds s. It panics when the key is not one of KnownKeys, is
// already registered, or claims a packages.ecosystem value another spec
// claims, since each of those is a programming error.
func (r *Registry) Register(s Spec) {
r.mu.Lock()
defer r.mu.Unlock()
if !IsKnown(s.Key) {
panic(fmt.Sprintf("retention: unknown ecosystem key %q", s.Key))
}
if _, ok := r.specs[s.Key]; ok {
panic(fmt.Sprintf("retention: ecosystem %q registered twice", s.Key))
}
values := s.dbEcosystems()
for _, v := range values {
if owner, ok := r.byDB[v]; ok {
panic(fmt.Sprintf("retention: ecosystem value %q claimed by both %q and %q", v, owner, s.Key))
}
}
r.specs[s.Key] = s
for _, v := range values {
r.byDB[v] = s.Key
}
}
// Lookup returns the spec registered for key.
func (r *Registry) Lookup(key string) (Spec, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.specs[key]
return s, ok
}
// Keys returns the registered keys in sorted order.
func (r *Registry) Keys() []string {
r.mu.RLock()
defer r.mu.RUnlock()
keys := make([]string, 0, len(r.specs))
for k := range r.specs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// KeyForDBEcosystem returns the registered key a packages.ecosystem value
// belongs to.
func (r *Registry) KeyForDBEcosystem(ecosystem string) (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
key, ok := r.byDB[ecosystem]
return key, ok
}
// Canonical returns the policy key for a configured package PURL.
func (r *Registry) Canonical(p *purl.PURL) (string, bool) {
for _, key := range r.Keys() {
s, _ := r.Lookup(key)
if s.CanonicalPackage == nil {
continue
}
if canonical, ok := s.CanonicalPackage(p); ok {
return canonical, true
}
}
return "", false
}
// Source returns the ecosystem key whose SourcePackage claims p.
func (r *Registry) Source(p *purl.PURL) (string, bool) {
for _, key := range r.Keys() {
s, _ := r.Lookup(key)
if s.SourcePackage != nil && s.SourcePackage(p) {
return key, true
}
}
return "", false
}
// Rules are resolved retention durations. A duration of zero means an
// artifact is never evicted by age.
type Rules struct {
// Default applies to registered ecosystems without their own rule,
// except generic, which requires an explicit rule.
Default time.Duration
// Ecosystems holds rules keyed by registered ecosystem key.
Ecosystems map[string]time.Duration
// Packages holds rules keyed by package PURL, with Go keys lowercased.
Packages map[string]time.Duration
// Sources holds package rules not resolved to a stored package PURL
// yet, by ecosystem key and then by the configured PURL (see
// Spec.SourcePackage). An ecosystem with any is not swept, since one of
// them may keep an artifact the sweep would otherwise clear.
Sources map[string]map[string]time.Duration
}
// Pending reports whether the ecosystem key has package rules still to be
// resolved, and must not be swept.
func (r Rules) Pending(key string) bool {
return len(r.Sources[key]) > 0
}
// Resolve moves the rule for source in ecosystem key to every stored package
// PURL it resolved to. A different rule already set for any of them is a
// conflict, and the source stays pending with none of them set.
func (r Rules) Resolve(key, source string, canonical []string) error {
d, ok := r.Sources[key][source]
if !ok {
return nil
}
if len(canonical) == 0 {
return fmt.Errorf("%s resolves to no stored package", source)
}
for _, c := range canonical {
if prev, set := r.Packages[c]; set && prev != d {
return fmt.Errorf("%s resolves to %s, which another rule sets differently", source, c)
}
}
for _, c := range canonical {
r.Packages[c] = d
}
delete(r.Sources[key], source)
return nil
}
// For returns the retention for an artifact of the given ecosystem key and
// stored package PURL: the package rule, else the ecosystem rule, else the
// default. Generic artifacts require an explicit package or ecosystem rule.
func (r Rules) For(key, packagePURL string) time.Duration {
packagePURL = packageurl.PolicyPackageKey(packagePURL)
if d, ok := r.Packages[packagePURL]; ok {
return d
}
if d, ok := r.Ecosystems[key]; ok {
return d
}
// Release assets may disappear upstream, so keep generic opt-in.
if key == "generic" {
return 0
}
return r.Default
}
// MinPositive returns the shortest non-zero duration among all rules, or
// zero when every rule is zero.
func (r Rules) MinPositive() time.Duration {
minimum := time.Duration(0)
consider := func(d time.Duration) {
if d > 0 && (minimum == 0 || d < minimum) {
minimum = d
}
}
consider(r.Default)
for _, d := range r.Ecosystems {
consider(d)
}
for _, d := range r.Packages {
consider(d)
}
for _, sources := range r.Sources {
for _, d := range sources {
consider(d)
}
}
return minimum
}