Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-08-23 04:14:57 -04:00

Compare commits

...
Author SHA1 Message Date
Andrew Nesbitt
0a64d9ad9a
Address review: upstream-hash refetch and cache PURL cleanup
- Re-fetch instead of 502 when a cached artifact's hash disagrees with
  the upstream-declared checksum; log and discard the stale entry.
- Rename expectedHash to upstreamHash and document how the check
  differs from checkCache's stream integrity verification.
- Drop the repository_url qualifier from swift cache PURLs so cache
  entries survive an upstream.swift change, matching other ecosystems.
- Pass name to handleSourceArchiveHead instead of re-deriving it.
2026-08-20 09:18:05 +01:00
Andrew Nesbitt
7f04f4faac
Use stored PURLs for cache lookups 2026-08-20 09:03:14 +01:00
Andrew Nesbitt
535617197d
Handle Swift registry cache identities 2026-08-20 09:01:33 +01:00
Andrew Nesbitt
92cae1e1e7
Canonicalize Swift package identifiers 2026-08-20 09:00:20 +01:00
Andrew Nesbitt
53f2fee3c3
Fix Swift registry pagination and archive HEAD requests 2026-08-20 09:00:20 +01:00
Andrew Nesbitt
0af8d6b654
Fix Swift archive integrity handling 2026-08-20 09:00:20 +01:00
Andrew Nesbitt
38264bdd65
Add Swift package registry support 2026-08-20 09:00:19 +01:00
20 changed files with 1705 additions and 35 deletions

View file

@ -39,6 +39,7 @@ Resolution order: package override, then ecosystem override, then global default
| Conda | Python/R | Yes | ✓ |
| CRAN | R | | ✓ |
| Julia | Julia | | ✓ |
| Swift | Swift | | ✓ |
| Container | Docker/OCI | | ✓ |
| Debian | Debian/Ubuntu | | ✓ |
| RPM | RHEL/Fedora | | ✓ |
@ -47,7 +48,6 @@ Resolution order: package override, then ecosystem override, then global default
| Chef | Chef | | ✗ |
| Generic | Any | | ✗ |
| Helm | Kubernetes | | ✗ |
| Swift | Swift | | ✗ |
| Vagrant | Vagrant | | ✗ |
Cooldown requires publish timestamps in metadata. Registries without a "Yes" in the cooldown column either don't expose timestamps or haven't been wired up yet.
@ -340,6 +340,25 @@ ENV["JULIA_PKG_SERVER"] = "http://localhost:8080/julia"
using Pkg; Pkg.update()
```
### Swift
Configure the proxy as the default registry for the current Swift package:
```bash
swift package-registry set --allow-insecure-http http://localhost:8080/swift
```
Registry dependencies use their scoped package identifier in `Package.swift`:
```swift
dependencies: [
.package(id: "apple.swift-argument-parser", from: "1.2.0")
]
```
The proxy supports dependency resolution and source downloads. Publishing with
`swift package-registry publish` is not supported.
### Docker / Container Registry
Configure Docker to use the proxy as a registry mirror in `/etc/docker/daemon.json`:
@ -473,6 +492,7 @@ PROXY_DATABASE_URL=postgres://user:pass@localhost/proxy?sslmode=disable
PROXY_LOG_LEVEL=info
PROXY_LOG_FORMAT=text
PROXY_ACCESS_LOG_PATH=/var/log/proxy/access.jsonl
PROXY_UPSTREAM_SWIFT=https://tuist.dev/api/registry/swift
```
### Configuration File
@ -500,6 +520,7 @@ access_log:
upstream:
npm: "https://registry.npmjs.org"
cargo: "https://index.crates.io"
swift: "https://tuist.dev/api/registry/swift"
# Optional: version cooldown (see above)
cooldown:
@ -669,6 +690,7 @@ Recently cached:
| `GET /conda/*` | Conda/Anaconda protocol |
| `GET /cran/*` | CRAN (R) protocol |
| `GET /julia/*` | Julia Pkg server protocol |
| `GET /swift/*` | Swift Package Registry v1 protocol |
| `GET /helm/{repository}/*` | HTTP Helm chart repository protocol |
| `GET /v2/*` | OCI/Docker registry protocol |
| `GET /debian/*` | Debian/APT repository protocol |

View file

@ -208,6 +208,7 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_ACCESS_LOG_PATH JSONL access log path\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_SWIFT Swift Package Registry upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n")

View file

@ -99,6 +99,9 @@ upstream:
# Cargo crate download URL
cargo_download: "https://static.crates.io/crates"
# Swift Package Registry URL (used by /swift endpoint)
swift: "https://tuist.dev/api/registry/swift"
# Debian/APT repository URL (used by /debian endpoint)
debian: "http://deb.debian.org/debian"

View file

@ -269,6 +269,10 @@ HTTP protocol handlers for each registry type.
- `handleIndex()` - Proxy sparse index
- `handleDownload()` - Serve cached crate
**SwiftHandler:**
- Proxies the Swift Package Registry v1 read endpoints
- Rewrites release URLs and caches source archives
### `internal/server`
HTTP server setup, web UI, and API handlers.

View file

@ -143,6 +143,7 @@ upstream:
gradle_plugin_portal: "https://plugins.gradle.org/m2"
cargo: "https://index.crates.io"
cargo_download: "https://static.crates.io/crates"
swift: "https://tuist.dev/api/registry/swift"
# Named HTTP Helm chart repositories, served at /helm/{name}/.
helm:

View file

@ -63,6 +63,9 @@ import (
"gopkg.in/yaml.v3"
)
// DefaultSwiftUpstream is the Swift Package Registry used when none is configured.
const DefaultSwiftUpstream = "https://tuist.dev/api/registry/swift"
// Config holds all configuration for the proxy server.
type Config struct {
// Listen is the address to listen on (e.g., ":8080", "127.0.0.1:8080").
@ -313,6 +316,10 @@ type UpstreamConfig struct {
// Default: https://static.crates.io/crates
CargoDownload string `json:"cargo_download" yaml:"cargo_download"`
// Swift is the upstream Swift Package Registry URL.
// Default: https://tuist.dev/api/registry/swift
Swift string `json:"swift" yaml:"swift"`
// Debian is the upstream APT repository base URL.
// Example: http://archive.ubuntu.com/ubuntu would get Ubuntu.
// Default: http://deb.debian.org/debian
@ -475,6 +482,7 @@ func Default() *Config {
GradlePluginPortal: "https://plugins.gradle.org/m2",
Cargo: "https://index.crates.io",
CargoDownload: "https://static.crates.io/crates",
Swift: DefaultSwiftUpstream,
Debian: "http://deb.debian.org/debian",
},
Gradle: GradleConfig{
@ -546,6 +554,7 @@ func setEnvBool(dst *bool, key string) {
// - PROXY_LOG_LEVEL
// - PROXY_LOG_FORMAT
// - PROXY_ACCESS_LOG_PATH
// - PROXY_UPSTREAM_SWIFT
// - PROXY_HEALTH_STORAGE_PROBE_INTERVAL
func (c *Config) LoadFromEnv() {
setEnvString(&c.Listen, "PROXY_LISTEN")
@ -565,6 +574,7 @@ func (c *Config) LoadFromEnv() {
setEnvString(&c.AccessLog.Path, "PROXY_ACCESS_LOG_PATH")
setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN")
setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL")
setEnvString(&c.Upstream.Swift, "PROXY_UPSTREAM_SWIFT")
setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN")
setEnvString(&c.Cooldown.Default, "PROXY_COOLDOWN_DEFAULT")
setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA")

View file

@ -41,6 +41,9 @@ func TestDefault(t *testing.T) {
if cfg.Upstream.GradlePluginPortal != "https://plugins.gradle.org/m2" {
t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.gradle.org/m2")
}
if cfg.Upstream.Swift != "https://tuist.dev/api/registry/swift" {
t.Errorf("Upstream.Swift = %q, want %q", cfg.Upstream.Swift, "https://tuist.dev/api/registry/swift")
}
if cfg.Upstream.Debian != "http://deb.debian.org/debian" {
t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://deb.debian.org/debian")
}
@ -286,6 +289,7 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("PROXY_ACCESS_LOG_PATH", "/tmp/proxy-access.jsonl")
t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public")
t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2")
t.Setenv("PROXY_UPSTREAM_SWIFT", "https://swift.example.com/registry")
t.Setenv("PROXY_UPSTREAM_DEBIAN", "http://archive.ubuntu.com/ubuntu")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
@ -319,6 +323,9 @@ func TestLoadFromEnv(t *testing.T) {
if cfg.Upstream.GradlePluginPortal != "https://plugins.example.com/m2" {
t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.example.com/m2")
}
if cfg.Upstream.Swift != "https://swift.example.com/registry" {
t.Errorf("Upstream.Swift = %q, want %q", cfg.Upstream.Swift, "https://swift.example.com/registry")
}
if cfg.Upstream.Debian != "http://archive.ubuntu.com/ubuntu" {
t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://archive.ubuntu.com/ubuntu")
}

View file

@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries"
_ "github.com/git-pkgs/registries/all" // Import all registry implementations
@ -67,7 +68,10 @@ type VulnInfo struct {
// EnrichPackage fetches metadata for a package from registry APIs.
func (s *Service) EnrichPackage(ctx context.Context, ecosystem, name string) (*PackageInfo, error) {
purlStr := purl.MakePURLString(ecosystem, name, "")
purlStr := packageurl.MakeString(ecosystem, name, "")
if purlStr == "" {
return nil, nil
}
pkg, err := registries.FetchPackageFromPURL(ctx, purlStr, s.regClient)
if err != nil {
@ -102,7 +106,10 @@ func (s *Service) EnrichPackage(ctx context.Context, ecosystem, name string) (*P
// EnrichVersion fetches metadata for a specific package version.
func (s *Service) EnrichVersion(ctx context.Context, ecosystem, name, version string) (*VersionInfo, error) {
purlStr := purl.MakePURLString(ecosystem, name, version)
purlStr := packageurl.MakeString(ecosystem, name, version)
if purlStr == "" {
return nil, nil
}
ver, err := registries.FetchVersionFromPURL(ctx, purlStr, s.regClient)
if err != nil {
@ -134,9 +141,14 @@ func (s *Service) EnrichVersion(ctx context.Context, ecosystem, name, version st
// BulkEnrichPackages fetches metadata for multiple packages in parallel.
func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Ecosystem, Name string }) map[string]*PackageInfo {
purls := make([]string, len(packages))
for i, pkg := range packages {
purls[i] = purl.MakePURLString(pkg.Ecosystem, pkg.Name, "")
purls := make([]string, 0, len(packages))
for _, pkg := range packages {
if purlStr := packageurl.MakeString(pkg.Ecosystem, pkg.Name, ""); purlStr != "" {
purls = append(purls, purlStr)
}
}
if len(purls) == 0 {
return map[string]*PackageInfo{}
}
pkgData := registries.BulkFetchPackages(ctx, purls, s.regClient)
@ -147,7 +159,10 @@ func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Eco
continue
}
p, _ := purl.Parse(purlStr)
p, err := purl.Parse(purlStr)
if err != nil {
continue
}
info := &PackageInfo{
Ecosystem: p.Type,
Name: pkg.Name,
@ -174,7 +189,10 @@ func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Eco
// CheckVulnerabilities queries for vulnerabilities affecting a package version.
func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, version string) ([]VulnInfo, error) {
p := purl.MakePURL(ecosystem, name, version)
p := packageurl.Make(ecosystem, name, version)
if p == nil {
return nil, nil
}
vulnList, err := s.vulnSource.Query(ctx, p)
if err != nil {
@ -203,9 +221,17 @@ func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, ver
// BulkCheckVulnerabilities queries vulnerabilities for multiple package versions.
func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struct{ Ecosystem, Name, Version string }) (map[string][]VulnInfo, error) {
purls := make([]*purl.PURL, len(packages))
purls := make([]*purl.PURL, 0, len(packages))
supported := make([]int, 0, len(packages))
for i, pkg := range packages {
purls[i] = purl.MakePURL(pkg.Ecosystem, pkg.Name, pkg.Version)
if packagePURL := packageurl.Make(pkg.Ecosystem, pkg.Name, pkg.Version); packagePURL != nil {
purls = append(purls, packagePURL)
supported = append(supported, i)
}
}
result := make(map[string][]VulnInfo, len(purls))
if len(purls) == 0 {
return result, nil
}
vulnResults, err := s.vulnSource.QueryBatch(ctx, purls)
@ -213,10 +239,9 @@ func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struc
return nil, err
}
result := make(map[string][]VulnInfo, len(packages))
for i, vulnList := range vulnResults {
pkg := packages[i]
key := purl.MakePURLString(pkg.Ecosystem, pkg.Name, pkg.Version)
pkg := packages[supported[i]]
key := purls[i].String()
var infos []VulnInfo
for _, v := range vulnList {
@ -248,7 +273,10 @@ func (s *Service) IsOutdated(currentVersion, latestVersion string) bool {
// GetLatestVersion fetches the latest version for a package.
func (s *Service) GetLatestVersion(ctx context.Context, ecosystem, name string) (string, error) {
purlStr := purl.MakePURLString(ecosystem, name, "")
purlStr := packageurl.MakeString(ecosystem, name, "")
if purlStr == "" {
return "", nil
}
latest, err := registries.FetchLatestVersionFromPURL(ctx, purlStr, s.regClient)
if err != nil {

View file

@ -1,11 +1,40 @@
package enrichment
import (
"context"
"log/slog"
"os"
"testing"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/vulns"
)
type recordingVulnerabilitySource struct {
purls []*purl.PURL
}
func (s *recordingVulnerabilitySource) Name() string {
return "recording"
}
func (s *recordingVulnerabilitySource) Query(context.Context, *purl.PURL) ([]vulns.Vulnerability, error) {
return nil, nil
}
func (s *recordingVulnerabilitySource) QueryBatch(_ context.Context, purls []*purl.PURL) ([][]vulns.Vulnerability, error) {
s.purls = purls
results := make([][]vulns.Vulnerability, len(purls))
for i := range results {
results[i] = []vulns.Vulnerability{{ID: "TEST-1"}}
}
return results, nil
}
func (s *recordingVulnerabilitySource) Get(context.Context, string) (*vulns.Vulnerability, error) {
return nil, nil
}
func TestNew(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := New(logger)
@ -23,6 +52,68 @@ func TestNew(t *testing.T) {
}
}
func TestSwiftRegistryIdentitySkipsPURLDependentLookups(t *testing.T) {
svc := New(slog.New(slog.NewTextHandler(os.Stdout, nil)))
ctx := context.Background()
packageInfo, err := svc.EnrichPackage(ctx, "swift", "apple/example")
if err != nil || packageInfo != nil {
t.Errorf("EnrichPackage() = %#v, %v; want nil, nil", packageInfo, err)
}
versionInfo, err := svc.EnrichVersion(ctx, "swift", "apple/example", "1.2.3")
if err != nil || versionInfo != nil {
t.Errorf("EnrichVersion() = %#v, %v; want nil, nil", versionInfo, err)
}
vulnerabilities, err := svc.CheckVulnerabilities(ctx, "swift", "apple/example", "1.2.3")
if err != nil || vulnerabilities != nil {
t.Errorf("CheckVulnerabilities() = %#v, %v; want nil, nil", vulnerabilities, err)
}
latest, err := svc.GetLatestVersion(ctx, "swift", "apple/example")
if err != nil || latest != "" {
t.Errorf("GetLatestVersion() = %q, %v; want empty string, nil", latest, err)
}
packages := []struct{ Ecosystem, Name string }{{Ecosystem: "swift", Name: "apple/example"}}
if got := svc.BulkEnrichPackages(ctx, packages); len(got) != 0 {
t.Errorf("BulkEnrichPackages() = %#v, want empty result", got)
}
versions := []struct{ Ecosystem, Name, Version string }{
{Ecosystem: "swift", Name: "apple/example", Version: "1.2.3"},
}
got, err := svc.BulkCheckVulnerabilities(ctx, versions)
if err != nil || len(got) != 0 {
t.Errorf("BulkCheckVulnerabilities() = %#v, %v; want empty result, nil", got, err)
}
}
func TestBulkCheckVulnerabilitiesFiltersUnsupportedPackageIdentities(t *testing.T) {
source := &recordingVulnerabilitySource{}
svc := New(slog.New(slog.NewTextHandler(os.Stdout, nil)))
svc.vulnSource = source
packages := []struct{ Ecosystem, Name, Version string }{
{Ecosystem: "swift", Name: "apple/example", Version: "1.2.3"},
{Ecosystem: "npm", Name: "lodash", Version: "4.17.21"},
}
got, err := svc.BulkCheckVulnerabilities(context.Background(), packages)
if err != nil {
t.Fatalf("BulkCheckVulnerabilities() error = %v", err)
}
if len(source.purls) != 1 || source.purls[0].String() != "pkg:npm/lodash@4.17.21" {
t.Fatalf("queried PURLs = %#v, want only lodash", source.purls)
}
if len(got["pkg:npm/lodash@4.17.21"]) != 1 {
t.Errorf("result = %#v, want lodash vulnerability", got)
}
if _, exists := got[""]; exists {
t.Error("result contains an empty PURL key")
}
}
func TestIsOutdated(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := New(logger)

View file

@ -19,6 +19,7 @@ import (
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
@ -61,7 +62,18 @@ var artifactCopyBufferPool = sync.Pool{ //nolint:gochecknoglobals // shared acro
// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
func canonicalPackagePURL(ecosystem, name string) string {
return purl.MakePURLString(ecosystem, name, "")
return packageurl.MakeString(ecosystem, name, "")
}
var errUnsupportedPackageIdentity = errors.New("package identity cannot be represented as a PURL")
func packagePURLStrings(ecosystem, name, version string) (string, string, error) {
packagePURL := packageurl.MakeString(ecosystem, name, "")
versionPURL := packageurl.MakeString(ecosystem, name, version)
if packagePURL == "" || versionPURL == "" {
return "", "", fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
}
return packagePURL, versionPURL, nil
}
const contentTypeJSON = "application/json"
@ -140,27 +152,32 @@ type CacheResult struct {
ContentType string
Hash string
Cached bool
storagePath string
}
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
}
metrics.RecordCacheMiss(ecosystem)
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
return p.fetchAndCache(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL)
}
// GetCachedArtifact retrieves an artifact from cache without contacting an upstream.
// It returns nil when no usable cache entry exists.
func (p *Proxy) GetCachedArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
return p.checkCache(ctx, pkgPURL, versionPURL, filename)
}
@ -170,8 +187,10 @@ func (p *Proxy) ClearCachedArtifact(ctx context.Context, ecosystem, name, versio
if p.DB == nil || p.Storage == nil {
return nil
}
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return err
}
cached, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
if err != nil {
return fmt.Errorf("looking up cached artifact: %w", err)
@ -205,6 +224,7 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
ContentType: artifact.ContentType.String,
Hash: artifact.ContentHash.String,
Cached: true,
storagePath: artifact.StoragePath,
}
if p.DirectServe {
@ -864,19 +884,55 @@ func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name,
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
// with additional request-specific HTTP headers.
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "")
}
func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
return p.getOrFetchArtifactFromURLWithCachePURLs(
ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash,
)
}
func (p *Proxy) getOrFetchArtifactFromURLWithCachePURLs(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
if cached, err := p.getCachedArtifactWithUpstreamHash(ctx, pkgPURL, versionPURL, filename, upstreamHash); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
}
metrics.RecordCacheMiss(ecosystem)
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers)
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash)
}
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) {
// getCachedArtifactWithUpstreamHash returns a cached artifact whose recorded
// content hash matches the checksum the upstream currently declares for it.
// This detects an upstream re-publishing under the same version, which the
// stream integrity check in checkCache cannot: that check only verifies the
// stored blob against the hash recorded when it was cached. On mismatch the
// stale entry is discarded and nil is returned so the caller re-fetches.
func (p *Proxy) getCachedArtifactWithUpstreamHash(ctx context.Context, pkgPURL, versionPURL, filename, upstreamHash string) (*CacheResult, error) {
cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename)
if err != nil || cached == nil {
return cached, err
}
if artifactHashMatches(cached.Hash, upstreamHash) {
return cached, nil
}
if cached.Reader != nil {
_ = cached.Reader.Close()
}
p.Logger.Warn("cached artifact hash disagrees with upstream metadata, discarding",
"purl", versionPURL, "filename", filename, "cached", cached.Hash, "upstream", upstreamHash)
p.discardCachedArtifact(ctx, versionPURL, filename, cached.storagePath)
return nil, nil
}
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
@ -894,6 +950,12 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
if err != nil {
return nil, fmt.Errorf("storing artifact: %w", err)
}
if !artifactHashMatches(hash, upstreamHash) {
if err := p.Storage.Delete(ctx, storagePath); err != nil {
p.Logger.Warn("failed to discard artifact with mismatched checksum", "path", storagePath, "error", err)
}
return nil, fmt.Errorf("artifact checksum mismatch: upstream declared %s, got %s", upstreamHash, hash)
}
if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
@ -912,3 +974,18 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
Cached: false,
}, nil
}
func artifactHashMatches(got, expected string) bool {
return expected == "" || strings.EqualFold(got, expected)
}
func (p *Proxy) discardCachedArtifact(ctx context.Context, versionPURL, filename, storagePath string) {
if storagePath != "" {
if err := p.Storage.Delete(ctx, storagePath); err != nil {
p.Logger.Warn("failed to discard cached artifact", "path", storagePath, "error", err)
}
}
if err := p.DB.ClearArtifactCache(versionPURL, filename); err != nil {
p.Logger.Warn("failed to clear artifact cache record", "purl", versionPURL, "filename", filename, "error", err)
}
}

View file

@ -105,15 +105,17 @@ type mockFetcher struct {
fetchErrByURL map[string]error
fetchCalled bool
fetchedURL string
fetchedHeader http.Header
}
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
return f.FetchWithHeaders(ctx, url, nil)
}
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
f.fetchCalled = true
f.fetchedURL = url
f.fetchedHeader = headers.Clone()
if f.fetchErrByURL != nil {
if err, ok := f.fetchErrByURL[url]; ok {
return nil, err
@ -403,6 +405,28 @@ func TestGetOrFetchArtifactFromURL_CacheMiss_StorageMissing(t *testing.T) {
}
}
func TestArtifactCacheRejectsUnsupportedPackageIdentity(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
_, err := proxy.GetCachedArtifact(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
)
if !errors.Is(err, errUnsupportedPackageIdentity) {
t.Fatalf("GetCachedArtifact() error = %v, want unsupported package identity", err)
}
_, err = proxy.GetOrFetchArtifactFromURL(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
"https://registry.example/apple/example/1.2.3.zip",
)
if !errors.Is(err, errUnsupportedPackageIdentity) {
t.Fatalf("GetOrFetchArtifactFromURL() error = %v, want unsupported package identity", err)
}
if fetcher.fetchCalled {
t.Error("unsupported package identity reached the artifact fetcher")
}
}
func TestGetOrFetchArtifact_DirectServe_Redirect(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")

645
internal/handler/swift.go Normal file
View file

@ -0,0 +1,645 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/packageurl"
)
const (
swiftAcceptJSON = "application/vnd.swift.registry.v1+json"
swiftAcceptManifest = "application/vnd.swift.registry.v1+swift"
swiftAcceptArchive = "application/vnd.swift.registry.v1+zip"
swiftContentVersion = "1"
swiftMaxScopeLength = 39
swiftMaxNameLength = 100
)
// SwiftHandler handles the read-only Swift Package Registry v1 protocol.
type SwiftHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string
}
// NewSwiftHandler creates a Swift Package Registry protocol handler.
func NewSwiftHandler(proxy *Proxy, proxyURL, upstreamURL string) *SwiftHandler {
if strings.TrimSpace(upstreamURL) == "" {
upstreamURL = config.DefaultSwiftUpstream
}
return &SwiftHandler{
proxy: proxy,
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// Routes returns the HTTP handler for Swift registry requests.
func (h *SwiftHandler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /identifiers", h.handleIdentifiers)
mux.HandleFunc("GET /{scope}/{name}/{version}/Package.swift", h.handleManifest)
mux.HandleFunc("GET /{scope}/{name}/{version}", h.handleRelease)
mux.HandleFunc("PUT /{scope}/{name}/{version}", h.handlePublishingUnsupported)
mux.HandleFunc("GET /{scope}/{name}", h.handlePackageReleases)
return mux
}
func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Request) {
scope := r.PathValue("scope")
name := strings.TrimSuffix(r.PathValue("name"), ".json")
if !validSwiftScope(scope) || !validSwiftPackageName(name) {
writeSwiftProblem(w, http.StatusBadRequest, "invalid package identifier")
return
}
scope, name = canonicalSwiftPackage(scope, name)
upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery)
body, contentType, responseHeaders, err := h.fetchMetadataWithHeaders(
r.Context(), upstreamURL, requestAccept(r, swiftAcceptJSON),
)
if err != nil {
h.writeMetadataError(w, err)
return
}
rewritten, err := h.rewriteReleaseURLs(scope, name, body)
if err != nil {
h.proxy.Logger.Warn("failed to rewrite Swift release URLs", "error", err)
rewritten = body
}
for _, link := range responseHeaders.Values("Link") {
w.Header().Add("Link", h.rewriteLinkHeader(link, upstreamURL))
}
writeSwiftMetadata(w, r, rewritten, contentType)
}
func (h *SwiftHandler) handleRelease(w http.ResponseWriter, r *http.Request) {
scope := r.PathValue("scope")
name := r.PathValue("name")
version := r.PathValue("version")
if strings.HasSuffix(version, ".zip") {
h.handleSourceArchive(w, r, scope, name, strings.TrimSuffix(version, ".zip"))
return
}
version = strings.TrimSuffix(version, ".json")
if !validSwiftPackageReference(scope, name, version) {
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
return
}
scope, name = canonicalSwiftPackage(scope, name)
upstreamURL := h.buildUpstreamURL(scope, name, version, "", r.URL.RawQuery)
body, contentType, err := h.proxy.FetchOrCacheMetadata(
r.Context(), "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, requestAccept(r, swiftAcceptJSON),
)
if err != nil {
h.writeMetadataError(w, err)
return
}
writeSwiftMetadata(w, r, body, contentType)
}
func (h *SwiftHandler) handleManifest(w http.ResponseWriter, r *http.Request) {
scope := r.PathValue("scope")
name := r.PathValue("name")
version := r.PathValue("version")
if !validSwiftPackageReference(scope, name, version) {
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
return
}
scope, name = canonicalSwiftPackage(scope, name)
upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", r.URL.RawQuery)
h.proxySwiftResource(w, r, upstreamURL, swiftAcceptManifest)
}
func (h *SwiftHandler) handleIdentifiers(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("url") == "" {
writeSwiftProblem(w, http.StatusBadRequest, "url query parameter is required")
return
}
upstreamURL := h.upstreamURL + "/identifiers?" + r.URL.RawQuery
cacheKey := swiftMetadataCacheKey("identifiers", r.URL.RawQuery)
body, contentType, err := h.proxy.FetchOrCacheMetadata(
r.Context(), "swift", cacheKey, upstreamURL, requestAccept(r, swiftAcceptJSON),
)
if err != nil {
h.writeMetadataError(w, err)
return
}
writeSwiftMetadata(w, r, body, contentType)
}
func (h *SwiftHandler) handlePublishingUnsupported(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", "GET, HEAD")
writeSwiftProblem(w, http.StatusMethodNotAllowed, "publishing isn't supported")
}
func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Request, scope, name, version string) {
if !validSwiftPackageReference(scope, name, version) {
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
return
}
scope, name = canonicalSwiftPackage(scope, name)
packageName := scope + "/" + name
filename := fmt.Sprintf("%s-%s.zip", name, version)
upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery)
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", packageName, version)
if packagePURL == "" || versionPURL == "" {
h.writeArtifactError(w, fmt.Errorf("%w: swift %q", errUnsupportedPackageIdentity, packageName))
return
}
archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version)
if infoErr != nil {
h.writeArtifactError(w, fmt.Errorf("fetching release metadata: %w", infoErr))
return
}
if r.Method == http.MethodHead {
h.handleSourceArchiveHead(w, r, name, version, filename, packagePURL, versionPURL, upstreamURL, archiveInfo)
return
}
headers := make(http.Header)
headers.Set("Accept", requestAccept(r, swiftAcceptArchive))
result, err := h.proxy.getOrFetchArtifactFromURLWithCachePURLs(
r.Context(), "swift", packageName, version, filename, packagePURL, versionPURL,
upstreamURL, headers, archiveInfo.checksum,
)
if err != nil {
h.writeArtifactError(w, err)
return
}
result.ContentType = "application/zip"
setSwiftArchiveHeaders(w.Header(), name, version, result.Hash, archiveInfo)
serveArtifact(w, r.Method, result)
}
func (h *SwiftHandler) handleSourceArchiveHead(
w http.ResponseWriter,
r *http.Request,
name, version, filename, packagePURL, versionPURL, upstreamURL string,
archiveInfo swiftArchiveInfo,
) {
result, err := h.proxy.getCachedArtifactWithUpstreamHash(
r.Context(), packagePURL, versionPURL, filename, archiveInfo.checksum,
)
if err != nil {
h.writeArtifactError(w, err)
return
}
if result != nil {
result.ContentType = "application/zip"
setSwiftArchiveHeaders(w.Header(), name, version, result.Hash, archiveInfo)
serveArtifact(w, r.Method, result)
return
}
size, err := h.probeSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive))
if err != nil {
h.writeArtifactError(w, err)
return
}
setSwiftArchiveHeaders(w.Header(), name, version, "", archiveInfo)
w.Header().Set("Content-Type", "application/zip")
if size >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
w.WriteHeader(http.StatusOK)
}
func (h *SwiftHandler) probeSourceArchive(ctx context.Context, upstreamURL, accept string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if err != nil {
return 0, fmt.Errorf("creating upstream archive request: %w", err)
}
req.Header.Set("Accept", accept)
req.Header.Set("Range", "bytes=0-0")
h.proxy.applyUpstreamAuth(req)
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return 0, fmt.Errorf("requesting upstream archive: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return 0, ErrUpstreamNotFound
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("upstream archive returned %d", resp.StatusCode)
}
if resp.StatusCode == http.StatusPartialContent {
_, total, found := strings.Cut(resp.Header.Get("Content-Range"), "/")
if !found || total == "*" {
return -1, nil
}
if parsed, parseErr := strconv.ParseInt(total, 10, 64); parseErr == nil {
return parsed, nil
}
return -1, nil
}
size := int64(-1)
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
if parsed, parseErr := strconv.ParseInt(contentLength, 10, 64); parseErr == nil {
size = parsed
}
}
return size, nil
}
func (h *SwiftHandler) fetchMetadataWithHeaders(
ctx context.Context,
upstreamURL, accept string,
) ([]byte, string, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if err != nil {
return nil, "", nil, fmt.Errorf("creating upstream metadata request: %w", err)
}
req.Header.Set("Accept", accept)
h.proxy.applyUpstreamAuth(req)
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return nil, "", nil, fmt.Errorf("requesting upstream metadata: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, "", nil, ErrUpstreamNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, "", nil, fmt.Errorf("upstream metadata returned %d", resp.StatusCode)
}
body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil {
return nil, "", nil, fmt.Errorf("reading upstream metadata: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = contentTypeJSON
}
return body, contentType, resp.Header.Clone(), nil
}
type swiftReleaseMetadata struct {
Resources []struct {
Name string `json:"name"`
Type string `json:"type"`
Checksum string `json:"checksum"`
Signing *struct {
Signature string `json:"signatureBase64Encoded"`
Format string `json:"signatureFormat"`
} `json:"signing"`
} `json:"resources"`
}
type swiftArchiveInfo struct {
checksum string
signature string
signatureFormat string
}
func (h *SwiftHandler) fetchArchiveInfo(ctx context.Context, scope, name, version string) (swiftArchiveInfo, error) {
upstreamURL := h.buildUpstreamURL(scope, name, version, "", "")
body, _, err := h.proxy.FetchOrCacheMetadata(
ctx, "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, swiftAcceptJSON,
)
if err != nil {
return swiftArchiveInfo{}, err
}
var metadata swiftReleaseMetadata
if err := json.Unmarshal(body, &metadata); err != nil {
return swiftArchiveInfo{}, fmt.Errorf("parsing release metadata: %w", err)
}
for _, resource := range metadata.Resources {
if resource.Name != "source-archive" || resource.Type != "application/zip" {
continue
}
checksum, err := normalizeSwiftChecksum(resource.Checksum)
if err != nil {
return swiftArchiveInfo{}, err
}
info := swiftArchiveInfo{checksum: checksum}
if resource.Signing != nil {
if resource.Signing.Signature == "" || resource.Signing.Format == "" {
return swiftArchiveInfo{}, errors.New("source archive signing metadata is incomplete")
}
info.signature = resource.Signing.Signature
info.signatureFormat = resource.Signing.Format
}
return info, nil
}
return swiftArchiveInfo{}, errors.New("source archive is missing from release metadata")
}
func normalizeSwiftChecksum(checksum string) (string, error) {
digest, err := hex.DecodeString(checksum)
if err != nil || len(digest) != sha256.Size {
return "", errors.New("source archive checksum is not a SHA-256 digest")
}
return hex.EncodeToString(digest), nil
}
func setSwiftArchiveHeaders(header http.Header, name, version, contentHash string, info swiftArchiveInfo) {
header.Set("Cache-Control", "public, immutable")
header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.zip"`, name, version))
header.Set("Content-Version", swiftContentVersion)
checksum := info.checksum
if checksum == "" {
checksum = contentHash
}
if digest := swiftDigestHeader(checksum); digest != "" {
header.Set("Digest", digest)
}
if info.signature != "" && info.signatureFormat != "" {
header.Set("X-Swift-Package-Signature", info.signature)
header.Set("X-Swift-Package-Signature-Format", info.signatureFormat)
}
}
func swiftDigestHeader(checksum string) string {
digest, err := hex.DecodeString(checksum)
if err != nil || len(digest) != sha256.Size {
return ""
}
return "sha-256=" + base64.StdEncoding.EncodeToString(digest)
}
func (h *SwiftHandler) proxySwiftResource(w http.ResponseWriter, r *http.Request, upstreamURL, defaultAccept string) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
writeSwiftProblem(w, http.StatusInternalServerError, "failed to create upstream request")
return
}
req.Header.Set("Accept", requestAccept(r, defaultAccept))
for _, name := range []string{"If-Modified-Since", "If-None-Match"} {
if value := r.Header.Get(name); value != "" {
req.Header.Set(name, value)
}
}
h.proxy.applyUpstreamAuth(req)
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed")
return
}
defer func() { _ = resp.Body.Close() }()
copySwiftResponseHeaders(w.Header(), resp.Header)
if location := resp.Header.Get("Location"); location != "" {
w.Header().Set("Location", h.rewriteRegistryURL(location, upstreamURL))
}
for _, link := range resp.Header.Values("Link") {
w.Header().Add("Link", h.rewriteLinkHeader(link, upstreamURL))
}
if w.Header().Get("Content-Version") == "" {
w.Header().Set("Content-Version", swiftContentVersion)
}
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, resp.Body)
}
}
func copySwiftResponseHeaders(dst, src http.Header) {
for _, name := range []string{
"Cache-Control", "Content-Disposition", "Content-Language", "Content-Length",
"Content-Type", "Content-Version", "Digest", "ETag", "Last-Modified",
"Retry-After", "Vary", "Warning", "X-Swift-Package-Signature",
"X-Swift-Package-Signature-Format",
} {
for _, value := range src.Values(name) {
dst.Add(name, value)
}
}
}
func (h *SwiftHandler) rewriteLinkHeader(value, upstreamRequestURL string) string {
var result strings.Builder
for len(value) > 0 {
start := strings.IndexByte(value, '<')
if start < 0 {
result.WriteString(value)
break
}
endOffset := strings.IndexByte(value[start+1:], '>')
if endOffset < 0 {
result.WriteString(value)
break
}
end := start + 1 + endOffset
result.WriteString(value[:start+1])
result.WriteString(h.rewriteRegistryURL(value[start+1:end], upstreamRequestURL))
result.WriteByte('>')
value = value[end+1:]
}
return result.String()
}
func (h *SwiftHandler) rewriteRegistryURL(rawURL, upstreamRequestURL string) string {
base, err := url.Parse(h.upstreamURL)
if err != nil {
return rawURL
}
requestURL, err := url.Parse(upstreamRequestURL)
if err != nil {
return rawURL
}
reference, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
absolute := requestURL.ResolveReference(reference)
if !strings.EqualFold(absolute.Scheme, base.Scheme) || !strings.EqualFold(absolute.Host, base.Host) {
return rawURL
}
basePath := strings.TrimSuffix(base.EscapedPath(), "/")
absolutePath := absolute.EscapedPath()
if absolutePath != basePath && !strings.HasPrefix(absolutePath, basePath+"/") {
return rawURL
}
suffix := strings.TrimPrefix(absolutePath, basePath)
rewritten := h.proxyURL + "/swift" + suffix
if absolute.RawQuery != "" {
rewritten += "?" + absolute.RawQuery
}
if absolute.Fragment != "" {
rewritten += "#" + absolute.Fragment
}
return rewritten
}
func (h *SwiftHandler) rewriteReleaseURLs(scope, name string, body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
releases, ok := metadata["releases"].(map[string]any)
if !ok {
return body, nil
}
for version, value := range releases {
release, ok := value.(map[string]any)
if !ok {
continue
}
if _, hasURL := release["url"]; !hasURL {
continue
}
release["url"] = fmt.Sprintf(
"%s/swift/%s/%s/%s",
h.proxyURL,
url.PathEscape(scope),
url.PathEscape(name),
url.PathEscape(version),
)
}
return json.Marshal(metadata)
}
func (h *SwiftHandler) buildUpstreamURL(scope, name, version, resource, rawQuery string) string {
parts := []string{h.upstreamURL, url.PathEscape(scope), url.PathEscape(name)}
if version != "" {
parts = append(parts, url.PathEscape(version))
}
if resource != "" {
parts = append(parts, resource)
}
result := strings.Join(parts, "/")
if rawQuery != "" {
result += "?" + rawQuery
}
return result
}
func swiftMetadataCacheKey(parts ...string) string {
joined := strings.Join(parts, "\x00")
digest := sha256.Sum256([]byte(joined))
return hex.EncodeToString(digest[:])
}
func swiftReleaseCacheKey(scope, name, version string) string {
return swiftMetadataCacheKey("release", scope, name, version)
}
func requestAccept(r *http.Request, fallback string) string {
if accept := r.Header.Get("Accept"); accept != "" {
return accept
}
return fallback
}
func writeSwiftMetadata(w http.ResponseWriter, r *http.Request, body []byte, contentType string) {
if contentType == "" {
contentType = "application/json"
}
digest := sha256.Sum256(body)
etag := fmt.Sprintf(`"%x"`, digest)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Version", swiftContentVersion)
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
func (h *SwiftHandler) writeMetadataError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrUpstreamNotFound) {
writeSwiftProblem(w, http.StatusNotFound, "not found")
return
}
h.proxy.Logger.Error("Swift metadata request failed", "error", err)
writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed")
}
func (h *SwiftHandler) writeArtifactError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrUpstreamNotFound) {
writeSwiftProblem(w, http.StatusNotFound, "release not found")
return
}
h.proxy.Logger.Error("Swift archive request failed", "error", err)
writeSwiftProblem(w, http.StatusBadGateway, "failed to fetch package")
}
func writeSwiftProblem(w http.ResponseWriter, status int, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.Header().Set("Content-Version", swiftContentVersion)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"detail": detail})
}
func validSwiftPackageReference(scope, name, version string) bool {
return validSwiftScope(scope) && validSwiftPackageName(name) && version != "" && version != "." && version != ".." && !strings.ContainsAny(version, "/\\")
}
func canonicalSwiftPackage(scope, name string) (string, string) {
return strings.ToLower(scope), strings.ToLower(name)
}
func validSwiftScope(scope string) bool {
return validSwiftIdentifier(scope, swiftMaxScopeLength, "-")
}
func validSwiftPackageName(name string) bool {
return validSwiftIdentifier(name, swiftMaxNameLength, "-_")
}
func validSwiftIdentifier(value string, maxLength int, separators string) bool {
if value == "" || len(value) > maxLength {
return false
}
previousSeparator := false
for i := 0; i < len(value); i++ {
character := value[i]
separator := strings.ContainsRune(separators, rune(character))
if separator {
if i == 0 || i == len(value)-1 || previousSeparator {
return false
}
previousSeparator = true
continue
}
if (character < 'a' || character > 'z') &&
(character < 'A' || character > 'Z') &&
(character < '0' || character > '9') {
return false
}
previousSeparator = false
}
return true
}

View file

@ -0,0 +1,534 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/registries/fetch"
)
func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) {
var gotAccept string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/registry/apple/swift-argument-parser" {
t.Errorf("upstream path = %q", r.URL.Path)
}
gotAccept = r.Header.Get("Accept")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Version", "1")
w.Header().Add("Link", `</registry/apple/swift-argument-parser?page=2>; rel="next"`)
_, _ = io.WriteString(w, `{"releases":{"1.2.0":{"url":"/registry/apple/swift-argument-parser/1.2.0"},"1.1.0":{}}}`)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
req := httptest.NewRequest(http.MethodGet, "/APPLE/SWIFT-ARGUMENT-PARSER", nil)
req.Header.Set("Accept", swiftAcceptJSON)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if gotAccept != swiftAcceptJSON {
t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptJSON)
}
if got := w.Header().Get("Content-Version"); got != "1" {
t.Errorf("Content-Version = %q, want 1", got)
}
if got := w.Header().Get("Link"); got != `<https://proxy.example/swift/apple/swift-argument-parser?page=2>; rel="next"` {
t.Errorf("Link = %q", got)
}
var body struct {
Releases map[string]struct {
URL string `json:"url"`
} `json:"releases"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got := body.Releases["1.2.0"].URL; got != "https://proxy.example/swift/apple/swift-argument-parser/1.2.0" {
t.Errorf("release URL = %q", got)
}
if got := body.Releases["1.1.0"].URL; got != "" {
t.Errorf("release without upstream URL gained URL %q", got)
}
}
func TestSwiftReleaseMetadataSupportsJSONExtensionAndHead(t *testing.T) {
var requestMethods []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestMethods = append(requestMethods, r.Method)
if r.URL.Path != "/registry/apple/example/1.2.3" {
t.Errorf("upstream path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"apple.example","version":"1.2.3","resources":[]}`)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
for _, method := range []string{http.MethodGet, http.MethodHead} {
req := httptest.NewRequest(method, "/APPLE/EXAMPLE/1.2.3.json", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status = %d, want 200", method, w.Code)
}
if method == http.MethodHead && w.Body.Len() != 0 {
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
}
}
if len(requestMethods) != 2 || requestMethods[0] != http.MethodGet || requestMethods[1] != http.MethodGet {
t.Errorf("upstream methods = %v, want metadata GETs", requestMethods)
}
}
func TestSwiftManifestProxiesQueryAndRewritesLinks(t *testing.T) {
var upstream *httptest.Server
var gotAccept string
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("upstream method = %s, want GET", r.Method)
}
if r.URL.Path != "/registry/apple/example/1.2.3/Package.swift" {
t.Errorf("upstream path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("swift-version"); got != "5.9" {
t.Errorf("swift-version = %q, want 5.9", got)
}
gotAccept = r.Header.Get("Accept")
w.Header().Set("Content-Type", "text/x-swift")
w.Header().Add("Link", fmt.Sprintf(`<%s/registry/apple/example/1.2.3/Package.swift?swift-version=5.8>; rel="alternate"; filename="Package@swift-5.8.swift"`, upstream.URL))
w.Header().Add("Link", `<https://github.com/apple/example>; rel="canonical"`)
_, _ = io.WriteString(w, "// swift-tools-version: 5.9\n")
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
req := httptest.NewRequest(http.MethodGet, "/APPLE/EXAMPLE/1.2.3/Package.swift?swift-version=5.9", nil)
req.Header.Set("Accept", swiftAcceptManifest)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotAccept != swiftAcceptManifest {
t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptManifest)
}
links := strings.Join(w.Header().Values("Link"), ",")
if !strings.Contains(links, "https://proxy.example/swift/apple/example/1.2.3/Package.swift?swift-version=5.8") {
t.Errorf("internal manifest Link was not rewritten: %q", links)
}
if !strings.Contains(links, "https://github.com/apple/example") {
t.Errorf("external canonical Link was changed: %q", links)
}
if got := w.Header().Get("Content-Version"); got != "1" {
t.Errorf("Content-Version = %q, want 1", got)
}
}
func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) {
archive := []byte("swift source archive")
checksumBytes := sha256.Sum256(archive)
checksum := hex.EncodeToString(checksumBytes[:])
signature := base64.StdEncoding.EncodeToString([]byte("signature"))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/registry/apple/example/1.2.3" {
t.Errorf("metadata path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q,"signing":{"signatureBase64Encoded":%q,"signatureFormat":"cms-1.0.0"}}]}`, checksum, signature)
}))
defer upstream.Close()
proxy, db, _, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(string(archive))),
Size: int64(len(archive)),
ContentType: "application/zip",
}
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
requestArchive := func(method string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/apple/example/1.2.3.zip", nil)
req.Header.Set("Accept", swiftAcceptArchive)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
w := requestArchive(http.MethodGet)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if got := w.Body.Bytes(); string(got) != string(archive) {
t.Errorf("archive body = %q", got)
}
if !fetcher.fetchCalled {
t.Fatal("archive fetcher was not called")
}
if got := fetcher.fetchedURL; got != upstream.URL+"/registry/apple/example/1.2.3.zip" {
t.Errorf("fetched URL = %q", got)
}
if got := fetcher.fetchedHeader.Get("Accept"); got != swiftAcceptArchive {
t.Errorf("archive Accept = %q, want %q", got, swiftAcceptArchive)
}
if got := w.Header().Get("Digest"); got != "sha-256="+base64.StdEncoding.EncodeToString(checksumBytes[:]) {
t.Errorf("Digest = %q", got)
}
if got := w.Header().Get("X-Swift-Package-Signature"); got != signature {
t.Errorf("signature = %q", got)
}
if got := w.Header().Get("X-Swift-Package-Signature-Format"); got != "cms-1.0.0" {
t.Errorf("signature format = %q", got)
}
if got := w.Header().Get("Content-Disposition"); got != `attachment; filename="example-1.2.3.zip"` {
t.Errorf("Content-Disposition = %q", got)
}
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
if strings.HasPrefix(packagePURL, "pkg:swift/") {
t.Fatalf("registry identity produced source PURL %q", packagePURL)
}
versionRecord, err := db.GetVersionByPURL(versionPURL)
if err != nil {
t.Fatalf("cached Swift version %q not found: %v", versionPURL, err)
}
if versionRecord == nil {
t.Fatalf("cached Swift version %q not found", versionPURL)
}
if versionRecord.PackagePURL != packagePURL {
t.Errorf("cached package PURL = %q, want %q", versionRecord.PackagePURL, packagePURL)
}
fetcher.fetchCalled = false
w = requestArchive(http.MethodHead)
if w.Code != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("HEAD body length = %d, want 0", w.Body.Len())
}
if got := w.Header().Get("Content-Length"); got != fmt.Sprint(len(archive)) {
t.Errorf("HEAD Content-Length = %q", got)
}
w = requestArchive(http.MethodGet)
if w.Code != http.StatusOK || w.Body.String() != string(archive) {
t.Fatalf("cached response = %d %q", w.Code, w.Body.Bytes())
}
if fetcher.fetchCalled {
t.Error("cached archive contacted artifact upstream")
}
}
func TestSwiftSourceArchiveRejectsChecksumMismatch(t *testing.T) {
archive := []byte("unexpected archive")
expectedChecksum := sha256.Sum256([]byte("expected archive"))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(expectedChecksum[:]))
}))
defer upstream.Close()
proxy, db, store, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(string(archive))),
Size: int64(len(archive)),
ContentType: "application/zip",
}
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
}
if len(store.files) != 0 {
t.Errorf("mismatched archive remained in storage: %v", store.files)
}
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
cached, err := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip")
if err != nil {
t.Fatalf("checking cache: %v", err)
}
if cached != nil {
t.Error("mismatched archive gained a cache record")
}
}
func TestSwiftSourceArchiveCanonicalizesPackageIdentity(t *testing.T) {
archive := []byte("swift source archive")
checksumBytes := sha256.Sum256(archive)
checksum := hex.EncodeToString(checksumBytes[:])
var metadataPaths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metadataPaths = append(metadataPaths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
}))
defer upstream.Close()
proxy, db, store, fetcher := setupTestProxy(t)
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
requestArchive := func(path string) {
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(string(archive))),
Size: int64(len(archive)),
ContentType: "application/zip",
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200; body: %s", path, w.Code, w.Body.String())
}
}
requestArchive("/apple/example/1.2.3.zip")
requestArchive("/APPLE/EXAMPLE/1.2.3.zip")
if len(store.files) != 1 {
t.Errorf("cached files = %d, want 1", len(store.files))
}
for _, path := range metadataPaths {
if path != "/apple/example/1.2.3" {
t.Errorf("metadata path = %q, want canonical lowercase path", path)
}
}
canonicalPURL, _ := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
canonical, err := db.GetPackageByPURL(canonicalPURL)
if err != nil {
t.Fatalf("getting canonical package: %v", err)
}
if canonical == nil {
t.Fatalf("canonical package %q not found", canonicalPURL)
}
nonCanonicalPURL, _ := packageurl.MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
if nonCanonicalPURL != canonicalPURL {
t.Errorf("uppercase cache PURL = %q, want %q", nonCanonicalPURL, canonicalPURL)
}
}
func TestSwiftSourceArchiveHeadDiscardsCachedChecksumMismatch(t *testing.T) {
archive := []byte("cached archive")
upstreamChecksum := sha256.Sum256([]byte("upstream archive"))
var probed bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".zip") {
probed = true
w.Header().Set("Content-Range", "bytes 0-0/456")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(upstreamChecksum[:]))
}))
defer upstream.Close()
proxy, db, store, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(string(archive))),
Size: int64(len(archive)),
ContentType: "application/zip",
}
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
cached, err := proxy.getOrFetchArtifactFromURLWithCachePURLs(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
packagePURL, versionPURL, upstream.URL+"/apple/example/1.2.3.zip", nil, "",
)
if err != nil {
t.Fatalf("seeding cache: %v", err)
}
_ = cached.Reader.Close()
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if !probed {
t.Error("stale cache entry was not replaced by an upstream probe")
}
if got := w.Header().Get("Content-Length"); got != "456" {
t.Errorf("Content-Length = %q, want 456 from upstream probe", got)
}
if len(store.files) != 0 {
t.Errorf("mismatched cached archive remained in storage: %v", store.files)
}
if rec, _ := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip"); rec != nil {
t.Error("mismatched cache record was not cleared")
}
}
func TestSwiftSourceArchiveRequiresReleaseMetadata(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer upstream.Close()
proxy, _, store, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("signed archive")),
ContentType: "application/zip",
}
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
}
if fetcher.fetchCalled {
t.Error("archive was fetched without release security metadata")
}
if len(store.files) != 0 {
t.Errorf("archive was cached without release security metadata: %v", store.files)
}
}
func TestSwiftSourceArchiveColdHeadUsesRangeGetAcrossRedirect(t *testing.T) {
checksum := strings.Repeat("a", sha256.Size*2)
var archiveAccept string
var archiveMethod string
var archiveRange string
download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
archiveMethod = r.Method
archiveRange = r.Header.Get("Range")
w.Header().Set("Content-Range", "bytes 0-0/123")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
}))
defer download.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/apple/example/1.2.3":
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
case "/apple/example/1.2.3.zip":
archiveAccept = r.Header.Get("Accept")
http.Redirect(w, r, download.URL, http.StatusSeeOther)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if archiveMethod != http.MethodGet {
t.Errorf("download method = %q, want GET", archiveMethod)
}
if archiveRange != "bytes=0-0" {
t.Errorf("download Range = %q, want bytes=0-0", archiveRange)
}
if archiveAccept != swiftAcceptArchive {
t.Errorf("upstream Accept = %q, want %q", archiveAccept, swiftAcceptArchive)
}
if got := w.Header().Get("Content-Length"); got != "123" {
t.Errorf("Content-Length = %q, want 123", got)
}
}
func TestSwiftIdentifiersAndPublishingUnsupported(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/registry/identifiers" {
t.Errorf("upstream path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("url"); got != "https://github.com/apple/example" {
t.Errorf("lookup URL = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"identifiers":["apple.example"]}`)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
req := httptest.NewRequest(http.MethodGet, "/identifiers?url=https%3A%2F%2Fgithub.com%2Fapple%2Fexample", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "apple.example") {
t.Fatalf("identifier response = %d %q", w.Code, w.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/identifiers", nil)
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("missing URL status = %d, want 400", w.Code)
}
req = httptest.NewRequest(http.MethodPut, "/apple/example/1.2.3", strings.NewReader("ignored"))
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("publish status = %d, want 405", w.Code)
}
if got := w.Header().Get("Allow"); got != "GET, HEAD" {
t.Errorf("Allow = %q", got)
}
}
func TestSwiftIdentifierValidation(t *testing.T) {
tests := []struct {
name string
value string
valid func(string) bool
want bool
}{
{"scope", "apple", validSwiftScope, true},
{"scope hyphen", "swift-server", validSwiftScope, true},
{"scope underscore", "swift_server", validSwiftScope, false},
{"scope repeated separator", "swift--server", validSwiftScope, false},
{"package", "swift-argument_parser", validSwiftPackageName, true},
{"package repeated separators", "swift-_argument", validSwiftPackageName, false},
{"package trailing separator", "example-", validSwiftPackageName, false},
{"package non-ASCII", "café", validSwiftPackageName, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.valid(test.value); got != test.want {
t.Errorf("validation of %q = %v, want %v", test.value, got, test.want)
}
})
}
}

View file

@ -0,0 +1,62 @@
// Package packageurl builds package URLs from ecosystem-native package names.
package packageurl
import (
"strings"
"github.com/git-pkgs/purl"
)
// Make constructs a package URL from an ecosystem-native package name.
func Make(ecosystem, name, version string) *purl.PURL {
return purl.MakePURL(ecosystem, name, version)
}
// MakeString constructs a package URL string. It returns an empty string when
// the package identity cannot be represented as a PURL.
func MakeString(ecosystem, name, version string) string {
return purl.MakePURLString(ecosystem, name, version)
}
// WithVersionString returns a package PURL with its version replaced. It
// returns an empty string when packagePURL is invalid.
func WithVersionString(packagePURL, version string) string {
pkg, err := purl.Parse(packagePURL)
if err != nil {
return ""
}
return pkg.WithVersion(version).String()
}
// MakeCacheStrings returns package and version PURLs suitable for artifact
// cache records. Swift registry identities use an explicit generic PURL until
// their source repository has been resolved. The result is independent of the
// configured upstream so cache entries survive an upstream.swift change,
// matching every other ecosystem.
func MakeCacheStrings(ecosystem, name, version string) (packagePURL, versionPURL string) {
if pkg := Make(ecosystem, name, ""); pkg != nil {
return pkg.String(), pkg.WithVersion(version).String()
}
if purl.NormalizeEcosystem(ecosystem) != "swift" {
return "", ""
}
identity, ok := swiftRegistryIdentity(name)
if !ok {
return "", ""
}
pkg := purl.New("generic", "swift-registry", identity, "", nil)
return pkg.String(), pkg.WithVersion(version).String()
}
func swiftRegistryIdentity(name string) (string, bool) {
scope, packageName, found := strings.Cut(name, "/")
if !found {
scope, packageName, found = strings.Cut(name, ".")
}
if !found || scope == "" || packageName == "" || strings.ContainsAny(packageName, "/.") {
return "", false
}
return strings.ToLower(scope) + "." + strings.ToLower(packageName), true
}

View file

@ -0,0 +1,75 @@
package packageurl
import "testing"
func TestMakeSwiftRegistryIdentityUnsupported(t *testing.T) {
identities := []string{"apple.swift-argument-parser", "apple/swift-argument-parser"}
for _, identity := range identities {
t.Run(identity, func(t *testing.T) {
if got := Make("swift", identity, "1.8.2"); got != nil {
t.Errorf("Make() = %q, want nil", got.String())
}
if got := MakeString("swift", identity, "1.8.2"); got != "" {
t.Errorf("MakeString() = %q, want empty string", got)
}
})
}
}
func TestMakeStringSwiftSourceCoordinate(t *testing.T) {
got := MakeString("swift", "github.com/apple/swift-package-manager", "1.7.0")
want := "pkg:swift/github.com/apple/swift-package-manager@1.7.0"
if got != want {
t.Errorf("MakeString() = %q, want %q", got, want)
}
}
func TestWithVersionStringPreservesQualifiers(t *testing.T) {
packagePURL := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Fold.example%2Fswift"
got := WithVersionString(packagePURL, "1.2.3")
want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Fold.example%2Fswift"
if got != want {
t.Errorf("WithVersionString() = %q, want %q", got, want)
}
if got := WithVersionString("not a purl", "1.2.3"); got != "" {
t.Errorf("WithVersionString() = %q for invalid PURL, want empty string", got)
}
}
func TestMakeCacheStringsSwiftRegistryIdentity(t *testing.T) {
packagePURL, versionPURL := MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
wantPackage := "pkg:generic/swift-registry/apple.example"
if packagePURL != wantPackage {
t.Errorf("package PURL = %q, want %q", packagePURL, wantPackage)
}
wantVersion := "pkg:generic/swift-registry/apple.example@1.2.3"
if versionPURL != wantVersion {
t.Errorf("version PURL = %q, want %q", versionPURL, wantVersion)
}
dottedPackage, dottedVersion := MakeCacheStrings("swift", "apple.example", "1.2.3")
if dottedPackage != packagePURL || dottedVersion != versionPURL {
t.Errorf("dotted identity cache PURLs = %q, %q; want %q, %q", dottedPackage, dottedVersion, packagePURL, versionPURL)
}
}
func TestMakeCacheStringsUsesSourcePURLWhenAvailable(t *testing.T) {
packagePURL, versionPURL := MakeCacheStrings("swift", "github.com/apple/swift-package-manager", "1.7.0")
if packagePURL != "pkg:swift/github.com/apple/swift-package-manager" {
t.Errorf("package PURL = %q", packagePURL)
}
if versionPURL != "pkg:swift/github.com/apple/swift-package-manager@1.7.0" {
t.Errorf("version PURL = %q", versionPURL)
}
}
func TestMakeStringDelegatesOtherEcosystems(t *testing.T) {
got := MakeString("npm", "@babel/core", "7.23.0")
want := "pkg:npm/%40babel/core@7.23.0"
if got != want {
t.Errorf("MakeString() = %q, want %q", got, want)
}
}

View file

@ -14,7 +14,6 @@ import (
"github.com/git-pkgs/magic"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/purl"
"github.com/go-chi/chi/v5"
)
@ -226,7 +225,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
dirPath := r.URL.Query().Get("path")
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
versionPURL := s.cachedVersionPURL(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
notFound(w, "version not found")
@ -313,7 +312,7 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
}
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
versionPURL := s.cachedVersionPURL(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
notFound(w, "version not found")
@ -534,8 +533,8 @@ type BrowseSourceData struct {
// @Router /ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
// Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
toPURL := purl.MakePURLString(ecosystem, name, toVersion)
fromPURL := s.cachedVersionPURL(ecosystem, name, fromVersion)
toPURL := s.cachedVersionPURL(ecosystem, name, toVersion)
fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL)
if err != nil || len(fromArtifacts) == 0 {

View file

@ -2,6 +2,7 @@ package server
import (
"html/template"
"strings"
"github.com/git-pkgs/proxy/internal/database"
)
@ -140,6 +141,7 @@ func supportedEcosystems() []string {
"pub",
"pypi",
"rpm",
"swift",
}
}
@ -184,6 +186,8 @@ func ecosystemBadgeClasses(ecosystem string) string {
return base + " bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"
case "julia":
return base + " bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
case "swift":
return base + " bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
case "oci":
return base + " bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
case "deb":
@ -196,6 +200,11 @@ func ecosystemBadgeClasses(ecosystem string) string {
}
func getRegistryConfigs(baseURL string) []RegistryConfig {
swiftInsecureFlag := ""
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
swiftInsecureFlag = "--allow-insecure-http "
}
return []RegistryConfig{
{
ID: "npm",
@ -396,6 +405,15 @@ local({
<p class="config-note">Or inside a running session:</p>
<pre><code>ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
using Pkg; Pkg.update()</code></pre>`),
},
{
ID: "swift",
Name: "Swift Package Registry",
Language: "Swift",
Endpoint: "/swift/",
Instructions: template.HTML(`<p class="config-note">Configure SwiftPM to use the proxy for this project:</p>
<pre><code>swift package-registry set ` + swiftInsecureFlag + baseURL + `/swift</code></pre>
<p class="config-note">Use scoped package identifiers in Package.swift, for example <code>apple.swift-argument-parser</code>.</p>`),
},
{
ID: "oci",

View file

@ -16,6 +16,7 @@
// - /conda/* - Conda/Anaconda protocol
// - /cran/* - CRAN (R) protocol
// - /julia/* - Julia Pkg server protocol
// - /swift/* - Swift Package Registry protocol
// - /v2/* - OCI/Docker container registry protocol
// - /debian/* - Debian/APT repository protocol
// - /rpm/* - RPM/Yum repository protocol
@ -69,8 +70,8 @@ import (
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/mirror"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
"github.com/git-pkgs/registries/safehttp"
"github.com/git-pkgs/spdx"
@ -259,6 +260,7 @@ func (s *Server) Start() error {
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
swiftHandler := handler.NewSwiftHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Swift)
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.OCI)
helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm)
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian)
@ -279,6 +281,7 @@ func (s *Server) Start() error {
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
r.Mount("/swift", http.StripPrefix("/swift", swiftHandler.Routes()))
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
@ -813,7 +816,7 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem,
return
}
versionPURL := purl.MakePURLString(ecosystem, name, version)
versionPURL := packageurl.WithVersionString(pkg.PURL, version)
ver, err := s.db.GetVersionByPURL(versionPURL)
if err != nil || ver == nil {
s.logger.Error("failed to get version", "error", err)
@ -855,6 +858,14 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem,
}
}
func (s *Server) cachedVersionPURL(ecosystem, name, version string) string {
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
if err != nil || pkg == nil {
return ""
}
return packageurl.WithVersionString(pkg.PURL, version)
}
func (s *Server) showBrowseSource(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
data := BrowseSourceData{
Layout: s.layoutFor(r),

View file

@ -83,6 +83,7 @@ func newTestServer(t *testing.T) *testServer {
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
swiftHandler := handler.NewSwiftHandler(proxy, cfg.BaseURL, cfg.Upstream.Swift)
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
@ -90,6 +91,7 @@ func newTestServer(t *testing.T) *testServer {
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
r.Mount("/swift", http.StripPrefix("/swift", swiftHandler.Routes()))
hc, err := newHealthCache(store, "30s", logger)
if err != nil {
@ -328,11 +330,53 @@ func TestDashboard(t *testing.T) {
if !strings.Contains(body, ">debian<") {
t.Error("dashboard should show debian in supported ecosystems")
}
if !strings.Contains(body, ">swift<") {
t.Error("dashboard should show swift in supported ecosystems")
}
if !strings.Contains(body, "/openapi.json") {
t.Error("page should link to the OpenAPI JSON spec")
}
}
func TestSwiftHandlerMounted(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest(http.MethodPut, "/swift/apple/example/1.2.3", strings.NewReader("ignored"))
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405; body: %s", w.Code, w.Body.String())
}
}
func TestSwiftCachedVersionPURLUsesStoredPackagePURL(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
packagePURL := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Fold.example%2Fswift"
if err := ts.db.UpsertPackage(&database.Package{
PURL: packagePURL,
Ecosystem: "swift",
Name: "apple/example",
}); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
s := &Server{
cfg: &config.Config{
Upstream: config.UpstreamConfig{Swift: "https://new.example/swift"},
},
db: ts.db,
}
got := s.cachedVersionPURL("swift", "apple/example", "1.2.3")
want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Fold.example%2Fswift"
if got != want {
t.Errorf("cachedVersionPURL() = %q, want %q", got, want)
}
}
func min(a, b int) int {
if a < b {
return a

View file

@ -461,6 +461,20 @@ func TestEcosystemBadgeLabel(t *testing.T) {
}
}
func TestSwiftRegistryInstructionsAllowLocalHTTP(t *testing.T) {
registries := getRegistryConfigs("http://localhost:8080")
for _, registry := range registries {
if registry.ID != "swift" {
continue
}
if !strings.Contains(string(registry.Instructions), "--allow-insecure-http") {
t.Error("Swift HTTP instructions do not allow the insecure local registry")
}
return
}
t.Fatal("Swift registry instructions not found")
}
func TestEcosystemBadgeClasses(t *testing.T) {
// Every supported ecosystem should return a non-empty class string
ecosystems := supportedEcosystems()