From 59abbf11639806234b4ed1c4173c8d8a60f64b0e Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 09:45:05 +0100 Subject: [PATCH 1/6] Add Swift package registry support --- README.md | 24 +- cmd/proxy/main.go | 1 + config.example.yaml | 3 + docs/architecture.md | 4 + docs/configuration.md | 1 + internal/config/config.go | 7 + internal/config/config_test.go | 7 + internal/enrichment/enrichment.go | 15 +- internal/handler/handler.go | 15 +- internal/handler/handler_test.go | 4 +- internal/handler/swift.go | 534 +++++++++++++++++++++++++ internal/handler/swift_test.go | 297 ++++++++++++++ internal/packageurl/packageurl.go | 25 ++ internal/packageurl/packageurl_test.go | 27 ++ internal/server/browse.go | 10 +- internal/server/dashboard.go | 18 + internal/server/server.go | 7 +- internal/server/server_test.go | 18 + internal/server/templates_test.go | 14 + 19 files changed, 1008 insertions(+), 23 deletions(-) create mode 100644 internal/handler/swift.go create mode 100644 internal/handler/swift_test.go create mode 100644 internal/packageurl/packageurl.go create mode 100644 internal/packageurl/packageurl_test.go diff --git a/README.md b/README.md index 320a737..5fdd668 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 9054eee..9db230d 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -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") diff --git a/config.example.yaml b/config.example.yaml index 1df95b3..b5c4c1f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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" diff --git a/docs/architecture.md b/docs/architecture.md index 6d9bfda..cc070ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 3b8b935..2506347 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: diff --git a/internal/config/config.go b/internal/config/config.go index fdf890d..e18b88a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -313,6 +313,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 +479,7 @@ func Default() *Config { GradlePluginPortal: "https://plugins.gradle.org/m2", Cargo: "https://index.crates.io", CargoDownload: "https://static.crates.io/crates", + Swift: "https://tuist.dev/api/registry/swift", Debian: "http://deb.debian.org/debian", }, Gradle: GradleConfig{ @@ -546,6 +551,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 +571,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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ccc308..60c9cce 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") } diff --git a/internal/enrichment/enrichment.go b/internal/enrichment/enrichment.go index 247dd2b..2b4ac14 100644 --- a/internal/enrichment/enrichment.go +++ b/internal/enrichment/enrichment.go @@ -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,7 @@ 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, "") pkg, err := registries.FetchPackageFromPURL(ctx, purlStr, s.regClient) if err != nil { @@ -102,7 +103,7 @@ 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) ver, err := registries.FetchVersionFromPURL(ctx, purlStr, s.regClient) if err != nil { @@ -136,7 +137,7 @@ func (s *Service) EnrichVersion(ctx context.Context, ecosystem, name, version st 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[i] = packageurl.MakeString(pkg.Ecosystem, pkg.Name, "") } pkgData := registries.BulkFetchPackages(ctx, purls, s.regClient) @@ -174,7 +175,7 @@ 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) vulnList, err := s.vulnSource.Query(ctx, p) if err != nil { @@ -205,7 +206,7 @@ func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, ver func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struct{ Ecosystem, Name, Version string }) (map[string][]VulnInfo, error) { purls := make([]*purl.PURL, len(packages)) for i, pkg := range packages { - purls[i] = purl.MakePURL(pkg.Ecosystem, pkg.Name, pkg.Version) + purls[i] = packageurl.Make(pkg.Ecosystem, pkg.Name, pkg.Version) } vulnResults, err := s.vulnSource.QueryBatch(ctx, purls) @@ -216,7 +217,7 @@ func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struc result := make(map[string][]VulnInfo, len(packages)) for i, vulnList := range vulnResults { pkg := packages[i] - key := purl.MakePURLString(pkg.Ecosystem, pkg.Name, pkg.Version) + key := packageurl.MakeString(pkg.Ecosystem, pkg.Name, pkg.Version) var infos []VulnInfo for _, v := range vulnList { @@ -248,7 +249,7 @@ 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, "") latest, err := registries.FetchLatestVersionFromPURL(ctx, purlStr, s.regClient) if err != nil { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8dcc9c9..d0910a1 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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,7 @@ 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 { - p := purl.MakePURL(ecosystem, name, "") + p := packageurl.Make(ecosystem, name, "") _ = p.Normalize() return p.String() } @@ -153,16 +154,16 @@ func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version } metrics.RecordCacheMiss(ecosystem) - pkgPURL := purl.MakePURLString(ecosystem, name, "") - versionPURL := purl.MakePURLString(ecosystem, name, version) + pkgPURL := packageurl.MakeString(ecosystem, name, "") + versionPURL := packageurl.MakeString(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 := packageurl.MakeString(ecosystem, name, "") + versionPURL := packageurl.MakeString(ecosystem, name, version) return p.checkCache(ctx, pkgPURL, versionPURL, filename) } @@ -853,8 +854,8 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys } metrics.RecordCacheMiss(ecosystem) - pkgPURL := purl.MakePURLString(ecosystem, name, "") - versionPURL := purl.MakePURLString(ecosystem, name, version) + pkgPURL := packageurl.MakeString(ecosystem, name, "") + versionPURL := packageurl.MakeString(ecosystem, name, version) return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers) } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index d8660d7..a23935b 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -108,15 +108,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 diff --git a/internal/handler/swift.go b/internal/handler/swift.go new file mode 100644 index 0000000..14c9113 --- /dev/null +++ b/internal/handler/swift.go @@ -0,0 +1,534 @@ +package handler + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + swiftDefaultUpstream = "https://tuist.dev/api/registry/swift" + 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 = swiftDefaultUpstream + } + + 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 + } + + upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery) + cacheKey := swiftMetadataCacheKey(scope, name, "releases", 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 + } + + rewritten, err := h.rewriteReleaseURLs(scope, name, body) + if err != nil { + h.proxy.Logger.Warn("failed to rewrite Swift release URLs", "error", err) + rewritten = body + } + 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 + } + + 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 + } + + 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 + } + + packageName := scope + "/" + name + filename := fmt.Sprintf("%s-%s.zip", name, version) + upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery) + archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version) + if infoErr != nil { + h.proxy.Logger.Debug("failed to fetch Swift archive metadata", "error", infoErr) + } + + if r.Method == http.MethodHead { + h.handleSourceArchiveHead(w, r, packageName, version, filename, upstreamURL, archiveInfo) + return + } + + headers := make(http.Header) + headers.Set("Accept", requestAccept(r, swiftAcceptArchive)) + result, err := h.proxy.GetOrFetchArtifactFromURLWithHeaders( + r.Context(), "swift", packageName, version, filename, upstreamURL, headers, + ) + 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, + packageName, version, filename, upstreamURL string, + archiveInfo swiftArchiveInfo, +) { + result, err := h.proxy.GetCachedArtifact(r.Context(), "swift", packageName, version, filename) + if err != nil { + h.writeArtifactError(w, err) + return + } + if result != nil { + result.ContentType = "application/zip" + _, name, _ := strings.Cut(packageName, "/") + setSwiftArchiveHeaders(w.Header(), name, version, result.Hash, archiveInfo) + serveArtifact(w, r.Method, result) + return + } + + size, _, err := h.proxy.Fetcher.Head(r.Context(), upstreamURL) + if err != nil { + h.writeArtifactError(w, err) + return + } + _, name, _ := strings.Cut(packageName, "/") + 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) +} + +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 + } + info := swiftArchiveInfo{checksum: resource.Checksum} + if resource.Signing != nil { + info.signature = resource.Signing.Signature + info.signatureFormat = resource.Signing.Format + } + return info, nil + } + + return swiftArchiveInfo{}, 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 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 +} diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go new file mode 100644 index 0000000..6dc8029 --- /dev/null +++ b/internal/handler/swift_test.go @@ -0,0 +1,297 @@ +package handler + +import ( + "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") + _, _ = 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) + } + + 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", `; 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) + } + + versionPURL := packageurl.MakeString("swift", "apple/example", "1.2.3") + 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) + } + + 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 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) + } + }) + } +} diff --git a/internal/packageurl/packageurl.go b/internal/packageurl/packageurl.go new file mode 100644 index 0000000..d148d21 --- /dev/null +++ b/internal/packageurl/packageurl.go @@ -0,0 +1,25 @@ +// Package packageurl builds package URLs from ecosystem-native package names. +package packageurl + +import ( + "strings" + + "github.com/git-pkgs/purl" +) + +// Make constructs a package URL, including the namespace required by Swift +// registry package identifiers such as apple/swift-argument-parser. +func Make(ecosystem, name, version string) *purl.PURL { + if purl.NormalizeEcosystem(ecosystem) == "swift" { + if split := strings.LastIndexByte(name, '/'); split > 0 && split < len(name)-1 { + return purl.New("swift", name[:split], name[split+1:], version, nil) + } + } + + return purl.MakePURL(ecosystem, name, version) +} + +// MakeString constructs a package URL string. +func MakeString(ecosystem, name, version string) string { + return Make(ecosystem, name, version).String() +} diff --git a/internal/packageurl/packageurl_test.go b/internal/packageurl/packageurl_test.go new file mode 100644 index 0000000..705b653 --- /dev/null +++ b/internal/packageurl/packageurl_test.go @@ -0,0 +1,27 @@ +package packageurl + +import "testing" + +func TestMakeStringSwiftNamespace(t *testing.T) { + got := MakeString("swift", "apple/swift-argument-parser", "1.8.2") + want := "pkg:swift/apple/swift-argument-parser@1.8.2" + if got != want { + t.Errorf("MakeString() = %q, want %q", got, want) + } +} + +func TestMakeStringSwiftNestedNamespace(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 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) + } +} diff --git a/internal/server/browse.go b/internal/server/browse.go index 43ad9ae..802b022 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -14,7 +14,7 @@ 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/git-pkgs/proxy/internal/packageurl" "github.com/go-chi/chi/v5" ) @@ -226,7 +226,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 := packageurl.MakeString(ecosystem, name, version) artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL) if err != nil { notFound(w, "version not found") @@ -313,7 +313,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 := packageurl.MakeString(ecosystem, name, version) artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL) if err != nil { notFound(w, "version not found") @@ -534,8 +534,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 := packageurl.MakeString(ecosystem, name, fromVersion) + toPURL := packageurl.MakeString(ecosystem, name, toVersion) fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL) if err != nil || len(fromArtifacts) == 0 { diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 797a9ca..c4882fd 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -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({

Or inside a running session:

ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
 using Pkg; Pkg.update()
`), + }, + { + ID: "swift", + Name: "Swift Package Registry", + Language: "Swift", + Endpoint: "/swift/", + Instructions: template.HTML(`

Configure SwiftPM to use the proxy for this project:

+
swift package-registry set ` + swiftInsecureFlag + baseURL + `/swift
+

Use scoped package identifiers in Package.swift, for example apple.swift-argument-parser.

`), }, { ID: "oci", diff --git a/internal/server/server.go b/internal/server/server.go index 71af1af..3778454 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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.MakeString(ecosystem, name, version) ver, err := s.db.GetVersionByPURL(versionPURL) if err != nil || ver == nil { s.logger.Error("failed to get version", "error", err) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a870106..0fe3d17 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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,27 @@ 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 min(a, b int) int { if a < b { return a diff --git a/internal/server/templates_test.go b/internal/server/templates_test.go index e9a4967..d42b5c1 100644 --- a/internal/server/templates_test.go +++ b/internal/server/templates_test.go @@ -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() From 46076d8497e63e92c7041d4475aadb0fe01f5702 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 10:34:22 +0100 Subject: [PATCH 2/6] Fix Swift archive integrity handling --- internal/handler/handler.go | 57 ++++++++++++- internal/handler/swift.go | 62 +++++++++++++-- internal/handler/swift_test.go | 141 +++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 11 deletions(-) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d0910a1..2fe81aa 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -143,6 +143,7 @@ type CacheResult struct { ContentType string Hash string Cached bool + storagePath string } // GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream. @@ -203,6 +204,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 { @@ -847,19 +849,41 @@ 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, expectedHash string) (*CacheResult, error) { + pkgPURL := packageurl.MakeString(ecosystem, name, "") + versionPURL := packageurl.MakeString(ecosystem, name, version) + + if cached, err := p.getCachedArtifactWithExpectedHash(ctx, ecosystem, name, version, filename, expectedHash); err != nil { return nil, err } else if cached != nil { return cached, nil } metrics.RecordCacheMiss(ecosystem) - pkgPURL := packageurl.MakeString(ecosystem, name, "") + return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, expectedHash) +} + +func (p *Proxy) getCachedArtifactWithExpectedHash(ctx context.Context, ecosystem, name, version, filename, expectedHash string) (*CacheResult, error) { + cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename) + if err != nil || cached == nil { + return cached, err + } + if artifactHashMatches(cached.Hash, expectedHash) { + return cached, nil + } + + if cached.Reader != nil { + _ = cached.Reader.Close() + } versionPURL := packageurl.MakeString(ecosystem, name, version) - return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers) + p.discardCachedArtifact(ctx, versionPURL, filename, cached.storagePath) + return nil, artifactHashMismatchError(expectedHash, cached.Hash) } -func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) { +func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, expectedHash string) (*CacheResult, error) { p.Logger.Info("fetching from upstream", "ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL) @@ -877,6 +901,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, expectedHash) { + 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, artifactHashMismatchError(expectedHash, 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) @@ -895,3 +925,22 @@ 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 artifactHashMismatchError(expected, got string) error { + return fmt.Errorf("artifact checksum mismatch: expected %s, got %s", expected, got) +} + +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) + } +} diff --git a/internal/handler/swift.go b/internal/handler/swift.go index 14c9113..a7f6e3c 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -155,7 +155,8 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery) archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version) if infoErr != nil { - h.proxy.Logger.Debug("failed to fetch Swift archive metadata", "error", infoErr) + h.writeArtifactError(w, fmt.Errorf("fetching release metadata: %w", infoErr)) + return } if r.Method == http.MethodHead { @@ -165,8 +166,8 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques headers := make(http.Header) headers.Set("Accept", requestAccept(r, swiftAcceptArchive)) - result, err := h.proxy.GetOrFetchArtifactFromURLWithHeaders( - r.Context(), "swift", packageName, version, filename, upstreamURL, headers, + result, err := h.proxy.getOrFetchArtifactFromURL( + r.Context(), "swift", packageName, version, filename, upstreamURL, headers, archiveInfo.checksum, ) if err != nil { h.writeArtifactError(w, err) @@ -184,7 +185,9 @@ func (h *SwiftHandler) handleSourceArchiveHead( packageName, version, filename, upstreamURL string, archiveInfo swiftArchiveInfo, ) { - result, err := h.proxy.GetCachedArtifact(r.Context(), "swift", packageName, version, filename) + result, err := h.proxy.getCachedArtifactWithExpectedHash( + r.Context(), "swift", packageName, version, filename, archiveInfo.checksum, + ) if err != nil { h.writeArtifactError(w, err) return @@ -197,7 +200,7 @@ func (h *SwiftHandler) handleSourceArchiveHead( return } - size, _, err := h.proxy.Fetcher.Head(r.Context(), upstreamURL) + size, err := h.headSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive)) if err != nil { h.writeArtifactError(w, err) return @@ -211,6 +214,36 @@ func (h *SwiftHandler) handleSourceArchiveHead( w.WriteHeader(http.StatusOK) } +func (h *SwiftHandler) headSourceArchive(ctx context.Context, upstreamURL, accept string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, upstreamURL, nil) + if err != nil { + return 0, fmt.Errorf("creating upstream archive request: %w", err) + } + req.Header.Set("Accept", accept) + 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 { + return 0, fmt.Errorf("upstream archive returned %d", resp.StatusCode) + } + + 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 +} + type swiftReleaseMetadata struct { Resources []struct { Name string `json:"name"` @@ -246,15 +279,30 @@ func (h *SwiftHandler) fetchArchiveInfo(ctx context.Context, scope, name, versio if resource.Name != "source-archive" || resource.Type != "application/zip" { continue } - info := swiftArchiveInfo{checksum: resource.Checksum} + 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{}, 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) { diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go index 6dc8029..022b55f 100644 --- a/internal/handler/swift_test.go +++ b/internal/handler/swift_test.go @@ -1,6 +1,7 @@ package handler import ( + "context" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -229,6 +230,146 @@ func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) { } } +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) + } + versionPURL := packageurl.MakeString("swift", "apple/example", "1.2.3") + cached, err := db.GetCachedArtifact(packageurl.MakeString("swift", "apple/example", ""), 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 TestSwiftSourceArchiveHeadRejectsCachedChecksumMismatch(t *testing.T) { + archive := []byte("cached 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, _, store, fetcher := setupTestProxy(t) + fetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader(string(archive))), + Size: int64(len(archive)), + ContentType: "application/zip", + } + cached, err := proxy.GetOrFetchArtifactFromURL( + context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip", upstream.URL+"/apple/example/1.2.3.zip", + ) + 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.StatusBadGateway { + t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String()) + } + if len(store.files) != 0 { + t.Errorf("cached mismatched archive remained in storage: %v", store.files) + } +} + +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 TestSwiftSourceArchiveColdHeadSendsArchiveAccept(t *testing.T) { + checksum := strings.Repeat("a", sha256.Size*2) + var archiveAccept string + var archiveMethod string + 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": + archiveMethod = r.Method + archiveAccept = r.Header.Get("Accept") + w.Header().Set("Content-Length", "123") + 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.MethodHead { + t.Errorf("upstream method = %q, want HEAD", archiveMethod) + } + 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" { From b41cd20fdb5e56711629554f3f1c0ef5346538a1 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 18:04:53 +0100 Subject: [PATCH 3/6] Fix Swift registry pagination and archive HEAD requests --- internal/handler/swift.go | 63 ++++++++++++++++++++++++++++++---- internal/handler/swift_test.go | 26 +++++++++++--- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/internal/handler/swift.go b/internal/handler/swift.go index a7f6e3c..e089264 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -65,9 +65,8 @@ func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Requ } upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery) - cacheKey := swiftMetadataCacheKey(scope, name, "releases", r.URL.RawQuery) - body, contentType, err := h.proxy.FetchOrCacheMetadata( - r.Context(), "swift", cacheKey, upstreamURL, requestAccept(r, swiftAcceptJSON), + body, contentType, responseHeaders, err := h.fetchMetadataWithHeaders( + r.Context(), upstreamURL, requestAccept(r, swiftAcceptJSON), ) if err != nil { h.writeMetadataError(w, err) @@ -79,6 +78,9 @@ func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Requ 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) } @@ -200,7 +202,7 @@ func (h *SwiftHandler) handleSourceArchiveHead( return } - size, err := h.headSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive)) + size, err := h.probeSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive)) if err != nil { h.writeArtifactError(w, err) return @@ -214,12 +216,13 @@ func (h *SwiftHandler) handleSourceArchiveHead( w.WriteHeader(http.StatusOK) } -func (h *SwiftHandler) headSourceArchive(ctx context.Context, upstreamURL, accept string) (int64, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, upstreamURL, nil) +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) @@ -231,10 +234,21 @@ func (h *SwiftHandler) headSourceArchive(ctx context.Context, upstreamURL, accep if resp.StatusCode == http.StatusNotFound { return 0, ErrUpstreamNotFound } - if resp.StatusCode != http.StatusOK { + 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 { @@ -244,6 +258,41 @@ func (h *SwiftHandler) headSourceArchive(ctx context.Context, upstreamURL, accep 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"` diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go index 022b55f..4e03787 100644 --- a/internal/handler/swift_test.go +++ b/internal/handler/swift_test.go @@ -26,6 +26,7 @@ func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) { 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", `; rel="next"`) _, _ = io.WriteString(w, `{"releases":{"1.2.0":{"url":"/registry/apple/swift-argument-parser/1.2.0"},"1.1.0":{}}}`) })) defer upstream.Close() @@ -46,6 +47,9 @@ func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) { if got := w.Header().Get("Content-Version"); got != "1" { t.Errorf("Content-Version = %q, want 1", got) } + if got := w.Header().Get("Link"); got != `; rel="next"` { + t.Errorf("Link = %q", got) + } var body struct { Releases map[string]struct { @@ -330,19 +334,28 @@ func TestSwiftSourceArchiveRequiresReleaseMetadata(t *testing.T) { } } -func TestSwiftSourceArchiveColdHeadSendsArchiveAccept(t *testing.T) { +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": - archiveMethod = r.Method archiveAccept = r.Header.Get("Accept") - w.Header().Set("Content-Length", "123") + http.Redirect(w, r, download.URL, http.StatusSeeOther) default: http.NotFound(w, r) } @@ -359,8 +372,11 @@ func TestSwiftSourceArchiveColdHeadSendsArchiveAccept(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - if archiveMethod != http.MethodHead { - t.Errorf("upstream method = %q, want HEAD", archiveMethod) + 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) From 720b8fcc41880c4542f4deca34f5f04335b6cfd6 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 19:05:23 +0100 Subject: [PATCH 4/6] Canonicalize Swift package identifiers --- internal/handler/swift.go | 8 +++++ internal/handler/swift_test.go | 65 ++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/internal/handler/swift.go b/internal/handler/swift.go index e089264..6d78128 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -63,6 +63,7 @@ func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Requ 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( @@ -98,6 +99,7 @@ func (h *SwiftHandler) handleRelease(w http.ResponseWriter, r *http.Request) { 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( @@ -118,6 +120,7 @@ func (h *SwiftHandler) handleManifest(w http.ResponseWriter, r *http.Request) { 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) @@ -151,6 +154,7 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques writeSwiftProblem(w, http.StatusBadRequest, "invalid package release") return } + scope, name = canonicalSwiftPackage(scope, name) packageName := scope + "/" + name filename := fmt.Sprintf("%s-%s.zip", name, version) @@ -597,6 +601,10 @@ 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, "-") } diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go index 4e03787..3e63abf 100644 --- a/internal/handler/swift_test.go +++ b/internal/handler/swift_test.go @@ -33,7 +33,7 @@ func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes() - req := httptest.NewRequest(http.MethodGet, "/apple/swift-argument-parser", nil) + req := httptest.NewRequest(http.MethodGet, "/APPLE/SWIFT-ARGUMENT-PARSER", nil) req.Header.Set("Accept", swiftAcceptJSON) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -83,7 +83,7 @@ func TestSwiftReleaseMetadataSupportsJSONExtensionAndHead(t *testing.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) + req := httptest.NewRequest(method, "/APPLE/EXAMPLE/1.2.3.json", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { @@ -121,7 +121,7 @@ func TestSwiftManifestProxiesQueryAndRewritesLinks(t *testing.T) { 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 := 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) @@ -271,6 +271,65 @@ func TestSwiftSourceArchiveRejectsChecksumMismatch(t *testing.T) { } } +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.MakeString("swift", "apple/example", "") + 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.MakeString("swift", "APPLE/EXAMPLE", "") + nonCanonical, err := db.GetPackageByPURL(nonCanonicalPURL) + if err != nil { + t.Fatalf("getting non-canonical package: %v", err) + } + if nonCanonical != nil { + t.Errorf("non-canonical package %q was cached", nonCanonicalPURL) + } +} + func TestSwiftSourceArchiveHeadRejectsCachedChecksumMismatch(t *testing.T) { archive := []byte("cached archive") expectedChecksum := sha256.Sum256([]byte("expected archive")) From 1ab9d071c526f10ec668567124f8866a7481e218 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 16 Aug 2026 23:10:33 +0100 Subject: [PATCH 5/6] Handle Swift registry cache identities --- go.mod | 2 +- go.sum | 4 +- internal/config/config.go | 5 +- internal/enrichment/enrichment.go | 45 ++++++++++--- internal/enrichment/enrichment_test.go | 91 ++++++++++++++++++++++++++ internal/handler/handler.go | 53 +++++++++++---- internal/handler/handler_test.go | 22 +++++++ internal/handler/swift.go | 34 ++++++---- internal/handler/swift_test.go | 32 +++++---- internal/packageurl/packageurl.go | 49 +++++++++++--- internal/packageurl/packageurl_test.go | 53 +++++++++++++-- internal/server/browse.go | 9 ++- internal/server/server.go | 17 ++++- internal/server/server_test.go | 10 +++ 14 files changed, 351 insertions(+), 75 deletions(-) diff --git a/go.mod b/go.mod index 0bb2e1b..e03735a 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/git-pkgs/cooldown v0.1.1 github.com/git-pkgs/enrichment v0.6.5 github.com/git-pkgs/magic v0.2.0 - github.com/git-pkgs/purl v0.1.16 + github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162 github.com/git-pkgs/registries v0.7.0 github.com/git-pkgs/spdx v0.3.1 github.com/git-pkgs/vers v0.3.1 diff --git a/go.sum b/go.sum index 1ff787f..5559066 100644 --- a/go.sum +++ b/go.sum @@ -256,8 +256,8 @@ github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6 github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4= github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY= github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= -github.com/git-pkgs/purl v0.1.16 h1:VAX6tv0hhdTENbkrGMoPZbOAl1Y8U1/ZnzoCsYuNBYM= -github.com/git-pkgs/purl v0.1.16/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= +github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162 h1:+EbHLUV5ih8UX7GPUk+ohYxKmhIxqu+PaG+2kgwhPkU= +github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= github.com/git-pkgs/registries v0.7.0 h1:+LbOOMHbvjmXGfsi88hcGH+SfTXYsXA3UY5KYI5mB7s= github.com/git-pkgs/registries v0.7.0/go.mod h1:VCD4q+ZW0fInopzseg9rAmBEL553R2JQe60UHXtv26w= github.com/git-pkgs/spdx v0.3.1 h1:58JPY5X9pYpXvnzzZIgehItlBykeOOw52pNc4OBcS+c= diff --git a/internal/config/config.go b/internal/config/config.go index e18b88a..f33853c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"). @@ -479,7 +482,7 @@ func Default() *Config { GradlePluginPortal: "https://plugins.gradle.org/m2", Cargo: "https://index.crates.io", CargoDownload: "https://static.crates.io/crates", - Swift: "https://tuist.dev/api/registry/swift", + Swift: DefaultSwiftUpstream, Debian: "http://deb.debian.org/debian", }, Gradle: GradleConfig{ diff --git a/internal/enrichment/enrichment.go b/internal/enrichment/enrichment.go index 2b4ac14..0c63b91 100644 --- a/internal/enrichment/enrichment.go +++ b/internal/enrichment/enrichment.go @@ -69,6 +69,9 @@ 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 := packageurl.MakeString(ecosystem, name, "") + if purlStr == "" { + return nil, nil + } pkg, err := registries.FetchPackageFromPURL(ctx, purlStr, s.regClient) if err != nil { @@ -104,6 +107,9 @@ 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 := packageurl.MakeString(ecosystem, name, version) + if purlStr == "" { + return nil, nil + } ver, err := registries.FetchVersionFromPURL(ctx, purlStr, s.regClient) if err != nil { @@ -135,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] = packageurl.MakeString(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) @@ -148,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, @@ -176,6 +190,9 @@ 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 := packageurl.Make(ecosystem, name, version) + if p == nil { + return nil, nil + } vulnList, err := s.vulnSource.Query(ctx, p) if err != nil { @@ -204,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] = packageurl.Make(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) @@ -214,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 := packageurl.MakeString(pkg.Ecosystem, pkg.Name, pkg.Version) + pkg := packages[supported[i]] + key := purls[i].String() var infos []VulnInfo for _, v := range vulnList { @@ -250,6 +274,9 @@ 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 := packageurl.MakeString(ecosystem, name, "") + if purlStr == "" { + return "", nil + } latest, err := registries.FetchLatestVersionFromPURL(ctx, purlStr, s.regClient) if err != nil { diff --git a/internal/enrichment/enrichment_test.go b/internal/enrichment/enrichment_test.go index aa9a16e..10113e8 100644 --- a/internal/enrichment/enrichment_test.go +++ b/internal/enrichment/enrichment_test.go @@ -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) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 2fe81aa..65aa2a5 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -21,7 +21,6 @@ import ( "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" ) @@ -63,10 +62,24 @@ var artifactCopyBufferPool = sync.Pool{ //nolint:gochecknoglobals // shared acro // lookups match keys produced by config.CooldownConfig.NormalizedPackages. func canonicalPackagePURL(ecosystem, name string) string { p := packageurl.Make(ecosystem, name, "") + if p == nil { + return "" + } _ = p.Normalize() return p.String() } +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" const headerAcceptEncoding = "Accept-Encoding" @@ -148,23 +161,27 @@ type CacheResult struct { // 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 := packageurl.MakeString(ecosystem, name, "") - versionPURL := packageurl.MakeString(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 := packageurl.MakeString(ecosystem, name, "") - versionPURL := packageurl.MakeString(ecosystem, name, version) + pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version) + if err != nil { + return nil, err + } return p.checkCache(ctx, pkgPURL, versionPURL, filename) } @@ -174,8 +191,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) @@ -853,10 +872,17 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys } func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, expectedHash string) (*CacheResult, error) { - pkgPURL := packageurl.MakeString(ecosystem, name, "") - versionPURL := packageurl.MakeString(ecosystem, name, version) + 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, expectedHash, + ) +} - if cached, err := p.getCachedArtifactWithExpectedHash(ctx, ecosystem, name, version, filename, expectedHash); err != nil { +func (p *Proxy) getOrFetchArtifactFromURLWithCachePURLs(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, expectedHash string) (*CacheResult, error) { + if cached, err := p.getCachedArtifactWithExpectedHash(ctx, pkgPURL, versionPURL, filename, expectedHash); err != nil { return nil, err } else if cached != nil { return cached, nil @@ -866,8 +892,8 @@ func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, expectedHash) } -func (p *Proxy) getCachedArtifactWithExpectedHash(ctx context.Context, ecosystem, name, version, filename, expectedHash string) (*CacheResult, error) { - cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename) +func (p *Proxy) getCachedArtifactWithExpectedHash(ctx context.Context, pkgPURL, versionPURL, filename, expectedHash string) (*CacheResult, error) { + cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename) if err != nil || cached == nil { return cached, err } @@ -878,7 +904,6 @@ func (p *Proxy) getCachedArtifactWithExpectedHash(ctx context.Context, ecosystem if cached.Reader != nil { _ = cached.Reader.Close() } - versionPURL := packageurl.MakeString(ecosystem, name, version) p.discardCachedArtifact(ctx, versionPURL, filename, cached.storagePath) return nil, artifactHashMismatchError(expectedHash, cached.Hash) } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index a23935b..f3339c7 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -342,6 +342,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") diff --git a/internal/handler/swift.go b/internal/handler/swift.go index 6d78128..c02d7af 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -13,16 +13,18 @@ import ( "net/url" "strconv" "strings" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/packageurl" ) const ( - swiftDefaultUpstream = "https://tuist.dev/api/registry/swift" - 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 + 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. @@ -35,7 +37,7 @@ type SwiftHandler struct { // NewSwiftHandler creates a Swift Package Registry protocol handler. func NewSwiftHandler(proxy *Proxy, proxyURL, upstreamURL string) *SwiftHandler { if strings.TrimSpace(upstreamURL) == "" { - upstreamURL = swiftDefaultUpstream + upstreamURL = config.DefaultSwiftUpstream } return &SwiftHandler{ @@ -159,6 +161,11 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques 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, h.upstreamURL) + 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)) @@ -166,14 +173,15 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques } if r.Method == http.MethodHead { - h.handleSourceArchiveHead(w, r, packageName, version, filename, upstreamURL, archiveInfo) + h.handleSourceArchiveHead(w, r, packageName, version, filename, packagePURL, versionPURL, upstreamURL, archiveInfo) return } headers := make(http.Header) headers.Set("Accept", requestAccept(r, swiftAcceptArchive)) - result, err := h.proxy.getOrFetchArtifactFromURL( - r.Context(), "swift", packageName, version, filename, upstreamURL, headers, archiveInfo.checksum, + result, err := h.proxy.getOrFetchArtifactFromURLWithCachePURLs( + r.Context(), "swift", packageName, version, filename, packagePURL, versionPURL, + upstreamURL, headers, archiveInfo.checksum, ) if err != nil { h.writeArtifactError(w, err) @@ -188,11 +196,11 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques func (h *SwiftHandler) handleSourceArchiveHead( w http.ResponseWriter, r *http.Request, - packageName, version, filename, upstreamURL string, + packageName, version, filename, packagePURL, versionPURL, upstreamURL string, archiveInfo swiftArchiveInfo, ) { result, err := h.proxy.getCachedArtifactWithExpectedHash( - r.Context(), "swift", packageName, version, filename, archiveInfo.checksum, + r.Context(), packagePURL, versionPURL, filename, archiveInfo.checksum, ) if err != nil { h.writeArtifactError(w, err) diff --git a/internal/handler/swift_test.go b/internal/handler/swift_test.go index 3e63abf..ae3fc9e 100644 --- a/internal/handler/swift_test.go +++ b/internal/handler/swift_test.go @@ -204,7 +204,12 @@ func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) { t.Errorf("Content-Disposition = %q", got) } - versionPURL := packageurl.MakeString("swift", "apple/example", "1.2.3") + packagePURL, versionPURL := packageurl.MakeCacheStrings( + "swift", "apple/example", "1.2.3", upstream.URL+"/registry", + ) + 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) @@ -212,6 +217,9 @@ func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) { 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) @@ -261,8 +269,8 @@ func TestSwiftSourceArchiveRejectsChecksumMismatch(t *testing.T) { if len(store.files) != 0 { t.Errorf("mismatched archive remained in storage: %v", store.files) } - versionPURL := packageurl.MakeString("swift", "apple/example", "1.2.3") - cached, err := db.GetCachedArtifact(packageurl.MakeString("swift", "apple/example", ""), versionPURL, "example-1.2.3.zip") + packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3", upstream.URL) + cached, err := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip") if err != nil { t.Fatalf("checking cache: %v", err) } @@ -311,7 +319,7 @@ func TestSwiftSourceArchiveCanonicalizesPackageIdentity(t *testing.T) { } } - canonicalPURL := packageurl.MakeString("swift", "apple/example", "") + canonicalPURL, _ := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3", upstream.URL) canonical, err := db.GetPackageByPURL(canonicalPURL) if err != nil { t.Fatalf("getting canonical package: %v", err) @@ -320,13 +328,9 @@ func TestSwiftSourceArchiveCanonicalizesPackageIdentity(t *testing.T) { t.Fatalf("canonical package %q not found", canonicalPURL) } - nonCanonicalPURL := packageurl.MakeString("swift", "APPLE/EXAMPLE", "") - nonCanonical, err := db.GetPackageByPURL(nonCanonicalPURL) - if err != nil { - t.Fatalf("getting non-canonical package: %v", err) - } - if nonCanonical != nil { - t.Errorf("non-canonical package %q was cached", nonCanonicalPURL) + nonCanonicalPURL, _ := packageurl.MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3", upstream.URL) + if nonCanonicalPURL != canonicalPURL { + t.Errorf("uppercase cache PURL = %q, want %q", nonCanonicalPURL, canonicalPURL) } } @@ -346,8 +350,10 @@ func TestSwiftSourceArchiveHeadRejectsCachedChecksumMismatch(t *testing.T) { Size: int64(len(archive)), ContentType: "application/zip", } - cached, err := proxy.GetOrFetchArtifactFromURL( - context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip", upstream.URL+"/apple/example/1.2.3.zip", + packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3", upstream.URL) + 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) diff --git a/internal/packageurl/packageurl.go b/internal/packageurl/packageurl.go index d148d21..e782bd1 100644 --- a/internal/packageurl/packageurl.go +++ b/internal/packageurl/packageurl.go @@ -7,19 +7,48 @@ import ( "github.com/git-pkgs/purl" ) -// Make constructs a package URL, including the namespace required by Swift -// registry package identifiers such as apple/swift-argument-parser. +// Make constructs a package URL from an ecosystem-native package name. func Make(ecosystem, name, version string) *purl.PURL { - if purl.NormalizeEcosystem(ecosystem) == "swift" { - if split := strings.LastIndexByte(name, '/'); split > 0 && split < len(name)-1 { - return purl.New("swift", name[:split], name[split+1:], version, nil) - } - } - return purl.MakePURL(ecosystem, name, version) } -// MakeString constructs a package URL string. +// 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 Make(ecosystem, name, version).String() + return purl.MakePURLString(ecosystem, name, version) +} + +// 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. +func MakeCacheStrings(ecosystem, name, version, registryURL 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 "", "" + } + + var qualifiers map[string]string + if registryURL != "" { + qualifiers = map[string]string{"repository_url": strings.TrimRight(registryURL, "/")} + } + pkg := purl.New("generic", "swift-registry", identity, "", qualifiers) + 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 } diff --git a/internal/packageurl/packageurl_test.go b/internal/packageurl/packageurl_test.go index 705b653..9ac8001 100644 --- a/internal/packageurl/packageurl_test.go +++ b/internal/packageurl/packageurl_test.go @@ -2,15 +2,21 @@ package packageurl import "testing" -func TestMakeStringSwiftNamespace(t *testing.T) { - got := MakeString("swift", "apple/swift-argument-parser", "1.8.2") - want := "pkg:swift/apple/swift-argument-parser@1.8.2" - if got != want { - t.Errorf("MakeString() = %q, want %q", got, want) +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 TestMakeStringSwiftNestedNamespace(t *testing.T) { +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 { @@ -18,6 +24,41 @@ func TestMakeStringSwiftNestedNamespace(t *testing.T) { } } +func TestMakeCacheStringsSwiftRegistryIdentity(t *testing.T) { + packagePURL, versionPURL := MakeCacheStrings( + "swift", "APPLE/EXAMPLE", "1.2.3", "https://tuist.dev/api/registry/swift/", + ) + + wantPackage := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Ftuist.dev%2Fapi%2Fregistry%2Fswift" + if packagePURL != wantPackage { + t.Errorf("package PURL = %q, want %q", packagePURL, wantPackage) + } + wantVersion := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Ftuist.dev%2Fapi%2Fregistry%2Fswift" + if versionPURL != wantVersion { + t.Errorf("version PURL = %q, want %q", versionPURL, wantVersion) + } + + dottedPackage, dottedVersion := MakeCacheStrings( + "swift", "apple.example", "1.2.3", "https://tuist.dev/api/registry/swift", + ) + 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", "https://tuist.dev/api/registry/swift", + ) + + 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" diff --git a/internal/server/browse.go b/internal/server/browse.go index 802b022..c0581cc 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -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/proxy/internal/packageurl" "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 := packageurl.MakeString(ecosystem, name, version) + versionPURL := s.cachePURLString(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 := packageurl.MakeString(ecosystem, name, version) + versionPURL := s.cachePURLString(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 := packageurl.MakeString(ecosystem, name, fromVersion) - toPURL := packageurl.MakeString(ecosystem, name, toVersion) + fromPURL := s.cachePURLString(ecosystem, name, fromVersion) + toPURL := s.cachePURLString(ecosystem, name, toVersion) fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL) if err != nil || len(fromArtifacts) == 0 { diff --git a/internal/server/server.go b/internal/server/server.go index 3778454..a1822b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -816,7 +816,7 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem, return } - versionPURL := packageurl.MakeString(ecosystem, name, version) + versionPURL := s.cachePURLString(ecosystem, name, version) ver, err := s.db.GetVersionByPURL(versionPURL) if err != nil || ver == nil { s.logger.Error("failed to get version", "error", err) @@ -858,6 +858,21 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem, } } +func (s *Server) cachePURLString(ecosystem, name, version string) string { + registryURL := "" + if s.cfg != nil { + registryURL = s.cfg.Upstream.Swift + } + if registryURL == "" { + registryURL = config.DefaultSwiftUpstream + } + packagePURL, versionPURL := packageurl.MakeCacheStrings(ecosystem, name, version, registryURL) + if version == "" { + return packagePURL + } + return versionPURL +} + func (s *Server) showBrowseSource(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { data := BrowseSourceData{ Layout: s.layoutFor(r), diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0fe3d17..40fcad4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -351,6 +351,16 @@ func TestSwiftHandlerMounted(t *testing.T) { } } +func TestSwiftCachePURLString(t *testing.T) { + s := &Server{} + + got := s.cachePURLString("swift", "apple/example", "1.2.3") + want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Ftuist.dev%2Fapi%2Fregistry%2Fswift" + if got != want { + t.Errorf("cachePURLString() = %q, want %q", got, want) + } +} + func min(a, b int) int { if a < b { return a From d4be9062ba20cce284354c567c275fd45e8016da Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 16 Aug 2026 23:28:12 +0100 Subject: [PATCH 6/6] Use stored PURLs for cache lookups --- go.mod | 2 +- go.sum | 4 ++-- internal/packageurl/packageurl.go | 10 ++++++++++ internal/packageurl/packageurl_test.go | 13 +++++++++++++ internal/server/browse.go | 8 ++++---- internal/server/server.go | 19 ++++++------------- internal/server/server_test.go | 26 +++++++++++++++++++++----- 7 files changed, 57 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index e03735a..c8bb4f6 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/git-pkgs/cooldown v0.1.1 github.com/git-pkgs/enrichment v0.6.5 github.com/git-pkgs/magic v0.2.0 - github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162 + github.com/git-pkgs/purl v0.1.17 github.com/git-pkgs/registries v0.7.0 github.com/git-pkgs/spdx v0.3.1 github.com/git-pkgs/vers v0.3.1 diff --git a/go.sum b/go.sum index 5559066..699e1cc 100644 --- a/go.sum +++ b/go.sum @@ -256,8 +256,8 @@ github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6 github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4= github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY= github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= -github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162 h1:+EbHLUV5ih8UX7GPUk+ohYxKmhIxqu+PaG+2kgwhPkU= -github.com/git-pkgs/purl v0.1.17-0.20260816214411-73c23ea64162/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= +github.com/git-pkgs/purl v0.1.17 h1:oRSd8tqllTLl74Wa4WnuqU500hXd9OdUnImOEswQUVE= +github.com/git-pkgs/purl v0.1.17/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= github.com/git-pkgs/registries v0.7.0 h1:+LbOOMHbvjmXGfsi88hcGH+SfTXYsXA3UY5KYI5mB7s= github.com/git-pkgs/registries v0.7.0/go.mod h1:VCD4q+ZW0fInopzseg9rAmBEL553R2JQe60UHXtv26w= github.com/git-pkgs/spdx v0.3.1 h1:58JPY5X9pYpXvnzzZIgehItlBykeOOw52pNc4OBcS+c= diff --git a/internal/packageurl/packageurl.go b/internal/packageurl/packageurl.go index e782bd1..68fad64 100644 --- a/internal/packageurl/packageurl.go +++ b/internal/packageurl/packageurl.go @@ -18,6 +18,16 @@ 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. diff --git a/internal/packageurl/packageurl_test.go b/internal/packageurl/packageurl_test.go index 9ac8001..091fb63 100644 --- a/internal/packageurl/packageurl_test.go +++ b/internal/packageurl/packageurl_test.go @@ -24,6 +24,19 @@ func TestMakeStringSwiftSourceCoordinate(t *testing.T) { } } +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", "https://tuist.dev/api/registry/swift/", diff --git a/internal/server/browse.go b/internal/server/browse.go index c0581cc..fc5e658 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -225,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 := s.cachePURLString(ecosystem, name, version) + versionPURL := s.cachedVersionPURL(ecosystem, name, version) artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL) if err != nil { notFound(w, "version not found") @@ -312,7 +312,7 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n } // Get the artifact for this version - versionPURL := s.cachePURLString(ecosystem, name, version) + versionPURL := s.cachedVersionPURL(ecosystem, name, version) artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL) if err != nil { notFound(w, "version not found") @@ -533,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 := s.cachePURLString(ecosystem, name, fromVersion) - toPURL := s.cachePURLString(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 { diff --git a/internal/server/server.go b/internal/server/server.go index a1822b4..c4f5e23 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -816,7 +816,7 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem, return } - versionPURL := s.cachePURLString(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) @@ -858,19 +858,12 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem, } } -func (s *Server) cachePURLString(ecosystem, name, version string) string { - registryURL := "" - if s.cfg != nil { - registryURL = s.cfg.Upstream.Swift - } - if registryURL == "" { - registryURL = config.DefaultSwiftUpstream - } - packagePURL, versionPURL := packageurl.MakeCacheStrings(ecosystem, name, version, registryURL) - if version == "" { - return packagePURL +func (s *Server) cachedVersionPURL(ecosystem, name, version string) string { + pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) + if err != nil || pkg == nil { + return "" } - return versionPURL + return packageurl.WithVersionString(pkg.PURL, version) } func (s *Server) showBrowseSource(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 40fcad4..db788d1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -351,13 +351,29 @@ func TestSwiftHandlerMounted(t *testing.T) { } } -func TestSwiftCachePURLString(t *testing.T) { - s := &Server{} +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) + } - got := s.cachePURLString("swift", "apple/example", "1.2.3") - want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Ftuist.dev%2Fapi%2Fregistry%2Fswift" + 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("cachePURLString() = %q, want %q", got, want) + t.Errorf("cachedVersionPURL() = %q, want %q", got, want) } }