Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/config/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

169 lines
5.8 KiB
Go

package config
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
)
// parseDayDuration parses a duration that may use a "d" suffix for days, as
// cooldown values do. Day counts that are not finite or exceed
// maxDurationDays are rejected, since converting them to a time.Duration
// would overflow.
func parseDayDuration(s string) (time.Duration, error) {
if num, ok := strings.CutSuffix(strings.TrimSpace(s), "d"); ok {
days, err := strconv.ParseFloat(num, 64)
if err == nil && (math.IsNaN(days) || math.IsInf(days, 0) || math.Abs(days) > maxDurationDays) {
return 0, fmt.Errorf("invalid duration %q: day count out of range", s)
}
}
return cooldown.ParseDuration(s)
}
// parseRetentionDuration parses one retention value. field names the
// setting in error messages.
func parseRetentionDuration(field, value string) (time.Duration, error) {
d, err := parseDayDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", field, err)
}
if d < 0 {
return 0, fmt.Errorf("invalid %s %q: must not be negative", field, value)
}
return d, nil
}
// RetentionRules validates storage.retention against the ecosystems
// registered with reg and resolves it. An ecosystem or package that reg
// does not support is an error, so a rule is never silently ignored.
func (c *Config) RetentionRules(reg *retention.Registry) (retention.Rules, error) {
r := c.Storage.Retention
rules := retention.Rules{
Ecosystems: map[string]time.Duration{},
Packages: map[string]time.Duration{},
Sources: map[string]map[string]time.Duration{},
}
var err error
if rules.Default, err = parseRetentionDuration("storage.retention.default", r.Default); err != nil {
return retention.Rules{}, err
}
if r.SweepInterval != "" {
d, err := parseDayDuration(r.SweepInterval)
if err != nil {
return retention.Rules{}, fmt.Errorf("invalid storage.retention.sweep_interval: %w", err)
}
if d < minRetentionSweepInterval {
return retention.Rules{}, fmt.Errorf("invalid storage.retention.sweep_interval %q: must be at least %s",
r.SweepInterval, minRetentionSweepInterval)
}
}
for _, key := range sortedKeys(r.Ecosystems) {
field := "storage.retention.ecosystems." + key
if !retention.IsKnown(key) {
return retention.Rules{}, fmt.Errorf("invalid %s: unknown ecosystem %q (valid: %s)",
field, key, strings.Join(retention.KnownKeys, ", "))
}
if _, ok := reg.Lookup(key); !ok {
return retention.Rules{}, fmt.Errorf("invalid %s: retention is not supported for %q in this version", field, key)
}
d, err := parseRetentionDuration(field, r.Ecosystems[key])
if err != nil {
return retention.Rules{}, err
}
rules.Ecosystems[key] = d
}
for _, key := range sortedKeys(r.Packages) {
field := fmt.Sprintf("storage.retention.packages[%q]", key)
canonical, sourceOf, err := retentionPackageKey(reg, key)
if err != nil {
return retention.Rules{}, fmt.Errorf("invalid %s: %w", field, err)
}
d, err := parseRetentionDuration(field, r.Packages[key])
if err != nil {
return retention.Rules{}, err
}
if sourceOf != "" {
if rules.Sources[sourceOf] == nil {
rules.Sources[sourceOf] = map[string]time.Duration{}
}
rules.Sources[sourceOf][key] = d
continue
}
if prev, ok := rules.Packages[canonical]; ok && prev != d {
return retention.Rules{}, fmt.Errorf("invalid %s: conflicts with another key for package %s", field, canonical)
}
rules.Packages[canonical] = d
}
return rules, nil
}
// retentionPackageKey returns the stored package PURL a configured package
// key stands for, or, for a key that has to be resolved through the upstream
// first, the ecosystem key it belongs to.
func retentionPackageKey(reg *retention.Registry, key string) (canonical, sourceOf string, _ error) {
// A julia PURL needs a uuid qualifier to parse at all, and the julia
// handler stores none, so no key could ever match one of its packages.
if strings.HasPrefix(strings.ToLower(key), "pkg:julia/") {
return "", "", fmt.Errorf("package rules are not supported for julia")
}
p, err := purl.Parse(key)
if err != nil {
return "", "", fmt.Errorf("not a valid package PURL: %w", err)
}
if p.Version != "" {
return "", "", fmt.Errorf("package PURL must not carry a version")
}
// A rule names a whole package; qualifiers and subpaths would be
// dropped on the way to the stored PURL, so refuse them rather than
// let "?type=pom" quietly cover every file of the package.
if len(p.Qualifiers) > 0 || p.Subpath != "" {
return "", "", fmt.Errorf("package PURL must not carry qualifiers or a subpath")
}
if canonical, ok := reg.Canonical(p); ok {
return canonical, "", nil
}
if ecosystem, ok := reg.Source(p); ok {
return "", ecosystem, nil
}
ecosystem, registered := reg.KeyForDBEcosystem(purl.PURLTypeToEcosystem(p.Type))
if !registered {
return "", "", fmt.Errorf("retention is not supported for %s packages in this version", p.Type)
}
if spec, _ := reg.Lookup(ecosystem); spec.CanonicalPackage == nil {
return "", "", fmt.Errorf("package rules are not supported for %s; use storage.retention.ecosystems.%s", ecosystem, ecosystem)
}
return "", "", fmt.Errorf("does not match how %s packages are stored; see the package rule forms in docs/configuration.md", ecosystem)
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ParseRetentionSweepInterval returns how often the retention sweep runs.
func (c *Config) ParseRetentionSweepInterval() time.Duration {
if c.Storage.Retention.SweepInterval == "" {
return defaultRetentionSweepInterval
}
d, err := parseDayDuration(c.Storage.Retention.SweepInterval)
if err != nil || d < minRetentionSweepInterval {
return defaultRetentionSweepInterval
}
return d
}