mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 04:14:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a64d9ad9a |
||
|
|
7f04f4faac |
||
|
|
535617197d |
||
|
|
92cae1e1e7 |
||
|
|
53f2fee3c3 |
||
|
|
0af8d6b654 |
||
|
|
38264bdd65 |
20 changed files with 1705 additions and 35 deletions
24
README.md
24
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 |
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DefaultSwiftUpstream is the Swift Package Registry used when none is configured.
|
||||
const DefaultSwiftUpstream = "https://tuist.dev/api/registry/swift"
|
||||
|
||||
// Config holds all configuration for the proxy server.
|
||||
type Config struct {
|
||||
// Listen is the address to listen on (e.g., ":8080", "127.0.0.1:8080").
|
||||
|
|
@ -313,6 +316,10 @@ type UpstreamConfig struct {
|
|||
// Default: https://static.crates.io/crates
|
||||
CargoDownload string `json:"cargo_download" yaml:"cargo_download"`
|
||||
|
||||
// Swift is the upstream Swift Package Registry URL.
|
||||
// Default: https://tuist.dev/api/registry/swift
|
||||
Swift string `json:"swift" yaml:"swift"`
|
||||
|
||||
// Debian is the upstream APT repository base URL.
|
||||
// Example: http://archive.ubuntu.com/ubuntu would get Ubuntu.
|
||||
// Default: http://deb.debian.org/debian
|
||||
|
|
@ -475,6 +482,7 @@ func Default() *Config {
|
|||
GradlePluginPortal: "https://plugins.gradle.org/m2",
|
||||
Cargo: "https://index.crates.io",
|
||||
CargoDownload: "https://static.crates.io/crates",
|
||||
Swift: DefaultSwiftUpstream,
|
||||
Debian: "http://deb.debian.org/debian",
|
||||
},
|
||||
Gradle: GradleConfig{
|
||||
|
|
@ -546,6 +554,7 @@ func setEnvBool(dst *bool, key string) {
|
|||
// - PROXY_LOG_LEVEL
|
||||
// - PROXY_LOG_FORMAT
|
||||
// - PROXY_ACCESS_LOG_PATH
|
||||
// - PROXY_UPSTREAM_SWIFT
|
||||
// - PROXY_HEALTH_STORAGE_PROBE_INTERVAL
|
||||
func (c *Config) LoadFromEnv() {
|
||||
setEnvString(&c.Listen, "PROXY_LISTEN")
|
||||
|
|
@ -565,6 +574,7 @@ func (c *Config) LoadFromEnv() {
|
|||
setEnvString(&c.AccessLog.Path, "PROXY_ACCESS_LOG_PATH")
|
||||
setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN")
|
||||
setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL")
|
||||
setEnvString(&c.Upstream.Swift, "PROXY_UPSTREAM_SWIFT")
|
||||
setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN")
|
||||
setEnvString(&c.Cooldown.Default, "PROXY_COOLDOWN_DEFAULT")
|
||||
setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/packageurl"
|
||||
"github.com/git-pkgs/purl"
|
||||
"github.com/git-pkgs/registries"
|
||||
_ "github.com/git-pkgs/registries/all" // Import all registry implementations
|
||||
|
|
@ -67,7 +68,10 @@ type VulnInfo struct {
|
|||
|
||||
// EnrichPackage fetches metadata for a package from registry APIs.
|
||||
func (s *Service) EnrichPackage(ctx context.Context, ecosystem, name string) (*PackageInfo, error) {
|
||||
purlStr := purl.MakePURLString(ecosystem, name, "")
|
||||
purlStr := packageurl.MakeString(ecosystem, name, "")
|
||||
if purlStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pkg, err := registries.FetchPackageFromPURL(ctx, purlStr, s.regClient)
|
||||
if err != nil {
|
||||
|
|
@ -102,7 +106,10 @@ func (s *Service) EnrichPackage(ctx context.Context, ecosystem, name string) (*P
|
|||
|
||||
// EnrichVersion fetches metadata for a specific package version.
|
||||
func (s *Service) EnrichVersion(ctx context.Context, ecosystem, name, version string) (*VersionInfo, error) {
|
||||
purlStr := purl.MakePURLString(ecosystem, name, version)
|
||||
purlStr := packageurl.MakeString(ecosystem, name, version)
|
||||
if purlStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ver, err := registries.FetchVersionFromPURL(ctx, purlStr, s.regClient)
|
||||
if err != nil {
|
||||
|
|
@ -134,9 +141,14 @@ func (s *Service) EnrichVersion(ctx context.Context, ecosystem, name, version st
|
|||
|
||||
// BulkEnrichPackages fetches metadata for multiple packages in parallel.
|
||||
func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Ecosystem, Name string }) map[string]*PackageInfo {
|
||||
purls := make([]string, len(packages))
|
||||
for i, pkg := range packages {
|
||||
purls[i] = purl.MakePURLString(pkg.Ecosystem, pkg.Name, "")
|
||||
purls := make([]string, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
if purlStr := packageurl.MakeString(pkg.Ecosystem, pkg.Name, ""); purlStr != "" {
|
||||
purls = append(purls, purlStr)
|
||||
}
|
||||
}
|
||||
if len(purls) == 0 {
|
||||
return map[string]*PackageInfo{}
|
||||
}
|
||||
|
||||
pkgData := registries.BulkFetchPackages(ctx, purls, s.regClient)
|
||||
|
|
@ -147,7 +159,10 @@ func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Eco
|
|||
continue
|
||||
}
|
||||
|
||||
p, _ := purl.Parse(purlStr)
|
||||
p, err := purl.Parse(purlStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info := &PackageInfo{
|
||||
Ecosystem: p.Type,
|
||||
Name: pkg.Name,
|
||||
|
|
@ -174,7 +189,10 @@ func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Eco
|
|||
|
||||
// CheckVulnerabilities queries for vulnerabilities affecting a package version.
|
||||
func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, version string) ([]VulnInfo, error) {
|
||||
p := purl.MakePURL(ecosystem, name, version)
|
||||
p := packageurl.Make(ecosystem, name, version)
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
vulnList, err := s.vulnSource.Query(ctx, p)
|
||||
if err != nil {
|
||||
|
|
@ -203,9 +221,17 @@ func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, ver
|
|||
|
||||
// BulkCheckVulnerabilities queries vulnerabilities for multiple package versions.
|
||||
func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struct{ Ecosystem, Name, Version string }) (map[string][]VulnInfo, error) {
|
||||
purls := make([]*purl.PURL, len(packages))
|
||||
purls := make([]*purl.PURL, 0, len(packages))
|
||||
supported := make([]int, 0, len(packages))
|
||||
for i, pkg := range packages {
|
||||
purls[i] = purl.MakePURL(pkg.Ecosystem, pkg.Name, pkg.Version)
|
||||
if packagePURL := packageurl.Make(pkg.Ecosystem, pkg.Name, pkg.Version); packagePURL != nil {
|
||||
purls = append(purls, packagePURL)
|
||||
supported = append(supported, i)
|
||||
}
|
||||
}
|
||||
result := make(map[string][]VulnInfo, len(purls))
|
||||
if len(purls) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
vulnResults, err := s.vulnSource.QueryBatch(ctx, purls)
|
||||
|
|
@ -213,10 +239,9 @@ func (s *Service) BulkCheckVulnerabilities(ctx context.Context, packages []struc
|
|||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]VulnInfo, len(packages))
|
||||
for i, vulnList := range vulnResults {
|
||||
pkg := packages[i]
|
||||
key := purl.MakePURLString(pkg.Ecosystem, pkg.Name, pkg.Version)
|
||||
pkg := packages[supported[i]]
|
||||
key := purls[i].String()
|
||||
|
||||
var infos []VulnInfo
|
||||
for _, v := range vulnList {
|
||||
|
|
@ -248,7 +273,10 @@ func (s *Service) IsOutdated(currentVersion, latestVersion string) bool {
|
|||
|
||||
// GetLatestVersion fetches the latest version for a package.
|
||||
func (s *Service) GetLatestVersion(ctx context.Context, ecosystem, name string) (string, error) {
|
||||
purlStr := purl.MakePURLString(ecosystem, name, "")
|
||||
purlStr := packageurl.MakeString(ecosystem, name, "")
|
||||
if purlStr == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
latest, err := registries.FetchLatestVersionFromPURL(ctx, purlStr, s.regClient)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/git-pkgs/cooldown"
|
||||
"github.com/git-pkgs/proxy/internal/database"
|
||||
"github.com/git-pkgs/proxy/internal/metrics"
|
||||
"github.com/git-pkgs/proxy/internal/packageurl"
|
||||
"github.com/git-pkgs/proxy/internal/storage"
|
||||
"github.com/git-pkgs/purl"
|
||||
"github.com/git-pkgs/registries/fetch"
|
||||
|
|
@ -61,7 +62,18 @@ var artifactCopyBufferPool = sync.Pool{ //nolint:gochecknoglobals // shared acro
|
|||
// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
|
||||
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
|
||||
func canonicalPackagePURL(ecosystem, name string) string {
|
||||
return purl.MakePURLString(ecosystem, name, "")
|
||||
return packageurl.MakeString(ecosystem, name, "")
|
||||
}
|
||||
|
||||
var errUnsupportedPackageIdentity = errors.New("package identity cannot be represented as a PURL")
|
||||
|
||||
func packagePURLStrings(ecosystem, name, version string) (string, string, error) {
|
||||
packagePURL := packageurl.MakeString(ecosystem, name, "")
|
||||
versionPURL := packageurl.MakeString(ecosystem, name, version)
|
||||
if packagePURL == "" || versionPURL == "" {
|
||||
return "", "", fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
|
||||
}
|
||||
return packagePURL, versionPURL, nil
|
||||
}
|
||||
|
||||
const contentTypeJSON = "application/json"
|
||||
|
|
@ -140,27 +152,32 @@ type CacheResult struct {
|
|||
ContentType string
|
||||
Hash string
|
||||
Cached bool
|
||||
storagePath string
|
||||
}
|
||||
|
||||
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
|
||||
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
|
||||
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
|
||||
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
|
||||
return nil, err
|
||||
} else if cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
metrics.RecordCacheMiss(ecosystem)
|
||||
|
||||
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
return p.fetchAndCache(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL)
|
||||
}
|
||||
|
||||
// GetCachedArtifact retrieves an artifact from cache without contacting an upstream.
|
||||
// It returns nil when no usable cache entry exists.
|
||||
func (p *Proxy) GetCachedArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
|
||||
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.checkCache(ctx, pkgPURL, versionPURL, filename)
|
||||
}
|
||||
|
||||
|
|
@ -170,8 +187,10 @@ func (p *Proxy) ClearCachedArtifact(ctx context.Context, ecosystem, name, versio
|
|||
if p.DB == nil || p.Storage == nil {
|
||||
return nil
|
||||
}
|
||||
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cached, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up cached artifact: %w", err)
|
||||
|
|
@ -205,6 +224,7 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
|
|||
ContentType: artifact.ContentType.String,
|
||||
Hash: artifact.ContentHash.String,
|
||||
Cached: true,
|
||||
storagePath: artifact.StoragePath,
|
||||
}
|
||||
|
||||
if p.DirectServe {
|
||||
|
|
@ -864,19 +884,55 @@ func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name,
|
|||
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
|
||||
// with additional request-specific HTTP headers.
|
||||
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
|
||||
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
|
||||
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "")
|
||||
}
|
||||
|
||||
func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
|
||||
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getOrFetchArtifactFromURLWithCachePURLs(
|
||||
ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Proxy) getOrFetchArtifactFromURLWithCachePURLs(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
|
||||
if cached, err := p.getCachedArtifactWithUpstreamHash(ctx, pkgPURL, versionPURL, filename, upstreamHash); err != nil {
|
||||
return nil, err
|
||||
} else if cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
metrics.RecordCacheMiss(ecosystem)
|
||||
|
||||
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers)
|
||||
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash)
|
||||
}
|
||||
|
||||
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) {
|
||||
// getCachedArtifactWithUpstreamHash returns a cached artifact whose recorded
|
||||
// content hash matches the checksum the upstream currently declares for it.
|
||||
// This detects an upstream re-publishing under the same version, which the
|
||||
// stream integrity check in checkCache cannot: that check only verifies the
|
||||
// stored blob against the hash recorded when it was cached. On mismatch the
|
||||
// stale entry is discarded and nil is returned so the caller re-fetches.
|
||||
func (p *Proxy) getCachedArtifactWithUpstreamHash(ctx context.Context, pkgPURL, versionPURL, filename, upstreamHash string) (*CacheResult, error) {
|
||||
cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename)
|
||||
if err != nil || cached == nil {
|
||||
return cached, err
|
||||
}
|
||||
if artifactHashMatches(cached.Hash, upstreamHash) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if cached.Reader != nil {
|
||||
_ = cached.Reader.Close()
|
||||
}
|
||||
p.Logger.Warn("cached artifact hash disagrees with upstream metadata, discarding",
|
||||
"purl", versionPURL, "filename", filename, "cached", cached.Hash, "upstream", upstreamHash)
|
||||
p.discardCachedArtifact(ctx, versionPURL, filename, cached.storagePath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) {
|
||||
p.Logger.Info("fetching from upstream",
|
||||
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
|
||||
|
||||
|
|
@ -894,6 +950,12 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("storing artifact: %w", err)
|
||||
}
|
||||
if !artifactHashMatches(hash, upstreamHash) {
|
||||
if err := p.Storage.Delete(ctx, storagePath); err != nil {
|
||||
p.Logger.Warn("failed to discard artifact with mismatched checksum", "path", storagePath, "error", err)
|
||||
}
|
||||
return nil, fmt.Errorf("artifact checksum mismatch: upstream declared %s, got %s", upstreamHash, hash)
|
||||
}
|
||||
|
||||
if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
|
||||
p.Logger.Warn("failed to update cache database", "error", err)
|
||||
|
|
@ -912,3 +974,18 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
|
|||
Cached: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func artifactHashMatches(got, expected string) bool {
|
||||
return expected == "" || strings.EqualFold(got, expected)
|
||||
}
|
||||
|
||||
func (p *Proxy) discardCachedArtifact(ctx context.Context, versionPURL, filename, storagePath string) {
|
||||
if storagePath != "" {
|
||||
if err := p.Storage.Delete(ctx, storagePath); err != nil {
|
||||
p.Logger.Warn("failed to discard cached artifact", "path", storagePath, "error", err)
|
||||
}
|
||||
}
|
||||
if err := p.DB.ClearArtifactCache(versionPURL, filename); err != nil {
|
||||
p.Logger.Warn("failed to clear artifact cache record", "purl", versionPURL, "filename", filename, "error", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,15 +105,17 @@ type mockFetcher struct {
|
|||
fetchErrByURL map[string]error
|
||||
fetchCalled bool
|
||||
fetchedURL string
|
||||
fetchedHeader http.Header
|
||||
}
|
||||
|
||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
|
||||
return f.FetchWithHeaders(ctx, url, nil)
|
||||
}
|
||||
|
||||
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
|
||||
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
|
||||
f.fetchCalled = true
|
||||
f.fetchedURL = url
|
||||
f.fetchedHeader = headers.Clone()
|
||||
if f.fetchErrByURL != nil {
|
||||
if err, ok := f.fetchErrByURL[url]; ok {
|
||||
return nil, err
|
||||
|
|
@ -403,6 +405,28 @@ func TestGetOrFetchArtifactFromURL_CacheMiss_StorageMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArtifactCacheRejectsUnsupportedPackageIdentity(t *testing.T) {
|
||||
proxy, _, _, fetcher := setupTestProxy(t)
|
||||
|
||||
_, err := proxy.GetCachedArtifact(
|
||||
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
|
||||
)
|
||||
if !errors.Is(err, errUnsupportedPackageIdentity) {
|
||||
t.Fatalf("GetCachedArtifact() error = %v, want unsupported package identity", err)
|
||||
}
|
||||
|
||||
_, err = proxy.GetOrFetchArtifactFromURL(
|
||||
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
|
||||
"https://registry.example/apple/example/1.2.3.zip",
|
||||
)
|
||||
if !errors.Is(err, errUnsupportedPackageIdentity) {
|
||||
t.Fatalf("GetOrFetchArtifactFromURL() error = %v, want unsupported package identity", err)
|
||||
}
|
||||
if fetcher.fetchCalled {
|
||||
t.Error("unsupported package identity reached the artifact fetcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrFetchArtifact_DirectServe_Redirect(t *testing.T) {
|
||||
proxy, db, store, fetcher := setupTestProxy(t)
|
||||
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
|
||||
|
|
|
|||
645
internal/handler/swift.go
Normal file
645
internal/handler/swift.go
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/config"
|
||||
"github.com/git-pkgs/proxy/internal/packageurl"
|
||||
)
|
||||
|
||||
const (
|
||||
swiftAcceptJSON = "application/vnd.swift.registry.v1+json"
|
||||
swiftAcceptManifest = "application/vnd.swift.registry.v1+swift"
|
||||
swiftAcceptArchive = "application/vnd.swift.registry.v1+zip"
|
||||
swiftContentVersion = "1"
|
||||
swiftMaxScopeLength = 39
|
||||
swiftMaxNameLength = 100
|
||||
)
|
||||
|
||||
// SwiftHandler handles the read-only Swift Package Registry v1 protocol.
|
||||
type SwiftHandler struct {
|
||||
proxy *Proxy
|
||||
upstreamURL string
|
||||
proxyURL string
|
||||
}
|
||||
|
||||
// NewSwiftHandler creates a Swift Package Registry protocol handler.
|
||||
func NewSwiftHandler(proxy *Proxy, proxyURL, upstreamURL string) *SwiftHandler {
|
||||
if strings.TrimSpace(upstreamURL) == "" {
|
||||
upstreamURL = config.DefaultSwiftUpstream
|
||||
}
|
||||
|
||||
return &SwiftHandler{
|
||||
proxy: proxy,
|
||||
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
|
||||
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for Swift registry requests.
|
||||
func (h *SwiftHandler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /identifiers", h.handleIdentifiers)
|
||||
mux.HandleFunc("GET /{scope}/{name}/{version}/Package.swift", h.handleManifest)
|
||||
mux.HandleFunc("GET /{scope}/{name}/{version}", h.handleRelease)
|
||||
mux.HandleFunc("PUT /{scope}/{name}/{version}", h.handlePublishingUnsupported)
|
||||
mux.HandleFunc("GET /{scope}/{name}", h.handlePackageReleases)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.PathValue("scope")
|
||||
name := strings.TrimSuffix(r.PathValue("name"), ".json")
|
||||
if !validSwiftScope(scope) || !validSwiftPackageName(name) {
|
||||
writeSwiftProblem(w, http.StatusBadRequest, "invalid package identifier")
|
||||
return
|
||||
}
|
||||
scope, name = canonicalSwiftPackage(scope, name)
|
||||
|
||||
upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery)
|
||||
body, contentType, responseHeaders, err := h.fetchMetadataWithHeaders(
|
||||
r.Context(), upstreamURL, requestAccept(r, swiftAcceptJSON),
|
||||
)
|
||||
if err != nil {
|
||||
h.writeMetadataError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
rewritten, err := h.rewriteReleaseURLs(scope, name, body)
|
||||
if err != nil {
|
||||
h.proxy.Logger.Warn("failed to rewrite Swift release URLs", "error", err)
|
||||
rewritten = body
|
||||
}
|
||||
for _, link := range responseHeaders.Values("Link") {
|
||||
w.Header().Add("Link", h.rewriteLinkHeader(link, upstreamURL))
|
||||
}
|
||||
writeSwiftMetadata(w, r, rewritten, contentType)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handleRelease(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.PathValue("scope")
|
||||
name := r.PathValue("name")
|
||||
version := r.PathValue("version")
|
||||
if strings.HasSuffix(version, ".zip") {
|
||||
h.handleSourceArchive(w, r, scope, name, strings.TrimSuffix(version, ".zip"))
|
||||
return
|
||||
}
|
||||
|
||||
version = strings.TrimSuffix(version, ".json")
|
||||
if !validSwiftPackageReference(scope, name, version) {
|
||||
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
|
||||
return
|
||||
}
|
||||
scope, name = canonicalSwiftPackage(scope, name)
|
||||
|
||||
upstreamURL := h.buildUpstreamURL(scope, name, version, "", r.URL.RawQuery)
|
||||
body, contentType, err := h.proxy.FetchOrCacheMetadata(
|
||||
r.Context(), "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, requestAccept(r, swiftAcceptJSON),
|
||||
)
|
||||
if err != nil {
|
||||
h.writeMetadataError(w, err)
|
||||
return
|
||||
}
|
||||
writeSwiftMetadata(w, r, body, contentType)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.PathValue("scope")
|
||||
name := r.PathValue("name")
|
||||
version := r.PathValue("version")
|
||||
if !validSwiftPackageReference(scope, name, version) {
|
||||
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
|
||||
return
|
||||
}
|
||||
scope, name = canonicalSwiftPackage(scope, name)
|
||||
|
||||
upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", r.URL.RawQuery)
|
||||
h.proxySwiftResource(w, r, upstreamURL, swiftAcceptManifest)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handleIdentifiers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("url") == "" {
|
||||
writeSwiftProblem(w, http.StatusBadRequest, "url query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
upstreamURL := h.upstreamURL + "/identifiers?" + r.URL.RawQuery
|
||||
cacheKey := swiftMetadataCacheKey("identifiers", r.URL.RawQuery)
|
||||
body, contentType, err := h.proxy.FetchOrCacheMetadata(
|
||||
r.Context(), "swift", cacheKey, upstreamURL, requestAccept(r, swiftAcceptJSON),
|
||||
)
|
||||
if err != nil {
|
||||
h.writeMetadataError(w, err)
|
||||
return
|
||||
}
|
||||
writeSwiftMetadata(w, r, body, contentType)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handlePublishingUnsupported(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
writeSwiftProblem(w, http.StatusMethodNotAllowed, "publishing isn't supported")
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Request, scope, name, version string) {
|
||||
if !validSwiftPackageReference(scope, name, version) {
|
||||
writeSwiftProblem(w, http.StatusBadRequest, "invalid package release")
|
||||
return
|
||||
}
|
||||
scope, name = canonicalSwiftPackage(scope, name)
|
||||
|
||||
packageName := scope + "/" + name
|
||||
filename := fmt.Sprintf("%s-%s.zip", name, version)
|
||||
upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery)
|
||||
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", packageName, version)
|
||||
if packagePURL == "" || versionPURL == "" {
|
||||
h.writeArtifactError(w, fmt.Errorf("%w: swift %q", errUnsupportedPackageIdentity, packageName))
|
||||
return
|
||||
}
|
||||
archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version)
|
||||
if infoErr != nil {
|
||||
h.writeArtifactError(w, fmt.Errorf("fetching release metadata: %w", infoErr))
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
h.handleSourceArchiveHead(w, r, name, version, filename, packagePURL, versionPURL, upstreamURL, archiveInfo)
|
||||
return
|
||||
}
|
||||
|
||||
headers := make(http.Header)
|
||||
headers.Set("Accept", requestAccept(r, swiftAcceptArchive))
|
||||
result, err := h.proxy.getOrFetchArtifactFromURLWithCachePURLs(
|
||||
r.Context(), "swift", packageName, version, filename, packagePURL, versionPURL,
|
||||
upstreamURL, headers, archiveInfo.checksum,
|
||||
)
|
||||
if err != nil {
|
||||
h.writeArtifactError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result.ContentType = "application/zip"
|
||||
setSwiftArchiveHeaders(w.Header(), name, version, result.Hash, archiveInfo)
|
||||
serveArtifact(w, r.Method, result)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) handleSourceArchiveHead(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
name, version, filename, packagePURL, versionPURL, upstreamURL string,
|
||||
archiveInfo swiftArchiveInfo,
|
||||
) {
|
||||
result, err := h.proxy.getCachedArtifactWithUpstreamHash(
|
||||
r.Context(), packagePURL, versionPURL, filename, archiveInfo.checksum,
|
||||
)
|
||||
if err != nil {
|
||||
h.writeArtifactError(w, err)
|
||||
return
|
||||
}
|
||||
if result != nil {
|
||||
result.ContentType = "application/zip"
|
||||
setSwiftArchiveHeaders(w.Header(), name, version, result.Hash, archiveInfo)
|
||||
serveArtifact(w, r.Method, result)
|
||||
return
|
||||
}
|
||||
|
||||
size, err := h.probeSourceArchive(r.Context(), upstreamURL, requestAccept(r, swiftAcceptArchive))
|
||||
if err != nil {
|
||||
h.writeArtifactError(w, err)
|
||||
return
|
||||
}
|
||||
setSwiftArchiveHeaders(w.Header(), name, version, "", archiveInfo)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
if size >= 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) probeSourceArchive(ctx context.Context, upstreamURL, accept string) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating upstream archive request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
h.proxy.applyUpstreamAuth(req)
|
||||
|
||||
resp, err := h.proxy.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("requesting upstream archive: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return 0, ErrUpstreamNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
return 0, fmt.Errorf("upstream archive returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusPartialContent {
|
||||
_, total, found := strings.Cut(resp.Header.Get("Content-Range"), "/")
|
||||
if !found || total == "*" {
|
||||
return -1, nil
|
||||
}
|
||||
if parsed, parseErr := strconv.ParseInt(total, 10, 64); parseErr == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
size := int64(-1)
|
||||
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
||||
if parsed, parseErr := strconv.ParseInt(contentLength, 10, 64); parseErr == nil {
|
||||
size = parsed
|
||||
}
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) fetchMetadataWithHeaders(
|
||||
ctx context.Context,
|
||||
upstreamURL, accept string,
|
||||
) ([]byte, string, http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("creating upstream metadata request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
h.proxy.applyUpstreamAuth(req)
|
||||
|
||||
resp, err := h.proxy.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("requesting upstream metadata: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, "", nil, ErrUpstreamNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", nil, fmt.Errorf("upstream metadata returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := h.proxy.ReadMetadata(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("reading upstream metadata: %w", err)
|
||||
}
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = contentTypeJSON
|
||||
}
|
||||
return body, contentType, resp.Header.Clone(), nil
|
||||
}
|
||||
|
||||
type swiftReleaseMetadata struct {
|
||||
Resources []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Checksum string `json:"checksum"`
|
||||
Signing *struct {
|
||||
Signature string `json:"signatureBase64Encoded"`
|
||||
Format string `json:"signatureFormat"`
|
||||
} `json:"signing"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
type swiftArchiveInfo struct {
|
||||
checksum string
|
||||
signature string
|
||||
signatureFormat string
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) fetchArchiveInfo(ctx context.Context, scope, name, version string) (swiftArchiveInfo, error) {
|
||||
upstreamURL := h.buildUpstreamURL(scope, name, version, "", "")
|
||||
body, _, err := h.proxy.FetchOrCacheMetadata(
|
||||
ctx, "swift", swiftReleaseCacheKey(scope, name, version), upstreamURL, swiftAcceptJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return swiftArchiveInfo{}, err
|
||||
}
|
||||
|
||||
var metadata swiftReleaseMetadata
|
||||
if err := json.Unmarshal(body, &metadata); err != nil {
|
||||
return swiftArchiveInfo{}, fmt.Errorf("parsing release metadata: %w", err)
|
||||
}
|
||||
for _, resource := range metadata.Resources {
|
||||
if resource.Name != "source-archive" || resource.Type != "application/zip" {
|
||||
continue
|
||||
}
|
||||
checksum, err := normalizeSwiftChecksum(resource.Checksum)
|
||||
if err != nil {
|
||||
return swiftArchiveInfo{}, err
|
||||
}
|
||||
info := swiftArchiveInfo{checksum: checksum}
|
||||
if resource.Signing != nil {
|
||||
if resource.Signing.Signature == "" || resource.Signing.Format == "" {
|
||||
return swiftArchiveInfo{}, errors.New("source archive signing metadata is incomplete")
|
||||
}
|
||||
info.signature = resource.Signing.Signature
|
||||
info.signatureFormat = resource.Signing.Format
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
return swiftArchiveInfo{}, errors.New("source archive is missing from release metadata")
|
||||
}
|
||||
|
||||
func normalizeSwiftChecksum(checksum string) (string, error) {
|
||||
digest, err := hex.DecodeString(checksum)
|
||||
if err != nil || len(digest) != sha256.Size {
|
||||
return "", errors.New("source archive checksum is not a SHA-256 digest")
|
||||
}
|
||||
return hex.EncodeToString(digest), nil
|
||||
}
|
||||
|
||||
func setSwiftArchiveHeaders(header http.Header, name, version, contentHash string, info swiftArchiveInfo) {
|
||||
header.Set("Cache-Control", "public, immutable")
|
||||
header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.zip"`, name, version))
|
||||
header.Set("Content-Version", swiftContentVersion)
|
||||
|
||||
checksum := info.checksum
|
||||
if checksum == "" {
|
||||
checksum = contentHash
|
||||
}
|
||||
if digest := swiftDigestHeader(checksum); digest != "" {
|
||||
header.Set("Digest", digest)
|
||||
}
|
||||
if info.signature != "" && info.signatureFormat != "" {
|
||||
header.Set("X-Swift-Package-Signature", info.signature)
|
||||
header.Set("X-Swift-Package-Signature-Format", info.signatureFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func swiftDigestHeader(checksum string) string {
|
||||
digest, err := hex.DecodeString(checksum)
|
||||
if err != nil || len(digest) != sha256.Size {
|
||||
return ""
|
||||
}
|
||||
return "sha-256=" + base64.StdEncoding.EncodeToString(digest)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) proxySwiftResource(w http.ResponseWriter, r *http.Request, upstreamURL, defaultAccept string) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
||||
if err != nil {
|
||||
writeSwiftProblem(w, http.StatusInternalServerError, "failed to create upstream request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", requestAccept(r, defaultAccept))
|
||||
for _, name := range []string{"If-Modified-Since", "If-None-Match"} {
|
||||
if value := r.Header.Get(name); value != "" {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
h.proxy.applyUpstreamAuth(req)
|
||||
|
||||
resp, err := h.proxy.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed")
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
copySwiftResponseHeaders(w.Header(), resp.Header)
|
||||
if location := resp.Header.Get("Location"); location != "" {
|
||||
w.Header().Set("Location", h.rewriteRegistryURL(location, upstreamURL))
|
||||
}
|
||||
for _, link := range resp.Header.Values("Link") {
|
||||
w.Header().Add("Link", h.rewriteLinkHeader(link, upstreamURL))
|
||||
}
|
||||
if w.Header().Get("Content-Version") == "" {
|
||||
w.Header().Set("Content-Version", swiftContentVersion)
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func copySwiftResponseHeaders(dst, src http.Header) {
|
||||
for _, name := range []string{
|
||||
"Cache-Control", "Content-Disposition", "Content-Language", "Content-Length",
|
||||
"Content-Type", "Content-Version", "Digest", "ETag", "Last-Modified",
|
||||
"Retry-After", "Vary", "Warning", "X-Swift-Package-Signature",
|
||||
"X-Swift-Package-Signature-Format",
|
||||
} {
|
||||
for _, value := range src.Values(name) {
|
||||
dst.Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) rewriteLinkHeader(value, upstreamRequestURL string) string {
|
||||
var result strings.Builder
|
||||
for len(value) > 0 {
|
||||
start := strings.IndexByte(value, '<')
|
||||
if start < 0 {
|
||||
result.WriteString(value)
|
||||
break
|
||||
}
|
||||
endOffset := strings.IndexByte(value[start+1:], '>')
|
||||
if endOffset < 0 {
|
||||
result.WriteString(value)
|
||||
break
|
||||
}
|
||||
end := start + 1 + endOffset
|
||||
result.WriteString(value[:start+1])
|
||||
result.WriteString(h.rewriteRegistryURL(value[start+1:end], upstreamRequestURL))
|
||||
result.WriteByte('>')
|
||||
value = value[end+1:]
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) rewriteRegistryURL(rawURL, upstreamRequestURL string) string {
|
||||
base, err := url.Parse(h.upstreamURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
requestURL, err := url.Parse(upstreamRequestURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
reference, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
absolute := requestURL.ResolveReference(reference)
|
||||
if !strings.EqualFold(absolute.Scheme, base.Scheme) || !strings.EqualFold(absolute.Host, base.Host) {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
basePath := strings.TrimSuffix(base.EscapedPath(), "/")
|
||||
absolutePath := absolute.EscapedPath()
|
||||
if absolutePath != basePath && !strings.HasPrefix(absolutePath, basePath+"/") {
|
||||
return rawURL
|
||||
}
|
||||
suffix := strings.TrimPrefix(absolutePath, basePath)
|
||||
rewritten := h.proxyURL + "/swift" + suffix
|
||||
if absolute.RawQuery != "" {
|
||||
rewritten += "?" + absolute.RawQuery
|
||||
}
|
||||
if absolute.Fragment != "" {
|
||||
rewritten += "#" + absolute.Fragment
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) rewriteReleaseURLs(scope, name string, body []byte) ([]byte, error) {
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal(body, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releases, ok := metadata["releases"].(map[string]any)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
for version, value := range releases {
|
||||
release, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, hasURL := release["url"]; !hasURL {
|
||||
continue
|
||||
}
|
||||
release["url"] = fmt.Sprintf(
|
||||
"%s/swift/%s/%s/%s",
|
||||
h.proxyURL,
|
||||
url.PathEscape(scope),
|
||||
url.PathEscape(name),
|
||||
url.PathEscape(version),
|
||||
)
|
||||
}
|
||||
return json.Marshal(metadata)
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) buildUpstreamURL(scope, name, version, resource, rawQuery string) string {
|
||||
parts := []string{h.upstreamURL, url.PathEscape(scope), url.PathEscape(name)}
|
||||
if version != "" {
|
||||
parts = append(parts, url.PathEscape(version))
|
||||
}
|
||||
if resource != "" {
|
||||
parts = append(parts, resource)
|
||||
}
|
||||
result := strings.Join(parts, "/")
|
||||
if rawQuery != "" {
|
||||
result += "?" + rawQuery
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func swiftMetadataCacheKey(parts ...string) string {
|
||||
joined := strings.Join(parts, "\x00")
|
||||
digest := sha256.Sum256([]byte(joined))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func swiftReleaseCacheKey(scope, name, version string) string {
|
||||
return swiftMetadataCacheKey("release", scope, name, version)
|
||||
}
|
||||
|
||||
func requestAccept(r *http.Request, fallback string) string {
|
||||
if accept := r.Header.Get("Accept"); accept != "" {
|
||||
return accept
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func writeSwiftMetadata(w http.ResponseWriter, r *http.Request, body []byte, contentType string) {
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
etag := fmt.Sprintf(`"%x"`, digest)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Version", swiftContentVersion)
|
||||
w.Header().Set("ETag", etag)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) writeMetadataError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
writeSwiftProblem(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("Swift metadata request failed", "error", err)
|
||||
writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed")
|
||||
}
|
||||
|
||||
func (h *SwiftHandler) writeArtifactError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, ErrUpstreamNotFound) {
|
||||
writeSwiftProblem(w, http.StatusNotFound, "release not found")
|
||||
return
|
||||
}
|
||||
h.proxy.Logger.Error("Swift archive request failed", "error", err)
|
||||
writeSwiftProblem(w, http.StatusBadGateway, "failed to fetch package")
|
||||
}
|
||||
|
||||
func writeSwiftProblem(w http.ResponseWriter, status int, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.Header().Set("Content-Version", swiftContentVersion)
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": detail})
|
||||
}
|
||||
|
||||
func validSwiftPackageReference(scope, name, version string) bool {
|
||||
return validSwiftScope(scope) && validSwiftPackageName(name) && version != "" && version != "." && version != ".." && !strings.ContainsAny(version, "/\\")
|
||||
}
|
||||
|
||||
func canonicalSwiftPackage(scope, name string) (string, string) {
|
||||
return strings.ToLower(scope), strings.ToLower(name)
|
||||
}
|
||||
|
||||
func validSwiftScope(scope string) bool {
|
||||
return validSwiftIdentifier(scope, swiftMaxScopeLength, "-")
|
||||
}
|
||||
|
||||
func validSwiftPackageName(name string) bool {
|
||||
return validSwiftIdentifier(name, swiftMaxNameLength, "-_")
|
||||
}
|
||||
|
||||
func validSwiftIdentifier(value string, maxLength int, separators string) bool {
|
||||
if value == "" || len(value) > maxLength {
|
||||
return false
|
||||
}
|
||||
previousSeparator := false
|
||||
for i := 0; i < len(value); i++ {
|
||||
character := value[i]
|
||||
separator := strings.ContainsRune(separators, rune(character))
|
||||
if separator {
|
||||
if i == 0 || i == len(value)-1 || previousSeparator {
|
||||
return false
|
||||
}
|
||||
previousSeparator = true
|
||||
continue
|
||||
}
|
||||
if (character < 'a' || character > 'z') &&
|
||||
(character < 'A' || character > 'Z') &&
|
||||
(character < '0' || character > '9') {
|
||||
return false
|
||||
}
|
||||
previousSeparator = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
534
internal/handler/swift_test.go
Normal file
534
internal/handler/swift_test.go
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/packageurl"
|
||||
"github.com/git-pkgs/registries/fetch"
|
||||
)
|
||||
|
||||
func TestSwiftPackageReleasesRewritesRegistryURLs(t *testing.T) {
|
||||
var gotAccept string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/registry/apple/swift-argument-parser" {
|
||||
t.Errorf("upstream path = %q", r.URL.Path)
|
||||
}
|
||||
gotAccept = r.Header.Get("Accept")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Version", "1")
|
||||
w.Header().Add("Link", `</registry/apple/swift-argument-parser?page=2>; rel="next"`)
|
||||
_, _ = io.WriteString(w, `{"releases":{"1.2.0":{"url":"/registry/apple/swift-argument-parser/1.2.0"},"1.1.0":{}}}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/APPLE/SWIFT-ARGUMENT-PARSER", nil)
|
||||
req.Header.Set("Accept", swiftAcceptJSON)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotAccept != swiftAcceptJSON {
|
||||
t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptJSON)
|
||||
}
|
||||
if got := w.Header().Get("Content-Version"); got != "1" {
|
||||
t.Errorf("Content-Version = %q, want 1", got)
|
||||
}
|
||||
if got := w.Header().Get("Link"); got != `<https://proxy.example/swift/apple/swift-argument-parser?page=2>; rel="next"` {
|
||||
t.Errorf("Link = %q", got)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Releases map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"releases"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got := body.Releases["1.2.0"].URL; got != "https://proxy.example/swift/apple/swift-argument-parser/1.2.0" {
|
||||
t.Errorf("release URL = %q", got)
|
||||
}
|
||||
if got := body.Releases["1.1.0"].URL; got != "" {
|
||||
t.Errorf("release without upstream URL gained URL %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftReleaseMetadataSupportsJSONExtensionAndHead(t *testing.T) {
|
||||
var requestMethods []string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethods = append(requestMethods, r.Method)
|
||||
if r.URL.Path != "/registry/apple/example/1.2.3" {
|
||||
t.Errorf("upstream path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"id":"apple.example","version":"1.2.3","resources":[]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
req := httptest.NewRequest(method, "/APPLE/EXAMPLE/1.2.3.json", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200", method, w.Code)
|
||||
}
|
||||
if method == http.MethodHead && w.Body.Len() != 0 {
|
||||
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
|
||||
}
|
||||
}
|
||||
if len(requestMethods) != 2 || requestMethods[0] != http.MethodGet || requestMethods[1] != http.MethodGet {
|
||||
t.Errorf("upstream methods = %v, want metadata GETs", requestMethods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftManifestProxiesQueryAndRewritesLinks(t *testing.T) {
|
||||
var upstream *httptest.Server
|
||||
var gotAccept string
|
||||
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("upstream method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/registry/apple/example/1.2.3/Package.swift" {
|
||||
t.Errorf("upstream path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("swift-version"); got != "5.9" {
|
||||
t.Errorf("swift-version = %q, want 5.9", got)
|
||||
}
|
||||
gotAccept = r.Header.Get("Accept")
|
||||
w.Header().Set("Content-Type", "text/x-swift")
|
||||
w.Header().Add("Link", fmt.Sprintf(`<%s/registry/apple/example/1.2.3/Package.swift?swift-version=5.8>; rel="alternate"; filename="Package@swift-5.8.swift"`, upstream.URL))
|
||||
w.Header().Add("Link", `<https://github.com/apple/example>; rel="canonical"`)
|
||||
_, _ = io.WriteString(w, "// swift-tools-version: 5.9\n")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/APPLE/EXAMPLE/1.2.3/Package.swift?swift-version=5.9", nil)
|
||||
req.Header.Set("Accept", swiftAcceptManifest)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if gotAccept != swiftAcceptManifest {
|
||||
t.Errorf("upstream Accept = %q, want %q", gotAccept, swiftAcceptManifest)
|
||||
}
|
||||
links := strings.Join(w.Header().Values("Link"), ",")
|
||||
if !strings.Contains(links, "https://proxy.example/swift/apple/example/1.2.3/Package.swift?swift-version=5.8") {
|
||||
t.Errorf("internal manifest Link was not rewritten: %q", links)
|
||||
}
|
||||
if !strings.Contains(links, "https://github.com/apple/example") {
|
||||
t.Errorf("external canonical Link was changed: %q", links)
|
||||
}
|
||||
if got := w.Header().Get("Content-Version"); got != "1" {
|
||||
t.Errorf("Content-Version = %q, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveCachesAndPreservesSecurityMetadata(t *testing.T) {
|
||||
archive := []byte("swift source archive")
|
||||
checksumBytes := sha256.Sum256(archive)
|
||||
checksum := hex.EncodeToString(checksumBytes[:])
|
||||
signature := base64.StdEncoding.EncodeToString([]byte("signature"))
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/registry/apple/example/1.2.3" {
|
||||
t.Errorf("metadata path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q,"signing":{"signatureBase64Encoded":%q,"signatureFormat":"cms-1.0.0"}}]}`, checksum, signature)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, _, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader(string(archive))),
|
||||
Size: int64(len(archive)),
|
||||
ContentType: "application/zip",
|
||||
}
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
|
||||
|
||||
requestArchive := func(method string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "/apple/example/1.2.3.zip", nil)
|
||||
req.Header.Set("Accept", swiftAcceptArchive)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
w := requestArchive(http.MethodGet)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.Bytes(); string(got) != string(archive) {
|
||||
t.Errorf("archive body = %q", got)
|
||||
}
|
||||
if !fetcher.fetchCalled {
|
||||
t.Fatal("archive fetcher was not called")
|
||||
}
|
||||
if got := fetcher.fetchedURL; got != upstream.URL+"/registry/apple/example/1.2.3.zip" {
|
||||
t.Errorf("fetched URL = %q", got)
|
||||
}
|
||||
if got := fetcher.fetchedHeader.Get("Accept"); got != swiftAcceptArchive {
|
||||
t.Errorf("archive Accept = %q, want %q", got, swiftAcceptArchive)
|
||||
}
|
||||
if got := w.Header().Get("Digest"); got != "sha-256="+base64.StdEncoding.EncodeToString(checksumBytes[:]) {
|
||||
t.Errorf("Digest = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Swift-Package-Signature"); got != signature {
|
||||
t.Errorf("signature = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Swift-Package-Signature-Format"); got != "cms-1.0.0" {
|
||||
t.Errorf("signature format = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Disposition"); got != `attachment; filename="example-1.2.3.zip"` {
|
||||
t.Errorf("Content-Disposition = %q", got)
|
||||
}
|
||||
|
||||
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
|
||||
if strings.HasPrefix(packagePURL, "pkg:swift/") {
|
||||
t.Fatalf("registry identity produced source PURL %q", packagePURL)
|
||||
}
|
||||
versionRecord, err := db.GetVersionByPURL(versionPURL)
|
||||
if err != nil {
|
||||
t.Fatalf("cached Swift version %q not found: %v", versionPURL, err)
|
||||
}
|
||||
if versionRecord == nil {
|
||||
t.Fatalf("cached Swift version %q not found", versionPURL)
|
||||
}
|
||||
if versionRecord.PackagePURL != packagePURL {
|
||||
t.Errorf("cached package PURL = %q, want %q", versionRecord.PackagePURL, packagePURL)
|
||||
}
|
||||
|
||||
fetcher.fetchCalled = false
|
||||
w = requestArchive(http.MethodHead)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("HEAD status = %d, want 200", w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("HEAD body length = %d, want 0", w.Body.Len())
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != fmt.Sprint(len(archive)) {
|
||||
t.Errorf("HEAD Content-Length = %q", got)
|
||||
}
|
||||
|
||||
w = requestArchive(http.MethodGet)
|
||||
if w.Code != http.StatusOK || w.Body.String() != string(archive) {
|
||||
t.Fatalf("cached response = %d %q", w.Code, w.Body.Bytes())
|
||||
}
|
||||
if fetcher.fetchCalled {
|
||||
t.Error("cached archive contacted artifact upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveRejectsChecksumMismatch(t *testing.T) {
|
||||
archive := []byte("unexpected archive")
|
||||
expectedChecksum := sha256.Sum256([]byte("expected archive"))
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(expectedChecksum[:]))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, store, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader(string(archive))),
|
||||
Size: int64(len(archive)),
|
||||
ContentType: "application/zip",
|
||||
}
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(store.files) != 0 {
|
||||
t.Errorf("mismatched archive remained in storage: %v", store.files)
|
||||
}
|
||||
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
|
||||
cached, err := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("checking cache: %v", err)
|
||||
}
|
||||
if cached != nil {
|
||||
t.Error("mismatched archive gained a cache record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveCanonicalizesPackageIdentity(t *testing.T) {
|
||||
archive := []byte("swift source archive")
|
||||
checksumBytes := sha256.Sum256(archive)
|
||||
checksum := hex.EncodeToString(checksumBytes[:])
|
||||
var metadataPaths []string
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
metadataPaths = append(metadataPaths, r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, store, fetcher := setupTestProxy(t)
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
|
||||
requestArchive := func(path string) {
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader(string(archive))),
|
||||
Size: int64(len(archive)),
|
||||
ContentType: "application/zip",
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200; body: %s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
requestArchive("/apple/example/1.2.3.zip")
|
||||
requestArchive("/APPLE/EXAMPLE/1.2.3.zip")
|
||||
|
||||
if len(store.files) != 1 {
|
||||
t.Errorf("cached files = %d, want 1", len(store.files))
|
||||
}
|
||||
for _, path := range metadataPaths {
|
||||
if path != "/apple/example/1.2.3" {
|
||||
t.Errorf("metadata path = %q, want canonical lowercase path", path)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPURL, _ := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
|
||||
canonical, err := db.GetPackageByPURL(canonicalPURL)
|
||||
if err != nil {
|
||||
t.Fatalf("getting canonical package: %v", err)
|
||||
}
|
||||
if canonical == nil {
|
||||
t.Fatalf("canonical package %q not found", canonicalPURL)
|
||||
}
|
||||
|
||||
nonCanonicalPURL, _ := packageurl.MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
|
||||
if nonCanonicalPURL != canonicalPURL {
|
||||
t.Errorf("uppercase cache PURL = %q, want %q", nonCanonicalPURL, canonicalPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveHeadDiscardsCachedChecksumMismatch(t *testing.T) {
|
||||
archive := []byte("cached archive")
|
||||
upstreamChecksum := sha256.Sum256([]byte("upstream archive"))
|
||||
|
||||
var probed bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".zip") {
|
||||
probed = true
|
||||
w.Header().Set("Content-Range", "bytes 0-0/456")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("x"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(upstreamChecksum[:]))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, db, store, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader(string(archive))),
|
||||
Size: int64(len(archive)),
|
||||
ContentType: "application/zip",
|
||||
}
|
||||
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
|
||||
cached, err := proxy.getOrFetchArtifactFromURLWithCachePURLs(
|
||||
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
|
||||
packagePURL, versionPURL, upstream.URL+"/apple/example/1.2.3.zip", nil, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seeding cache: %v", err)
|
||||
}
|
||||
_ = cached.Reader.Close()
|
||||
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !probed {
|
||||
t.Error("stale cache entry was not replaced by an upstream probe")
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != "456" {
|
||||
t.Errorf("Content-Length = %q, want 456 from upstream probe", got)
|
||||
}
|
||||
if len(store.files) != 0 {
|
||||
t.Errorf("mismatched cached archive remained in storage: %v", store.files)
|
||||
}
|
||||
if rec, _ := db.GetCachedArtifact(packagePURL, versionPURL, "example-1.2.3.zip"); rec != nil {
|
||||
t.Error("mismatched cache record was not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveRequiresReleaseMetadata(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, store, fetcher := setupTestProxy(t)
|
||||
fetcher.artifact = &fetch.Artifact{
|
||||
Body: io.NopCloser(strings.NewReader("signed archive")),
|
||||
ContentType: "application/zip",
|
||||
}
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if fetcher.fetchCalled {
|
||||
t.Error("archive was fetched without release security metadata")
|
||||
}
|
||||
if len(store.files) != 0 {
|
||||
t.Errorf("archive was cached without release security metadata: %v", store.files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftSourceArchiveColdHeadUsesRangeGetAcrossRedirect(t *testing.T) {
|
||||
checksum := strings.Repeat("a", sha256.Size*2)
|
||||
var archiveAccept string
|
||||
var archiveMethod string
|
||||
var archiveRange string
|
||||
download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
archiveMethod = r.Method
|
||||
archiveRange = r.Header.Get("Range")
|
||||
w.Header().Set("Content-Range", "bytes 0-0/123")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("x"))
|
||||
}))
|
||||
defer download.Close()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/apple/example/1.2.3":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, checksum)
|
||||
case "/apple/example/1.2.3.zip":
|
||||
archiveAccept = r.Header.Get("Accept")
|
||||
http.Redirect(w, r, download.URL, http.StatusSeeOther)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
proxy.HTTPClient = upstream.Client()
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if archiveMethod != http.MethodGet {
|
||||
t.Errorf("download method = %q, want GET", archiveMethod)
|
||||
}
|
||||
if archiveRange != "bytes=0-0" {
|
||||
t.Errorf("download Range = %q, want bytes=0-0", archiveRange)
|
||||
}
|
||||
if archiveAccept != swiftAcceptArchive {
|
||||
t.Errorf("upstream Accept = %q, want %q", archiveAccept, swiftAcceptArchive)
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != "123" {
|
||||
t.Errorf("Content-Length = %q, want 123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftIdentifiersAndPublishingUnsupported(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/registry/identifiers" {
|
||||
t.Errorf("upstream path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("url"); got != "https://github.com/apple/example" {
|
||||
t.Errorf("lookup URL = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"identifiers":["apple.example"]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, _, _, _ := setupTestProxy(t)
|
||||
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/identifiers?url=https%3A%2F%2Fgithub.com%2Fapple%2Fexample", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "apple.example") {
|
||||
t.Fatalf("identifier response = %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/identifiers", nil)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("missing URL status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPut, "/apple/example/1.2.3", strings.NewReader("ignored"))
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("publish status = %d, want 405", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Allow"); got != "GET, HEAD" {
|
||||
t.Errorf("Allow = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftIdentifierValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
valid func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{"scope", "apple", validSwiftScope, true},
|
||||
{"scope hyphen", "swift-server", validSwiftScope, true},
|
||||
{"scope underscore", "swift_server", validSwiftScope, false},
|
||||
{"scope repeated separator", "swift--server", validSwiftScope, false},
|
||||
{"package", "swift-argument_parser", validSwiftPackageName, true},
|
||||
{"package repeated separators", "swift-_argument", validSwiftPackageName, false},
|
||||
{"package trailing separator", "example-", validSwiftPackageName, false},
|
||||
{"package non-ASCII", "café", validSwiftPackageName, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.valid(test.value); got != test.want {
|
||||
t.Errorf("validation of %q = %v, want %v", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
62
internal/packageurl/packageurl.go
Normal file
62
internal/packageurl/packageurl.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Package packageurl builds package URLs from ecosystem-native package names.
|
||||
package packageurl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/git-pkgs/purl"
|
||||
)
|
||||
|
||||
// Make constructs a package URL from an ecosystem-native package name.
|
||||
func Make(ecosystem, name, version string) *purl.PURL {
|
||||
return purl.MakePURL(ecosystem, name, version)
|
||||
}
|
||||
|
||||
// MakeString constructs a package URL string. It returns an empty string when
|
||||
// the package identity cannot be represented as a PURL.
|
||||
func MakeString(ecosystem, name, version string) string {
|
||||
return purl.MakePURLString(ecosystem, name, version)
|
||||
}
|
||||
|
||||
// WithVersionString returns a package PURL with its version replaced. It
|
||||
// returns an empty string when packagePURL is invalid.
|
||||
func WithVersionString(packagePURL, version string) string {
|
||||
pkg, err := purl.Parse(packagePURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return pkg.WithVersion(version).String()
|
||||
}
|
||||
|
||||
// MakeCacheStrings returns package and version PURLs suitable for artifact
|
||||
// cache records. Swift registry identities use an explicit generic PURL until
|
||||
// their source repository has been resolved. The result is independent of the
|
||||
// configured upstream so cache entries survive an upstream.swift change,
|
||||
// matching every other ecosystem.
|
||||
func MakeCacheStrings(ecosystem, name, version string) (packagePURL, versionPURL string) {
|
||||
if pkg := Make(ecosystem, name, ""); pkg != nil {
|
||||
return pkg.String(), pkg.WithVersion(version).String()
|
||||
}
|
||||
if purl.NormalizeEcosystem(ecosystem) != "swift" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
identity, ok := swiftRegistryIdentity(name)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
pkg := purl.New("generic", "swift-registry", identity, "", nil)
|
||||
return pkg.String(), pkg.WithVersion(version).String()
|
||||
}
|
||||
|
||||
func swiftRegistryIdentity(name string) (string, bool) {
|
||||
scope, packageName, found := strings.Cut(name, "/")
|
||||
if !found {
|
||||
scope, packageName, found = strings.Cut(name, ".")
|
||||
}
|
||||
if !found || scope == "" || packageName == "" || strings.ContainsAny(packageName, "/.") {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(scope) + "." + strings.ToLower(packageName), true
|
||||
}
|
||||
75
internal/packageurl/packageurl_test.go
Normal file
75
internal/packageurl/packageurl_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package packageurl
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMakeSwiftRegistryIdentityUnsupported(t *testing.T) {
|
||||
identities := []string{"apple.swift-argument-parser", "apple/swift-argument-parser"}
|
||||
for _, identity := range identities {
|
||||
t.Run(identity, func(t *testing.T) {
|
||||
if got := Make("swift", identity, "1.8.2"); got != nil {
|
||||
t.Errorf("Make() = %q, want nil", got.String())
|
||||
}
|
||||
if got := MakeString("swift", identity, "1.8.2"); got != "" {
|
||||
t.Errorf("MakeString() = %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeStringSwiftSourceCoordinate(t *testing.T) {
|
||||
got := MakeString("swift", "github.com/apple/swift-package-manager", "1.7.0")
|
||||
want := "pkg:swift/github.com/apple/swift-package-manager@1.7.0"
|
||||
if got != want {
|
||||
t.Errorf("MakeString() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithVersionStringPreservesQualifiers(t *testing.T) {
|
||||
packagePURL := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Fold.example%2Fswift"
|
||||
got := WithVersionString(packagePURL, "1.2.3")
|
||||
want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Fold.example%2Fswift"
|
||||
if got != want {
|
||||
t.Errorf("WithVersionString() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := WithVersionString("not a purl", "1.2.3"); got != "" {
|
||||
t.Errorf("WithVersionString() = %q for invalid PURL, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeCacheStringsSwiftRegistryIdentity(t *testing.T) {
|
||||
packagePURL, versionPURL := MakeCacheStrings("swift", "APPLE/EXAMPLE", "1.2.3")
|
||||
|
||||
wantPackage := "pkg:generic/swift-registry/apple.example"
|
||||
if packagePURL != wantPackage {
|
||||
t.Errorf("package PURL = %q, want %q", packagePURL, wantPackage)
|
||||
}
|
||||
wantVersion := "pkg:generic/swift-registry/apple.example@1.2.3"
|
||||
if versionPURL != wantVersion {
|
||||
t.Errorf("version PURL = %q, want %q", versionPURL, wantVersion)
|
||||
}
|
||||
|
||||
dottedPackage, dottedVersion := MakeCacheStrings("swift", "apple.example", "1.2.3")
|
||||
if dottedPackage != packagePURL || dottedVersion != versionPURL {
|
||||
t.Errorf("dotted identity cache PURLs = %q, %q; want %q, %q", dottedPackage, dottedVersion, packagePURL, versionPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeCacheStringsUsesSourcePURLWhenAvailable(t *testing.T) {
|
||||
packagePURL, versionPURL := MakeCacheStrings("swift", "github.com/apple/swift-package-manager", "1.7.0")
|
||||
|
||||
if packagePURL != "pkg:swift/github.com/apple/swift-package-manager" {
|
||||
t.Errorf("package PURL = %q", packagePURL)
|
||||
}
|
||||
if versionPURL != "pkg:swift/github.com/apple/swift-package-manager@1.7.0" {
|
||||
t.Errorf("version PURL = %q", versionPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeStringDelegatesOtherEcosystems(t *testing.T) {
|
||||
got := MakeString("npm", "@babel/core", "7.23.0")
|
||||
want := "pkg:npm/%40babel/core@7.23.0"
|
||||
if got != want {
|
||||
t.Errorf("MakeString() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/git-pkgs/magic"
|
||||
"github.com/git-pkgs/proxy/internal/database"
|
||||
"github.com/git-pkgs/proxy/internal/handler"
|
||||
"github.com/git-pkgs/purl"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
|
|
@ -226,7 +225,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
|
|||
dirPath := r.URL.Query().Get("path")
|
||||
|
||||
// Get the artifact for this version
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
versionPURL := s.cachedVersionPURL(ecosystem, name, version)
|
||||
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
|
||||
if err != nil {
|
||||
notFound(w, "version not found")
|
||||
|
|
@ -313,7 +312,7 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
|
|||
}
|
||||
|
||||
// Get the artifact for this version
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
versionPURL := s.cachedVersionPURL(ecosystem, name, version)
|
||||
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
|
||||
if err != nil {
|
||||
notFound(w, "version not found")
|
||||
|
|
@ -534,8 +533,8 @@ type BrowseSourceData struct {
|
|||
// @Router /ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
|
||||
func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
|
||||
// Get artifacts for both versions
|
||||
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
|
||||
toPURL := purl.MakePURLString(ecosystem, name, toVersion)
|
||||
fromPURL := s.cachedVersionPURL(ecosystem, name, fromVersion)
|
||||
toPURL := s.cachedVersionPURL(ecosystem, name, toVersion)
|
||||
|
||||
fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL)
|
||||
if err != nil || len(fromArtifacts) == 0 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/git-pkgs/proxy/internal/database"
|
||||
)
|
||||
|
|
@ -140,6 +141,7 @@ func supportedEcosystems() []string {
|
|||
"pub",
|
||||
"pypi",
|
||||
"rpm",
|
||||
"swift",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +186,8 @@ func ecosystemBadgeClasses(ecosystem string) string {
|
|||
return base + " bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||
case "julia":
|
||||
return base + " bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
|
||||
case "swift":
|
||||
return base + " bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
|
||||
case "oci":
|
||||
return base + " bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
|
||||
case "deb":
|
||||
|
|
@ -196,6 +200,11 @@ func ecosystemBadgeClasses(ecosystem string) string {
|
|||
}
|
||||
|
||||
func getRegistryConfigs(baseURL string) []RegistryConfig {
|
||||
swiftInsecureFlag := ""
|
||||
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
|
||||
swiftInsecureFlag = "--allow-insecure-http "
|
||||
}
|
||||
|
||||
return []RegistryConfig{
|
||||
{
|
||||
ID: "npm",
|
||||
|
|
@ -396,6 +405,15 @@ local({
|
|||
<p class="config-note">Or inside a running session:</p>
|
||||
<pre><code>ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
|
||||
using Pkg; Pkg.update()</code></pre>`),
|
||||
},
|
||||
{
|
||||
ID: "swift",
|
||||
Name: "Swift Package Registry",
|
||||
Language: "Swift",
|
||||
Endpoint: "/swift/",
|
||||
Instructions: template.HTML(`<p class="config-note">Configure SwiftPM to use the proxy for this project:</p>
|
||||
<pre><code>swift package-registry set ` + swiftInsecureFlag + baseURL + `/swift</code></pre>
|
||||
<p class="config-note">Use scoped package identifiers in Package.swift, for example <code>apple.swift-argument-parser</code>.</p>`),
|
||||
},
|
||||
{
|
||||
ID: "oci",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// - /conda/* - Conda/Anaconda protocol
|
||||
// - /cran/* - CRAN (R) protocol
|
||||
// - /julia/* - Julia Pkg server protocol
|
||||
// - /swift/* - Swift Package Registry protocol
|
||||
// - /v2/* - OCI/Docker container registry protocol
|
||||
// - /debian/* - Debian/APT repository protocol
|
||||
// - /rpm/* - RPM/Yum repository protocol
|
||||
|
|
@ -69,8 +70,8 @@ import (
|
|||
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
|
||||
"github.com/git-pkgs/proxy/internal/metrics"
|
||||
"github.com/git-pkgs/proxy/internal/mirror"
|
||||
"github.com/git-pkgs/proxy/internal/packageurl"
|
||||
"github.com/git-pkgs/proxy/internal/storage"
|
||||
"github.com/git-pkgs/purl"
|
||||
"github.com/git-pkgs/registries/fetch"
|
||||
"github.com/git-pkgs/registries/safehttp"
|
||||
"github.com/git-pkgs/spdx"
|
||||
|
|
@ -259,6 +260,7 @@ func (s *Server) Start() error {
|
|||
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
|
||||
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
|
||||
juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
|
||||
swiftHandler := handler.NewSwiftHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Swift)
|
||||
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.OCI)
|
||||
helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm)
|
||||
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian)
|
||||
|
|
@ -279,6 +281,7 @@ func (s *Server) Start() error {
|
|||
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
|
||||
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
|
||||
r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
|
||||
r.Mount("/swift", http.StripPrefix("/swift", swiftHandler.Routes()))
|
||||
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
|
||||
r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes()))
|
||||
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
|
||||
|
|
@ -813,7 +816,7 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem,
|
|||
return
|
||||
}
|
||||
|
||||
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
||||
versionPURL := packageurl.WithVersionString(pkg.PURL, version)
|
||||
ver, err := s.db.GetVersionByPURL(versionPURL)
|
||||
if err != nil || ver == nil {
|
||||
s.logger.Error("failed to get version", "error", err)
|
||||
|
|
@ -855,6 +858,14 @@ func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem,
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) cachedVersionPURL(ecosystem, name, version string) string {
|
||||
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
|
||||
if err != nil || pkg == nil {
|
||||
return ""
|
||||
}
|
||||
return packageurl.WithVersionString(pkg.PURL, version)
|
||||
}
|
||||
|
||||
func (s *Server) showBrowseSource(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
|
||||
data := BrowseSourceData{
|
||||
Layout: s.layoutFor(r),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ func newTestServer(t *testing.T) *testServer {
|
|||
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
|
||||
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
|
||||
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
|
||||
swiftHandler := handler.NewSwiftHandler(proxy, cfg.BaseURL, cfg.Upstream.Swift)
|
||||
|
||||
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
|
||||
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
|
||||
|
|
@ -90,6 +91,7 @@ func newTestServer(t *testing.T) *testServer {
|
|||
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
|
||||
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
|
||||
r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
|
||||
r.Mount("/swift", http.StripPrefix("/swift", swiftHandler.Routes()))
|
||||
|
||||
hc, err := newHealthCache(store, "30s", logger)
|
||||
if err != nil {
|
||||
|
|
@ -328,11 +330,53 @@ func TestDashboard(t *testing.T) {
|
|||
if !strings.Contains(body, ">debian<") {
|
||||
t.Error("dashboard should show debian in supported ecosystems")
|
||||
}
|
||||
if !strings.Contains(body, ">swift<") {
|
||||
t.Error("dashboard should show swift in supported ecosystems")
|
||||
}
|
||||
if !strings.Contains(body, "/openapi.json") {
|
||||
t.Error("page should link to the OpenAPI JSON spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftHandlerMounted(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/swift/apple/example/1.2.3", strings.NewReader("ignored"))
|
||||
w := httptest.NewRecorder()
|
||||
ts.handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftCachedVersionPURLUsesStoredPackagePURL(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.close()
|
||||
|
||||
packagePURL := "pkg:generic/swift-registry/apple.example?repository_url=https:%2F%2Fold.example%2Fswift"
|
||||
if err := ts.db.UpsertPackage(&database.Package{
|
||||
PURL: packagePURL,
|
||||
Ecosystem: "swift",
|
||||
Name: "apple/example",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to upsert package: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: &config.Config{
|
||||
Upstream: config.UpstreamConfig{Swift: "https://new.example/swift"},
|
||||
},
|
||||
db: ts.db,
|
||||
}
|
||||
got := s.cachedVersionPURL("swift", "apple/example", "1.2.3")
|
||||
want := "pkg:generic/swift-registry/apple.example@1.2.3?repository_url=https:%2F%2Fold.example%2Fswift"
|
||||
if got != want {
|
||||
t.Errorf("cachedVersionPURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue