-help' for more information on a command.
@@ -201,14 +176,6 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\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_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")
- fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n")
- fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL Gradle cache eviction sweep interval\n")
- fmt.Fprintf(os.Stderr, " PROXY_HEALTH_STORAGE_PROBE_INTERVAL Storage health probe cache interval\n")
}
_ = fs.Parse(os.Args[1:])
@@ -239,7 +206,7 @@ func runServe() {
cfg.Storage.URL = *storageURL
}
if *storagePath != "" {
- cfg.Storage.Path = *storagePath //nolint:staticcheck // backwards compat
+ cfg.Storage.Path = *storagePath
}
if *databaseDriver != "" {
cfg.Database.Driver = *databaseDriver
@@ -275,6 +242,7 @@ func runServe() {
// Handle shutdown signals
ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
go func() {
sigCh := make(chan os.Signal, 1)
@@ -293,12 +261,10 @@ func runServe() {
// Wait for shutdown or error
select {
case <-ctx.Done():
- cancel()
if err := srv.Shutdown(context.Background()); err != nil {
logger.Error("shutdown error", "error", err)
}
case err := <-errCh:
- cancel()
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
@@ -312,8 +278,8 @@ func runStats() {
databasePath := fs.String("database-path", "./cache/proxy.db", "Path to SQLite database file")
databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
asJSON := fs.Bool("json", false, "Output as JSON")
- popular := fs.Int("popular", defaultTopN, "Show top N most popular packages")
- recent := fs.Int("recent", defaultTopN, "Show N recently cached packages")
+ popular := fs.Int("popular", 10, "Show top N most popular packages")
+ recent := fs.Int("recent", 10, "Show N recently cached packages")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Show cache statistics\n\n")
@@ -355,186 +321,36 @@ func runStats() {
db, err = database.Open(*databasePath)
}
- if err != nil {
- fmt.Fprintf(os.Stderr, "error opening database: %v\n", err)
- os.Exit(1)
- }
-
- if err := printStats(db, *popular, *recent, *asJSON); err != nil {
- fmt.Fprintf(os.Stderr, "%v\n", err)
- os.Exit(1)
- }
-}
-
-func runMirror() {
- fs := flag.NewFlagSet("mirror", flag.ExitOnError)
- configPath := fs.String("config", "", "Path to configuration file")
- storageURL := fs.String("storage-url", "", "Storage URL (file:// or s3://)")
- databaseDriver := fs.String("database-driver", "", "Database driver: sqlite or postgres")
- databasePath := fs.String("database-path", "", "Path to SQLite database file")
- databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
- sbomPath := fs.String("sbom", "", "Path to CycloneDX or SPDX SBOM file")
- concurrency := fs.Int("concurrency", 4, "Number of parallel downloads") //nolint:mnd // default concurrency
- dryRun := fs.Bool("dry-run", false, "Show what would be mirrored without downloading")
-
- fs.Usage = func() {
- fmt.Fprintf(os.Stderr, "git-pkgs proxy - Pre-populate cache\n\n")
- fmt.Fprintf(os.Stderr, "Usage: proxy mirror [flags] [purl...]\n\n")
- fmt.Fprintf(os.Stderr, "Examples:\n")
- fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash@4.17.21\n")
- fmt.Fprintf(os.Stderr, " proxy mirror --sbom sbom.cdx.json\n")
- fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash # all versions\n\n")
- fmt.Fprintf(os.Stderr, "Flags:\n")
- fs.PrintDefaults()
- }
-
- _ = fs.Parse(os.Args[1:])
- purls := fs.Args()
-
- // Determine source
- var source mirror.Source
- switch {
- case *sbomPath != "":
- source = &mirror.SBOMSource{Path: *sbomPath}
- case len(purls) > 0:
- source = &mirror.PURLSource{PURLs: purls}
- default:
- fmt.Fprintf(os.Stderr, "error: provide PURLs or --sbom\n")
- fs.Usage()
- os.Exit(1)
- }
-
- // Load config
- cfg, err := loadConfig(*configPath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
- os.Exit(1)
- }
- cfg.LoadFromEnv()
-
- if *storageURL != "" {
- cfg.Storage.URL = *storageURL
- }
- if *databaseDriver != "" {
- cfg.Database.Driver = *databaseDriver
- }
- if *databasePath != "" {
- cfg.Database.Path = *databasePath
- }
- if *databaseURL != "" {
- cfg.Database.URL = *databaseURL
- }
-
- if err := cfg.Validate(); err != nil {
- fmt.Fprintf(os.Stderr, "invalid configuration: %v\n", err)
- os.Exit(1)
- }
-
- logger := setupLogger("info", "text")
-
- // Open database
- var db *database.DB
- switch cfg.Database.Driver {
- case "postgres":
- db, err = database.OpenPostgresOrCreate(cfg.Database.URL)
- default:
- db, err = database.OpenOrCreate(cfg.Database.Path)
- }
if err != nil {
fmt.Fprintf(os.Stderr, "error opening database: %v\n", err)
os.Exit(1)
}
defer func() { _ = db.Close() }()
- if err := db.MigrateSchema(); err != nil {
- _ = db.Close()
- fmt.Fprintf(os.Stderr, "error migrating schema: %v\n", err)
- os.Exit(1) //nolint:gocritic // db closed above
- }
-
- // Open storage
- sURL := cfg.Storage.URL
- if sURL == "" {
- sURL = "file://" + cfg.Storage.Path //nolint:staticcheck // backwards compat
- }
- store, err := storage.OpenBucket(context.Background(), sURL)
- if err != nil {
- _ = db.Close()
- fmt.Fprintf(os.Stderr, "error opening storage: %v\n", err)
- os.Exit(1) //nolint:gocritic // db closed above
- }
-
- // Build proxy (reuses same pipeline as serve)
- fetcher := fetch.NewFetcher()
- resolver := fetch.NewResolver()
- proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
- proxy.CacheMetadata = true // mirror always caches metadata
- proxy.MetadataTTL = cfg.ParseMetadataTTL()
-
- m := mirror.New(proxy, db, store, logger, *concurrency)
-
- ctx, cancel := context.WithCancel(context.Background())
- go func() {
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
- <-sigCh
- cancel()
- }()
-
- if *dryRun {
- items, err := m.RunDryRun(ctx, source)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf("Would mirror %d package versions:\n", len(items))
- for _, item := range items {
- fmt.Printf(" %s\n", item)
- }
- return
- }
-
- progress, err := m.Run(ctx, source)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("Mirror complete: %d downloaded, %d skipped (cached), %d failed, %s total\n",
- progress.Completed, progress.Skipped, progress.Failed, formatSize(progress.Bytes))
-
- if len(progress.Errors) > 0 {
- fmt.Fprintf(os.Stderr, "\nErrors:\n")
- for _, e := range progress.Errors {
- fmt.Fprintf(os.Stderr, " %s/%s@%s: %s\n", e.Ecosystem, e.Name, e.Version, e.Error)
- }
- }
-}
-
-func printStats(db *database.DB, popular, recent int, asJSON bool) error {
- defer func() { _ = db.Close() }()
-
+ // Get stats
stats, err := db.GetCacheStats()
if err != nil {
- return fmt.Errorf("error getting stats: %w", err)
+ fmt.Fprintf(os.Stderr, "error getting stats: %v\n", err)
+ os.Exit(1)
}
- popularPkgs, err := db.GetMostPopularPackages(popular)
+ popularPkgs, err := db.GetMostPopularPackages(*popular)
if err != nil {
- return fmt.Errorf("error getting popular packages: %w", err)
+ fmt.Fprintf(os.Stderr, "error getting popular packages: %v\n", err)
+ os.Exit(1)
}
- recentPkgs, err := db.GetRecentlyCachedPackages(recent)
+ recentPkgs, err := db.GetRecentlyCachedPackages(*recent)
if err != nil {
- return fmt.Errorf("error getting recent packages: %w", err)
+ fmt.Fprintf(os.Stderr, "error getting recent packages: %v\n", err)
+ os.Exit(1)
}
- if asJSON {
+ if *asJSON {
outputJSON(stats, popularPkgs, recentPkgs)
} else {
outputText(stats, popularPkgs, recentPkgs)
}
- return nil
}
type jsonOutput struct {
diff --git a/config.example.yaml b/config.example.yaml
index 11c751c..3d53483 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -29,23 +29,6 @@ storage:
# Empty or "0" means unlimited
max_size: ""
- # Redirect cached artifact downloads to presigned storage URLs (HTTP 302)
- # instead of streaming through the proxy. Only effective for S3 and Azure.
- # Leave disabled if clients reach the proxy through an authenticating gateway,
- # since presigned URLs bypass it.
- direct_serve: false
-
- # How long presigned URLs remain valid (e.g. "5m", "1h"). Default: "15m".
- direct_serve_ttl: "15m"
-
- # Public base URL to substitute into presigned URLs. Set this when the
- # proxy reaches storage at an internal address (127.0.0.1, a Docker
- # service name) but clients must use a public hostname. Only scheme and
- # host are used; the signed path and query are preserved. For S3/MinIO
- # the reverse proxy at this address must forward requests with the
- # internal Host header or the SigV4 signature will not validate.
- # direct_serve_base_url: "https://minio.example.com"
-
# Database configuration
database:
# Database driver: "sqlite" (default) or "postgres"
@@ -71,12 +54,6 @@ upstream:
# npm registry URL
npm: "https://registry.npmjs.org"
- # Maven repository URL (used by /maven endpoint)
- maven: "https://repo1.maven.org/maven2"
-
- # Gradle Plugin Portal Maven URL (fallback for plugin marker artifacts)
- gradle_plugin_portal: "https://plugins.gradle.org/m2"
-
# Cargo sparse index URL
cargo: "https://index.crates.io"
@@ -113,50 +90,3 @@ upstream:
# type: header
# header_name: "X-Auth-Token"
# header_value: "${MAVEN_TOKEN}"
-
-# Gradle HttpBuildCache configuration
-gradle:
- build_cache:
- # Set to true to disable PUT uploads (read-only cache mode)
- read_only: false
-
- # Maximum accepted Gradle cache upload body size
- # Required and must be > 0
- max_upload_size: "100MB"
-
- # Evict entries older than this age (set to "0" to disable age-based eviction)
- max_age: "168h"
-
- # Cap total Gradle cache size; oldest entries are deleted first
- # ("0" disables size-based eviction)
- # max_size: "20GB"
-
- # How often eviction runs when max_age or max_size is set
- sweep_interval: "10m"
-
-# Health endpoint configuration.
-health:
- # Minimum time between storage backend probes.
- # The /health endpoint runs a write/read/verify/delete round-trip
- # against the configured storage backend and caches the result for
- # this interval. Set to "0" to probe on every request.
- # Default: "30s".
- storage_probe_interval: "30s"
-
-# Version cooldown configuration
-# Hides package versions published too recently, giving the community time
-# to spot malicious releases before they're pulled into projects.
-# Supported durations: "7d" (days), "48h" (hours), "30m" (minutes), "0" (disabled)
-cooldown:
- # Global default cooldown for all ecosystems
- # default: "3d"
-
- # Per-ecosystem overrides
- # ecosystems:
- # npm: "7d"
- # cargo: "0"
-
- # Per-package overrides (keyed by PURL)
- # packages:
- # "pkg:npm/lodash": "0"
- # "pkg:npm/@babel/core": "14d"
diff --git a/docs/architecture.md b/docs/architecture.md
index 85e5aaf..be9a6a6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -7,24 +7,29 @@ This document describes the internal architecture of the git-pkgs proxy.
The proxy is a caching HTTP server that sits between package manager clients and upstream registries. It intercepts requests, checks a local cache, and either serves cached content or fetches from upstream.
```
-┌──────────────────────────────────────────────────────────────────┐
-│ HTTP Server │
-│ ┌──────────────────────────────────────────────────────────┐ │
-│ │ Router (Chi) │ │
-│ │ /npm/* -> NPMHandler /health -> healthHandler │ │
-│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
-│ │ /gem/* -> GemHandler /metrics -> prometheus │ │
-│ │ ...17 ecosystems /api/* -> APIHandler │ │
-│ │ / -> Web UI │ │
-│ └──────────────────────────────────────────────────────────┘ │
-│ │ │ │ │
-│ ▼ ▼ ▼ │
+┌─────────────────────────────────────────────────────────────────┐
+│ HTTP Server │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ Router (ServeMux) │ │
+│ │ /npm/* -> NPMHandler │ │
+│ │ /cargo/* -> CargoHandler │ │
+│ │ /health -> healthHandler │ │
+│ │ /stats -> statsHandler │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ Proxy │ │
+│ │ - GetOrFetchArtifact() │ │
+│ │ - Coordinates DB, Storage, Fetcher │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │ │ │ │
+│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌─────────────┐ ┌─────────────┐ │
-│ │ Database │ │ Storage │ │ Upstream │ │
-│ │ SQLite or │ │ Filesystem │ │ Registries │ │
-│ │ Postgres │ │ or S3 │ │ (Fetcher) │ │
+│ │ Database │ │ Storage │ │ Upstream │ │
+│ │ (SQLite) │ │ (Filesystem)│ │ (Fetcher) │ │
│ └───────────┘ └─────────────┘ └─────────────┘ │
-└──────────────────────────────────────────────────────────────────┘
+└─────────────────────────────────────────────────────────────────┘
```
## Request Flow
@@ -86,101 +91,29 @@ Metadata is not cached - always fetched fresh. This ensures clients see new vers
### `internal/database`
-SQLite or PostgreSQL database for cache metadata. SQLite uses `modernc.org/sqlite` (pure Go, no CGO). PostgreSQL uses `lib/pq`.
-
-The schema is compatible with [git-pkgs](https://github.com/git-pkgs) databases. The proxy adds the `artifacts` and `vulnerabilities` tables on top of the shared `packages` and `versions` tables, so both tools can point at the same database.
+SQLite database for cache metadata. Uses `modernc.org/sqlite` (pure Go, no CGO).
**Tables:**
```sql
packages (
- id INTEGER PRIMARY KEY, -- SERIAL on Postgres
- purl TEXT NOT NULL, -- unique, e.g. pkg:npm/lodash
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- latest_version TEXT,
- license TEXT,
- description TEXT,
- homepage TEXT,
- repository_url TEXT,
- registry_url TEXT,
- supplier_name TEXT,
- supplier_type TEXT,
- source TEXT,
- enriched_at DATETIME,
- vulns_synced_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
+ id, purl, ecosystem, name, namespace, latest_version,
+ license, description, homepage, repository_url, upstream_url,
+ metadata_fetched_at, created_at, updated_at
)
--- indexes: purl (unique), (ecosystem, name)
versions (
- id INTEGER PRIMARY KEY,
- purl TEXT NOT NULL, -- unique, e.g. pkg:npm/lodash@4.17.21
- package_purl TEXT NOT NULL, -- FK to packages.purl
- license TEXT,
- published_at DATETIME,
- integrity TEXT, -- subresource integrity hash
- yanked INTEGER DEFAULT 0, -- BOOLEAN on Postgres
- source TEXT,
- enriched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
+ id, purl, package_id, version, license, integrity,
+ published_at, yanked, metadata_fetched_at, created_at, updated_at
)
--- indexes: purl (unique), package_purl
artifacts (
- id INTEGER PRIMARY KEY,
- version_purl TEXT NOT NULL,
- filename TEXT NOT NULL,
- upstream_url TEXT NOT NULL,
- storage_path TEXT, -- null until cached
- content_hash TEXT, -- SHA-256
- size INTEGER, -- BIGINT on Postgres
- content_type TEXT,
- fetched_at DATETIME,
- hit_count INTEGER DEFAULT 0, -- BIGINT on Postgres
- last_accessed_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
+ id, version_id, filename, upstream_url, storage_path,
+ content_hash, size, content_type, fetched_at,
+ hit_count, last_accessed_at, created_at, updated_at
)
--- indexes: (version_purl, filename) unique, storage_path, last_accessed_at
-
-vulnerabilities (
- id INTEGER PRIMARY KEY,
- vuln_id TEXT NOT NULL, -- e.g. CVE-2021-1234
- ecosystem TEXT NOT NULL,
- package_name TEXT NOT NULL,
- severity TEXT,
- summary TEXT,
- fixed_version TEXT,
- cvss_score REAL,
- "references" TEXT, -- JSON array
- fetched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
-)
--- indexes: (vuln_id, ecosystem, package_name) unique, (ecosystem, package_name)
-
-metadata_cache (
- id INTEGER PRIMARY KEY,
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- storage_path TEXT NOT NULL,
- etag TEXT,
- content_type TEXT,
- size INTEGER, -- BIGINT on Postgres
- fetched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
-)
--- indexes: (ecosystem, name) unique
```
-On PostgreSQL, `INTEGER PRIMARY KEY` becomes `SERIAL`, `DATETIME` becomes `TIMESTAMP`, `INTEGER DEFAULT 0` booleans become `BOOLEAN DEFAULT FALSE`, and size/count columns use `BIGINT`.
-
-The `MigrateSchema()` function handles backward compatibility with older git-pkgs databases by running named migrations that add missing columns and tables. See [migrations.md](migrations.md) for how to add new schema changes.
-
**Key operations:**
- `GetPackageByPURL()` - Look up package by PURL
- `GetVersionByPURL()` - Look up version by PURL
@@ -188,7 +121,6 @@ The `MigrateSchema()` function handles backward compatibility with older git-pkg
- `UpsertPackage/Version/Artifact()` - Insert or update records
- `RecordArtifactHit()` - Increment hit counter, update access time
- `GetLeastRecentlyUsedArtifacts()` - For cache eviction
-- `SearchPackages()` - Full-text search across cached packages
### `internal/storage`
@@ -269,33 +201,12 @@ HTTP protocol handlers for each registry type.
### `internal/server`
-HTTP server setup, web UI, and API handlers.
+HTTP server setup.
- Creates and wires together all components
-- Mounts protocol handlers at ecosystem-specific paths
-- Middleware: request ID, real IP, logging, panic recovery, active request tracking
-- Web UI: dashboard, package browser, source browser, version comparison
-- Templates are embedded in the binary via `//go:embed`
-- Enrichment API for package metadata, vulnerability scanning, and outdated detection
-- Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends.
-
-### `internal/metrics`
-
-Prometheus metrics for cache performance, upstream latency, storage operations, and active requests. See the Monitoring section of the README for the full metric list.
-
-### Cooldown
-
-Version age filtering for supply chain attack mitigation, provided by [github.com/git-pkgs/cooldown](https://github.com/git-pkgs/cooldown). Configurable at global, ecosystem, and per-package levels. Supported by npm, PyPI, pub.dev, and Composer handlers.
-
-### `internal/enrichment`
-
-Package metadata enrichment. Fetches license, description, homepage, repository URL, and vulnerability data from upstream registries. Powers the `/api/` endpoints and the web UI's package detail pages.
-
-### `internal/mirror`
-
-Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work.
-
-The package also provides a `MetadataCache` for storing raw upstream metadata blobs so the proxy can serve metadata responses offline. The `JobStore` manages async mirror jobs exposed via the `/api/mirror` endpoints.
+- Mounts handlers at appropriate paths
+- Adds logging middleware
+- Health and stats endpoints
### `internal/config`
@@ -346,11 +257,10 @@ Eviction can be implemented as:
- Ensures clients fetch artifacts through proxy
- Alternative: Let clients fetch directly, miss cache opportunity
-**Why not cache metadata (by default)?**
+**Why not cache metadata?**
- Simplicity - no invalidation logic needed
- Fresh data - new versions visible immediately
- Metadata is small, upstream fetch is fast
-- Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table
**Why stream artifacts?**
- Memory efficient - don't load large files into RAM
diff --git a/docs/configuration.md b/docs/configuration.md
index cf6c101..8c79deb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -114,8 +114,6 @@ Override default upstream registry URLs:
```yaml
upstream:
npm: "https://registry.npmjs.org"
- maven: "https://repo1.maven.org/maven2"
- gradle_plugin_portal: "https://plugins.gradle.org/m2"
cargo: "https://index.crates.io"
cargo_download: "https://static.crates.io/crates"
```
@@ -186,118 +184,6 @@ upstream:
token: "${PRIVATE_TOKEN}"
```
-## Gradle Build Cache
-
-The `/gradle` endpoint supports optional safeguards for upload control and cache retention.
-
-```yaml
-gradle:
- build_cache:
- read_only: false
- max_upload_size: "100MB"
- max_age: "168h"
- max_size: "20GB"
- sweep_interval: "10m"
-```
-
-| Config | Environment | Description |
-|--------|-------------|-------------|
-| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Disable PUT uploads and keep GET/HEAD read-only |
-| `gradle.build_cache.max_upload_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE` | Maximum accepted PUT body size (must be > 0) |
-| `gradle.build_cache.max_age` | `PROXY_GRADLE_BUILD_CACHE_MAX_AGE` | Delete entries older than this duration (default `168h`, set `0` to disable) |
-| `gradle.build_cache.max_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_SIZE` | Total size cap for `_gradle/http-build-cache`, deleting oldest first (`0` disables) |
-| `gradle.build_cache.sweep_interval` | `PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL` | Frequency for background eviction sweeps |
-
-`max_age` and `max_size` are independent and can be combined. When both are set, age-based eviction runs first, then size-based eviction trims remaining entries oldest-first.
-
-## Cooldown
-
-The cooldown feature hides package versions published too recently, giving the community time to spot malicious releases before they reach your projects. When a version is within its cooldown period, it's stripped from metadata responses so package managers won't install it.
-
-```yaml
-cooldown:
- default: "3d"
- ecosystems:
- npm: "7d"
- cargo: "0"
- packages:
- "pkg:npm/lodash": "0"
- "pkg:npm/@babel/core": "14d"
-```
-
-| Config | Environment | Description |
-|--------|-------------|-------------|
-| `cooldown.default` | `PROXY_COOLDOWN_DEFAULT` | Global default cooldown |
-| `cooldown.ecosystems` | - | Per-ecosystem overrides |
-| `cooldown.packages` | - | Per-package overrides (keyed by PURL) |
-
-Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable.
-
-Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.
-
-Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata.
-
-Note: Hex cooldown requires disabling registry signature verification since the proxy re-encodes the protobuf payload without the original signature. Set `HEX_NO_VERIFY_REPO_ORIGIN=1` or configure your repo with `no_verify: true`.
-
-## Metadata Caching
-
-By default the proxy fetches metadata fresh from upstream on every request. Enable `cache_metadata` to store metadata responses in the database and storage backend for offline fallback. When upstream is unreachable, the proxy serves the last cached copy. ETag-based revalidation avoids re-downloading unchanged metadata.
-
-```yaml
-cache_metadata: true
-```
-
-Or via environment variable: `PROXY_CACHE_METADATA=true`.
-
-The `proxy mirror` command always enables metadata caching regardless of this setting.
-
-### Metadata TTL
-
-When metadata caching is enabled, `metadata_ttl` controls how long a cached response is considered fresh before revalidating with upstream. During the TTL window, cached metadata is served directly without contacting upstream, reducing latency and upstream load.
-
-```yaml
-metadata_ttl: "5m" # default
-```
-
-Or via environment variable: `PROXY_METADATA_TTL=10m`.
-
-Set to `"0"` to always revalidate with upstream (ETag-based conditional requests still avoid re-downloading unchanged content).
-
-When upstream is unreachable and the cached entry is past its TTL, the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated.
-
-## Mirror API
-
-The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:
-
-```yaml
-mirror_api: true
-```
-
-Or via environment variable: `PROXY_MIRROR_API=true`.
-
-When disabled, the endpoints are not registered and return 404.
-
-## Mirror Command
-
-The `proxy mirror` command pre-populates the cache from various sources. It accepts the same storage and database flags as `serve`.
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--sbom` | | Path to CycloneDX or SPDX SBOM file |
-| `--concurrency` | `4` | Number of parallel downloads |
-| `--dry-run` | `false` | Show what would be mirrored without downloading |
-| `--config` | | Path to configuration file |
-| `--storage-url` | | Storage URL |
-| `--database-driver` | | Database driver |
-| `--database-path` | | SQLite database file |
-| `--database-url` | | PostgreSQL connection URL |
-
-Positional arguments are treated as PURLs:
-
-```bash
-proxy mirror pkg:npm/lodash@4.17.21 pkg:cargo/serde@1.0.0
-```
-
## Docker
### SQLite with Local Storage
diff --git a/docs/migrations.md b/docs/migrations.md
deleted file mode 100644
index 21ecb2e..0000000
--- a/docs/migrations.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Database Migrations
-
-Schema changes are tracked in a `migrations` table. Each migration has a name and a function. On startup, `MigrateSchema()` loads the set of already-applied names in one query and runs anything new.
-
-Fresh databases created via `Create()` get the full schema and all migrations are recorded as already applied.
-
-## Adding a migration
-
-In `internal/database/schema.go`:
-
-1. Write a migration function:
-
-```go
-func migrateAddWidgetColumn(db *DB) error {
- hasCol, err := db.HasColumn("packages", "widget")
- if err != nil {
- return fmt.Errorf("checking column widget: %w", err)
- }
- if !hasCol {
- colType := "TEXT"
- if db.dialect == DialectPostgres {
- colType = "TEXT" // adjust if types differ
- }
- if _, err := db.Exec(fmt.Sprintf("ALTER TABLE packages ADD COLUMN widget %s", colType)); err != nil {
- return fmt.Errorf("adding column widget: %w", err)
- }
- }
- return nil
-}
-```
-
-2. Append it to the `migrations` slice with the next sequential prefix:
-
-```go
-var migrations = []migration{
- {"001_add_packages_enrichment_columns", migrateAddPackagesEnrichmentColumns},
- {"002_add_versions_enrichment_columns", migrateAddVersionsEnrichmentColumns},
- {"003_ensure_artifacts_table", migrateEnsureArtifactsTable},
- {"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
- {"005_add_widget_column", migrateAddWidgetColumn}, // new
-}
-```
-
-3. Add the same column to both `schemaSQLite` and `schemaPostgres` at the top of the file so fresh databases start with the full schema.
-
-## Rules
-
-- Migration functions must be idempotent. Use `HasColumn`/`HasTable` checks or `IF NOT EXISTS` clauses so they're safe to run against a database that already has the change.
-- Handle both SQLite and Postgres dialects. Common differences: `DATETIME` vs `TIMESTAMP`, `INTEGER DEFAULT 0` vs `BOOLEAN DEFAULT FALSE`, `INTEGER PRIMARY KEY` vs `SERIAL PRIMARY KEY`.
-- Never reorder or rename existing entries. The name string is the migration's identity in the database.
-- Never remove old migrations from the list. They won't run on already-migrated databases, but they need to exist for older databases upgrading for the first time.
diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go
deleted file mode 100644
index 23ff54a..0000000
--- a/docs/swagger/docs.go
+++ /dev/null
@@ -1,777 +0,0 @@
-// Package swagger Code generated by swaggo/swag. DO NOT EDIT
-package swagger
-
-import "github.com/swaggo/swag"
-
-const docTemplate = `{
- "schemes": {{ marshal .Schemes }},
- "swagger": "2.0",
- "info": {
- "description": "{{escape .Description}}",
- "title": "{{.Title}}",
- "contact": {},
- "version": "{{.Version}}"
- },
- "host": "{{.Host}}",
- "basePath": "{{.BasePath}}",
- "paths": {
- "/api/browse/{ecosystem}/{name}/{version}": {
- "get": {
- "description": "Lists files from the first cached artifact for a package version.",
- "produces": [
- "application/json"
- ],
- "tags": [
- "browse"
- ],
- "summary": "List files inside a cached artifact",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Version",
- "name": "version",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Directory path inside the archive",
- "name": "path",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.BrowseListResponse"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
- "get": {
- "description": "Streams a single file from the cached artifact. The file path may contain slashes.",
- "produces": [
- "application/octet-stream"
- ],
- "tags": [
- "browse"
- ],
- "summary": "Fetch a file inside a cached artifact",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Version",
- "name": "version",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "File path inside the archive",
- "name": "filepath",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "file"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/bulk": {
- "post": {
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Bulk package lookup by PURL",
- "parameters": [
- {
- "description": "PURLs",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/server.BulkRequest"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.BulkResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
- "get": {
- "description": "Returns a structured diff for two cached versions.",
- "produces": [
- "application/json"
- ],
- "tags": [
- "browse"
- ],
- "summary": "Compare two cached versions",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "From version",
- "name": "fromVersion",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "To version",
- "name": "toVersion",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "object",
- "additionalProperties": true
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/outdated": {
- "post": {
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Check outdated packages",
- "parameters": [
- {
- "description": "Packages to check",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/server.OutdatedRequest"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.OutdatedResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/packages": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "List cached packages",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "query"
- },
- {
- "enum": [
- "hits",
- "name",
- "size",
- "cached_at",
- "ecosystem",
- "vulns"
- ],
- "type": "string",
- "description": "Sort",
- "name": "sort",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.PackagesListResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/search": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Search cached packages",
- "parameters": [
- {
- "type": "string",
- "description": "Query",
- "name": "q",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.SearchResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/health": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "meta"
- ],
- "summary": "Health check",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.HealthResponse"
- }
- },
- "503": {
- "description": "Service Unavailable",
- "schema": {
- "$ref": "#/definitions/server.HealthResponse"
- }
- }
- }
- }
- },
- "/stats": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "meta"
- ],
- "summary": "Cache statistics",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.StatsResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- }
- },
- "definitions": {
- "server.BrowseFileInfo": {
- "type": "object",
- "properties": {
- "is_dir": {
- "type": "boolean"
- },
- "mod_time": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "path": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- }
- }
- },
- "server.BrowseListResponse": {
- "type": "object",
- "properties": {
- "files": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.BrowseFileInfo"
- }
- },
- "path": {
- "type": "string"
- }
- }
- },
- "server.BulkRequest": {
- "type": "object",
- "properties": {
- "purls": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
- "server.BulkResponse": {
- "type": "object",
- "properties": {
- "packages": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.PackageResponse"
- }
- }
- }
- },
- "server.ErrorResponse": {
- "type": "object",
- "properties": {
- "code": {
- "type": "string"
- },
- "message": {
- "type": "string"
- }
- }
- },
- "server.HealthCheck": {
- "type": "object",
- "properties": {
- "error": {
- "type": "string"
- },
- "status": {
- "type": "string"
- },
- "step": {
- "type": "string"
- }
- }
- },
- "server.HealthResponse": {
- "type": "object",
- "properties": {
- "checks": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.HealthCheck"
- }
- },
- "status": {
- "type": "string"
- }
- }
- },
- "server.OutdatedPackage": {
- "type": "object",
- "properties": {
- "ecosystem": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "server.OutdatedRequest": {
- "type": "object",
- "properties": {
- "packages": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.OutdatedPackage"
- }
- }
- }
- },
- "server.OutdatedResponse": {
- "type": "object",
- "properties": {
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.OutdatedResult"
- }
- }
- }
- },
- "server.OutdatedResult": {
- "type": "object",
- "properties": {
- "ecosystem": {
- "type": "string"
- },
- "is_outdated": {
- "type": "boolean"
- },
- "latest_version": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "server.PackageListResult": {
- "type": "object",
- "properties": {
- "cached_at": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "hits": {
- "type": "integer"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "license_category": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- },
- "vuln_count": {
- "type": "integer"
- }
- }
- },
- "server.PackageResponse": {
- "type": "object",
- "properties": {
- "description": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "homepage": {
- "type": "string"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "license_category": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "registry_url": {
- "type": "string"
- },
- "repository": {
- "type": "string"
- }
- }
- },
- "server.PackagesListResponse": {
- "type": "object",
- "properties": {
- "count": {
- "type": "integer"
- },
- "ecosystem": {
- "type": "string"
- },
- "page": {
- "type": "integer"
- },
- "per_page": {
- "type": "integer"
- },
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.PackageListResult"
- }
- },
- "sort_by": {
- "type": "string"
- },
- "total": {
- "type": "integer"
- }
- }
- },
- "server.SearchPackageResult": {
- "type": "object",
- "properties": {
- "cached_at": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "hits": {
- "type": "integer"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- }
- }
- },
- "server.SearchResponse": {
- "type": "object",
- "properties": {
- "count": {
- "type": "integer"
- },
- "query": {
- "type": "string"
- },
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.SearchPackageResult"
- }
- }
- }
- },
- "server.StatsResponse": {
- "type": "object",
- "properties": {
- "cached_artifacts": {
- "type": "integer"
- },
- "database_path": {
- "type": "string"
- },
- "storage_url": {
- "type": "string"
- },
- "total_size": {
- "type": "string"
- },
- "total_size_bytes": {
- "type": "integer"
- }
- }
- }
- }
-}`
-
-// SwaggerInfo holds exported Swagger Info so clients can modify it
-var SwaggerInfo = &swag.Spec{
- Version: "0.1.0",
- Host: "",
- BasePath: "/",
- Schemes: []string{},
- Title: "git-pkgs proxy API",
- Description: "HTTP API for package enrichment, vulnerability lookup, cache stats, and source browsing.",
- InfoInstanceName: "swagger",
- SwaggerTemplate: docTemplate,
- LeftDelim: "{{",
- RightDelim: "}}",
-}
-
-func init() {
- swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
-}
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
deleted file mode 100644
index c2b4dfc..0000000
--- a/docs/swagger/swagger.json
+++ /dev/null
@@ -1,752 +0,0 @@
-{
- "swagger": "2.0",
- "info": {
- "description": "HTTP API for package enrichment, vulnerability lookup, cache stats, and source browsing.",
- "title": "git-pkgs proxy API",
- "contact": {},
- "version": "0.1.0"
- },
- "basePath": "/",
- "paths": {
- "/api/browse/{ecosystem}/{name}/{version}": {
- "get": {
- "description": "Lists files from the first cached artifact for a package version.",
- "produces": [
- "application/json"
- ],
- "tags": [
- "browse"
- ],
- "summary": "List files inside a cached artifact",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Version",
- "name": "version",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Directory path inside the archive",
- "name": "path",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.BrowseListResponse"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
- "get": {
- "description": "Streams a single file from the cached artifact. The file path may contain slashes.",
- "produces": [
- "application/octet-stream"
- ],
- "tags": [
- "browse"
- ],
- "summary": "Fetch a file inside a cached artifact",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Version",
- "name": "version",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "File path inside the archive",
- "name": "filepath",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "file"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/bulk": {
- "post": {
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Bulk package lookup by PURL",
- "parameters": [
- {
- "description": "PURLs",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/server.BulkRequest"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.BulkResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
- "get": {
- "description": "Returns a structured diff for two cached versions.",
- "produces": [
- "application/json"
- ],
- "tags": [
- "browse"
- ],
- "summary": "Compare two cached versions",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Package name",
- "name": "name",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "From version",
- "name": "fromVersion",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "To version",
- "name": "toVersion",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "object",
- "additionalProperties": true
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/outdated": {
- "post": {
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Check outdated packages",
- "parameters": [
- {
- "description": "Packages to check",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/server.OutdatedRequest"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.OutdatedResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/packages": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "List cached packages",
- "parameters": [
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "query"
- },
- {
- "enum": [
- "hits",
- "name",
- "size",
- "cached_at",
- "ecosystem",
- "vulns"
- ],
- "type": "string",
- "description": "Sort",
- "name": "sort",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.PackagesListResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/api/search": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "api"
- ],
- "summary": "Search cached packages",
- "parameters": [
- {
- "type": "string",
- "description": "Query",
- "name": "q",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Ecosystem",
- "name": "ecosystem",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.SearchResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- },
- "/health": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "meta"
- ],
- "summary": "Health check",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.HealthResponse"
- }
- },
- "503": {
- "description": "Service Unavailable",
- "schema": {
- "$ref": "#/definitions/server.HealthResponse"
- }
- }
- }
- }
- },
- "/stats": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "meta"
- ],
- "summary": "Cache statistics",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/server.StatsResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/server.ErrorResponse"
- }
- }
- }
- }
- }
- },
- "definitions": {
- "server.BrowseFileInfo": {
- "type": "object",
- "properties": {
- "is_dir": {
- "type": "boolean"
- },
- "mod_time": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "path": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- }
- }
- },
- "server.BrowseListResponse": {
- "type": "object",
- "properties": {
- "files": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.BrowseFileInfo"
- }
- },
- "path": {
- "type": "string"
- }
- }
- },
- "server.BulkRequest": {
- "type": "object",
- "properties": {
- "purls": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
- "server.BulkResponse": {
- "type": "object",
- "properties": {
- "packages": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.PackageResponse"
- }
- }
- }
- },
- "server.ErrorResponse": {
- "type": "object",
- "properties": {
- "code": {
- "type": "string"
- },
- "message": {
- "type": "string"
- }
- }
- },
- "server.HealthCheck": {
- "type": "object",
- "properties": {
- "error": {
- "type": "string"
- },
- "status": {
- "type": "string"
- },
- "step": {
- "type": "string"
- }
- }
- },
- "server.HealthResponse": {
- "type": "object",
- "properties": {
- "checks": {
- "type": "object",
- "additionalProperties": {
- "$ref": "#/definitions/server.HealthCheck"
- }
- },
- "status": {
- "type": "string"
- }
- }
- },
- "server.OutdatedPackage": {
- "type": "object",
- "properties": {
- "ecosystem": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "server.OutdatedRequest": {
- "type": "object",
- "properties": {
- "packages": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.OutdatedPackage"
- }
- }
- }
- },
- "server.OutdatedResponse": {
- "type": "object",
- "properties": {
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.OutdatedResult"
- }
- }
- }
- },
- "server.OutdatedResult": {
- "type": "object",
- "properties": {
- "ecosystem": {
- "type": "string"
- },
- "is_outdated": {
- "type": "boolean"
- },
- "latest_version": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "server.PackageListResult": {
- "type": "object",
- "properties": {
- "cached_at": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "hits": {
- "type": "integer"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "license_category": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- },
- "vuln_count": {
- "type": "integer"
- }
- }
- },
- "server.PackageResponse": {
- "type": "object",
- "properties": {
- "description": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "homepage": {
- "type": "string"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "license_category": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "registry_url": {
- "type": "string"
- },
- "repository": {
- "type": "string"
- }
- }
- },
- "server.PackagesListResponse": {
- "type": "object",
- "properties": {
- "count": {
- "type": "integer"
- },
- "ecosystem": {
- "type": "string"
- },
- "page": {
- "type": "integer"
- },
- "per_page": {
- "type": "integer"
- },
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.PackageListResult"
- }
- },
- "sort_by": {
- "type": "string"
- },
- "total": {
- "type": "integer"
- }
- }
- },
- "server.SearchPackageResult": {
- "type": "object",
- "properties": {
- "cached_at": {
- "type": "string"
- },
- "ecosystem": {
- "type": "string"
- },
- "hits": {
- "type": "integer"
- },
- "latest_version": {
- "type": "string"
- },
- "license": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- }
- }
- },
- "server.SearchResponse": {
- "type": "object",
- "properties": {
- "count": {
- "type": "integer"
- },
- "query": {
- "type": "string"
- },
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/server.SearchPackageResult"
- }
- }
- }
- },
- "server.StatsResponse": {
- "type": "object",
- "properties": {
- "cached_artifacts": {
- "type": "integer"
- },
- "database_path": {
- "type": "string"
- },
- "storage_url": {
- "type": "string"
- },
- "total_size": {
- "type": "string"
- },
- "total_size_bytes": {
- "type": "integer"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 199c8c8..0271182 100644
--- a/go.mod
+++ b/go.mod
@@ -1,37 +1,29 @@
module github.com/git-pkgs/proxy
-go 1.25.6
+go 1.25.7
require (
- github.com/BurntSushi/toml v1.6.0
- github.com/CycloneDX/cyclonedx-go v0.11.0
- github.com/git-pkgs/archives v0.3.0
- github.com/git-pkgs/cooldown v0.1.1
- github.com/git-pkgs/enrichment v0.2.3
- github.com/git-pkgs/purl v0.1.12
- github.com/git-pkgs/registries v0.6.1
- github.com/git-pkgs/spdx v0.1.4
- github.com/git-pkgs/vers v0.2.6
- github.com/git-pkgs/vulns v0.1.5
+ github.com/git-pkgs/archives v0.2.0
+ github.com/git-pkgs/enrichment v0.1.5
+ github.com/git-pkgs/git-pkgs v0.15.1-0.20260304191500-e296d0146017
+ github.com/git-pkgs/purl v0.1.9
+ github.com/git-pkgs/registries v0.3.0
+ github.com/git-pkgs/spdx v0.1.1
+ github.com/git-pkgs/vers v0.2.3
+ github.com/git-pkgs/vulns v0.1.3
github.com/go-chi/chi/v5 v5.2.5
github.com/jmoiron/sqlx v1.4.0
- github.com/lib/pq v1.12.3
+ github.com/lib/pq v1.11.2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
- github.com/spdx/tools-golang v0.5.7
- github.com/swaggo/swag v1.16.6
gocloud.dev v0.45.0
- golang.org/x/sync v0.20.0
- google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
- modernc.org/sqlite v1.50.1
+ modernc.org/sqlite v1.46.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
- cloud.google.com/go/auth v0.18.2 // indirect
- cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
@@ -44,20 +36,11 @@ require (
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
- github.com/Azure/go-autorest v14.2.0+incompatible // indirect
- github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
- github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
- github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
- github.com/PuerkitoBio/purell v1.1.1 // indirect
- github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
@@ -65,7 +48,6 @@ require (
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
- github.com/anchore/go-struct-converter v0.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
@@ -110,7 +92,6 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
@@ -128,15 +109,10 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/git-pkgs/packageurl-go v0.3.1 // indirect
- github.com/git-pkgs/pom v0.1.4 // indirect
- github.com/github/go-spdx/v2 v2.7.0 // indirect
+ github.com/github/go-spdx/v2 v2.4.0 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-openapi/jsonpointer v0.19.5 // indirect
- github.com/go-openapi/jsonreference v0.19.6 // indirect
- github.com/go-openapi/spec v0.20.4 // indirect
- github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
@@ -149,7 +125,6 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
- github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
@@ -162,10 +137,8 @@ require (
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
@@ -181,14 +154,12 @@ require (
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
- github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
- github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
@@ -200,7 +171,6 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
@@ -221,11 +191,9 @@ require (
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/oapi-codegen/runtime v1.2.0 // indirect
- github.com/package-url/packageurl-go v0.1.6 // indirect
github.com/pandatix/go-cvss v0.6.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
- github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
@@ -240,7 +208,6 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
github.com/rubyist/circuitbreaker v2.2.1+incompatible // indirect
- github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
@@ -271,7 +238,6 @@ require (
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
- github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
@@ -294,28 +260,27 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
- golang.org/x/oauth2 v0.35.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.269.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.1 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
- modernc.org/libc v1.72.3 // indirect
+ modernc.org/libc v1.69.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
- sigs.k8s.io/yaml v1.6.0 // indirect
)
tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint
diff --git a/go.sum b/go.sum
index 23c3df7..c9349d4 100644
--- a/go.sum
+++ b/go.sum
@@ -43,31 +43,8 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
-github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
-github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
-github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
-github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
-github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc=
-github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
-github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
-github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3wo7apkBFI=
-github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8=
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
@@ -76,18 +53,12 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
-github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
-github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
-github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
-github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
@@ -107,8 +78,6 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
-github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30=
-github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo=
@@ -168,8 +137,6 @@ github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+j
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
-github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
-github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
@@ -202,10 +169,7 @@ github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7Lsp
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
@@ -250,28 +214,26 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
-github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78=
-github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU=
-github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w=
-github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E=
-github.com/git-pkgs/enrichment v0.2.3 h1:42mqoUhQZNGhlEO671pboI/Cu6F+DoffJoFbVhb2jlw=
-github.com/git-pkgs/enrichment v0.2.3/go.mod h1:MBv5nhHzjwLxeSgx2+7waCcpReUjhCD+9B0bvufpMO0=
+github.com/git-pkgs/archives v0.2.0 h1:8OuuGwAB+Eww8/1ayyYpZzP0wVEH0/VWBG3mQrfi9SM=
+github.com/git-pkgs/archives v0.2.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU=
+github.com/git-pkgs/enrichment v0.1.5 h1:xhZkQMciofrPPrDtVRQqz+suRmKTtk53ibSUYCYviCI=
+github.com/git-pkgs/enrichment v0.1.5/go.mod h1:5LB52Ei3cUI4LqLkshpMfxcSguwWT5L3exv+erfIcNI=
+github.com/git-pkgs/git-pkgs v0.15.1-0.20260304191500-e296d0146017 h1:jZ/2h7dmOIFX7KC7nF7FF/ZGjWdqanzE7LUMwJ7OePE=
+github.com/git-pkgs/git-pkgs v0.15.1-0.20260304191500-e296d0146017/go.mod h1:YP6B+ij6pmaLMYWcoKyvCtCJvPeVPJbYcDmQEBTuZZc=
github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M=
github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
-github.com/git-pkgs/pom v0.1.4 h1:C6st+XSbF75eKuwfdkDZZtYHoTcaWRIEQYar5VtszUo=
-github.com/git-pkgs/pom v0.1.4/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
-github.com/git-pkgs/purl v0.1.12 h1:qCskrEU1LWQhCkIVZd992W5++Bsxazvx2Cx1/65qCvU=
-github.com/git-pkgs/purl v0.1.12/go.mod h1:ofp4mHsR0cUeVONQaf33n6Wxg2QTEvtUdRfCedI8ouA=
-github.com/git-pkgs/registries v0.6.1 h1:xZfVZQmffIfdeJthn5o2EozbVJ6gBeImYwKQnfdKUfU=
-github.com/git-pkgs/registries v0.6.1/go.mod h1:a3BP/56VW3O/CFRqiJCtSy+OqRrSH25wF1PWHP76ka0=
-github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs=
-github.com/git-pkgs/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto=
-github.com/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8=
-github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
-github.com/git-pkgs/vulns v0.1.5 h1:mtX88/27toFl+B95kaH5QbAdOCQ3YIDGjJrlrrnqQTE=
-github.com/git-pkgs/vulns v0.1.5/go.mod h1:bZFikfrR/5gC0ZMwXh7qcEu2gpKfXMBhVsy4kF12Ae0=
-github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ=
-github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
+github.com/git-pkgs/purl v0.1.9 h1:zSHKBVwRTJiMGwiYIiHgoIUfJTdtC7kVQ0+0RHckwxc=
+github.com/git-pkgs/purl v0.1.9/go.mod h1:6YX25yhztts1Byktw4pOlykru57GOJaanA+WmOBFtdU=
+github.com/git-pkgs/registries v0.3.0 h1:eIM78ry7l1CfwbPMXQ/vCsN9xJNWN1uDmkl76MS+OT8=
+github.com/git-pkgs/registries v0.3.0/go.mod h1:RAqG9XyGLV56F8tBXXyzmEaHTBkub7MWFD9KGjt4WtQ=
+github.com/git-pkgs/spdx v0.1.1 h1:jjchxLhvTnTR7fLcdXdNVDh/tLq6B2S6LnaKEzBjhRQ=
+github.com/git-pkgs/spdx v0.1.1/go.mod h1:nbZdJ09OuZg9/bgRnnyEM5F5uR8K7Iwf5oDHQvK3WcE=
+github.com/git-pkgs/vers v0.2.3 h1:elyuJZ2mBRIncRUF6SjpnwIwSuRRnPdAEJBZcVgU450=
+github.com/git-pkgs/vers v0.2.3/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
+github.com/git-pkgs/vulns v0.1.3 h1:Q9GixxhAYpP5vVDetKNMACHxGnWwB8aE5c9kbE8xxqU=
+github.com/git-pkgs/vulns v0.1.3/go.mod h1:/PVy7S1oZNVF9X8yVOZ9SX5MFpyVWCtLnIX0kAfPjY0=
+github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M=
+github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
@@ -283,16 +245,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
-github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
-github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
-github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
-github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
-github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
-github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -329,8 +281,6 @@ github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5W
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
-github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
-github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
@@ -415,26 +365,19 @@ github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgY
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
-github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
-github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
@@ -460,19 +403,14 @@ github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
-github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
+github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
@@ -509,7 +447,6 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
@@ -529,8 +466,6 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
-github.com/package-url/packageurl-go v0.1.6 h1:YO3p6u1XmCUliivUg/qWphaY8vI6hxSnnPv7Bfg3m5M=
-github.com/package-url/packageurl-go v0.1.6/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
github.com/pandatix/go-cvss v0.6.2 h1:TFiHlzUkT67s6UkelHmK6s1INKVUG7nlKYiWWDTITGI=
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
@@ -539,8 +474,6 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterbourgon/g2s v0.0.0-20170223122336-d4e7ad98afea h1:sKwxy1H95npauwu8vtF95vG/syrL0p8fSZo/XlDg5gk=
github.com/peterbourgon/g2s v0.0.0-20170223122336-d4e7ad98afea/go.mod h1:1VcHEd3ro4QMoHfiNl/j7Jkln9+KQuorp0PItHMJYNg=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -576,8 +509,6 @@ github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/rubyist/circuitbreaker v2.2.1+incompatible h1:KUKd/pV8Geg77+8LNDwdow6rVCAYOp8+kHUyFvL6Mhk=
github.com/rubyist/circuitbreaker v2.2.1+incompatible/go.mod h1:Ycs3JgJADPuzJDwffe12k6BZT8hxVi6lFK+gWYJLN4A=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
@@ -593,11 +524,10 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
-github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
-github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
@@ -606,8 +536,6 @@ github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
-github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg=
-github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
@@ -635,19 +563,14 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
-github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
-github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
-github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo=
-github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw=
github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
@@ -664,18 +587,10 @@ github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLk
github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
-github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
-github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -766,7 +681,6 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -786,8 +700,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -795,7 +709,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -803,14 +716,13 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -868,26 +780,21 @@ google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
-modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
-modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
-modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
+modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -896,18 +803,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
-modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
+modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
+modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
-modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
-modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
+modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -916,5 +823,3 @@ mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU=
-sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
-sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/internal/config/config.go b/internal/config/config.go
index 87e23ac..d5bef56 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -51,12 +51,10 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
- "net/url"
"os"
"path/filepath"
"strconv"
"strings"
- "time"
"gopkg.in/yaml.v3"
)
@@ -82,42 +80,6 @@ type Config struct {
// Upstream configures upstream registry URLs (optional overrides).
Upstream UpstreamConfig `json:"upstream" yaml:"upstream"`
-
- // Cooldown configures version age filtering to mitigate supply chain attacks.
- Cooldown CooldownConfig `json:"cooldown" yaml:"cooldown"`
-
- // CacheMetadata enables caching of upstream metadata responses for offline fallback.
- // When enabled, metadata is stored in the database and storage backend.
- // The mirror command always enables this regardless of this setting.
- CacheMetadata bool `json:"cache_metadata" yaml:"cache_metadata"`
-
- // MetadataTTL is how long cached metadata is considered fresh before
- // revalidating with upstream. Uses Go duration syntax (e.g. "5m", "1h").
- // Default: "5m". Set to "0" to always revalidate.
- MetadataTTL string `json:"metadata_ttl" yaml:"metadata_ttl"`
-
- // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP.
- // Disabled by default to prevent unauthenticated users from triggering downloads.
- MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"`
-
- // Gradle configures Gradle HttpBuildCache behavior.
- Gradle GradleConfig `json:"gradle" yaml:"gradle"`
-
- // Health configures the /health endpoint behavior.
- Health HealthConfig `json:"health" yaml:"health"`
-}
-
-// CooldownConfig configures version cooldown periods.
-// Versions published more recently than the cooldown are hidden from metadata responses.
-type CooldownConfig struct {
- // Default is the global default cooldown (e.g., "3d", "48h", "0" to disable).
- Default string `json:"default" yaml:"default"`
-
- // Ecosystems overrides the default for specific ecosystems.
- Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
-
- // Packages overrides the cooldown for specific packages (keyed by PURL).
- Packages map[string]string `json:"packages" yaml:"packages"`
}
// StorageConfig configures artifact storage.
@@ -131,66 +93,14 @@ type StorageConfig struct {
URL string `json:"url" yaml:"url"`
// Path is the directory where cached artifacts are stored.
- // If URL is empty, this is used as file://{Path}.
- //
// Deprecated: Use URL with file:// scheme instead.
+ // If URL is empty, this is used as file://{Path}.
Path string `json:"path" yaml:"path"`
// MaxSize is the maximum cache size (e.g., "10GB", "500MB").
// When exceeded, least recently used artifacts are evicted.
// Empty or "0" means unlimited.
MaxSize string `json:"max_size" yaml:"max_size"`
-
- // DirectServe enables redirecting cached artifact downloads to presigned
- // storage URLs (HTTP 302) instead of streaming bytes through the proxy.
- // Only effective for backends that support URL signing (S3, Azure).
- DirectServe bool `json:"direct_serve" yaml:"direct_serve"`
-
- // DirectServeTTL is how long presigned URLs remain valid.
- // Uses Go duration syntax (e.g. "5m", "1h"). Default: "15m".
- DirectServeTTL string `json:"direct_serve_ttl" yaml:"direct_serve_ttl"`
-
- // DirectServeBaseURL overrides the scheme and host of presigned URLs
- // before returning them to clients. Useful when the proxy reaches
- // storage at an internal address (e.g. 127.0.0.1 or a Docker hostname)
- // but clients must use a public one.
- DirectServeBaseURL string `json:"direct_serve_base_url" yaml:"direct_serve_base_url"`
-}
-
-// GradleConfig configures Gradle-specific features.
-type GradleConfig struct {
- // BuildCache configures the /gradle HttpBuildCache endpoint.
- BuildCache GradleBuildCacheConfig `json:"build_cache" yaml:"build_cache"`
-}
-
-// GradleBuildCacheConfig configures Gradle HttpBuildCache safeguards.
-type GradleBuildCacheConfig struct {
- // ReadOnly disables PUT uploads and keeps cache reads (GET/HEAD) enabled.
- ReadOnly bool `json:"read_only" yaml:"read_only"`
-
- // MaxUploadSize caps a single PUT body size (e.g., "100MB"). Must be > 0.
- // Default: "100MB".
- MaxUploadSize string `json:"max_upload_size" yaml:"max_upload_size"`
-
- // MaxAge evicts entries older than this duration (e.g., "24h", "7d").
- // Empty or "0" disables age-based eviction.
- MaxAge string `json:"max_age" yaml:"max_age"`
-
- // MaxSize evicts oldest entries until total Gradle cache size is <= MaxSize.
- // Empty or "0" disables size-based eviction.
- MaxSize string `json:"max_size" yaml:"max_size"`
-
- // SweepInterval controls periodic eviction frequency.
- // Default: "10m".
- SweepInterval string `json:"sweep_interval" yaml:"sweep_interval"`
-}
-
-// HealthConfig configures the /health endpoint.
-type HealthConfig struct {
- // StorageProbeInterval is the minimum time between storage backend probes.
- // Uses Go duration syntax (e.g. "30s", "1m"). Default: "30s".
- // Set to "0" to probe on every /health request (useful for low-traffic deployments).
- StorageProbeInterval string `json:"storage_probe_interval" yaml:"storage_probe_interval"`
}
// DatabaseConfig configures the cache database.
@@ -221,15 +131,6 @@ type UpstreamConfig struct {
// Default: https://registry.npmjs.org
NPM string `json:"npm" yaml:"npm"`
- // Maven is the upstream Maven repository URL.
- // Default: https://repo1.maven.org/maven2
- Maven string `json:"maven" yaml:"maven"`
-
- // GradlePluginPortal is the upstream Gradle Plugin Portal Maven URL.
- // Used to resolve Gradle plugin marker artifacts.
- // Default: https://plugins.gradle.org/m2
- GradlePluginPortal string `json:"gradle_plugin_portal" yaml:"gradle_plugin_portal"`
-
// Cargo is the upstream cargo index URL.
// Default: https://index.crates.io
Cargo string `json:"cargo" yaml:"cargo"`
@@ -307,20 +208,9 @@ func Default() *Config {
Format: "text",
},
Upstream: UpstreamConfig{
- NPM: "https://registry.npmjs.org",
- Maven: "https://repo1.maven.org/maven2",
- GradlePluginPortal: "https://plugins.gradle.org/m2",
- Cargo: "https://index.crates.io",
- CargoDownload: "https://static.crates.io/crates",
- },
- Gradle: GradleConfig{
- BuildCache: GradleBuildCacheConfig{
- ReadOnly: false,
- MaxUploadSize: defaultGradleMaxUploadSizeStr,
- MaxAge: "168h",
- MaxSize: "",
- SweepInterval: defaultGradleSweepIntervalStr,
- },
+ NPM: "https://registry.npmjs.org",
+ Cargo: "https://index.crates.io",
+ CargoDownload: "https://static.crates.io/crates",
},
}
}
@@ -365,7 +255,6 @@ func Load(path string) (*Config, error) {
// - PROXY_DATABASE_PATH
// - PROXY_LOG_LEVEL
// - PROXY_LOG_FORMAT
-// - PROXY_HEALTH_STORAGE_PROBE_INTERVAL
func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_LISTEN"); v != "" {
c.Listen = v
@@ -382,15 +271,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_STORAGE_MAX_SIZE"); v != "" {
c.Storage.MaxSize = v
}
- if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE"); v != "" {
- c.Storage.DirectServe = envBool(v)
- }
- if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE_TTL"); v != "" {
- c.Storage.DirectServeTTL = v
- }
- if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE_BASE_URL"); v != "" {
- c.Storage.DirectServeBaseURL = v
- }
if v := os.Getenv("PROXY_DATABASE_DRIVER"); v != "" {
c.Database.Driver = v
}
@@ -406,42 +286,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_LOG_FORMAT"); v != "" {
c.Log.Format = v
}
- if v := os.Getenv("PROXY_UPSTREAM_MAVEN"); v != "" {
- c.Upstream.Maven = v
- }
- if v := os.Getenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL"); v != "" {
- c.Upstream.GradlePluginPortal = v
- }
- if v := os.Getenv("PROXY_COOLDOWN_DEFAULT"); v != "" {
- c.Cooldown.Default = v
- }
- if v := os.Getenv("PROXY_CACHE_METADATA"); v != "" {
- c.CacheMetadata = envBool(v)
- }
- if v := os.Getenv("PROXY_MIRROR_API"); v != "" {
- c.MirrorAPI = envBool(v)
- }
- if v := os.Getenv("PROXY_METADATA_TTL"); v != "" {
- c.MetadataTTL = v
- }
- if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" {
- c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1"
- }
- if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE"); v != "" {
- c.Gradle.BuildCache.MaxUploadSize = v
- }
- if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE"); v != "" {
- c.Gradle.BuildCache.MaxAge = v
- }
- if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE"); v != "" {
- c.Gradle.BuildCache.MaxSize = v
- }
- if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL"); v != "" {
- c.Gradle.BuildCache.SweepInterval = v
- }
- if v := os.Getenv("PROXY_HEALTH_STORAGE_PROBE_INTERVAL"); v != "" {
- c.Health.StorageProbeInterval = v
- }
}
// Validate checks the configuration for errors.
@@ -491,197 +335,9 @@ func (c *Config) Validate() error {
}
}
- // Validate direct serve TTL if specified
- if c.Storage.DirectServeTTL != "" {
- if _, err := time.ParseDuration(c.Storage.DirectServeTTL); err != nil {
- return fmt.Errorf("invalid storage.direct_serve_ttl %q: %w", c.Storage.DirectServeTTL, err)
- }
- }
-
- // Validate direct serve base URL if specified
- if c.Storage.DirectServeBaseURL != "" {
- u, err := url.Parse(c.Storage.DirectServeBaseURL)
- if err != nil || u.Scheme == "" || u.Host == "" {
- return fmt.Errorf("invalid storage.direct_serve_base_url %q: must be an absolute URL", c.Storage.DirectServeBaseURL)
- }
- }
-
- // Validate metadata TTL if specified
- if c.MetadataTTL != "" && c.MetadataTTL != "0" {
- if _, err := time.ParseDuration(c.MetadataTTL); err != nil {
- return fmt.Errorf("invalid metadata_ttl %q: %w", c.MetadataTTL, err)
- }
- }
-
- if err := c.Health.Validate(); err != nil {
- return err
- }
-
- if err := c.Gradle.BuildCache.Validate(); err != nil {
- return err
- }
-
return nil
}
-// Validate checks the /health configuration. An unset interval is allowed
-// (the cache uses its default); explicit values must parse and be non-negative.
-func (h *HealthConfig) Validate() error {
- if h.StorageProbeInterval == "" || h.StorageProbeInterval == "0" {
- return nil
- }
- d, err := time.ParseDuration(h.StorageProbeInterval)
- if err != nil {
- return fmt.Errorf("invalid health.storage_probe_interval %q: %w", h.StorageProbeInterval, err)
- }
- if d < 0 {
- return fmt.Errorf("invalid health.storage_probe_interval %q: must be non-negative", h.StorageProbeInterval)
- }
- return nil
-}
-
-// Validate checks Gradle build cache settings, applying the default upload
-// size if unset.
-func (g *GradleBuildCacheConfig) Validate() error {
- if g.MaxUploadSize == "" {
- g.MaxUploadSize = defaultGradleMaxUploadSizeStr
- }
- uploadSize, err := ParseSize(g.MaxUploadSize)
- if err != nil {
- return fmt.Errorf("invalid gradle.build_cache.max_upload_size: %w", err)
- }
- if uploadSize <= 0 {
- return fmt.Errorf("invalid gradle.build_cache.max_upload_size %q: must be > 0", g.MaxUploadSize)
- }
-
- if g.MaxAge != "" && g.MaxAge != "0" {
- if _, err := time.ParseDuration(g.MaxAge); err != nil {
- return fmt.Errorf("invalid gradle.build_cache.max_age %q: %w", g.MaxAge, err)
- }
- }
-
- if g.MaxSize != "" {
- if _, err := ParseSize(g.MaxSize); err != nil {
- return fmt.Errorf("invalid gradle.build_cache.max_size: %w", err)
- }
- }
-
- if g.SweepInterval != "" {
- d, err := time.ParseDuration(g.SweepInterval)
- if err != nil {
- return fmt.Errorf("invalid gradle.build_cache.sweep_interval %q: %w", g.SweepInterval, err)
- }
- if d <= 0 {
- return fmt.Errorf("invalid gradle.build_cache.sweep_interval %q: must be > 0", g.SweepInterval)
- }
- }
-
- return nil
-}
-
-const (
- defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
- defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
- defaultGradleBuildCacheMaxUploadSize = 100 << 20
- defaultGradleBuildCacheSweepInterval = 10 * time.Minute
- defaultGradleMaxUploadSizeStr = "100MB"
- defaultGradleSweepIntervalStr = "10m"
-)
-
-// ParseMaxSize returns the maximum cache size in bytes.
-// Returns 0 if unset or explicitly disabled (meaning unlimited).
-func (c *Config) ParseMaxSize() int64 {
- if c.Storage.MaxSize == "" || c.Storage.MaxSize == "0" {
- return 0
- }
- size, err := ParseSize(c.Storage.MaxSize)
- if err != nil {
- return 0
- }
- return size
-}
-
-// ParseMetadataTTL returns the metadata TTL duration.
-// Returns 5 minutes if unset, 0 if explicitly disabled.
-func (c *Config) ParseMetadataTTL() time.Duration {
- if c.MetadataTTL == "" {
- return defaultMetadataTTL
- }
- if c.MetadataTTL == "0" {
- return 0
- }
- d, err := time.ParseDuration(c.MetadataTTL)
- if err != nil {
- return defaultMetadataTTL
- }
- return d
-}
-
-// ParseGradleBuildCacheMaxUploadSize returns the max accepted PUT body size.
-// Defaults to 100MB if unset or invalid.
-func (c *Config) ParseGradleBuildCacheMaxUploadSize() int64 {
- if c.Gradle.BuildCache.MaxUploadSize == "" {
- return defaultGradleBuildCacheMaxUploadSize
- }
- size, err := ParseSize(c.Gradle.BuildCache.MaxUploadSize)
- if err != nil || size <= 0 {
- return defaultGradleBuildCacheMaxUploadSize
- }
- return size
-}
-
-// ParseGradleBuildCacheMaxAge returns age-based eviction threshold.
-// Returns 0 when disabled or invalid.
-func (c *Config) ParseGradleBuildCacheMaxAge() time.Duration {
- if c.Gradle.BuildCache.MaxAge == "" || c.Gradle.BuildCache.MaxAge == "0" {
- return 0
- }
- d, err := time.ParseDuration(c.Gradle.BuildCache.MaxAge)
- if err != nil || d <= 0 {
- return 0
- }
- return d
-}
-
-// ParseGradleBuildCacheMaxSize returns total-size cap in bytes.
-// Returns 0 when disabled or invalid.
-func (c *Config) ParseGradleBuildCacheMaxSize() int64 {
- if c.Gradle.BuildCache.MaxSize == "" || c.Gradle.BuildCache.MaxSize == "0" {
- return 0
- }
- size, err := ParseSize(c.Gradle.BuildCache.MaxSize)
- if err != nil || size <= 0 {
- return 0
- }
- return size
-}
-
-// ParseGradleBuildCacheSweepInterval returns eviction sweep cadence.
-// Defaults to 10m if unset or invalid.
-func (c *Config) ParseGradleBuildCacheSweepInterval() time.Duration {
- if c.Gradle.BuildCache.SweepInterval == "" {
- return defaultGradleBuildCacheSweepInterval
- }
- d, err := time.ParseDuration(c.Gradle.BuildCache.SweepInterval)
- if err != nil || d <= 0 {
- return defaultGradleBuildCacheSweepInterval
- }
- return d
-}
-
-// ParseDirectServeTTL returns the presigned URL expiry duration.
-// Returns 15 minutes if unset.
-func (c *Config) ParseDirectServeTTL() time.Duration {
- if c.Storage.DirectServeTTL == "" {
- return defaultDirectServeTTL
- }
- d, err := time.ParseDuration(c.Storage.DirectServeTTL)
- if err != nil {
- return defaultDirectServeTTL
- }
- return d
-}
-
// ParseSize parses a human-readable size string (e.g., "10GB", "500MB").
// Returns the size in bytes.
func ParseSize(s string) (int64, error) {
@@ -762,7 +418,3 @@ func (a *AuthConfig) Header() (name, value string) {
func expandEnv(s string) string {
return os.Expand(s, os.Getenv)
}
-
-func envBool(v string) bool {
- return v == "true" || v == "1"
-}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 05fec3a..b167b53 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -4,13 +4,6 @@ import (
"os"
"path/filepath"
"testing"
- "time"
-)
-
-const (
- testDriverPostgres = "postgres"
- testInvalid = "invalid"
- testLevelDebug = "debug"
)
func TestDefault(t *testing.T) {
@@ -25,18 +18,6 @@ func TestDefault(t *testing.T) {
if cfg.Database.Path == "" {
t.Error("Database.Path should not be empty")
}
- if cfg.Gradle.BuildCache.MaxUploadSize != "100MB" {
- t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "100MB")
- }
- if cfg.Gradle.BuildCache.MaxAge != "168h" {
- t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "168h")
- }
- if cfg.Upstream.Maven != "https://repo1.maven.org/maven2" {
- t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://repo1.maven.org/maven2")
- }
- if cfg.Upstream.GradlePluginPortal != "https://plugins.gradle.org/m2" {
- t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.gradle.org/m2")
- }
}
func TestValidate(t *testing.T) {
@@ -82,27 +63,27 @@ func TestValidate(t *testing.T) {
},
{
name: "postgres without url",
- modify: func(c *Config) { c.Database.Driver = testDriverPostgres; c.Database.URL = "" },
+ modify: func(c *Config) { c.Database.Driver = "postgres"; c.Database.URL = "" },
wantErr: true,
},
{
name: "postgres with url",
- modify: func(c *Config) { c.Database.Driver = testDriverPostgres; c.Database.URL = "postgres://localhost/test" },
+ modify: func(c *Config) { c.Database.Driver = "postgres"; c.Database.URL = "postgres://localhost/test" },
wantErr: false,
},
{
name: "invalid log level",
- modify: func(c *Config) { c.Log.Level = testInvalid },
+ modify: func(c *Config) { c.Log.Level = "invalid" },
wantErr: true,
},
{
name: "invalid log format",
- modify: func(c *Config) { c.Log.Format = testInvalid },
+ modify: func(c *Config) { c.Log.Format = "invalid" },
wantErr: true,
},
{
name: "invalid max size",
- modify: func(c *Config) { c.Storage.MaxSize = testInvalid },
+ modify: func(c *Config) { c.Storage.MaxSize = "invalid" },
wantErr: true,
},
{
@@ -110,41 +91,6 @@ func TestValidate(t *testing.T) {
modify: func(c *Config) { c.Storage.MaxSize = "10GB" },
wantErr: false,
},
- {
- name: "invalid gradle upload size",
- modify: func(c *Config) { c.Gradle.BuildCache.MaxUploadSize = testInvalid },
- wantErr: true,
- },
- {
- name: "zero gradle upload size",
- modify: func(c *Config) { c.Gradle.BuildCache.MaxUploadSize = "0" },
- wantErr: true,
- },
- {
- name: "invalid gradle max age",
- modify: func(c *Config) { c.Gradle.BuildCache.MaxAge = testInvalid },
- wantErr: true,
- },
- {
- name: "valid gradle max age",
- modify: func(c *Config) { c.Gradle.BuildCache.MaxAge = "24h" },
- wantErr: false,
- },
- {
- name: "invalid gradle max size",
- modify: func(c *Config) { c.Gradle.BuildCache.MaxSize = testInvalid },
- wantErr: true,
- },
- {
- name: "invalid gradle sweep interval",
- modify: func(c *Config) { c.Gradle.BuildCache.SweepInterval = "0" },
- wantErr: true,
- },
- {
- name: "valid gradle sweep interval",
- modify: func(c *Config) { c.Gradle.BuildCache.SweepInterval = "30m" },
- wantErr: false,
- },
}
for _, tt := range tests {
@@ -230,8 +176,8 @@ log:
if cfg.Storage.MaxSize != "5GB" {
t.Errorf("Storage.MaxSize = %q, want %q", cfg.Storage.MaxSize, "5GB")
}
- if cfg.Log.Level != testLevelDebug {
- t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
+ if cfg.Log.Level != "debug" {
+ t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug")
}
if cfg.Log.Format != "json" {
t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json")
@@ -269,14 +215,7 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("PROXY_LISTEN", ":9000")
t.Setenv("PROXY_BASE_URL", "https://env.example.com")
t.Setenv("PROXY_STORAGE_PATH", "/env/cache")
- t.Setenv("PROXY_LOG_LEVEL", testLevelDebug)
- 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_GRADLE_BUILD_CACHE_READ_ONLY", "true")
- t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
- t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h")
- t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE", "10GB")
- t.Setenv("PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL", "15m")
+ t.Setenv("PROXY_LOG_LEVEL", "debug")
cfg.LoadFromEnv()
@@ -289,85 +228,8 @@ func TestLoadFromEnv(t *testing.T) {
if cfg.Storage.Path != "/env/cache" {
t.Errorf("Storage.Path = %q, want %q", cfg.Storage.Path, "/env/cache")
}
- if cfg.Log.Level != testLevelDebug {
- t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
- }
- if cfg.Upstream.Maven != "https://maven.example.com/repository/maven-public" {
- t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://maven.example.com/repository/maven-public")
- }
- 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.Gradle.BuildCache.ReadOnly {
- t.Error("Gradle.BuildCache.ReadOnly = false, want true")
- }
- if cfg.Gradle.BuildCache.MaxUploadSize != "32MB" {
- t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "32MB")
- }
- if cfg.Gradle.BuildCache.MaxAge != "12h" {
- t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "12h")
- }
- if cfg.Gradle.BuildCache.MaxSize != "10GB" {
- t.Errorf("Gradle.BuildCache.MaxSize = %q, want %q", cfg.Gradle.BuildCache.MaxSize, "10GB")
- }
- if cfg.Gradle.BuildCache.SweepInterval != "15m" {
- t.Errorf("Gradle.BuildCache.SweepInterval = %q, want %q", cfg.Gradle.BuildCache.SweepInterval, "15m")
- }
-}
-
-func TestLoadCooldownConfig(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "config.yaml")
-
- content := `
-listen: ":8080"
-base_url: "http://localhost:8080"
-storage:
- path: "/data/cache"
-database:
- path: "/data/proxy.db"
-cooldown:
- default: "3d"
- ecosystems:
- npm: "7d"
- cargo: "0"
- packages:
- "pkg:npm/lodash": "0"
- "pkg:npm/@babel/core": "14d"
-`
- if err := os.WriteFile(path, []byte(content), 0644); err != nil {
- t.Fatalf("writing config file: %v", err)
- }
-
- cfg, err := Load(path)
- if err != nil {
- t.Fatalf("Load failed: %v", err)
- }
-
- if cfg.Cooldown.Default != "3d" {
- t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "3d")
- }
- if cfg.Cooldown.Ecosystems["npm"] != "7d" {
- t.Errorf("Cooldown.Ecosystems[npm] = %q, want %q", cfg.Cooldown.Ecosystems["npm"], "7d")
- }
- if cfg.Cooldown.Ecosystems["cargo"] != "0" {
- t.Errorf("Cooldown.Ecosystems[cargo] = %q, want %q", cfg.Cooldown.Ecosystems["cargo"], "0")
- }
- if cfg.Cooldown.Packages["pkg:npm/lodash"] != "0" {
- t.Errorf("Cooldown.Packages[lodash] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/lodash"], "0")
- }
- if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" {
- t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d")
- }
-}
-
-func TestLoadCooldownFromEnv(t *testing.T) {
- cfg := Default()
- t.Setenv("PROXY_COOLDOWN_DEFAULT", "5d")
- cfg.LoadFromEnv()
-
- if cfg.Cooldown.Default != "5d" {
- t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "5d")
+ if cfg.Log.Level != "debug" {
+ t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug")
}
}
@@ -377,218 +239,3 @@ func TestLoadFileNotFound(t *testing.T) {
t.Error("expected error for nonexistent file")
}
}
-
-func TestParseMaxSize(t *testing.T) {
- tests := []struct {
- name string
- maxSize string
- want int64
- }{
- {"empty means unlimited", "", 0},
- {"zero means unlimited", "0", 0},
- {"10GB", "10GB", 10 * 1024 * 1024 * 1024},
- {"500MB", "500MB", 500 * 1024 * 1024},
- {"invalid returns 0", "invalid", 0},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := Default()
- cfg.Storage.MaxSize = tt.maxSize
- got := cfg.ParseMaxSize()
- if got != tt.want {
- t.Errorf("ParseMaxSize() = %d, want %d", got, tt.want)
- }
- })
- }
-}
-
-func TestParseMetadataTTL(t *testing.T) {
- tests := []struct {
- name string
- ttl string
- want time.Duration
- }{
- {"empty defaults to 5m", "", 5 * time.Minute},
- {"explicit zero", "0", 0},
- {"10 minutes", "10m", 10 * time.Minute},
- {"1 hour", "1h", 1 * time.Hour},
- {"invalid defaults to 5m", "not-a-duration", 5 * time.Minute},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := Default()
- cfg.MetadataTTL = tt.ttl
- got := cfg.ParseMetadataTTL()
- if got != tt.want {
- t.Errorf("ParseMetadataTTL() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestValidateMetadataTTL(t *testing.T) {
- cfg := Default()
- cfg.MetadataTTL = "invalid"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for invalid metadata_ttl")
- }
-
- cfg.MetadataTTL = "5m"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid metadata_ttl: %v", err)
- }
-
- cfg.MetadataTTL = "0"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for zero metadata_ttl: %v", err)
- }
-}
-
-func TestValidateHealthStorageProbeInterval(t *testing.T) {
- cfg := Default()
- cfg.Health.StorageProbeInterval = "not-a-duration"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for invalid health.storage_probe_interval")
- }
-
- cfg.Health.StorageProbeInterval = "30s"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = "0"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for zero health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = ""
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for empty health.storage_probe_interval: %v", err)
- }
-
- cfg.Health.StorageProbeInterval = "-5s"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for negative health.storage_probe_interval")
- }
-}
-
-func TestLoadMetadataTTLFromEnv(t *testing.T) {
- cfg := Default()
- t.Setenv("PROXY_METADATA_TTL", "10m")
- cfg.LoadFromEnv()
-
- if cfg.MetadataTTL != "10m" {
- t.Errorf("MetadataTTL = %q, want %q", cfg.MetadataTTL, "10m")
- }
-}
-
-func TestParseGradleBuildCacheConfig(t *testing.T) {
- cfg := Default()
-
- if got := cfg.ParseGradleBuildCacheMaxUploadSize(); got != 100*1024*1024 {
- t.Errorf("ParseGradleBuildCacheMaxUploadSize() = %d, want %d", got, 100*1024*1024)
- }
- if got := cfg.ParseGradleBuildCacheMaxAge(); got != 168*time.Hour {
- t.Errorf("ParseGradleBuildCacheMaxAge() = %v, want %v", got, 168*time.Hour)
- }
- if got := cfg.ParseGradleBuildCacheMaxSize(); got != 0 {
- t.Errorf("ParseGradleBuildCacheMaxSize() = %d, want 0", got)
- }
- if got := cfg.ParseGradleBuildCacheSweepInterval(); got != 10*time.Minute {
- t.Errorf("ParseGradleBuildCacheSweepInterval() = %v, want %v", got, 10*time.Minute)
- }
-
- cfg.Gradle.BuildCache.MaxUploadSize = "64MB"
- cfg.Gradle.BuildCache.MaxAge = "48h"
- cfg.Gradle.BuildCache.MaxSize = "2GB"
- cfg.Gradle.BuildCache.SweepInterval = "20m"
-
- if got := cfg.ParseGradleBuildCacheMaxUploadSize(); got != 64*1024*1024 {
- t.Errorf("ParseGradleBuildCacheMaxUploadSize() = %d, want %d", got, 64*1024*1024)
- }
- if got := cfg.ParseGradleBuildCacheMaxAge(); got != 48*time.Hour {
- t.Errorf("ParseGradleBuildCacheMaxAge() = %v, want %v", got, 48*time.Hour)
- }
- if got := cfg.ParseGradleBuildCacheMaxSize(); got != 2*1024*1024*1024 {
- t.Errorf("ParseGradleBuildCacheMaxSize() = %d, want %d", got, 2*1024*1024*1024)
- }
- if got := cfg.ParseGradleBuildCacheSweepInterval(); got != 20*time.Minute {
- t.Errorf("ParseGradleBuildCacheSweepInterval() = %v, want %v", got, 20*time.Minute)
- }
-}
-
-func TestParseDirectServeTTL(t *testing.T) {
- tests := []struct {
- name string
- ttl string
- want time.Duration
- }{
- {"empty defaults to 15m", "", 15 * time.Minute},
- {"5 minutes", "5m", 5 * time.Minute},
- {"1 hour", "1h", 1 * time.Hour},
- {"invalid defaults to 15m", "not-a-duration", 15 * time.Minute},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := Default()
- cfg.Storage.DirectServeTTL = tt.ttl
- got := cfg.ParseDirectServeTTL()
- if got != tt.want {
- t.Errorf("ParseDirectServeTTL() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestValidateDirectServeTTL(t *testing.T) {
- cfg := Default()
- cfg.Storage.DirectServeTTL = "invalid"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for invalid storage.direct_serve_ttl")
- }
-
- cfg.Storage.DirectServeTTL = "5m"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid storage.direct_serve_ttl: %v", err)
- }
-}
-
-func TestLoadDirectServeFromEnv(t *testing.T) {
- cfg := Default()
- t.Setenv("PROXY_STORAGE_DIRECT_SERVE", "true")
- t.Setenv("PROXY_STORAGE_DIRECT_SERVE_TTL", "30m")
- t.Setenv("PROXY_STORAGE_DIRECT_SERVE_BASE_URL", "https://cdn.example.com")
- cfg.LoadFromEnv()
-
- if !cfg.Storage.DirectServe {
- t.Error("Storage.DirectServe should be true")
- }
- if cfg.Storage.DirectServeTTL != "30m" {
- t.Errorf("Storage.DirectServeTTL = %q, want %q", cfg.Storage.DirectServeTTL, "30m")
- }
- if cfg.Storage.DirectServeBaseURL != "https://cdn.example.com" {
- t.Errorf("Storage.DirectServeBaseURL = %q, want %q", cfg.Storage.DirectServeBaseURL, "https://cdn.example.com")
- }
-}
-
-func TestValidateDirectServeBaseURL(t *testing.T) {
- cfg := Default()
-
- cfg.Storage.DirectServeBaseURL = "not a url"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for relative direct_serve_base_url")
- }
-
- cfg.Storage.DirectServeBaseURL = "://bad"
- if err := cfg.Validate(); err == nil {
- t.Error("expected validation error for unparseable direct_serve_base_url")
- }
-
- cfg.Storage.DirectServeBaseURL = "https://cdn.example.com"
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error for valid direct_serve_base_url: %v", err)
- }
-}
diff --git a/internal/database/database.go b/internal/database/database.go
index eded6d2..40f4ca6 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
+ gitpkgsdb "github.com/git-pkgs/git-pkgs/database"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
@@ -12,8 +13,6 @@ import (
const SchemaVersion = 1
-const dirPermissions = 0755
-
type Dialect string
const (
@@ -31,10 +30,8 @@ func (db *DB) Dialect() Dialect {
return db.dialect
}
-func Exists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
-}
+// Exists checks if a database file exists at the given path.
+var Exists = gitpkgsdb.Exists
func Create(path string) (*DB, error) {
if Exists(path) {
@@ -56,29 +53,21 @@ func Create(path string) (*DB, error) {
return db, nil
}
+// Open opens a SQLite database using the shared git-pkgs connection
+// settings (WAL mode, busy timeout, single connection).
func Open(path string) (*DB, error) {
if dir := filepath.Dir(path); dir != "." && dir != "/" {
- if err := os.MkdirAll(dir, dirPermissions); err != nil {
+ if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating database directory: %w", err)
}
}
- // Add busy_timeout to handle concurrent writes
- sqlDB, err := sqlx.Open("sqlite", path+"?_busy_timeout=5000")
+ sharedDB, err := gitpkgsdb.Open(path)
if err != nil {
- return nil, fmt.Errorf("opening database: %w", err)
+ return nil, err
}
- // Limit connections to 1 for SQLite to serialize writes
- sqlDB.SetMaxOpenConns(1)
-
- db := &DB{DB: sqlDB, dialect: DialectSQLite, path: path}
- if err := db.OptimizeForReads(); err != nil {
- _ = sqlDB.Close()
- return nil, fmt.Errorf("optimizing database: %w", err)
- }
-
- return db, nil
+ return &DB{DB: sharedDB.SQLX(), dialect: DialectSQLite, path: path}, nil
}
func OpenOrCreate(path string) (*DB, error) {
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index 6fca4ea..6a52320 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -147,8 +147,8 @@ func TestVersionCRUD(t *testing.T) {
if got == nil {
t.Fatal("expected version, got nil")
}
- if got.Version() != "4.17.21" {
- t.Errorf("expected version 4.17.21, got %s", got.Version())
+ if got.VersionString() != "4.17.21" {
+ t.Errorf("expected version 4.17.21, got %s", got.VersionString())
}
versions, err := db.GetVersionsByPackagePURL("pkg:npm/lodash")
@@ -651,159 +651,58 @@ func TestMigrationFromOldSchema(t *testing.T) {
}
defer func() { _ = db.Close() }()
- // Queries that require new columns should fail without migration
- if _, err := db.GetEnrichmentStats(); err == nil {
- t.Error("GetEnrichmentStats: expected error querying enriched_at column, got nil")
- }
- if _, err := db.GetPackageByEcosystemName("npm", "test-package"); err == nil {
- t.Error("GetPackageByEcosystemName: expected error querying registry_url column, got nil")
- }
- // SearchPackages should work even with old schema because it uses sql.NullString
- if _, err := db.SearchPackages("test", "", 10, 0); err != nil {
- t.Errorf("SearchPackages: unexpected error with old schema: %v", err)
- }
+ // Try to run queries that require new columns - these should fail without migration
+ t.Run("queries should fail without migration", func(t *testing.T) {
+ _, err := db.GetEnrichmentStats()
+ if err == nil {
+ t.Error("GetEnrichmentStats: expected error querying enriched_at column, got nil")
+ }
+
+ _, err = db.GetPackageByEcosystemName("npm", "test-package")
+ if err == nil {
+ t.Error("GetPackageByEcosystemName: expected error querying registry_url column, got nil")
+ }
+
+ // SearchPackages should work even with old schema because it uses sql.NullString
+ // for nullable columns, which can handle NULL values properly
+ _, err = db.SearchPackages("test", "", 10, 0)
+ if err != nil {
+ t.Errorf("SearchPackages: unexpected error with old schema: %v", err)
+ }
+ })
// Run migration
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema failed: %v", err)
- }
+ t.Run("migrate schema", func(t *testing.T) {
+ if err := db.MigrateSchema(); err != nil {
+ t.Fatalf("MigrateSchema failed: %v", err)
+ }
+ })
// Verify queries work after migration
- stats, err := db.GetEnrichmentStats()
- if err != nil {
- t.Errorf("GetEnrichmentStats failed after migration: %v", err)
- }
- if stats == nil {
- t.Error("GetEnrichmentStats returned nil after migration")
- }
-
- pkg, err := db.GetPackageByEcosystemName("npm", "test-package")
- if err != nil {
- t.Errorf("GetPackageByEcosystemName failed after migration: %v", err)
- }
- if pkg == nil {
- t.Fatal("GetPackageByEcosystemName returned nil after migration")
- }
- if pkg.Name != "test-package" {
- t.Errorf("expected package name test-package, got %s", pkg.Name)
- }
-
- // Verify migrations were recorded
- applied, err := db.appliedMigrations()
- if err != nil {
- t.Fatalf("appliedMigrations failed: %v", err)
- }
- for _, m := range migrations {
- if !applied[m.name] {
- t.Errorf("migration %s not recorded as applied", m.name)
+ t.Run("queries should work after migration", func(t *testing.T) {
+ stats, err := db.GetEnrichmentStats()
+ if err != nil {
+ t.Errorf("GetEnrichmentStats failed after migration: %v", err)
}
- }
-
- // Running again should be a no-op
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("second MigrateSchema failed: %v", err)
- }
-}
-
-func TestFreshDatabaseRecordsMigrations(t *testing.T) {
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "fresh.db")
-
- db, err := Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- applied, err := db.appliedMigrations()
- if err != nil {
- t.Fatalf("appliedMigrations failed: %v", err)
- }
-
- for _, m := range migrations {
- if !applied[m.name] {
- t.Errorf("migration %s not recorded in fresh database", m.name)
+ if stats == nil {
+ t.Error("GetEnrichmentStats returned nil after migration")
}
- }
-}
-func TestMigrateSchemaSkipsApplied(t *testing.T) {
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "test.db")
-
- db, err := Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // All migrations are already recorded from Create. Running MigrateSchema
- // should return without running any migration functions.
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema failed: %v", err)
- }
-
- // Verify count hasn't changed (no duplicate inserts)
- var count int
- if err := db.Get(&count, "SELECT COUNT(*) FROM migrations"); err != nil {
- t.Fatalf("counting migrations failed: %v", err)
- }
- if count != len(migrations) {
- t.Errorf("expected %d migrations, got %d", len(migrations), count)
- }
-}
-
-func TestMigrateSchemaUpgradeFromFullyMigrated(t *testing.T) {
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "existing.db")
-
- // Simulate an existing proxy database that has the full current schema
- // but no migrations table (i.e. it was running the previous version).
- sqlDB, err := sql.Open("sqlite", dbPath)
- if err != nil {
- t.Fatalf("failed to open database: %v", err)
- }
-
- if _, err := sqlDB.Exec(schemaSQLite); err != nil {
- t.Fatalf("failed to create schema: %v", err)
- }
- // Drop the migrations table that schemaSQLite now includes
- if _, err := sqlDB.Exec("DROP TABLE migrations"); err != nil {
- t.Fatalf("failed to drop migrations table: %v", err)
- }
- if _, err := sqlDB.Exec("INSERT INTO schema_info (version) VALUES (1)"); err != nil {
- t.Fatalf("failed to set schema version: %v", err)
- }
- if err := sqlDB.Close(); err != nil {
- t.Fatalf("failed to close database: %v", err)
- }
-
- db, err := Open(dbPath)
- if err != nil {
- t.Fatalf("Open failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // This should create the migrations table and record all migrations
- // without altering any tables (everything already exists).
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema failed: %v", err)
- }
-
- applied, err := db.appliedMigrations()
- if err != nil {
- t.Fatalf("appliedMigrations failed: %v", err)
- }
- for _, m := range migrations {
- if !applied[m.name] {
- t.Errorf("migration %s not recorded after upgrade", m.name)
+ pkg, err := db.GetPackageByEcosystemName("npm", "test-package")
+ if err != nil {
+ t.Errorf("GetPackageByEcosystemName failed after migration: %v", err)
+ }
+ if pkg == nil {
+ t.Fatal("GetPackageByEcosystemName returned nil after migration")
+ }
+ if pkg.Name != "test-package" {
+ t.Errorf("expected package name test-package, got %s", pkg.Name)
}
- }
- // Second run should be the fast path (single SELECT)
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("second MigrateSchema failed: %v", err)
- }
+ // Note: SearchPackages not tested here because old timestamp data
+ // stored as strings can't be scanned into time.Time. This is a data
+ // migration issue, not a schema migration issue.
+ })
}
func TestConcurrentWrites(t *testing.T) {
@@ -991,26 +890,3 @@ func TestSearchPackagesWithValues(t *testing.T) {
t.Errorf("expected 10 hits, got %d", result.Hits)
}
}
-
-func BenchmarkMigrateSchemaFullyMigrated(b *testing.B) {
- dir := b.TempDir()
- dbPath := filepath.Join(dir, "bench.db")
-
- db, err := Create(dbPath)
- if err != nil {
- b.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // First call to ensure everything is migrated
- if err := db.MigrateSchema(); err != nil {
- b.Fatalf("initial MigrateSchema failed: %v", err)
- }
-
- b.ResetTimer()
- for b.Loop() {
- if err := db.MigrateSchema(); err != nil {
- b.Fatalf("MigrateSchema failed: %v", err)
- }
- }
-}
diff --git a/internal/database/metadata_cache_test.go b/internal/database/metadata_cache_test.go
deleted file mode 100644
index 5701816..0000000
--- a/internal/database/metadata_cache_test.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package database
-
-import (
- "database/sql"
- "path/filepath"
- "testing"
- "time"
-)
-
-func setupMetadataCacheDB(t *testing.T) *DB {
- t.Helper()
- dbPath := filepath.Join(t.TempDir(), "test.db")
- db, err := Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema failed: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
- return db
-}
-
-func TestUpsertAndGetMetadataCache(t *testing.T) {
- db := setupMetadataCacheDB(t)
-
- entry := &MetadataCacheEntry{
- Ecosystem: testEcosystemNPM,
- Name: "lodash",
- StoragePath: "_metadata/npm/lodash/metadata",
- ETag: sql.NullString{String: `"abc123"`, Valid: true},
- ContentType: sql.NullString{String: "application/json", Valid: true},
- Size: sql.NullInt64{Int64: 1024, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
-
- err := db.UpsertMetadataCache(entry)
- if err != nil {
- t.Fatalf("UpsertMetadataCache() error = %v", err)
- }
-
- got, err := db.GetMetadataCache(testEcosystemNPM, "lodash")
- if err != nil {
- t.Fatalf("GetMetadataCache() error = %v", err)
- }
- if got == nil {
- t.Fatal("GetMetadataCache() returned nil")
- }
-
- if got.Ecosystem != testEcosystemNPM {
- t.Errorf("ecosystem = %q, want %q", got.Ecosystem, testEcosystemNPM)
- }
- if got.Name != "lodash" {
- t.Errorf("name = %q, want %q", got.Name, "lodash")
- }
- if got.StoragePath != "_metadata/npm/lodash/metadata" {
- t.Errorf("storage_path = %q, want %q", got.StoragePath, "_metadata/npm/lodash/metadata")
- }
- if !got.ETag.Valid || got.ETag.String != `"abc123"` {
- t.Errorf("etag = %v, want %q", got.ETag, `"abc123"`)
- }
- if !got.ContentType.Valid || got.ContentType.String != "application/json" {
- t.Errorf("content_type = %v, want %q", got.ContentType, "application/json")
- }
- if !got.Size.Valid || got.Size.Int64 != 1024 {
- t.Errorf("size = %v, want 1024", got.Size)
- }
-}
-
-func TestGetMetadataCacheMiss(t *testing.T) {
- db := setupMetadataCacheDB(t)
-
- got, err := db.GetMetadataCache(testEcosystemNPM, "nonexistent")
- if err != nil {
- t.Fatalf("GetMetadataCache() error = %v", err)
- }
- if got != nil {
- t.Errorf("expected nil for cache miss, got %v", got)
- }
-}
-
-func TestUpsertMetadataCacheOverwrite(t *testing.T) {
- db := setupMetadataCacheDB(t)
-
- // First insert
- entry1 := &MetadataCacheEntry{
- Ecosystem: testEcosystemNPM,
- Name: "lodash",
- StoragePath: "_metadata/npm/lodash/metadata",
- ETag: sql.NullString{String: `"v1"`, Valid: true},
- ContentType: sql.NullString{String: "application/json", Valid: true},
- Size: sql.NullInt64{Int64: 100, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
- if err := db.UpsertMetadataCache(entry1); err != nil {
- t.Fatalf("first UpsertMetadataCache() error = %v", err)
- }
-
- // Second insert (same ecosystem+name, different etag and size)
- entry2 := &MetadataCacheEntry{
- Ecosystem: testEcosystemNPM,
- Name: "lodash",
- StoragePath: "_metadata/npm/lodash/metadata",
- ETag: sql.NullString{String: `"v2"`, Valid: true},
- ContentType: sql.NullString{String: "application/json", Valid: true},
- Size: sql.NullInt64{Int64: 200, Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
- if err := db.UpsertMetadataCache(entry2); err != nil {
- t.Fatalf("second UpsertMetadataCache() error = %v", err)
- }
-
- got, err := db.GetMetadataCache(testEcosystemNPM, "lodash")
- if err != nil {
- t.Fatalf("GetMetadataCache() error = %v", err)
- }
- if got == nil {
- t.Fatal("expected entry after overwrite")
- }
- if got.ETag.String != `"v2"` {
- t.Errorf("etag = %q, want %q", got.ETag.String, `"v2"`)
- }
- if got.Size.Int64 != 200 {
- t.Errorf("size = %d, want 200", got.Size.Int64)
- }
-}
-
-func TestUpsertMetadataCacheNullableFields(t *testing.T) {
- db := setupMetadataCacheDB(t)
-
- entry := &MetadataCacheEntry{
- Ecosystem: "pypi",
- Name: "requests",
- StoragePath: "_metadata/pypi/requests/metadata",
- }
-
- if err := db.UpsertMetadataCache(entry); err != nil {
- t.Fatalf("UpsertMetadataCache() error = %v", err)
- }
-
- got, err := db.GetMetadataCache("pypi", "requests")
- if err != nil {
- t.Fatalf("GetMetadataCache() error = %v", err)
- }
- if got == nil {
- t.Fatal("expected entry")
- }
- if got.ETag.Valid {
- t.Error("expected null etag")
- }
- if got.ContentType.Valid {
- t.Error("expected null content_type")
- }
- if got.Size.Valid {
- t.Error("expected null size")
- }
-}
-
-func TestMetadataCacheTableCreatedByMigration(t *testing.T) {
- // Create a DB without the metadata_cache table, then migrate
- dbPath := filepath.Join(t.TempDir(), "test.db")
- db, err := Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // MigrateSchema should create the metadata_cache table
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("MigrateSchema() error = %v", err)
- }
-
- has, err := db.HasTable("metadata_cache")
- if err != nil {
- t.Fatalf("HasTable() error = %v", err)
- }
- if !has {
- t.Error("metadata_cache table should exist after migration")
- }
-}
diff --git a/internal/database/queries.go b/internal/database/queries.go
index 5d95596..fc6a3b3 100644
--- a/internal/database/queries.go
+++ b/internal/database/queries.go
@@ -887,66 +887,3 @@ func (db *DB) CountCachedPackages(ecosystem string) (int64, error) {
err = db.Get(&count, query, args...)
return count, err
}
-
-// Metadata cache queries
-
-func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, error) {
- var entry MetadataCacheEntry
- query := db.Rebind(`
- SELECT id, ecosystem, name, storage_path, etag, content_type,
- size, last_modified, fetched_at, created_at, updated_at
- FROM metadata_cache WHERE ecosystem = ? AND name = ?
- `)
- err := db.Get(&entry, query, ecosystem, name)
- if err == sql.ErrNoRows {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &entry, nil
-}
-
-func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
- now := time.Now()
- var query string
-
- if db.dialect == DialectPostgres {
- query = `
- INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
- size, last_modified, fetched_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
- ON CONFLICT(ecosystem, name) DO UPDATE SET
- storage_path = EXCLUDED.storage_path,
- etag = EXCLUDED.etag,
- content_type = EXCLUDED.content_type,
- size = EXCLUDED.size,
- last_modified = EXCLUDED.last_modified,
- fetched_at = EXCLUDED.fetched_at,
- updated_at = EXCLUDED.updated_at
- `
- } else {
- query = `
- INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
- size, last_modified, fetched_at, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(ecosystem, name) DO UPDATE SET
- storage_path = excluded.storage_path,
- etag = excluded.etag,
- content_type = excluded.content_type,
- size = excluded.size,
- last_modified = excluded.last_modified,
- fetched_at = excluded.fetched_at,
- updated_at = excluded.updated_at
- `
- }
-
- _, err := db.Exec(query,
- entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag,
- entry.ContentType, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
- )
- if err != nil {
- return fmt.Errorf("upserting metadata cache: %w", err)
- }
- return nil
-}
diff --git a/internal/database/queries_packages_list_test.go b/internal/database/queries_packages_list_test.go
index fc9356c..b77d34d 100644
--- a/internal/database/queries_packages_list_test.go
+++ b/internal/database/queries_packages_list_test.go
@@ -6,121 +6,118 @@ import (
"time"
)
-const testEcosystemNPM = "npm"
-
-func setupListCachedPackagesDB(t *testing.T) *DB {
- t.Helper()
-
+func TestListCachedPackages(t *testing.T) {
db, err := Create(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
-
- seedListCachedPackagesData(t, db)
-
- return db
-}
-
-func seedListCachedPackagesData(t *testing.T, db *DB) {
- t.Helper()
-
- packages := []*Package{
- {
- PURL: "pkg:npm/lodash",
- Ecosystem: testEcosystemNPM,
- Name: "lodash",
- LatestVersion: sql.NullString{String: "4.17.21", Valid: true},
- License: sql.NullString{String: "MIT", Valid: true},
- },
- {
- PURL: "pkg:cargo/serde",
- Ecosystem: "cargo",
- Name: "serde",
- LatestVersion: sql.NullString{String: "1.0.0", Valid: true},
- License: sql.NullString{String: "MIT OR Apache-2.0", Valid: true},
- },
- {
- PURL: "pkg:npm/react",
- Ecosystem: testEcosystemNPM,
- Name: "react",
- LatestVersion: sql.NullString{String: "18.0.0", Valid: true},
- License: sql.NullString{String: "MIT", Valid: true},
- },
- }
-
- for _, pkg := range packages {
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatal(err)
- }
- }
-
- versions := []*Version{
- {PURL: "pkg:npm/lodash@4.17.21", PackagePURL: packages[0].PURL},
- {PURL: "pkg:cargo/serde@1.0.0", PackagePURL: packages[1].PURL},
- {PURL: "pkg:npm/react@18.0.0", PackagePURL: packages[2].PURL},
- }
-
- for _, ver := range versions {
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatal(err)
- }
- }
-
- artifacts := []*Artifact{
- {
- VersionPURL: versions[0].PURL,
- Filename: "lodash.tgz",
- UpstreamURL: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- StoragePath: sql.NullString{String: "npm/lodash/4.17.21/lodash.tgz", Valid: true},
- Size: sql.NullInt64{Int64: 1024, Valid: true},
- HitCount: 100,
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- },
- {
- VersionPURL: versions[1].PURL,
- Filename: "serde.crate",
- UpstreamURL: "https://crates.io/api/v1/crates/serde/1.0.0/download",
- StoragePath: sql.NullString{String: "cargo/serde/1.0.0/serde.crate", Valid: true},
- Size: sql.NullInt64{Int64: 2048, Valid: true},
- HitCount: 50,
- FetchedAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true},
- },
- {
- VersionPURL: versions[2].PURL,
- Filename: "react.tgz",
- UpstreamURL: "https://registry.npmjs.org/react/-/react-18.0.0.tgz",
- StoragePath: sql.NullString{String: "npm/react/18.0.0/react.tgz", Valid: true},
- Size: sql.NullInt64{Int64: 512, Valid: true},
- HitCount: 200,
- FetchedAt: sql.NullTime{Time: time.Now().Add(-2 * time.Hour), Valid: true},
- },
- }
-
- for _, art := range artifacts {
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatal(err)
- }
- }
-}
-
-func TestListCachedPackages(t *testing.T) {
- db := setupListCachedPackagesDB(t)
defer func() { _ = db.Close() }()
- listAll := func(ecosystem, sortBy string) []PackageListItem {
- t.Helper()
- packages, err := db.ListCachedPackages(ecosystem, sortBy, 10, 0)
- if err != nil {
- t.Fatal(err)
- }
- return packages
+ // Create test packages
+ pkg1 := &Package{
+ PURL: "pkg:npm/lodash",
+ Ecosystem: "npm",
+ Name: "lodash",
+ LatestVersion: sql.NullString{String: "4.17.21", Valid: true},
+ License: sql.NullString{String: "MIT", Valid: true},
+ }
+ pkg2 := &Package{
+ PURL: "pkg:cargo/serde",
+ Ecosystem: "cargo",
+ Name: "serde",
+ LatestVersion: sql.NullString{String: "1.0.0", Valid: true},
+ License: sql.NullString{String: "MIT OR Apache-2.0", Valid: true},
+ }
+ pkg3 := &Package{
+ PURL: "pkg:npm/react",
+ Ecosystem: "npm",
+ Name: "react",
+ LatestVersion: sql.NullString{String: "18.0.0", Valid: true},
+ License: sql.NullString{String: "MIT", Valid: true},
+ }
+
+ if err := db.UpsertPackage(pkg1); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertPackage(pkg2); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertPackage(pkg3); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create versions
+ ver1 := &Version{
+ PURL: "pkg:npm/lodash@4.17.21",
+ PackagePURL: pkg1.PURL,
+ }
+ ver2 := &Version{
+ PURL: "pkg:cargo/serde@1.0.0",
+ PackagePURL: pkg2.PURL,
+ }
+ ver3 := &Version{
+ PURL: "pkg:npm/react@18.0.0",
+ PackagePURL: pkg3.PURL,
+ }
+
+ if err := db.UpsertVersion(ver1); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertVersion(ver2); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertVersion(ver3); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create artifacts
+ art1 := &Artifact{
+ VersionPURL: ver1.PURL,
+ Filename: "lodash.tgz",
+ UpstreamURL: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ StoragePath: sql.NullString{String: "npm/lodash/4.17.21/lodash.tgz", Valid: true},
+ Size: sql.NullInt64{Int64: 1024, Valid: true},
+ HitCount: 100,
+ FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
+ }
+ art2 := &Artifact{
+ VersionPURL: ver2.PURL,
+ Filename: "serde.crate",
+ UpstreamURL: "https://crates.io/api/v1/crates/serde/1.0.0/download",
+ StoragePath: sql.NullString{String: "cargo/serde/1.0.0/serde.crate", Valid: true},
+ Size: sql.NullInt64{Int64: 2048, Valid: true},
+ HitCount: 50,
+ FetchedAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true},
+ }
+ art3 := &Artifact{
+ VersionPURL: ver3.PURL,
+ Filename: "react.tgz",
+ UpstreamURL: "https://registry.npmjs.org/react/-/react-18.0.0.tgz",
+ StoragePath: sql.NullString{String: "npm/react/18.0.0/react.tgz", Valid: true},
+ Size: sql.NullInt64{Int64: 512, Valid: true},
+ HitCount: 200,
+ FetchedAt: sql.NullTime{Time: time.Now().Add(-2 * time.Hour), Valid: true},
+ }
+
+ if err := db.UpsertArtifact(art1); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertArtifact(art2); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpsertArtifact(art3); err != nil {
+ t.Fatal(err)
}
t.Run("list all packages", func(t *testing.T) {
- packages := listAll("", "hits")
+ packages, err := db.ListCachedPackages("", "hits", 10, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
if len(packages) != 3 {
t.Errorf("expected 3 packages, got %d", len(packages))
}
+ // Should be sorted by hits DESC
if packages[0].Name != "react" {
t.Errorf("expected first package to be react, got %s", packages[0].Name)
}
@@ -130,26 +127,35 @@ func TestListCachedPackages(t *testing.T) {
})
t.Run("filter by ecosystem", func(t *testing.T) {
- packages := listAll(testEcosystemNPM, "hits")
+ packages, err := db.ListCachedPackages("npm", "hits", 10, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
if len(packages) != 2 {
t.Errorf("expected 2 npm packages, got %d", len(packages))
}
for _, pkg := range packages {
- if pkg.Ecosystem != testEcosystemNPM {
+ if pkg.Ecosystem != "npm" {
t.Errorf("expected npm ecosystem, got %s", pkg.Ecosystem)
}
}
})
t.Run("sort by name", func(t *testing.T) {
- packages := listAll("", "name")
+ packages, err := db.ListCachedPackages("", "name", 10, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
if packages[0].Name != "lodash" {
t.Errorf("expected first package to be lodash, got %s", packages[0].Name)
}
})
t.Run("sort by size", func(t *testing.T) {
- packages := listAll("", "size")
+ packages, err := db.ListCachedPackages("", "size", 10, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
if packages[0].Name != "serde" {
t.Errorf("expected first package to be serde (largest), got %s", packages[0].Name)
}
@@ -164,7 +170,7 @@ func TestListCachedPackages(t *testing.T) {
t.Errorf("expected count 3, got %d", count)
}
- count, err = db.CountCachedPackages(testEcosystemNPM)
+ count, err = db.CountCachedPackages("npm")
if err != nil {
t.Fatal(err)
}
diff --git a/internal/database/schema.go b/internal/database/schema.go
index c8d8d1e..13363e7 100644
--- a/internal/database/schema.go
+++ b/internal/database/schema.go
@@ -1,16 +1,6 @@
package database
-import (
- "fmt"
- "strings"
- "time"
-)
-
-const (
- postgresTimestamp = "TIMESTAMP"
- sqliteDatetime = "DATETIME"
- colTypeText = "TEXT"
-)
+import "fmt"
// Schema for proxy-specific tables. The packages and versions tables
// are compatible with git-pkgs, allowing the proxy to use an existing
@@ -94,26 +84,6 @@ CREATE TABLE IF NOT EXISTS vulnerabilities (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
-
-CREATE TABLE IF NOT EXISTS metadata_cache (
- id INTEGER PRIMARY KEY,
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- storage_path TEXT NOT NULL,
- etag TEXT,
- content_type TEXT,
- size INTEGER,
- last_modified DATETIME,
- fetched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
-);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
-
-CREATE TABLE IF NOT EXISTS migrations (
- name TEXT NOT NULL PRIMARY KEY,
- applied_at DATETIME NOT NULL
-);
`
var schemaPostgres = `
@@ -194,26 +164,6 @@ CREATE TABLE IF NOT EXISTS vulnerabilities (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
-
-CREATE TABLE IF NOT EXISTS metadata_cache (
- id SERIAL PRIMARY KEY,
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- storage_path TEXT NOT NULL,
- etag TEXT,
- content_type TEXT,
- size BIGINT,
- last_modified TIMESTAMP,
- fetched_at TIMESTAMP,
- created_at TIMESTAMP,
- updated_at TIMESTAMP
-);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
-
-CREATE TABLE IF NOT EXISTS migrations (
- name TEXT NOT NULL PRIMARY KEY,
- applied_at TIMESTAMP NOT NULL
-);
`
// schemaArtifactsOnly contains just the artifacts table for adding to existing git-pkgs databases.
@@ -280,11 +230,6 @@ func (db *DB) CreateSchema() error {
return fmt.Errorf("setting schema version: %w", err)
}
- // Record all migrations as applied since the full schema is already current.
- if err := db.recordAllMigrations(); err != nil {
- return fmt.Errorf("recording migrations: %w", err)
- }
-
return db.OptimizeForReads()
}
@@ -345,290 +290,127 @@ func (db *DB) HasColumn(table, column string) (bool, error) {
return exists, err
}
-// migration represents a named schema migration.
-type migration struct {
- name string
- fn func(db *DB) error
-}
-
-// migrations is the ordered list of all schema migrations. See
-// docs/migrations.md for how to add new ones.
-var migrations = []migration{
- {"001_add_packages_enrichment_columns", migrateAddPackagesEnrichmentColumns},
- {"002_add_versions_enrichment_columns", migrateAddVersionsEnrichmentColumns},
- {"003_ensure_artifacts_table", migrateEnsureArtifactsTable},
- {"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
- {"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable},
-}
-
-// isTableNotFound returns true if the error indicates a missing table.
-// SQLite returns "no such table: X", Postgres returns "relation \"X\" does not exist".
-func isTableNotFound(err error) bool {
- msg := err.Error()
- return strings.Contains(msg, "no such table") ||
- strings.Contains(msg, "does not exist")
-}
-
-// createMigrationsTable creates the migrations table.
-func (db *DB) createMigrationsTable() error {
- var ts string
- if db.dialect == DialectPostgres {
- ts = postgresTimestamp
- } else {
- ts = sqliteDatetime
- }
-
- query := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS migrations (
- name TEXT NOT NULL PRIMARY KEY,
- applied_at %s NOT NULL
- )`, ts)
-
- if _, err := db.Exec(query); err != nil {
- return fmt.Errorf("creating migrations table: %w", err)
- }
- return nil
-}
-
-// appliedMigrations returns the set of migration names that have been recorded.
-// Returns nil if the migrations table does not exist yet.
-func (db *DB) appliedMigrations() (map[string]bool, error) {
- var names []string
- err := db.Select(&names, "SELECT name FROM migrations")
- if err != nil {
- // Table doesn't exist yet — this is a pre-migration database.
- if isTableNotFound(err) {
- return nil, nil
- }
- return nil, fmt.Errorf("loading applied migrations: %w", err)
- }
-
- applied := make(map[string]bool, len(names))
- for _, name := range names {
- applied[name] = true
- }
- return applied, nil
-}
-
-// recordMigration inserts a migration name into the migrations table.
-func (db *DB) recordMigration(name string) error {
- query := db.Rebind("INSERT INTO migrations (name, applied_at) VALUES (?, ?)")
- if _, err := db.Exec(query, name, time.Now().UTC()); err != nil {
- return fmt.Errorf("recording migration %s: %w", name, err)
- }
- return nil
-}
-
-// recordAllMigrations marks every known migration as applied.
-func (db *DB) recordAllMigrations() error {
- for _, m := range migrations {
- if err := db.recordMigration(m.name); err != nil {
- return err
- }
- }
- return nil
-}
-
-// MigrateSchema applies any unapplied migrations in order.
-// For a fully migrated database this executes a single SELECT query.
+// MigrateSchema adds missing columns to existing tables for backward compatibility.
func (db *DB) MigrateSchema() error {
- applied, err := db.appliedMigrations()
- if err != nil {
- return err
- }
-
- // If the migrations table didn't exist, create it now.
- if applied == nil {
- if err := db.createMigrationsTable(); err != nil {
- return err
- }
- applied = make(map[string]bool)
- }
-
- for _, m := range migrations {
- if applied[m.name] {
- continue
- }
- if err := m.fn(db); err != nil {
- return fmt.Errorf("migration %s: %w", m.name, err)
- }
- if err := db.recordMigration(m.name); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func migrateAddPackagesEnrichmentColumns(db *DB) error {
- columns := map[string]string{
- "registry_url": colTypeText,
- "supplier_name": colTypeText,
- "supplier_type": colTypeText,
- "source": colTypeText,
- "enriched_at": sqliteDatetime,
- "vulns_synced_at": sqliteDatetime,
+ // Check and add missing columns to packages table
+ packagesColumns := map[string]string{
+ "registry_url": "TEXT",
+ "supplier_name": "TEXT",
+ "supplier_type": "TEXT",
+ "source": "TEXT",
+ "enriched_at": "DATETIME",
+ "vulns_synced_at": "DATETIME",
}
if db.dialect == DialectPostgres {
- columns["enriched_at"] = postgresTimestamp
- columns["vulns_synced_at"] = postgresTimestamp
+ packagesColumns["enriched_at"] = "TIMESTAMP"
+ packagesColumns["vulns_synced_at"] = "TIMESTAMP"
}
- for column, colType := range columns {
+ for column, colType := range packagesColumns {
hasCol, err := db.HasColumn("packages", column)
if err != nil {
return fmt.Errorf("checking column %s: %w", column, err)
}
if !hasCol {
- alterQuery := fmt.Sprintf("ALTER TABLE packages ADD COLUMN %s %s", column, colType)
+ var alterQuery string
+ if db.dialect == DialectPostgres {
+ alterQuery = fmt.Sprintf("ALTER TABLE packages ADD COLUMN %s %s", column, colType)
+ } else {
+ alterQuery = fmt.Sprintf("ALTER TABLE packages ADD COLUMN %s %s", column, colType)
+ }
if _, err := db.Exec(alterQuery); err != nil {
return fmt.Errorf("adding column %s to packages: %w", column, err)
}
}
}
- return nil
-}
-func migrateAddVersionsEnrichmentColumns(db *DB) error {
- columns := map[string]string{
- "integrity": colTypeText,
+ // Check and add missing columns to versions table
+ versionsColumns := map[string]string{
+ "integrity": "TEXT",
"yanked": "INTEGER DEFAULT 0",
- "source": colTypeText,
- "enriched_at": sqliteDatetime,
+ "source": "TEXT",
+ "enriched_at": "DATETIME",
}
if db.dialect == DialectPostgres {
- columns["yanked"] = "BOOLEAN DEFAULT FALSE"
- columns["enriched_at"] = postgresTimestamp
+ versionsColumns["yanked"] = "BOOLEAN DEFAULT FALSE"
+ versionsColumns["enriched_at"] = "TIMESTAMP"
}
- for column, colType := range columns {
+ for column, colType := range versionsColumns {
hasCol, err := db.HasColumn("versions", column)
if err != nil {
return fmt.Errorf("checking column %s: %w", column, err)
}
if !hasCol {
- alterQuery := fmt.Sprintf("ALTER TABLE versions ADD COLUMN %s %s", column, colType)
+ var alterQuery string
+ if db.dialect == DialectPostgres {
+ alterQuery = fmt.Sprintf("ALTER TABLE versions ADD COLUMN %s %s", column, colType)
+ } else {
+ alterQuery = fmt.Sprintf("ALTER TABLE versions ADD COLUMN %s %s", column, colType)
+ }
if _, err := db.Exec(alterQuery); err != nil {
return fmt.Errorf("adding column %s to versions: %w", column, err)
}
}
}
- return nil
-}
-func migrateEnsureArtifactsTable(db *DB) error {
- return db.EnsureArtifactsTable()
-}
+ // Ensure artifacts table exists
+ if err := db.EnsureArtifactsTable(); err != nil {
+ return fmt.Errorf("ensuring artifacts table: %w", err)
+ }
-func migrateEnsureVulnerabilitiesTable(db *DB) error {
+ // Ensure vulnerabilities table exists
hasVulns, err := db.HasTable("vulnerabilities")
if err != nil {
return fmt.Errorf("checking vulnerabilities table: %w", err)
}
- if hasVulns {
- return nil
- }
-
- var vulnSchema string
- if db.dialect == DialectPostgres {
- vulnSchema = `
- CREATE TABLE vulnerabilities (
- id SERIAL PRIMARY KEY,
- vuln_id TEXT NOT NULL,
- ecosystem TEXT NOT NULL,
- package_name TEXT NOT NULL,
- severity TEXT,
- summary TEXT,
- fixed_version TEXT,
- cvss_score REAL,
- "references" TEXT,
- fetched_at TIMESTAMP,
- created_at TIMESTAMP,
- updated_at TIMESTAMP
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
- CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
- `
- } else {
- vulnSchema = `
- CREATE TABLE vulnerabilities (
- id INTEGER PRIMARY KEY,
- vuln_id TEXT NOT NULL,
- ecosystem TEXT NOT NULL,
- package_name TEXT NOT NULL,
- severity TEXT,
- summary TEXT,
- fixed_version TEXT,
- cvss_score REAL,
- "references" TEXT,
- fetched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
- CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
- `
- }
- if _, err := db.Exec(vulnSchema); err != nil {
- return fmt.Errorf("creating vulnerabilities table: %w", err)
+ if !hasVulns {
+ var vulnSchema string
+ if db.dialect == DialectPostgres {
+ vulnSchema = `
+ CREATE TABLE vulnerabilities (
+ id SERIAL PRIMARY KEY,
+ vuln_id TEXT NOT NULL,
+ ecosystem TEXT NOT NULL,
+ package_name TEXT NOT NULL,
+ severity TEXT,
+ summary TEXT,
+ fixed_version TEXT,
+ cvss_score REAL,
+ "references" TEXT,
+ fetched_at TIMESTAMP,
+ created_at TIMESTAMP,
+ updated_at TIMESTAMP
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
+ CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
+ `
+ } else {
+ vulnSchema = `
+ CREATE TABLE vulnerabilities (
+ id INTEGER PRIMARY KEY,
+ vuln_id TEXT NOT NULL,
+ ecosystem TEXT NOT NULL,
+ package_name TEXT NOT NULL,
+ severity TEXT,
+ summary TEXT,
+ fixed_version TEXT,
+ cvss_score REAL,
+ "references" TEXT,
+ fetched_at DATETIME,
+ created_at DATETIME,
+ updated_at DATETIME
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
+ CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
+ `
+ }
+ if _, err := db.Exec(vulnSchema); err != nil {
+ return fmt.Errorf("creating vulnerabilities table: %w", err)
+ }
}
return nil
}
-
-func migrateEnsureMetadataCacheTable(db *DB) error {
- return db.EnsureMetadataCacheTable()
-}
-
-// EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist.
-func (db *DB) EnsureMetadataCacheTable() error {
- has, err := db.HasTable("metadata_cache")
- if err != nil {
- return fmt.Errorf("checking metadata_cache table: %w", err)
- }
- if has {
- return nil
- }
-
- var schema string
- if db.dialect == DialectPostgres {
- schema = `
- CREATE TABLE metadata_cache (
- id SERIAL PRIMARY KEY,
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- storage_path TEXT NOT NULL,
- etag TEXT,
- content_type TEXT,
- size BIGINT,
- last_modified TIMESTAMP,
- fetched_at TIMESTAMP,
- created_at TIMESTAMP,
- updated_at TIMESTAMP
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
- `
- } else {
- schema = `
- CREATE TABLE metadata_cache (
- id INTEGER PRIMARY KEY,
- ecosystem TEXT NOT NULL,
- name TEXT NOT NULL,
- storage_path TEXT NOT NULL,
- etag TEXT,
- content_type TEXT,
- size INTEGER,
- last_modified DATETIME,
- fetched_at DATETIME,
- created_at DATETIME,
- updated_at DATETIME
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
- `
- }
- if _, err := db.Exec(schema); err != nil {
- return fmt.Errorf("creating metadata_cache table: %w", err)
- }
- return nil
-}
diff --git a/internal/database/types.go b/internal/database/types.go
index 47dc47e..52d08ba 100644
--- a/internal/database/types.go
+++ b/internal/database/types.go
@@ -2,56 +2,16 @@ package database
import (
"database/sql"
- "strings"
"time"
+
+ gitpkgsdb "github.com/git-pkgs/git-pkgs/database"
)
-// Package represents a package in the database.
-// Schema is compatible with git-pkgs.
-type Package struct {
- ID int64 `db:"id" json:"id"`
- PURL string `db:"purl" json:"purl"`
- Ecosystem string `db:"ecosystem" json:"ecosystem"`
- Name string `db:"name" json:"name"`
- LatestVersion sql.NullString `db:"latest_version" json:"latest_version,omitempty"`
- License sql.NullString `db:"license" json:"license,omitempty"`
- Description sql.NullString `db:"description" json:"description,omitempty"`
- Homepage sql.NullString `db:"homepage" json:"homepage,omitempty"`
- RepositoryURL sql.NullString `db:"repository_url" json:"repository_url,omitempty"`
- RegistryURL sql.NullString `db:"registry_url" json:"registry_url,omitempty"`
- SupplierName sql.NullString `db:"supplier_name" json:"supplier_name,omitempty"`
- SupplierType sql.NullString `db:"supplier_type" json:"supplier_type,omitempty"`
- Source sql.NullString `db:"source" json:"source,omitempty"`
- EnrichedAt sql.NullTime `db:"enriched_at" json:"enriched_at,omitempty"`
- VulnsSyncedAt sql.NullTime `db:"vulns_synced_at" json:"vulns_synced_at,omitempty"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
-}
-
-// Version represents a package version in the database.
-// Schema is compatible with git-pkgs.
-type Version struct {
- ID int64 `db:"id" json:"id"`
- PURL string `db:"purl" json:"purl"`
- PackagePURL string `db:"package_purl" json:"package_purl"`
- License sql.NullString `db:"license" json:"license,omitempty"`
- PublishedAt sql.NullTime `db:"published_at" json:"published_at,omitempty"`
- Integrity sql.NullString `db:"integrity" json:"integrity,omitempty"`
- Yanked bool `db:"yanked" json:"yanked"`
- Source sql.NullString `db:"source" json:"source,omitempty"`
- EnrichedAt sql.NullTime `db:"enriched_at" json:"enriched_at,omitempty"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
-}
-
-// Version extracts the version string from the PURL.
-// e.g., "pkg:npm/lodash@4.17.21" -> "4.17.21"
-func (v *Version) Version() string {
- if idx := strings.LastIndex(v.PURL, "@"); idx >= 0 {
- return v.PURL[idx+1:]
- }
- return ""
-}
+// Package and Version are shared with git-pkgs. The types and schema
+// are defined in the git-pkgs database package, keeping both projects
+// in sync automatically.
+type Package = gitpkgsdb.Package
+type Version = gitpkgsdb.Version
// Artifact represents a cached artifact in the database.
// This table is proxy-specific and not part of git-pkgs.
@@ -76,33 +36,18 @@ func (a *Artifact) IsCached() bool {
return a.StoragePath.Valid && a.FetchedAt.Valid
}
-// MetadataCacheEntry represents a cached metadata blob for offline serving.
-type MetadataCacheEntry struct {
+// Vulnerability represents a cached vulnerability record.
+type Vulnerability struct {
ID int64 `db:"id" json:"id"`
+ VulnID string `db:"vuln_id" json:"vuln_id"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
- Name string `db:"name" json:"name"`
- StoragePath string `db:"storage_path" json:"storage_path"`
- ETag sql.NullString `db:"etag" json:"etag,omitempty"`
- ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"`
- Size sql.NullInt64 `db:"size" json:"size,omitempty"`
- LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
+ PackageName string `db:"package_name" json:"package_name"`
+ Severity sql.NullString `db:"severity" json:"severity,omitempty"`
+ Summary sql.NullString `db:"summary" json:"summary,omitempty"`
+ FixedVersion sql.NullString `db:"fixed_version" json:"fixed_version,omitempty"`
+ CVSSScore sql.NullFloat64 `db:"cvss_score" json:"cvss_score,omitempty"`
+ References sql.NullString `db:"references" json:"references,omitempty"`
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
-
-// Vulnerability represents a cached vulnerability record.
-type Vulnerability struct {
- ID int64 `db:"id" json:"id"`
- VulnID string `db:"vuln_id" json:"vuln_id"`
- Ecosystem string `db:"ecosystem" json:"ecosystem"`
- PackageName string `db:"package_name" json:"package_name"`
- Severity sql.NullString `db:"severity" json:"severity,omitempty"`
- Summary sql.NullString `db:"summary" json:"summary,omitempty"`
- FixedVersion sql.NullString `db:"fixed_version" json:"fixed_version,omitempty"`
- CVSSScore sql.NullFloat64 `db:"cvss_score" json:"cvss_score,omitempty"`
- References sql.NullString `db:"references" json:"references,omitempty"`
- FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
-}
diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go
index 5d7810c..98b3069 100644
--- a/internal/handler/cargo.go
+++ b/internal/handler/cargo.go
@@ -1,24 +1,16 @@
package handler
import (
- "bufio"
"encoding/json"
- "errors"
"fmt"
+ "io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
cargoUpstream = "https://index.crates.io"
cargoDownloadBase = "https://static.crates.io/crates"
-
- cargoIndexLen1 = 1
- cargoIndexLen2 = 2
- cargoIndexLen3 = 3
)
// CargoHandler handles cargo registry protocol requests.
@@ -64,7 +56,7 @@ func (h *CargoHandler) Routes() http.Handler {
// CargoConfig is the registry configuration returned by config.json.
type CargoConfig struct {
- DL string `json:"dl"`
+ DL string `json:"dl"`
API string `json:"api,omitempty"`
}
@@ -88,76 +80,44 @@ func (h *CargoHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("cargo index request", "crate", name)
+ // Build the index path
indexPath := h.buildIndexPath(name)
upstreamURL := fmt.Sprintf("%s/%s", h.indexURL, indexPath)
- body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "cargo", name, upstreamURL, "text/plain")
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("failed to fetch upstream index", "error", err)
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
- if contentType == "" {
- contentType = "text/plain; charset=utf-8"
+ if resp.StatusCode == http.StatusNotFound {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
}
-
- w.Header().Set("Content-Type", contentType)
- w.WriteHeader(http.StatusOK)
- h.applyCooldownFiltering(w, body)
-}
-
-type crateIndexEntry struct {
- Name string `json:"name"`
- Version string `json:"vers"`
- PublishTime string `json:"pubtime,omitempty"`
-}
-
-func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWriter, body []byte) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- _, _ = downstreamResponse.Write(body)
+ if resp.StatusCode != http.StatusOK {
+ http.Error(w, fmt.Sprintf("upstream returned %d", resp.StatusCode), http.StatusBadGateway)
return
}
- scanner := bufio.NewScanner(strings.NewReader(string(body)))
-
- for scanner.Scan() {
- line := scanner.Text()
-
- var crate crateIndexEntry
- err := json.Unmarshal([]byte(line), &crate)
-
- if err != nil {
- h.proxy.Logger.Error("failed to parse json entry in index", "error", err)
- continue
- }
-
- publishedAt, err := time.Parse(time.RFC3339, crate.PublishTime)
-
- if crate.PublishTime == "" || err != nil {
- _, _ = downstreamResponse.Write([]byte(line + "\n"))
- continue
- }
-
- cratePURL := purl.MakePURLString("cargo", crate.Name, "")
-
- if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering cargo version",
- "crate", crate.Name, "version", crate.Version,
- "published", crate.PublishTime)
- continue
- }
-
- _, _ = downstreamResponse.Write([]byte(line + "\n"))
+ // Copy headers and body
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ if etag := resp.Header.Get("ETag"); etag != "" {
+ w.Header().Set("ETag", etag)
+ }
+ if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
+ w.Header().Set("Last-Modified", lastMod)
}
- if err := scanner.Err(); err != nil {
- h.proxy.Logger.Error("error reading index response", "error", err)
- }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.Copy(w, resp.Body)
}
// buildIndexPath builds the sparse index path for a crate name.
@@ -165,11 +125,11 @@ func (h *CargoHandler) buildIndexPath(name string) string {
name = strings.ToLower(name)
switch len(name) {
- case cargoIndexLen1:
+ case 1:
return fmt.Sprintf("1/%s", name)
- case cargoIndexLen2:
+ case 2:
return fmt.Sprintf("2/%s", name)
- case cargoIndexLen3:
+ case 3:
return fmt.Sprintf("3/%c/%s", name[0], name)
default:
return fmt.Sprintf("%s/%s/%s", name[0:2], name[2:4], name)
diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go
index 10d3faf..a2146f3 100644
--- a/internal/handler/cargo_test.go
+++ b/internal/handler/cargo_test.go
@@ -5,17 +5,12 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
- "strings"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
)
func cargoTestProxy() *Proxy {
return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
+ Logger: slog.Default(),
}
}
@@ -32,8 +27,8 @@ func TestCargoBuildIndexPath(t *testing.T) {
{"abcd", "ab/cd/abcd"},
{"serde", "se/rd/serde"},
{"tokio", "to/ki/tokio"},
- {"A", "1/a"}, // lowercase
- {"SERDE", "se/rd/serde"}, // lowercase
+ {"A", "1/a"}, // lowercase
+ {"SERDE", "se/rd/serde"}, // lowercase
{"rand_core", "ra/nd/rand_core"},
}
@@ -150,57 +145,3 @@ func TestCargoRoutes(t *testing.T) {
t.Errorf("config.json status = %d, want %d", w.Code, http.StatusOK)
}
}
-
-type filterTestCase struct {
- line string
- expected bool
-}
-
-func TestCargoCooldown(t *testing.T) {
- now := time.Now()
-
- createCase := func(name string, version string, age time.Duration, expected bool) filterTestCase {
- return filterTestCase{line: `{"name":"` + name + `","vers":"` + version + `","cksum":"abcd","features":{},"yanked":false,"pubtime":"` + now.Add(-1*age).Format(time.RFC3339) + `"}`, expected: expected}
- }
-
- testCases := []filterTestCase{
- // one week ago
- createCase("serde", "1.0.0", 168*time.Hour, true),
- // one hour ago
- createCase("serde", "1.0.1", 1*time.Hour, false),
- // two hours ago with custom filter (1h)
- createCase("tokio", "1.0.0", 2*time.Hour, true),
- // one hour ago with custom filter (1h)
- createCase("tokio", "1.0.0", 1*time.Minute, false),
- }
-
- var testInput strings.Builder
- var expectedOutput strings.Builder
-
- for _, testCase := range testCases {
- testInput.WriteString(testCase.line + "\n")
- if testCase.expected {
- expectedOutput.WriteString(testCase.line + "\n")
- }
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:cargo/tokio": "1h"},
- }
-
- h := &CargoHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- recorder := httptest.NewRecorder()
- h.applyCooldownFiltering(recorder, []byte(testInput.String()))
- output := recorder.Body.String()
-
- if output != expectedOutput.String() {
- t.Errorf("output = %q, want %q", output, expectedOutput.String())
- }
-
-}
diff --git a/internal/handler/composer.go b/internal/handler/composer.go
index 065ddf9..dd314fd 100644
--- a/internal/handler/composer.go
+++ b/internal/handler/composer.go
@@ -2,22 +2,15 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
"io"
"net/http"
- "path"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
- composerUpstream = "https://packagist.org"
- composerRepo = "https://repo.packagist.org"
- composerUnset = "__unset"
- vendorPackageParts = 2
+ composerUpstream = "https://packagist.org"
+ composerRepo = "https://repo.packagist.org"
)
// ComposerHandler handles Composer/Packagist registry protocol requests.
@@ -62,10 +55,10 @@ func (h *ComposerHandler) Routes() http.Handler {
func (h *ComposerHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) {
// Return a minimal service index pointing to our proxy
index := map[string]any{
- "packages": map[string]any{},
- "metadata-url": h.proxyURL + "/composer/p2/%package%.json",
- "notify-batch": h.upstreamURL + "/downloads/",
- "search": h.proxyURL + "/composer/search.json?q=%query%&type=%type%",
+ "packages": map[string]any{},
+ "metadata-url": h.proxyURL + "/composer/p2/%package%.json",
+ "notify-batch": h.upstreamURL + "/downloads/",
+ "search": h.proxyURL + "/composer/search.json?q=%query%&type=%type%",
"providers-lazy-url": h.proxyURL + "/composer/p2/%package%.json",
}
@@ -78,8 +71,8 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R
// Parse path: /p2/{vendor}/{package}.json
path := strings.TrimPrefix(r.URL.Path, "/p2/")
path = strings.TrimSuffix(path, ".json")
- parts := strings.SplitN(path, "/", vendorPackageParts)
- if len(parts) != vendorPackageParts || parts[0] == "" || parts[1] == "" {
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
http.Error(w, "invalid package path", http.StatusBadRequest)
return
}
@@ -89,18 +82,34 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R
h.proxy.Logger.Info("composer metadata request", "package", packageName)
+ // Fetch from repo.packagist.org (Composer v2 metadata)
upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg)
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "composer", packageName, upstreamURL)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteMetadata(body)
if err != nil {
@@ -115,9 +124,6 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R
}
// rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy.
-// If the metadata uses the minified Composer v2 format, it is expanded first so
-// that every version entry contains all fields. If cooldown is enabled, versions
-// published too recently are filtered out.
func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
@@ -129,170 +135,44 @@ func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) {
return body, nil
}
- minified := metadata["minified"] == "composer/2.0"
-
for packageName, versions := range packages {
versionList, ok := versions.([]any)
if !ok {
continue
}
- if minified {
- versionList = expandMinifiedVersions(versionList)
- }
-
- packages[packageName] = h.filterAndRewriteVersions(packageName, versionList)
- }
-
- delete(metadata, "minified")
-
- return json.Marshal(metadata)
-}
-
-// expandMinifiedVersions expands the Composer v2 minified format where each
-// version entry only contains fields that differ from the previous entry.
-// The "~dev" sentinel string resets the inheritance chain, and the "__unset"
-// value removes a field from the inherited state.
-func expandMinifiedVersions(versionList []any) []any {
- expanded := make([]any, 0, len(versionList))
- inherited := map[string]any{}
-
- for _, v := range versionList {
- // The "~dev" sentinel resets the inheritance chain for dev versions.
- if s, ok := v.(string); ok && s == "~dev" {
- inherited = map[string]any{}
- continue
- }
-
- vmap, ok := v.(map[string]any)
- if !ok {
- continue
- }
-
- // Merge inherited fields into a new map, then overlay current fields.
- // Deep copy values to avoid shared references between versions.
- merged := make(map[string]any, len(inherited)+len(vmap))
- for k, val := range inherited {
- merged[k] = deepCopyValue(val)
- }
- for k, val := range vmap {
- if val == composerUnset {
- delete(merged, k)
+ for _, v := range versionList {
+ vmap, ok := v.(map[string]any)
+ if !ok {
continue
}
- merged[k] = val
- }
- // Update inherited state for next iteration.
- inherited = merged
+ version, _ := vmap["version"].(string)
+ dist, ok := vmap["dist"].(map[string]any)
+ if !ok {
+ continue
+ }
- expanded = append(expanded, merged)
- }
+ // Rewrite the dist URL
+ if url, ok := dist["url"].(string); ok && url != "" {
+ // Extract filename from URL
+ filename := "package.zip"
+ if idx := strings.LastIndex(url, "/"); idx >= 0 {
+ filename = url[idx+1:]
+ }
- return expanded
-}
-
-// deepCopyValue returns a deep copy of JSON-like values (maps, slices, scalars).
-func deepCopyValue(v any) any {
- switch val := v.(type) {
- case map[string]any:
- m := make(map[string]any, len(val))
- for k, v := range val {
- m[k] = deepCopyValue(v)
- }
- return m
- case []any:
- s := make([]any, len(val))
- for i, v := range val {
- s[i] = deepCopyValue(v)
- }
- return s
- default:
- return v
- }
-}
-
-// filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs
-// for a single package's version list.
-func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any {
- packagePURL := purl.MakePURLString("composer", packageName, "")
-
- filtered := versionList[:0]
- for _, v := range versionList {
- vmap, ok := v.(map[string]any)
- if !ok {
- continue
- }
-
- version, _ := vmap["version"].(string)
-
- if h.shouldFilterVersion(packagePURL, packageName, version, vmap) {
- continue
- }
-
- h.rewriteDistURL(vmap, packageName, version)
- filtered = append(filtered, v)
- }
-
- return filtered
-}
-
-// shouldFilterVersion returns true if the version should be excluded due to cooldown.
-func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version string, vmap map[string]any) bool {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return false
- }
-
- timeStr, ok := vmap["time"].(string)
- if !ok {
- return false
- }
-
- publishedAt, err := time.Parse(time.RFC3339, timeStr)
- if err != nil {
- return false
- }
-
- if !h.proxy.Cooldown.IsAllowed("composer", packagePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering composer version",
- "package", packageName, "version", version)
- return true
- }
-
- return false
-}
-
-// rewriteDistURL rewrites the dist URL in a version entry to point at this proxy.
-func (h *ComposerHandler) rewriteDistURL(vmap map[string]any, packageName, version string) {
- dist, ok := vmap["dist"].(map[string]any)
- if !ok {
- return
- }
-
- url, ok := dist["url"].(string)
- if !ok || url == "" {
- return
- }
-
- filename := "package.zip"
- if idx := strings.LastIndex(url, "/"); idx >= 0 {
- filename = url[idx+1:]
- }
-
- // GitHub zipball URLs end with a bare commit hash (no extension).
- // Append .zip so the archives library can detect the format.
- if path.Ext(filename) == "" {
- if distType, _ := dist["type"].(string); distType == "zip" {
- filename += ".zip"
+ // Build new URL through our proxy
+ parts := strings.SplitN(packageName, "/", 2)
+ if len(parts) == 2 {
+ newURL := fmt.Sprintf("%s/composer/files/%s/%s/%s/%s",
+ h.proxyURL, parts[0], parts[1], version, filename)
+ dist["url"] = newURL
+ }
+ }
}
}
- parts := strings.SplitN(packageName, "/", vendorPackageParts)
- if len(parts) == vendorPackageParts {
- newURL := fmt.Sprintf("%s/composer/files/%s/%s/%s/%s",
- h.proxyURL, parts[0], parts[1], version, filename)
- dist["url"] = newURL
- }
+ return json.Marshal(metadata)
}
// handleDownload serves a package file, fetching and caching from upstream if needed.
@@ -317,7 +197,7 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
return
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("failed to fetch metadata", "error", err)
http.Error(w, "failed to fetch metadata", http.StatusBadGateway)
@@ -398,7 +278,7 @@ func (h *ComposerHandler) proxyUpstream(w http.ResponseWriter, r *http.Request)
return
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go
deleted file mode 100644
index baf13b6..0000000
--- a/internal/handler/composer_test.go
+++ /dev/null
@@ -1,519 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "log/slog"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-func TestComposerRewriteMetadata(t *testing.T) {
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "packages": {
- "symfony/console": [
- {
- "version": "6.0.0",
- "dist": {
- "url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
- "type": "zip"
- }
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/console"].([]any)
- v := versions[0].(map[string]any)
- dist := v["dist"].(map[string]any)
-
- expected := "http://localhost:8080/composer/files/symfony/console/6.0.0/abc123.zip"
- if dist["url"] != expected {
- t.Errorf("dist url = %q, want %q", dist["url"], expected)
- }
-}
-
-func TestComposerRewriteMetadataExpandsMinified(t *testing.T) {
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- // Minified format: first version has all fields, subsequent versions
- // only include fields that changed. The proxy must expand this so every
- // version has all fields (including "name").
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "symfony/console": [
- {
- "name": "symfony/console",
- "description": "Symfony Console Component",
- "version": "6.0.0",
- "dist": {
- "url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
- "type": "zip"
- }
- },
- {
- "version": "5.4.0",
- "dist": {
- "url": "https://repo.packagist.org/files/symfony/console/5.4.0/def456.zip",
- "type": "zip"
- }
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- // The minified key should be removed from output
- if _, ok := result["minified"]; ok {
- t.Error("expected minified key to be removed from output")
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/console"].([]any)
-
- // Second version should have inherited the "name" and "description" fields
- v1 := versions[1].(map[string]any)
- if v1["name"] != "symfony/console" {
- t.Errorf("second version name = %v, want %q", v1["name"], "symfony/console")
- }
- if v1["description"] != "Symfony Console Component" {
- t.Errorf("second version description = %v, want %q", v1["description"], "Symfony Console Component")
- }
-}
-
-func TestComposerRewriteMetadataMinifiedDevReset(t *testing.T) {
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- // The ~dev sentinel resets the inheritance chain for dev versions.
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "symfony/console": [
- {
- "name": "symfony/console",
- "description": "Symfony Console Component",
- "license": ["MIT"],
- "version": "6.0.0",
- "dist": {
- "url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
- "type": "zip"
- }
- },
- "~dev",
- {
- "name": "symfony/console",
- "version": "dev-main",
- "dist": {
- "url": "https://repo.packagist.org/files/symfony/console/dev-main/xyz789.zip",
- "type": "zip"
- }
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/console"].([]any)
-
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d", len(versions))
- }
-
- // Dev version should NOT have inherited "license" or "description"
- // from the tagged version (the ~dev sentinel resets inheritance).
- devVersion := versions[1].(map[string]any)
- if devVersion["version"] != "dev-main" {
- t.Errorf("dev version = %v, want %q", devVersion["version"], "dev-main")
- }
- if _, ok := devVersion["license"]; ok {
- t.Error("dev version should not have inherited license field after ~dev reset")
- }
- if _, ok := devVersion["description"]; ok {
- t.Error("dev version should not have inherited description field after ~dev reset")
- }
-}
-
-func TestComposerRewriteMetadataUnset(t *testing.T) {
- h := &ComposerHandler{
- proxy: &Proxy{Logger: slog.Default()},
- proxyURL: "http://localhost:8080",
- }
-
- // In the minified format, "__unset" removes a field from the inherited
- // state. v1.29.0 has require-dev, v1.28.0 unsets it, v1.27.0 inherits the
- // unset state. Composer rejects metadata where require-dev (or any link
- // field) is the literal string "__unset" rather than an object.
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "venturecraft/revisionable": [
- {
- "name": "venturecraft/revisionable",
- "version": "1.29.0",
- "require": {"php": ">=5.4"},
- "require-dev": {"orchestra/testbench": "~3.0"},
- "dist": {"url": "https://example.com/a.zip", "type": "zip"}
- },
- {
- "version": "1.28.0",
- "require-dev": "__unset"
- },
- {
- "version": "1.27.0"
- },
- {
- "version": "1.26.0",
- "require-dev": {"foo/bar": "1.0"}
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["packages"].(map[string]any)["venturecraft/revisionable"].([]any)
- if len(versions) != 4 {
- t.Fatalf("expected 4 versions, got %d", len(versions))
- }
-
- byVersion := map[string]map[string]any{}
- for _, v := range versions {
- vmap := v.(map[string]any)
- byVersion[vmap["version"].(string)] = vmap
- }
-
- if _, ok := byVersion["1.29.0"]["require-dev"].(map[string]any); !ok {
- t.Errorf("1.29.0 require-dev should be an object, got %T", byVersion["1.29.0"]["require-dev"])
- }
- if rd, ok := byVersion["1.28.0"]["require-dev"]; ok {
- t.Errorf("1.28.0 require-dev should be absent, got %v", rd)
- }
- if rd, ok := byVersion["1.27.0"]["require-dev"]; ok {
- t.Errorf("1.27.0 require-dev should be absent (inherited unset), got %v", rd)
- }
- if _, ok := byVersion["1.26.0"]["require-dev"].(map[string]any); !ok {
- t.Errorf("1.26.0 require-dev should be an object, got %T", byVersion["1.26.0"]["require-dev"])
- }
- if _, ok := byVersion["1.27.0"]["require"].(map[string]any); !ok {
- t.Error("1.27.0 should still inherit require from 1.29.0")
- }
-}
-
-func TestComposerRewriteMetadataCooldownPreservesNames(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- veryOld := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &ComposerHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- // Minified format where "name" only appears in first version.
- // When cooldown filters the first version, remaining versions must
- // still have the "name" field after expansion.
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "symfony/console": [
- {
- "name": "symfony/console",
- "description": "Symfony Console Component",
- "version": "7.0.0",
- "time": "` + recent + `",
- "dist": {"url": "https://repo.packagist.org/7.0.0.zip", "type": "zip"}
- },
- {
- "version": "6.0.0",
- "time": "` + old + `",
- "dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
- },
- {
- "version": "5.0.0",
- "time": "` + veryOld + `",
- "dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/console"].([]any)
-
- // v7.0.0 should be filtered by cooldown, leaving v6.0.0 and v5.0.0
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions after cooldown, got %d", len(versions))
- }
-
- // Both remaining versions must have the "name" field
- for _, v := range versions {
- vmap := v.(map[string]any)
- if vmap["name"] != "symfony/console" {
- t.Errorf("version %v missing name field, got %v", vmap["version"], vmap["name"])
- }
- }
-}
-
-func TestComposerRewriteDistURLGitHubZipball(t *testing.T) {
- // GitHub zipball URLs end with a bare commit hash, no file extension.
- // The proxy must produce a filename with .zip extension so that the
- // archives library can detect the format when browsing source.
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- vmap := map[string]any{
- "version": "v7.4.8",
- "dist": map[string]any{
- "url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856",
- "type": "zip",
- "shasum": "",
- "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856",
- },
- }
-
- h.rewriteDistURL(vmap, "symfony/asset", "v7.4.8")
-
- dist := vmap["dist"].(map[string]any)
- url := dist["url"].(string)
-
- // The rewritten URL's filename must have a .zip extension
- if !strings.HasSuffix(url, ".zip") {
- t.Errorf("rewritten dist URL filename has no .zip extension: %s", url)
- }
-}
-
-func TestComposerRewriteMetadataGitHubZipballFilenames(t *testing.T) {
- // End-to-end: metadata with GitHub zipball URLs should produce
- // download URLs that end in .zip so browse source can open them.
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "packages": {
- "symfony/config": [
- {
- "version": "v7.4.8",
- "dist": {
- "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39",
- "type": "zip",
- "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39"
- }
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/config"].([]any)
- v := versions[0].(map[string]any)
- dist := v["dist"].(map[string]any)
- url := dist["url"].(string)
-
- if !strings.HasSuffix(url, ".zip") {
- t.Errorf("rewritten URL should end in .zip, got %s", url)
- }
-}
-
-func TestComposerExpandMinifiedSharedDistReferences(t *testing.T) {
- // When a minified version inherits the dist field from a previous version
- // (i.e. it doesn't include its own dist), expanding + rewriting must not
- // corrupt the dist URLs via shared map references.
- h := &ComposerHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- // In this minified payload, v5.3.0 does NOT include a dist field,
- // so it inherits v5.4.0's dist. After expansion and URL rewriting,
- // each version must have its own correct dist URL.
- input := `{
- "minified": "composer/2.0",
- "packages": {
- "vendor/pkg": [
- {
- "name": "vendor/pkg",
- "version": "5.4.0",
- "dist": {
- "url": "https://api.github.com/repos/vendor/pkg/zipball/aaa111",
- "type": "zip",
- "reference": "aaa111"
- }
- },
- {
- "version": "5.3.0"
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["vendor/pkg"].([]any)
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d", len(versions))
- }
-
- v1 := versions[0].(map[string]any)
- v2 := versions[1].(map[string]any)
-
- dist1 := v1["dist"].(map[string]any)
- dist2 := v2["dist"].(map[string]any)
-
- url1 := dist1["url"].(string)
- url2 := dist2["url"].(string)
-
- // Each version must have its own URL with its own version in the path
- if !strings.Contains(url1, "/5.4.0/") {
- t.Errorf("v5.4.0 dist URL should contain /5.4.0/, got %s", url1)
- }
- if !strings.Contains(url2, "/5.3.0/") {
- t.Errorf("v5.3.0 dist URL should contain /5.3.0/, got %s", url2)
- }
-
- // The two URLs must be different
- if url1 == url2 {
- t.Errorf("both versions have the same dist URL (shared reference bug): %s", url1)
- }
-}
-
-func TestComposerRewriteMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &ComposerHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "packages": {
- "symfony/console": [
- {
- "version": "5.0.0",
- "time": "` + old + `",
- "dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
- },
- {
- "version": "6.0.0",
- "time": "` + recent + `",
- "dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
- }
- ]
- }
- }`
-
- output, err := h.rewriteMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- packages := result["packages"].(map[string]any)
- versions := packages["symfony/console"].([]any)
-
- if len(versions) != 1 {
- t.Fatalf("expected 1 version after cooldown, got %d", len(versions))
- }
-
- v := versions[0].(map[string]any)
- if v["version"] != "5.0.0" {
- t.Errorf("expected version 5.0.0, got %v", v["version"])
- }
-}
diff --git a/internal/handler/conan.go b/internal/handler/conan.go
index 53f6428..0a7dd8e 100644
--- a/internal/handler/conan.go
+++ b/internal/handler/conan.go
@@ -43,8 +43,8 @@ func (h *ConanHandler) Routes() http.Handler {
mux.HandleFunc("GET /v1/files/{name}/{version}/{user}/{channel}/{revision}/package/{pkgref}/{pkgrev}/{filename}", h.handlePackageFile)
mux.HandleFunc("GET /v2/files/{name}/{version}/{user}/{channel}/{revision}/package/{pkgref}/{pkgrev}/{filename}", h.handlePackageFile)
- // Proxy all other endpoints (metadata, search, etc.) with caching
- mux.HandleFunc("GET /", h.proxyCached)
+ // Proxy all other endpoints (metadata, search, etc.)
+ mux.HandleFunc("GET /", h.proxyUpstream)
return mux
}
@@ -147,20 +147,6 @@ func (h *ConanHandler) shouldCacheFile(filename string) bool {
return false
}
-// proxyCached forwards a request with metadata caching.
-func (h *ConanHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
- if r.URL.RawQuery != "" {
- cacheKey += "_" + r.URL.RawQuery
- }
- upstreamURL := h.upstreamURL + r.URL.Path
- if r.URL.RawQuery != "" {
- upstreamURL += "?" + r.URL.RawQuery
- }
- h.proxy.ProxyCached(w, r, upstreamURL, "conan", cacheKey, "*/*")
-}
-
// proxyUpstream forwards a request to conan center without caching.
func (h *ConanHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
@@ -181,7 +167,7 @@ func (h *ConanHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
req.Header.Set("Authorization", auth)
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
diff --git a/internal/handler/conan_test.go b/internal/handler/conan_test.go
deleted file mode 100644
index a7bd362..0000000
--- a/internal/handler/conan_test.go
+++ /dev/null
@@ -1,476 +0,0 @@
-package handler
-
-import (
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-)
-
-const testProxyURL = "http://localhost:8080"
-
-func conanTestProxy() *Proxy {
- return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
- }
-}
-
-func TestConanShouldCacheFile(t *testing.T) {
- h := &ConanHandler{}
-
- tests := []struct {
- filename string
- want bool
- }{
- {"conan_sources.tgz", true},
- {"conan_export.tgz", true},
- {"conan_package.tgz", true},
- {"conanfile.py", false},
- {"conanmanifest.txt", false},
- {"conaninfo.txt", false},
- {"random.tgz", false},
- {"", false},
- }
-
- for _, tt := range tests {
- got := h.shouldCacheFile(tt.filename)
- if got != tt.want {
- t.Errorf("shouldCacheFile(%q) = %v, want %v", tt.filename, got, tt.want)
- }
- }
-}
-
-func TestConanPingV1(t *testing.T) {
- h := &ConanHandler{
- proxy: conanTestProxy(),
- proxyURL: testProxyURL,
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v1/ping", nil)
- w := httptest.NewRecorder()
-
- h.handlePing(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- caps := w.Header().Get("X-Conan-Server-Capabilities")
- if caps != "revisions" {
- t.Errorf("X-Conan-Server-Capabilities = %q, want %q", caps, "revisions")
- }
-}
-
-func TestConanPingV2(t *testing.T) {
- h := &ConanHandler{
- proxy: conanTestProxy(),
- proxyURL: testProxyURL,
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/ping", nil)
- w := httptest.NewRecorder()
-
- h.handlePing(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- caps := w.Header().Get("X-Conan-Server-Capabilities")
- if caps != "revisions" {
- t.Errorf("X-Conan-Server-Capabilities = %q, want %q", caps, "revisions")
- }
-}
-
-func TestConanProxyUpstream(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v2/conans/search" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- if r.URL.Query().Get("q") != "zlib" {
- w.WriteHeader(http.StatusBadRequest)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"results":["zlib/1.2.13"]}`))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/search?q=zlib", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "zlib/1.2.13") {
- t.Errorf("response body does not contain expected result: %s", body)
- }
-}
-
-func TestConanProxyUpstreamNotFound(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/nonexistent", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestConanProxyUpstreamCopiesHeaders(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Custom-Header", "test-value")
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{}`))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Header().Get("X-Custom-Header") != "test-value" {
- t.Errorf("X-Custom-Header = %q, want %q", w.Header().Get("X-Custom-Header"), "test-value")
- }
-}
-
-func TestConanProxyUpstreamForwardsAuthHeader(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := r.Header.Get("Authorization")
- if auth != "Bearer mytoken" {
- w.WriteHeader(http.StatusUnauthorized)
- return
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
- req.Header.Set("Authorization", "Bearer mytoken")
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestConanProxyUpstreamBadUpstream(t *testing.T) {
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: "http://127.0.0.1:1", // unreachable
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestConanRecipeFileNonCacheable(t *testing.T) {
- // When a recipe file is not cacheable (e.g. conanfile.py), it should be proxied upstream.
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("conanfile content"))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/files/zlib/1.2.13/_/_/abc123/recipe/conanfile.py", nil)
- req.SetPathValue("name", "zlib")
- req.SetPathValue("version", "1.2.13")
- req.SetPathValue("user", "_")
- req.SetPathValue("channel", "_")
- req.SetPathValue("revision", "abc123")
- req.SetPathValue("filename", "conanfile.py")
-
- w := httptest.NewRecorder()
- h.handleRecipeFile(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "conanfile content" {
- t.Errorf("body = %q, want %q", body, "conanfile content")
- }
-}
-
-func TestConanPackageFileNonCacheable(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("conaninfo content"))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/files/zlib/1.2.13/_/_/abc123/package/pkgref1/pkgrev1/conaninfo.txt", nil)
- req.SetPathValue("name", "zlib")
- req.SetPathValue("version", "1.2.13")
- req.SetPathValue("user", "_")
- req.SetPathValue("channel", "_")
- req.SetPathValue("revision", "abc123")
- req.SetPathValue("pkgref", "pkgref1")
- req.SetPathValue("pkgrev", "pkgrev1")
- req.SetPathValue("filename", "conaninfo.txt")
-
- w := httptest.NewRecorder()
- h.handlePackageFile(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "conaninfo content" {
- t.Errorf("body = %q, want %q", body, "conaninfo content")
- }
-}
-
-func TestConanRoutes(t *testing.T) {
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: "http://localhost:1", // won't be called for ping
- proxyURL: "http://proxy.local",
- }
-
- routes := h.Routes()
-
- tests := []struct {
- path string
- wantStatus int
- }{
- {"/v1/ping", http.StatusOK},
- {"/v2/ping", http.StatusOK},
- }
-
- for _, tt := range tests {
- req := httptest.NewRequest(http.MethodGet, tt.path, nil)
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, req)
-
- if w.Code != tt.wantStatus {
- t.Errorf("GET %s: status = %d, want %d", tt.path, w.Code, tt.wantStatus)
- }
- }
-}
-
-func TestConanProxyUpstreamPreservesQueryString(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("q") != "boost" && r.URL.Query().Get("page") != "2" {
- w.WriteHeader(http.StatusBadRequest)
- return
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`ok`))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/search?q=boost&page=2", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestConanProxyUpstreamLargeResponse(t *testing.T) {
- largeBody := strings.Repeat("x", 1024*1024)
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(largeBody))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- if w.Body.Len() != len(largeBody) {
- t.Errorf("body length = %d, want %d", w.Body.Len(), len(largeBody))
- }
-}
-
-func TestNewConanHandler(t *testing.T) {
- proxy := conanTestProxy()
- h := NewConanHandler(proxy, "http://localhost:8080/")
-
- if h.proxy != proxy {
- t.Error("proxy not set correctly")
- }
- if h.upstreamURL != conanUpstream {
- t.Errorf("upstreamURL = %q, want %q", h.upstreamURL, conanUpstream)
- }
- if h.proxyURL != testProxyURL {
- t.Errorf("proxyURL = %q, want %q (trailing slash should be trimmed)", h.proxyURL, testProxyURL)
- }
-}
-
-func TestNewConanHandlerNoTrailingSlash(t *testing.T) {
- proxy := conanTestProxy()
- h := NewConanHandler(proxy, testProxyURL)
-
- if h.proxyURL != testProxyURL {
- t.Errorf("proxyURL = %q, want %q", h.proxyURL, testProxyURL)
- }
-}
-
-func TestConanProxyUpstreamNoAuthHeaderWhenNotProvided(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := r.Header.Get("Authorization")
- if auth != "" {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("unexpected auth header"))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestConanProxyUpstreamCopiesBody(t *testing.T) {
- expected := `{"name":"zlib","version":"1.2.13","user":"_","channel":"_"}`
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(expected))
- }))
- defer upstream.Close()
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/conans/zlib/1.2.13/_/_/latest", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- got, _ := io.ReadAll(w.Body)
- if string(got) != expected {
- t.Errorf("body = %q, want %q", string(got), expected)
- }
-}
-
-func TestConanProxyUpstreamPreservesStatusCodes(t *testing.T) {
- codes := []int{
- http.StatusOK,
- http.StatusNotFound,
- http.StatusForbidden,
- http.StatusInternalServerError,
- }
-
- for _, code := range codes {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(code)
- }))
-
- h := &ConanHandler{
- proxy: conanTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v2/test", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != code {
- t.Errorf("status = %d, want %d", w.Code, code)
- }
-
- upstream.Close()
- }
-}
diff --git a/internal/handler/conda.go b/internal/handler/conda.go
index 1336f94..bf2c0e8 100644
--- a/internal/handler/conda.go
+++ b/internal/handler/conda.go
@@ -1,18 +1,13 @@
package handler
import (
- "encoding/json"
"io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
condaUpstream = "https://conda.anaconda.org"
- minCondaParts = 3 // name-version-build requires at least 3 hyphen-separated parts
)
// CondaHandler handles Conda/Anaconda registry protocol requests.
@@ -36,9 +31,9 @@ func (h *CondaHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Channel index (repodata)
- mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.handleRepodata)
- mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyCached)
- mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.handleRepodata)
+ mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.proxyUpstream)
+ mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyUpstream)
+ mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.proxyUpstream)
// Package downloads (cache these)
mux.HandleFunc("GET /{channel}/{arch}/{filename}", h.handleDownload)
@@ -103,7 +98,7 @@ func (h *CondaHandler) parseFilename(filename string) (name, version string) {
// Split by hyphens, the format is name-version-build
// The name can contain hyphens, so we need to find version-build at the end
parts := strings.Split(base, "-")
- if len(parts) < minCondaParts {
+ if len(parts) < 3 {
return "", ""
}
@@ -124,25 +119,24 @@ func (h *CondaHandler) parseFilename(filename string) (name, version string) {
return name, version
}
-// handleRepodata proxies repodata.json, applying cooldown filtering when enabled.
-func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- h.proxyCached(w, r)
- return
- }
-
+// proxyUpstream forwards a request to Anaconda without caching.
+func (h *CondaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
- h.proxy.Logger.Debug("fetching repodata for cooldown filtering", "url", upstreamURL)
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
- req.Header.Set(headerAcceptEncoding, "gzip")
- resp, err := h.proxy.HTTPClient.Do(req)
+ // Copy accept-encoding for compression
+ if ae := r.Header.Get("Accept-Encoding"); ae != "" {
+ req.Header.Set("Accept-Encoding", ae)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
@@ -150,96 +144,12 @@ func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
}
defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK {
- for k, vv := range resp.Header {
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, resp.Body)
- return
- }
-
- body, err := ReadMetadata(resp.Body)
- if err != nil {
- http.Error(w, "failed to read response", http.StatusInternalServerError)
- return
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- h.proxy.Logger.Warn("failed to filter repodata, proxying original", "error", err)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(body)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(filtered)
-}
-
-// condaTimestampDivisor converts Conda's millisecond timestamps to seconds.
-const condaTimestampDivisor = 1000
-
-// applyCooldownFiltering removes entries from repodata.json that were
-// published too recently based on their timestamp field.
-func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return body, nil
- }
-
- var repodata map[string]any
- if err := json.Unmarshal(body, &repodata); err != nil {
- return nil, err
- }
-
- for _, key := range []string{"packages", "packages.conda"} {
- packages, ok := repodata[key].(map[string]any)
- if !ok {
- continue
- }
-
- for filename, entry := range packages {
- entryMap, ok := entry.(map[string]any)
- if !ok {
- continue
- }
-
- ts, ok := entryMap["timestamp"].(float64)
- if !ok || ts == 0 {
- continue
- }
-
- publishedAt := time.Unix(int64(ts)/condaTimestampDivisor, 0)
-
- name, _ := entryMap["name"].(string)
- if name == "" {
- continue
- }
-
- packagePURL := purl.MakePURLString("conda", name, "")
-
- if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
- version, _ := entryMap["version"].(string)
- h.proxy.Logger.Info("cooldown: filtering conda package",
- "name", name, "version", version, "filename", filename)
- delete(packages, filename)
- }
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
}
}
- return json.Marshal(repodata)
-}
-
-// proxyCached forwards a metadata request with caching.
-func (h *CondaHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "conda", cacheKey, "*/*")
-}
-
-// proxyUpstream forwards a request to Anaconda without caching.
-func (h *CondaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{headerAcceptEncoding})
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
diff --git a/internal/handler/conda_test.go b/internal/handler/conda_test.go
index 1b57039..457d201 100644
--- a/internal/handler/conda_test.go
+++ b/internal/handler/conda_test.go
@@ -1,14 +1,8 @@
package handler
import (
- "encoding/json"
"log/slog"
- "net/http"
- "net/http/httptest"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
)
func TestCondaParseFilename(t *testing.T) {
@@ -55,251 +49,3 @@ func TestCondaIsPackageFile(t *testing.T) {
}
}
}
-
-func TestCondaCooldownFiltering(t *testing.T) {
- now := time.Now()
- oldTimestamp := float64(now.Add(-7 * 24 * time.Hour).UnixMilli())
- recentTimestamp := float64(now.Add(-1 * time.Hour).UnixMilli())
-
- repodata := map[string]any{
- "info": map[string]any{},
- "packages": map[string]any{
- "numpy-1.24.0-old.tar.bz2": map[string]any{
- "name": "numpy",
- "version": "1.24.0",
- "timestamp": oldTimestamp,
- },
- "numpy-1.25.0-new.tar.bz2": map[string]any{
- "name": "numpy",
- "version": "1.25.0",
- "timestamp": recentTimestamp,
- },
- },
- "packages.conda": map[string]any{
- "scipy-1.11.0-old.conda": map[string]any{
- "name": "scipy",
- "version": "1.11.0",
- "timestamp": oldTimestamp,
- },
- "scipy-1.12.0-new.conda": map[string]any{
- "name": "scipy",
- "version": "1.12.0",
- "timestamp": recentTimestamp,
- },
- },
- }
-
- body, err := json.Marshal(repodata)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &CondaHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- packages := result["packages"].(map[string]any)
- if len(packages) != 1 {
- t.Fatalf("expected 1 package in packages, got %d", len(packages))
- }
- if _, ok := packages["numpy-1.24.0-old.tar.bz2"]; !ok {
- t.Error("expected old numpy to survive filtering")
- }
-
- condaPkgs := result["packages.conda"].(map[string]any)
- if len(condaPkgs) != 1 {
- t.Fatalf("expected 1 package in packages.conda, got %d", len(condaPkgs))
- }
- if _, ok := condaPkgs["scipy-1.11.0-old.conda"]; !ok {
- t.Error("expected old scipy to survive filtering")
- }
-}
-
-func TestCondaCooldownFilteringWithPackageOverride(t *testing.T) {
- now := time.Now()
- recentTimestamp := float64(now.Add(-2 * time.Hour).UnixMilli())
-
- repodata := map[string]any{
- "info": map[string]any{},
- "packages": map[string]any{
- "special-1.0.0-build.tar.bz2": map[string]any{
- "name": "special",
- "version": "1.0.0",
- "timestamp": recentTimestamp,
- },
- },
- "packages.conda": map[string]any{},
- }
-
- body, err := json.Marshal(repodata)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:conda/special": "1h"},
- }
-
- h := &CondaHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- packages := result["packages"].(map[string]any)
- if len(packages) != 1 {
- t.Fatalf("expected 1 package (override allows it), got %d", len(packages))
- }
-}
-
-func TestCondaCooldownFilteringNoTimestamp(t *testing.T) {
- repodata := map[string]any{
- "info": map[string]any{},
- "packages": map[string]any{
- "old-pkg-1.0.0-build.tar.bz2": map[string]any{
- "name": "old-pkg",
- "version": "1.0.0",
- // no timestamp field
- },
- },
- "packages.conda": map[string]any{},
- }
-
- body, err := json.Marshal(repodata)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &CondaHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- packages := result["packages"].(map[string]any)
- if len(packages) != 1 {
- t.Fatalf("entries without timestamp should pass through, got %d", len(packages))
- }
-}
-
-func TestCondaHandleRepodataWithCooldown(t *testing.T) {
- now := time.Now()
- oldTimestamp := float64(now.Add(-7 * 24 * time.Hour).UnixMilli())
- recentTimestamp := float64(now.Add(-1 * time.Hour).UnixMilli())
-
- repodataJSON, _ := json.Marshal(map[string]any{
- "info": map[string]any{},
- "packages": map[string]any{
- "old-1.0.0-build.tar.bz2": map[string]any{
- "name": "testpkg", "version": "1.0.0", "timestamp": oldTimestamp,
- },
- "new-2.0.0-build.tar.bz2": map[string]any{
- "name": "testpkg", "version": "2.0.0", "timestamp": recentTimestamp,
- },
- },
- "packages.conda": map[string]any{},
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(repodataJSON)
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &CondaHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/conda-forge/noarch/repodata.json", nil)
- req.SetPathValue("channel", "conda-forge")
- req.SetPathValue("arch", "noarch")
- w := httptest.NewRecorder()
- h.handleRepodata(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
-
- packages := result["packages"].(map[string]any)
- if len(packages) != 1 {
- t.Fatalf("expected 1 package after filtering, got %d", len(packages))
- }
-}
-
-func TestCondaHandleRepodataWithoutCooldown(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"info":{},"packages":{},"packages.conda":{}}`))
- }))
- defer upstream.Close()
-
- h := &CondaHandler{
- proxy: &Proxy{Logger: slog.Default(), HTTPClient: http.DefaultClient},
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/conda-forge/noarch/repodata.json", nil)
- req.SetPathValue("channel", "conda-forge")
- req.SetPathValue("arch", "noarch")
- w := httptest.NewRecorder()
- h.handleRepodata(w, req)
-
- // Without cooldown, should proxy directly (response comes from upstream)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
diff --git a/internal/handler/container.go b/internal/handler/container.go
index 8ba5e97..349d857 100644
--- a/internal/handler/container.go
+++ b/internal/handler/container.go
@@ -10,11 +10,8 @@ import (
)
const (
- dockerHubRegistry = "https://registry-1.docker.io"
- dockerHubAuth = "https://auth.docker.io"
- blobMatchCount = 3 // full match + name + digest
- manifestMatchCount = 3 // full match + name + reference
- tagsListMatchCount = 2 // full match + name
+ dockerHubRegistry = "https://registry-1.docker.io"
+ dockerHubAuth = "https://auth.docker.io"
)
// ContainerHandler handles OCI/Docker container registry protocol requests.
@@ -103,22 +100,20 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
return
}
- // Try to get from cache, or fetch from upstream with auth
+ // Try to get from cache first
filename := digest
- headers := http.Header{"Authorization": {"Bearer " + token}}
- result, err := h.proxy.GetOrFetchArtifactFromURLWithHeaders(
+ result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(),
"oci",
name,
digest, // use digest as version
filename,
fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest),
- headers,
)
if err != nil {
- h.proxy.Logger.Error("failed to fetch blob", "error", err)
- h.containerError(w, http.StatusBadGateway, "BLOB_UNKNOWN", "failed to fetch blob")
+ // Fetch directly with auth
+ h.proxyBlobWithAuth(w, r, name, digest, token)
return
}
@@ -177,7 +172,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request
}, ", "))
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("failed to fetch manifest", "error", err)
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
@@ -229,7 +224,7 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request
req.Header.Set("Authorization", "Bearer "+token)
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
return
@@ -253,7 +248,7 @@ func (h *ContainerHandler) getAuthToken(_ interface{ Done() <-chan struct{} }, r
return "", err
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
@@ -290,7 +285,7 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request,
req.Header.Set("Authorization", "Bearer "+token)
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
return
@@ -306,6 +301,35 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request,
w.WriteHeader(resp.StatusCode)
}
+// proxyBlobWithAuth proxies a blob download with authentication.
+func (h *ContainerHandler) proxyBlobWithAuth(w http.ResponseWriter, r *http.Request, name, digest, token string) {
+ upstreamURL := fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request")
+ return
+ }
+
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest"} {
+ if v := resp.Header.Get(header); v != "" {
+ w.Header().Set(header, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+}
+
// containerError writes an OCI-compliant error response.
func (h *ContainerHandler) containerError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
@@ -323,7 +347,7 @@ var blobPathPattern = regexp.MustCompile(`^(.+)/blobs/(sha256:[a-f0-9]+)$`)
// parseBlobPath extracts repository name and digest from a blob path.
func (h *ContainerHandler) parseBlobPath(path string) (name, digest string) {
matches := blobPathPattern.FindStringSubmatch(path)
- if len(matches) != blobMatchCount {
+ if len(matches) != 3 {
return "", ""
}
return matches[1], matches[2]
@@ -335,7 +359,7 @@ var manifestPathPattern = regexp.MustCompile(`^(.+)/manifests/(.+)$`)
// parseManifestPath extracts repository name and reference from a manifest path.
func (h *ContainerHandler) parseManifestPath(path string) (name, reference string) {
matches := manifestPathPattern.FindStringSubmatch(path)
- if len(matches) != manifestMatchCount {
+ if len(matches) != 3 {
return "", ""
}
return matches[1], matches[2]
@@ -347,7 +371,7 @@ var tagsListPathPattern = regexp.MustCompile(`^(.+)/tags/list$`)
// parseTagsListPath extracts repository name from a tags list path.
func (h *ContainerHandler) parseTagsListPath(path string) string {
matches := tagsListPathPattern.FindStringSubmatch(path)
- if len(matches) != tagsListMatchCount {
+ if len(matches) != 2 {
return ""
}
return matches[1]
diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go
index 853059e..b84adfd 100644
--- a/internal/handler/container_test.go
+++ b/internal/handler/container_test.go
@@ -1,17 +1,9 @@
package handler
import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "log/slog"
"net/http"
"net/http/httptest"
"testing"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/registries/fetch"
)
func TestContainerHandler_parseBlobPath(t *testing.T) {
@@ -86,8 +78,8 @@ func TestContainerHandler_parseManifestPath(t *testing.T) {
wantReference: "sha256:abc123",
},
{
- path: "invalid/path",
- wantName: "",
+ path: "invalid/path",
+ wantName: "",
},
}
@@ -135,92 +127,6 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) {
}
}
-func TestContainerHandler_BlobDownload_CachesWithAuth(t *testing.T) {
- // Set up a mock auth server that returns a token
- authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token-123"})
- }))
- defer authServer.Close()
-
- // Set up mock fetcher that captures headers
- var capturedHeaders http.Header
- mf := &mockFetcherWithHeaders{
- fetchFn: func(_ context.Context, _ string, headers http.Header) (*fetch.Artifact, error) {
- capturedHeaders = headers
- return &fetch.Artifact{
- Body: io.NopCloser(bytes.NewReader([]byte("blob-content"))),
- Size: 12,
- ContentType: "application/octet-stream",
- }, nil
- },
- }
-
- dir := t.TempDir()
- db, err := database.Create(dir + "/test.db")
- if err != nil {
- t.Fatalf("failed to create test database: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
-
- store := newMockStorage()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- proxy := &Proxy{
- DB: db,
- Storage: store,
- Fetcher: mf,
- Logger: logger,
- HTTPClient: &http.Client{},
- }
-
- h := &ContainerHandler{
- proxy: proxy,
- registryURL: "https://registry-1.docker.io",
- authURL: authServer.URL,
- proxyURL: "http://localhost:8080",
- }
-
- handler := h.Routes()
- req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
- }
-
- // Verify auth header was passed to the fetcher
- if capturedHeaders == nil {
- t.Fatal("expected headers to be passed to fetcher, got nil")
- }
- auth := capturedHeaders.Get("Authorization")
- if auth != "Bearer test-token-123" {
- t.Errorf("Authorization = %q, want %q", auth, "Bearer test-token-123")
- }
-
- // Verify response headers
- if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" {
- t.Errorf("Docker-Content-Digest = %q, want digest", got)
- }
-}
-
-// mockFetcherWithHeaders captures headers passed to FetchWithHeaders.
-type mockFetcherWithHeaders struct {
- fetchFn func(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error)
-}
-
-func (f *mockFetcherWithHeaders) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
- return f.FetchWithHeaders(ctx, url, nil)
-}
-
-func (f *mockFetcherWithHeaders) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
- return f.fetchFn(ctx, url, headers)
-}
-
-func (f *mockFetcherWithHeaders) Head(_ context.Context, _ string) (int64, string, error) {
- return 0, "", nil
-}
-
func TestContainerHandler_Routes_VersionCheck(t *testing.T) {
h := NewContainerHandler(nil, "http://localhost:8080")
diff --git a/internal/handler/cran.go b/internal/handler/cran.go
index 0ecd2a3..e10887f 100644
--- a/internal/handler/cran.go
+++ b/internal/handler/cran.go
@@ -1,6 +1,7 @@
package handler
import (
+ "io"
"net/http"
"strings"
)
@@ -30,14 +31,14 @@ func (h *CRANHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Package indexes
- mux.HandleFunc("GET /src/contrib/PACKAGES", h.proxyCached)
- mux.HandleFunc("GET /src/contrib/PACKAGES.gz", h.proxyCached)
- mux.HandleFunc("GET /src/contrib/PACKAGES.rds", h.proxyCached)
+ mux.HandleFunc("GET /src/contrib/PACKAGES", h.proxyUpstream)
+ mux.HandleFunc("GET /src/contrib/PACKAGES.gz", h.proxyUpstream)
+ mux.HandleFunc("GET /src/contrib/PACKAGES.rds", h.proxyUpstream)
// Binary package indexes
- mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES", h.proxyCached)
- mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.gz", h.proxyCached)
- mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.rds", h.proxyCached)
+ mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES", h.proxyUpstream)
+ mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.gz", h.proxyUpstream)
+ mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.rds", h.proxyUpstream)
// Source package downloads
mux.HandleFunc("GET /src/contrib/{filename}", h.handleSourceDownload)
@@ -150,14 +151,36 @@ func (h *CRANHandler) isBinaryPackage(filename string) bool {
return strings.HasSuffix(filename, ".zip") || strings.HasSuffix(filename, ".tgz")
}
-// proxyCached forwards a metadata request with caching.
-func (h *CRANHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "cran", cacheKey, "*/*")
-}
-
// proxyUpstream forwards a request to CRAN without caching.
func (h *CRANHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{headerAcceptEncoding})
+ upstreamURL := h.upstreamURL + r.URL.Path
+
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ if ae := r.Header.Get("Accept-Encoding"); ae != "" {
+ req.Header.Set("Accept-Encoding", ae)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("upstream request failed", "error", err)
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
diff --git a/internal/handler/debian.go b/internal/handler/debian.go
index b767f6d..e413ca3 100644
--- a/internal/handler/debian.go
+++ b/internal/handler/debian.go
@@ -2,6 +2,7 @@ package handler
import (
"fmt"
+ "io"
"net/http"
"regexp"
"strings"
@@ -9,7 +10,6 @@ import (
const (
debianUpstream = "http://deb.debian.org/debian"
- debMatchCount = 4 // full match + name + version + arch
)
// DebianHandler handles APT/Debian repository protocol requests.
@@ -40,11 +40,6 @@ func (h *DebianHandler) Routes() http.Handler {
path := strings.TrimPrefix(r.URL.Path, "/")
- if containsPathTraversal(path) {
- http.Error(w, "invalid path", http.StatusBadRequest)
- return
- }
-
// Route based on path type
switch {
case strings.HasPrefix(path, "pool/"):
@@ -93,13 +88,67 @@ func (h *DebianHandler) handlePackageDownload(w http.ResponseWriter, r *http.Req
// handleMetadata proxies repository metadata files.
// These change frequently so we don't cache them.
func (h *DebianHandler) handleMetadata(w http.ResponseWriter, r *http.Request, path string) {
- cacheKey := strings.ReplaceAll(path, "/", "_")
- h.proxy.ProxyCached(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path), "debian", cacheKey, "*/*")
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
+
+ h.proxy.Logger.Debug("debian metadata request", "path", path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ // Forward relevant headers
+ for _, header := range []string{"Accept", "Accept-Encoding", "If-Modified-Since", "If-None-Match"} {
+ if v := r.Header.Get(header); v != "" {
+ req.Header.Set(header, v)
+ }
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("failed to fetch upstream metadata", "error", err)
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Copy response headers
+ for _, header := range []string{"Content-Type", "Content-Length", "Last-Modified", "ETag"} {
+ if v := resp.Header.Get(header); v != "" {
+ w.Header().Set(header, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// proxyFile proxies any file directly without caching.
func (h *DebianHandler) proxyFile(w http.ResponseWriter, r *http.Request, path string) {
- h.proxy.ProxyFile(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path))
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for key, values := range resp.Header {
+ for _, v := range values {
+ w.Header().Add(key, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// debPackagePattern matches .deb filenames to extract name, version, and arch.
@@ -118,7 +167,7 @@ func (h *DebianHandler) parsePoolPath(path string) (name, version, arch string)
// Parse the filename
matches := debPackagePattern.FindStringSubmatch(filename)
- if len(matches) != debMatchCount {
+ if len(matches) != 4 {
return "", "", ""
}
diff --git a/internal/handler/debian_test.go b/internal/handler/debian_test.go
index dfdd326..bebffcf 100644
--- a/internal/handler/debian_test.go
+++ b/internal/handler/debian_test.go
@@ -1,23 +1,89 @@
package handler
import (
+ "net/http"
+ "net/http/httptest"
"testing"
)
func TestDebianHandler_parsePoolPath(t *testing.T) {
h := &DebianHandler{}
- assertPathParser(t, "parsePoolPath", h.parsePoolPath, []pathParseCase{
- {"pool/main/n/nginx/nginx_1.18.0-6_amd64.deb", "nginx", "1.18.0-6", "amd64"},
- {"pool/main/libn/libncurses/libncurses6_6.2-1_amd64.deb", "libncurses6", "6.2-1", "amd64"},
- {"pool/contrib/v/virtualbox/virtualbox_6.1.38-1_amd64.deb", "virtualbox", "6.1.38-1", "amd64"},
- {"pool/main/g/git/git_2.39.2-1_arm64.deb", "git", "2.39.2-1", "arm64"},
- {"invalid/path", "", "", ""},
- {"pool/main/n/nginx/nginx.deb", "", "", ""},
- })
+ tests := []struct {
+ path string
+ wantName string
+ wantVersion string
+ wantArch string
+ }{
+ {
+ path: "pool/main/n/nginx/nginx_1.18.0-6_amd64.deb",
+ wantName: "nginx",
+ wantVersion: "1.18.0-6",
+ wantArch: "amd64",
+ },
+ {
+ path: "pool/main/libn/libncurses/libncurses6_6.2-1_amd64.deb",
+ wantName: "libncurses6",
+ wantVersion: "6.2-1",
+ wantArch: "amd64",
+ },
+ {
+ path: "pool/contrib/v/virtualbox/virtualbox_6.1.38-1_amd64.deb",
+ wantName: "virtualbox",
+ wantVersion: "6.1.38-1",
+ wantArch: "amd64",
+ },
+ {
+ path: "pool/main/g/git/git_2.39.2-1_arm64.deb",
+ wantName: "git",
+ wantVersion: "2.39.2-1",
+ wantArch: "arm64",
+ },
+ {
+ path: "invalid/path",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ {
+ path: "pool/main/n/nginx/nginx.deb",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ name, version, arch := h.parsePoolPath(tt.path)
+ if name != tt.wantName {
+ t.Errorf("parsePoolPath() name = %q, want %q", name, tt.wantName)
+ }
+ if version != tt.wantVersion {
+ t.Errorf("parsePoolPath() version = %q, want %q", version, tt.wantVersion)
+ }
+ if arch != tt.wantArch {
+ t.Errorf("parsePoolPath() arch = %q, want %q", arch, tt.wantArch)
+ }
+ })
+ }
}
func TestDebianHandler_Routes(t *testing.T) {
h := NewDebianHandler(nil, "http://localhost:8080")
- assertRoutesBasics(t, h.Routes(), "/dists/stable/Release", "/pool/../../../etc/passwd")
+
+ // Test that handler doesn't panic on initialization
+ handler := h.Routes()
+ if handler == nil {
+ t.Fatal("Routes() returned nil")
+ }
+
+ // Test method not allowed
+ req := httptest.NewRequest(http.MethodPost, "/dists/stable/Release", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST request: got status %d, want %d", w.Code, http.StatusMethodNotAllowed)
+ }
}
diff --git a/internal/handler/download_test.go b/internal/handler/download_test.go
deleted file mode 100644
index 980e234..0000000
--- a/internal/handler/download_test.go
+++ /dev/null
@@ -1,1213 +0,0 @@
-package handler
-
-import (
- "database/sql"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// seedPackageWithPURL seeds a package using purl.MakePURLString for PURL generation,
-// matching how the handlers construct PURLs internally.
-func seedPackageWithPURL(t *testing.T, db *database.DB, store *mockStorage, ecosystem, name, version, filename, content string) {
- t.Helper()
-
- pkgPURL := purl.MakePURLString(ecosystem, name, "")
- versionPURL := purl.MakePURLString(ecosystem, name, version)
-
- pkg := &database.Package{
- PURL: pkgPURL,
- Ecosystem: ecosystem,
- Name: name,
- }
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- ver := &database.Version{
- PURL: versionPURL,
- PackagePURL: pkgPURL,
- }
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- storagePath := storage.ArtifactPath(ecosystem, "", name, version, filename)
- store.files[storagePath] = []byte(content)
-
- art := &database.Artifact{
- VersionPURL: versionPURL,
- Filename: filename,
- UpstreamURL: "https://example.com/" + filename,
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- ContentHash: sql.NullString{String: "abc123", Valid: true},
- Size: sql.NullInt64{Int64: int64(len(content)), Valid: true},
- ContentType: sql.NullString{String: "application/octet-stream", Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatalf("failed to upsert artifact: %v", err)
- }
-}
-
-// assertUpstreamProxied verifies that a handler proxies a request to the upstream
-// server and returns the expected response body. The makeHandler function receives
-// a configured Proxy and the upstream URL, and returns the handler to test.
-func assertUpstreamProxied(t *testing.T, wantBody, path string, makeHandler func(*Proxy, string) http.Handler) {
- t.Helper()
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = fmt.Fprint(w, wantBody)
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(makeHandler(proxy, upstream.URL))
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != wantBody {
- t.Errorf("body = %q, want %q", body, wantBody)
- }
-}
-
-func TestGemHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "gem", "rails", "7.1.0", "rails-7.1.0.gem", "gem binary data")
-
- h := NewGemHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/gems/rails-7.1.0.gem")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "gem binary data" {
- t.Errorf("body = %q, want %q", body, "gem binary data")
- }
-}
-
-func TestGemHandler_DownloadCacheHitMultiHyphen(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "gem", "aws-sdk-s3", "1.142.0", "aws-sdk-s3-1.142.0.gem", "aws gem")
-
- h := NewGemHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/gems/aws-sdk-s3-1.142.0.gem")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "aws gem" {
- t.Errorf("body = %q, want %q", body, "aws gem")
- }
-}
-
-func TestGemHandler_InvalidFilename(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGemHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- tests := []struct {
- path string
- code int
- }{
- {"/gems/notagem.tar.gz", http.StatusBadRequest},
- {"/gems/noversion.gem", http.StatusBadRequest},
- {"/gems/.gem", http.StatusBadRequest},
- }
-
- for _, tt := range tests {
- resp, err := http.Get(srv.URL + tt.path)
- if err != nil {
- t.Fatalf("request to %s failed: %v", tt.path, err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != tt.code {
- t.Errorf("GET %s: status = %d, want %d", tt.path, resp.StatusCode, tt.code)
- }
- }
-}
-
-func TestGemHandler_UpstreamProxy(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Test", "upstream")
- w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprint(w, "upstream specs data")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- h := &GemHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/versions")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "upstream specs data" {
- t.Errorf("body = %q, want %q", body, "upstream specs data")
- }
- // Metadata caching reads the response body into storage and serves it back,
- // so arbitrary upstream headers are not forwarded. Content-Type is preserved.
-}
-
-func TestGemHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched gem")),
- ContentType: "application/octet-stream",
- }
-
- h := NewGemHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/gems/sinatra-3.0.0.gem")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-}
-
-func TestGoHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "golang", "golang.org/x/text", "v0.14.0", "text@v0.14.0.zip", "go module zip")
-
- h := NewGoHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/golang.org/x/text/@v/v0.14.0.zip")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "go module zip" {
- t.Errorf("body = %q, want %q", body, "go module zip")
- }
-}
-
-func TestGoHandler_MethodNotAllowed(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGoHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Post(srv.URL+"/golang.org/x/text/@v/v0.14.0.zip", "", nil)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusMethodNotAllowed {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
- }
-}
-
-func TestGoHandler_NotFound(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGoHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/some/unknown/path")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-}
-
-func TestGoHandler_UnknownAtVSuffix(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGoHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/golang.org/x/text/@v/v0.14.0.unknown")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-}
-
-func TestGoHandler_UpstreamProxy(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = fmt.Fprint(w, "v0.14.0\nv0.13.0\n")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- h := &GoHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- tests := []string{
- "/golang.org/x/text/@v/list",
- "/golang.org/x/text/@v/v0.14.0.info",
- "/golang.org/x/text/@v/v0.14.0.mod",
- "/golang.org/x/text/@latest",
- "/sumdb/sum.golang.org/lookup/golang.org/x/text@v0.14.0",
- }
-
- for _, path := range tests {
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatalf("GET %s failed: %v", path, err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("GET %s: status = %d, want %d", path, resp.StatusCode, http.StatusOK)
- }
- }
-}
-
-func TestGoHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("module zip data")),
- ContentType: "application/zip",
- }
-
- h := NewGoHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/example.com/mod/@v/v1.0.0.zip")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-}
-
-func TestHexHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "hex", "phoenix", "1.7.10", "phoenix-1.7.10.tar", "hex tarball")
-
- h := NewHexHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/tarballs/phoenix-1.7.10.tar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "hex tarball" {
- t.Errorf("body = %q, want %q", body, "hex tarball")
- }
-}
-
-func TestHexHandler_InvalidFilename(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewHexHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- tests := []struct {
- path string
- code int
- }{
- {"/tarballs/notatar.zip", http.StatusBadRequest},
- {"/tarballs/noversion.tar", http.StatusBadRequest},
- }
-
- for _, tt := range tests {
- resp, err := http.Get(srv.URL + tt.path)
- if err != nil {
- t.Fatalf("request to %s failed: %v", tt.path, err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != tt.code {
- t.Errorf("GET %s: status = %d, want %d", tt.path, resp.StatusCode, tt.code)
- }
- }
-}
-
-func TestHexHandler_UpstreamProxy(t *testing.T) {
- assertUpstreamProxied(t, "hex registry data", "/packages/phoenix",
- func(proxy *Proxy, upstreamURL string) http.Handler {
- h := &HexHandler{proxy: proxy, upstreamURL: upstreamURL, proxyURL: "http://localhost"}
- return h.Routes()
- },
- )
-}
-
-func TestHexHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched hex")),
- ContentType: "application/x-tar",
- }
-
- h := NewHexHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/tarballs/plug-1.15.0.tar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-}
-
-func TestCondaHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackageWithPURL(t, db, store, "conda", "main/numpy", "1.24.0", "numpy-1.24.0-py311h64a7726_0.conda", "conda pkg")
-
- h := NewCondaHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/main/linux-64/numpy-1.24.0-py311h64a7726_0.conda")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "conda pkg" {
- t.Errorf("body = %q, want %q", body, "conda pkg")
- }
-}
-
-func TestCondaHandler_DownloadTarBz2CacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackageWithPURL(t, db, store, "conda", "main/scipy", "1.11.0", "scipy-1.11.0-py311hb2e3ea1_0.tar.bz2", "tar bz2 data")
-
- h := NewCondaHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/main/linux-64/scipy-1.11.0-py311hb2e3ea1_0.tar.bz2")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "tar bz2 data" {
- t.Errorf("body = %q, want %q", body, "tar bz2 data")
- }
-}
-
-func TestCondaHandler_NonPackageFileProxied(t *testing.T) {
- assertUpstreamProxied(t, "repodata json", "/main/linux-64/repodata.json",
- func(proxy *Proxy, upstreamURL string) http.Handler {
- h := &CondaHandler{proxy: proxy, upstreamURL: upstreamURL, proxyURL: "http://localhost"}
- return h.Routes()
- },
- )
-}
-
-func TestCondaHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched conda")),
- ContentType: "application/octet-stream",
- }
-
- h := NewCondaHandler(proxy, "http://localhost")
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- t.Error("should not hit upstream for .conda files when fetcher is set")
- }))
- defer upstream.Close()
- h.upstreamURL = upstream.URL
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/conda-forge/linux-64/pandas-2.0.0-py311h320fe9a_0.conda")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := upstream.URL + "/conda-forge/linux-64/pandas-2.0.0-py311h320fe9a_0.conda"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestCRANHandler_SourceDownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackageWithPURL(t, db, store, "cran", "ggplot2", "3.4.0", "ggplot2_3.4.0.tar.gz", "cran source")
-
- h := NewCRANHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/src/contrib/ggplot2_3.4.0.tar.gz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "cran source" {
- t.Errorf("body = %q, want %q", body, "cran source")
- }
-}
-
-func TestCRANHandler_BinaryDownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackageWithPURL(t, db, store, "cran", "dplyr", "1.1.0_windows_4.3", "dplyr_1.1.0.zip", "cran binary")
-
- h := NewCRANHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/bin/windows/contrib/4.3/dplyr_1.1.0.zip")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "cran binary" {
- t.Errorf("body = %q, want %q", body, "cran binary")
- }
-}
-
-func TestCRANHandler_NonPackageFileProxied(t *testing.T) {
- assertUpstreamProxied(t, "PACKAGES index", "/src/contrib/PACKAGES",
- func(proxy *Proxy, upstreamURL string) http.Handler {
- h := &CRANHandler{proxy: proxy, upstreamURL: upstreamURL, proxyURL: "http://localhost"}
- return h.Routes()
- },
- )
-}
-
-func TestCRANHandler_SourceNonTarGzProxied(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = fmt.Fprint(w, "some other file")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- h := &CRANHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/src/contrib/somefile.txt")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
-}
-
-func TestCRANHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched cran")),
- ContentType: "application/x-gzip",
- }
-
- h := NewCRANHandler(proxy, "http://localhost")
- h.upstreamURL = "https://cran.r-project.org"
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/src/contrib/tidyr_1.3.0.tar.gz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://cran.r-project.org/src/contrib/tidyr_1.3.0.tar.gz"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestCRANHandler_BinaryDownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched binary")),
- ContentType: "application/zip",
- }
-
- h := NewCRANHandler(proxy, "http://localhost")
- h.upstreamURL = "https://cran.r-project.org"
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/bin/windows/contrib/4.3/dplyr_1.1.0.zip")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://cran.r-project.org/bin/windows/contrib/4.3/dplyr_1.1.0.zip"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestMavenHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackageWithPURL(t, db, store, "maven", "com.google.guava:guava", "32.1.3-jre", "guava-32.1.3-jre.jar", "jar content")
-
- h := NewMavenHandler(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "jar content" {
- t.Errorf("body = %q, want %q", body, "jar content")
- }
-}
-
-func TestMavenHandler_MetadataProxied(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = fmt.Fprint(w, "")
- }))
- defer upstream.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- h := &MavenHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://localhost",
- }
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- paths := []string{
- "/com/google/guava/guava/maven-metadata.xml",
- "/com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar.sha1",
- "/com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar.md5",
- }
-
- for _, path := range paths {
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatalf("GET %s failed: %v", path, err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("GET %s: status = %d, want %d", path, resp.StatusCode, http.StatusOK)
- }
- }
-}
-
-func TestMavenHandler_EmptyPathNotFound(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewMavenHandler(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-}
-
-func TestMavenHandler_ArtifactExtensions(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
- for _, ext := range extensions {
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("artifact")),
- ContentType: "application/java-archive",
- }
- fetcher.fetchCalled = false
-
- h := NewMavenHandler(proxy, "http://localhost", "", "")
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- t.Errorf("should not proxy artifact file %s to upstream", ext)
- }))
- h.upstreamURL = upstream.URL
- proxy.HTTPClient = upstream.Client()
-
- srv := httptest.NewServer(h.Routes())
-
- path := fmt.Sprintf("/com/example/lib/1.0/lib-1.0%s", ext)
- resp, err := http.Get(srv.URL + path)
- if err != nil {
- t.Fatalf("GET %s failed: %v", path, err)
- }
- _ = resp.Body.Close()
-
- if !fetcher.fetchCalled {
- t.Errorf("fetcher not called for %s", ext)
- }
-
- srv.Close()
- upstream.Close()
- }
-}
-
-func TestMavenHandler_CacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched jar")),
- ContentType: "application/java-archive",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", "", "")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestMavenHandler_GradlePluginMarkerFallbackAndCache(t *testing.T) {
- tests := []struct {
- name string
- markerPath string
- }{
- {
- name: "Spotless",
- markerPath: "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom",
- },
- {
- name: "BenManes",
- markerPath: "/com/github/ben-manes/versions/com.github.ben-manes.versions.gradle.plugin/0.54.0/com.github.ben-manes.versions.gradle.plugin-0.54.0.pom",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- primaryURL := primaryUpstream + tt.markerPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("")),
- ContentType: "application/xml",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + tt.markerPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if string(body) != "" {
- t.Fatalf("body = %q, want %q", body, "")
- }
-
- wantFallbackURL := pluginPortalUpstream + tt.markerPath
- if fetcher.fetchedURL != wantFallbackURL {
- t.Fatalf("fallback URL = %q, want %q", fetcher.fetchedURL, wantFallbackURL)
- }
-
- fetcher.fetchCalled = false
- resp, err = http.Get(srv.URL + tt.markerPath)
- if err != nil {
- t.Fatalf("second request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("second status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if fetcher.fetchCalled {
- t.Fatal("expected plugin marker POM to be served from cache on second request")
- }
- })
- }
-}
-
-func TestMavenHandler_GradlePluginMarkerMetadataFallback(t *testing.T) {
- paths := map[string]string{
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1": "sha1",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha256": "sha256",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/8.4.0/com.diffplug.spotless.gradle.plugin-8.4.0.pom.md5": "md5",
- "/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/maven-metadata.xml": "",
- }
-
- primaryHits := map[string]int{}
- pluginHits := map[string]int{}
-
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- primaryHits[r.URL.Path]++
- if _, ok := paths[r.URL.Path]; ok {
- http.NotFound(w, r)
- return
- }
- t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
- }))
- defer primary.Close()
-
- pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- pluginHits[r.URL.Path]++
- body, ok := paths[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = io.WriteString(w, body)
- }))
- defer pluginPortal.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = primary.Client()
-
- h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- for reqPath, wantBody := range paths {
- resp, err := http.Get(srv.URL + reqPath)
- if err != nil {
- t.Fatalf("GET %s failed: %v", reqPath, err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
- }
- if string(body) != wantBody {
- t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
- }
-
- if primaryHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit primary upstream", reqPath)
- }
- if pluginHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
- }
- }
-}
-
-func TestMavenHandler_GradlePluginImplementationMetadataFallback(t *testing.T) {
- paths := map[string]string{
- "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha1": "impl-sha1",
- "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar.sha256": "impl-sha256",
- }
-
- primaryHits := map[string]int{}
- pluginHits := map[string]int{}
-
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- primaryHits[r.URL.Path]++
- if _, ok := paths[r.URL.Path]; ok {
- http.NotFound(w, r)
- return
- }
- t.Fatalf("unexpected path to primary upstream: %s", r.URL.Path)
- }))
- defer primary.Close()
-
- pluginPortal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- pluginHits[r.URL.Path]++
- body, ok := paths[r.URL.Path]
- if !ok {
- http.NotFound(w, r)
- return
- }
- _, _ = io.WriteString(w, body)
- }))
- defer pluginPortal.Close()
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.HTTPClient = primary.Client()
-
- h := NewMavenHandler(proxy, "http://localhost", primary.URL, pluginPortal.URL)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- for reqPath, wantBody := range paths {
- resp, err := http.Get(srv.URL + reqPath)
- if err != nil {
- t.Fatalf("GET %s failed: %v", reqPath, err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s: status = %d, want %d", reqPath, resp.StatusCode, http.StatusOK)
- }
- if string(body) != wantBody {
- t.Fatalf("GET %s: body = %q, want %q", reqPath, body, wantBody)
- }
-
- if primaryHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit primary upstream", reqPath)
- }
- if pluginHits[reqPath] == 0 {
- t.Fatalf("GET %s did not hit plugin portal fallback", reqPath)
- }
- }
-}
-
-func TestMavenHandler_GradlePluginImplementation_FallbackToPluginPortal(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
- primaryURL := primaryUpstream + implPath
- pluginPortalURL := pluginPortalUpstream + implPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- }
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("plugin impl jar")),
- ContentType: "application/java-archive",
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + implPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- if string(body) != "plugin impl jar" {
- t.Fatalf("body = %q, want %q", body, "plugin impl jar")
- }
-
- if fetcher.fetchedURL != pluginPortalURL {
- t.Fatalf("implementation artifact should fallback to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
- }
-}
-
-func TestMavenHandler_GradlePluginImplementation_NotFoundInBothUpstreams(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- primaryUpstream := "https://repo1.maven.org/maven2"
- pluginPortalUpstream := "https://plugins.gradle.org/m2"
- implPath := "/com/diffplug/spotless/spotless-plugin-gradle/8.4.0/spotless-plugin-gradle-8.4.0.jar"
- primaryURL := primaryUpstream + implPath
- pluginPortalURL := pluginPortalUpstream + implPath
-
- fetcher.fetchErrByURL = map[string]error{
- primaryURL: ErrUpstreamNotFound,
- pluginPortalURL: ErrUpstreamNotFound,
- }
-
- h := NewMavenHandler(proxy, "http://localhost", primaryUpstream, pluginPortalUpstream)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + implPath)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-
- if fetcher.fetchedURL != pluginPortalURL {
- t.Fatalf("expected fallback attempt to plugin portal; fetched URL = %q, want %q", fetcher.fetchedURL, pluginPortalURL)
- }
-}
-
-func TestNuGetHandler_DownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched nupkg")),
- ContentType: "application/octet-stream",
- }
-
- h := NewNuGetHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestConanHandler_RecipeFileCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("conan export")),
- ContentType: "application/octet-stream",
- }
-
- h := NewConanHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/v2/files/zlib/1.3/_/_/abc123/recipe/conan_export.tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://center.conan.io/v2/files/zlib/1.3/_/_/abc123/recipe/conan_export.tgz"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestConanHandler_PackageFileCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("conan package")),
- ContentType: "application/octet-stream",
- }
-
- h := NewConanHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/v2/files/zlib/1.3/_/_/abc123/package/def456/ghi789/conan_package.tgz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://center.conan.io/v2/files/zlib/1.3/_/_/abc123/package/def456/ghi789/conan_package.tgz"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestDebianHandler_DownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched deb")),
- ContentType: "application/vnd.debian.binary-package",
- }
-
- h := NewDebianHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "http://deb.debian.org/debian/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestRPMHandler_DownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched rpm")),
- ContentType: "application/x-rpm",
- }
-
- h := NewRPMHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-
- want := "https://dl.fedoraproject.org/pub/fedora/linux/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
diff --git a/internal/handler/gem.go b/internal/handler/gem.go
index 9ec57e3..bd47d6f 100644
--- a/internal/handler/gem.go
+++ b/internal/handler/gem.go
@@ -1,15 +1,10 @@
package handler
import (
- "bufio"
- "encoding/json"
"fmt"
"io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -40,13 +35,13 @@ func (h *GemHandler) Routes() http.Handler {
mux.HandleFunc("GET /gems/{filename}", h.handleDownload)
// Specs indexes (compressed Ruby Marshal format)
- mux.HandleFunc("GET /specs.4.8.gz", h.proxyCached)
- mux.HandleFunc("GET /latest_specs.4.8.gz", h.proxyCached)
- mux.HandleFunc("GET /prerelease_specs.4.8.gz", h.proxyCached)
+ mux.HandleFunc("GET /specs.4.8.gz", h.proxyUpstream)
+ mux.HandleFunc("GET /latest_specs.4.8.gz", h.proxyUpstream)
+ mux.HandleFunc("GET /prerelease_specs.4.8.gz", h.proxyUpstream)
// Compact index (bundler 2.x+)
- mux.HandleFunc("GET /versions", h.proxyCached)
- mux.HandleFunc("GET /info/{name}", h.handleCompactIndex)
+ mux.HandleFunc("GET /versions", h.proxyUpstream)
+ mux.HandleFunc("GET /info/{name}", h.proxyUpstream)
// Quick index
mux.HandleFunc("GET /quick/Marshal.4.8/{filename}", h.proxyUpstream)
@@ -103,198 +98,6 @@ func (h *GemHandler) parseGemFilename(filename string) (name, version string) {
return "", ""
}
-// handleCompactIndex serves the compact index for a gem, filtering versions
-// based on cooldown when enabled.
-func (h *GemHandler) handleCompactIndex(w http.ResponseWriter, r *http.Request) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- h.proxyCached(w, r)
- return
- }
-
- name := r.PathValue("name")
- if name == "" {
- http.Error(w, "invalid gem name", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Info("gem compact index request with cooldown", "name", name)
-
- indexResp, filteredVersions, err := h.fetchIndexAndVersions(r, name)
- if err != nil {
- h.proxy.Logger.Error("upstream compact index request failed", "error", err)
- http.Error(w, "upstream request failed", http.StatusBadGateway)
- return
- }
- defer func() { _ = indexResp.Body.Close() }()
-
- if indexResp.StatusCode != http.StatusOK {
- copyResponseHeaders(w, indexResp.Header)
- w.WriteHeader(indexResp.StatusCode)
- _, _ = io.Copy(w, indexResp.Body)
- return
- }
-
- if filteredVersions == nil {
- h.proxy.Logger.Warn("failed to fetch version timestamps, proxying unfiltered", "name", name)
- copyResponseHeaders(w, indexResp.Header)
- w.WriteHeader(http.StatusOK)
- _, _ = io.Copy(w, indexResp.Body)
- return
- }
-
- h.writeFilteredIndex(w, indexResp, name, filteredVersions)
-}
-
-// fetchIndexAndVersions fetches the compact index and versions API concurrently.
-// Returns the index response, a set of versions to filter (nil if versions API failed),
-// and an error if the index fetch itself failed.
-func (h *GemHandler) fetchIndexAndVersions(r *http.Request, name string) (*http.Response, map[string]bool, error) {
- type versionsResult struct {
- filtered map[string]bool
- err error
- }
-
- versionsCh := make(chan versionsResult, 1)
- go func() {
- filtered, err := h.fetchFilteredVersions(r, name)
- versionsCh <- versionsResult{filtered: filtered, err: err}
- }()
-
- indexResp, err := h.fetchCompactIndex(r, name)
-
- versionsRes := <-versionsCh
-
- if err != nil {
- return nil, nil, err
- }
-
- if versionsRes.err != nil {
- return indexResp, nil, nil
- }
-
- return indexResp, versionsRes.filtered, nil
-}
-
-// fetchCompactIndex fetches the compact index from upstream.
-func (h *GemHandler) fetchCompactIndex(r *http.Request, name string) (*http.Response, error) {
- indexURL := h.upstreamURL + "/info/" + name
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, indexURL, nil)
- if err != nil {
- return nil, err
- }
- for _, hdr := range []string{"Accept", headerAcceptEncoding, "If-None-Match", "If-Modified-Since"} {
- if v := r.Header.Get(hdr); v != "" {
- req.Header.Set(hdr, v)
- }
- }
- return h.proxy.HTTPClient.Do(req)
-}
-
-// writeFilteredIndex writes the compact index response with cooldown-filtered versions removed.
-func (h *GemHandler) writeFilteredIndex(w http.ResponseWriter, resp *http.Response, name string, filtered map[string]bool) {
- for k, vv := range resp.Header {
- if strings.EqualFold(k, "Content-Length") {
- continue // length will change after filtering
- }
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
- w.WriteHeader(http.StatusOK)
-
- scanner := bufio.NewScanner(resp.Body)
- for scanner.Scan() {
- line := scanner.Text()
-
- if line == "---" {
- _, _ = fmt.Fprintln(w, line)
- continue
- }
-
- version := line
- if spaceIdx := strings.IndexByte(line, ' '); spaceIdx > 0 {
- version = line[:spaceIdx]
- }
-
- if filtered[version] {
- h.proxy.Logger.Info("cooldown: filtering gem version",
- "gem", name, "version", version)
- continue
- }
-
- _, _ = fmt.Fprintln(w, line)
- }
-}
-
-// copyResponseHeaders copies HTTP headers from a response to a writer.
-func copyResponseHeaders(w http.ResponseWriter, headers http.Header) {
- for k, vv := range headers {
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
-}
-
-// gemVersion represents a version entry from the RubyGems versions API.
-type gemVersion struct {
- Number string `json:"number"`
- Platform string `json:"platform"`
- CreatedAt string `json:"created_at"`
-}
-
-// fetchFilteredVersions fetches the versions API and returns a set of version
-// strings that should be filtered out by cooldown.
-func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) {
- versionsURL := fmt.Sprintf("%s/api/v1/versions/%s.json", h.upstreamURL, name)
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, versionsURL, nil)
- if err != nil {
- return nil, err
- }
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("versions API returned %d", resp.StatusCode)
- }
-
- var versions []gemVersion
- if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
- return nil, err
- }
-
- packagePURL := purl.MakePURLString("gem", name, "")
- filtered := make(map[string]bool)
-
- for _, v := range versions {
- createdAt, err := time.Parse(time.RFC3339, v.CreatedAt)
- if err != nil {
- continue
- }
-
- if !h.proxy.Cooldown.IsAllowed("gem", packagePURL, createdAt) {
- // Build version string matching compact index format
- versionStr := v.Number
- if v.Platform != "" && v.Platform != "ruby" {
- versionStr = v.Number + "-" + v.Platform
- }
- filtered[versionStr] = true
- }
- }
-
- return filtered, nil
-}
-
-// proxyCached forwards a metadata request with caching.
-func (h *GemHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- upstreamURL := h.upstreamURL + r.URL.Path
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- h.proxy.ProxyCached(w, r, upstreamURL, "gem", cacheKey, "*/*")
-}
-
// proxyUpstream forwards a request to rubygems.org without caching.
func (h *GemHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
@@ -311,13 +114,13 @@ func (h *GemHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
}
// Copy relevant headers
- for _, h := range []string{"Accept", headerAcceptEncoding, "If-None-Match", "If-Modified-Since"} {
+ for _, h := range []string{"Accept", "Accept-Encoding", "If-None-Match", "If-Modified-Since"} {
if v := r.Header.Get(h); v != "" {
req.Header.Set(h, v)
}
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
diff --git a/internal/handler/gem_test.go b/internal/handler/gem_test.go
index 7d90946..6c83004 100644
--- a/internal/handler/gem_test.go
+++ b/internal/handler/gem_test.go
@@ -1,16 +1,8 @@
package handler
import (
- "encoding/json"
- "fmt"
"log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
)
func TestGemParseFilename(t *testing.T) {
@@ -36,217 +28,3 @@ func TestGemParseFilename(t *testing.T) {
}
}
}
-
-func TestGemCompactIndexCooldown(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- compactIndex := "---\n1.0.0 dep1:>= 1.0|checksum:abc123\n2.0.0 dep1:>= 1.0|checksum:def456\n"
-
- versionsJSON, _ := json.Marshal([]gemVersion{
- {Number: "1.0.0", Platform: "ruby", CreatedAt: oldTime},
- {Number: "2.0.0", Platform: "ruby", CreatedAt: recentTime},
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case strings.HasPrefix(r.URL.Path, "/info/"):
- w.Header().Set("Content-Type", "text/plain")
- _, _ = w.Write([]byte(compactIndex))
- case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"):
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(versionsJSON)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &GemHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil)
- req.SetPathValue("name", "testgem")
- w := httptest.NewRecorder()
- h.handleCompactIndex(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "1.0.0") {
- t.Error("expected version 1.0.0 to survive filtering")
- }
- if strings.Contains(body, "2.0.0") {
- t.Error("expected version 2.0.0 to be filtered out")
- }
- if !strings.HasPrefix(body, "---\n") {
- t.Error("expected compact index header to be preserved")
- }
-}
-
-func TestGemCompactIndexCooldownWithPlatformVersion(t *testing.T) {
- now := time.Now()
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n1.0.0-java dep:>= 1.0|checksum:def\n"
-
- versionsJSON, _ := json.Marshal([]gemVersion{
- {Number: "1.0.0", Platform: "ruby", CreatedAt: recentTime},
- {Number: "1.0.0", Platform: "java", CreatedAt: recentTime},
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case strings.HasPrefix(r.URL.Path, "/info/"):
- _, _ = w.Write([]byte(compactIndex))
- case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"):
- _, _ = w.Write(versionsJSON)
- }
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &GemHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil)
- req.SetPathValue("name", "testgem")
- w := httptest.NewRecorder()
- h.handleCompactIndex(w, req)
-
- body := w.Body.String()
- // Both ruby and java platform versions should be filtered
- lines := strings.Split(strings.TrimSpace(body), "\n")
- if len(lines) != 1 { // only "---"
- t.Errorf("expected only header line, got %d lines: %v", len(lines), lines)
- }
-}
-
-func TestGemCompactIndexNoCooldown(t *testing.T) {
- compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n"
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(compactIndex))
- }))
- defer upstream.Close()
-
- h := &GemHandler{
- proxy: testProxy(), // no cooldown
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil)
- req.SetPathValue("name", "testgem")
- w := httptest.NewRecorder()
- h.handleCompactIndex(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestGemCompactIndexVersionsAPIFails(t *testing.T) {
- compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n"
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case strings.HasPrefix(r.URL.Path, "/info/"):
- _, _ = w.Write([]byte(compactIndex))
- case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"):
- w.WriteHeader(http.StatusInternalServerError)
- }
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &GemHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil)
- req.SetPathValue("name", "testgem")
- w := httptest.NewRecorder()
- h.handleCompactIndex(w, req)
-
- // Should still return OK with unfiltered content
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "1.0.0") {
- t.Error("expected unfiltered content when versions API fails")
- }
-}
-
-func TestGemFetchFilteredVersions(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- versionsJSON, _ := json.Marshal([]gemVersion{
- {Number: "1.0.0", Platform: "ruby", CreatedAt: oldTime},
- {Number: "2.0.0", Platform: "ruby", CreatedAt: recentTime},
- {Number: "2.0.0", Platform: "java", CreatedAt: recentTime},
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(versionsJSON)
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &GemHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil)
- filtered, err := h.fetchFilteredVersions(req, "testgem")
- if err != nil {
- t.Fatal(err)
- }
-
- if filtered["1.0.0"] {
- t.Error("version 1.0.0 should not be filtered (old enough)")
- }
- if !filtered["2.0.0"] {
- t.Error("version 2.0.0 (ruby) should be filtered")
- }
- if !filtered["2.0.0-java"] {
- t.Error("version 2.0.0-java should be filtered")
- }
-
- _ = fmt.Sprintf // silence unused import
-}
diff --git a/internal/handler/go.go b/internal/handler/go.go
index 955a89c..9b43e8f 100644
--- a/internal/handler/go.go
+++ b/internal/handler/go.go
@@ -2,13 +2,13 @@ package handler
import (
"fmt"
+ "io"
"net/http"
"strings"
)
const (
- goUpstream = "https://proxy.golang.org"
- asciiCaseOffset = 32 // difference between lowercase and uppercase ASCII letters
+ goUpstream = "https://proxy.golang.org"
)
// GoHandler handles Go module proxy protocol requests.
@@ -54,19 +54,18 @@ func (h *GoHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
module := path[:idx]
rest := path[idx+4:] // after "/@v/"
- decodedMod := decodeGoModule(module)
switch {
case rest == "list":
// GET /{module}/@v/list - list versions
- h.proxyCached(w, r, decodedMod+"/@v/list")
+ h.proxyUpstream(w, r)
case strings.HasSuffix(rest, ".info"):
// GET /{module}/@v/{version}.info - version metadata
- h.proxyCached(w, r, decodedMod+"/@v/"+rest)
+ h.proxyUpstream(w, r)
case strings.HasSuffix(rest, ".mod"):
// GET /{module}/@v/{version}.mod - go.mod file
- h.proxyCached(w, r, decodedMod+"/@v/"+rest)
+ h.proxyUpstream(w, r)
case strings.HasSuffix(rest, ".zip"):
// GET /{module}/@v/{version}.zip - source archive (cache this)
@@ -81,8 +80,7 @@ func (h *GoHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
// Check for @latest
if strings.HasSuffix(path, "/@latest") {
- module := strings.TrimSuffix(path, "/@latest")
- h.proxyCached(w, r, decodeGoModule(module)+"/@latest")
+ h.proxyUpstream(w, r)
return
}
@@ -110,12 +108,33 @@ func (h *GoHandler) handleDownload(w http.ResponseWriter, r *http.Request, modul
// proxyUpstream forwards a request to proxy.golang.org without caching.
func (h *GoHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, nil)
-}
+ upstreamURL := h.upstreamURL + r.URL.Path
-// proxyCached forwards a request with metadata caching.
-func (h *GoHandler) proxyCached(w http.ResponseWriter, r *http.Request, cacheKey string) {
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "golang", cacheKey, "*/*")
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("upstream request failed", "error", err)
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Copy response headers
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// decodeGoModule decodes an encoded module path.
@@ -124,7 +143,7 @@ func decodeGoModule(encoded string) string {
var b strings.Builder
for i := 0; i < len(encoded); i++ {
if encoded[i] == '!' && i+1 < len(encoded) {
- b.WriteByte(encoded[i+1] - asciiCaseOffset) // lowercase to uppercase
+ b.WriteByte(encoded[i+1] - 32) // lowercase to uppercase
i++
} else {
b.WriteByte(encoded[i])
diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go
deleted file mode 100644
index 3b703be..0000000
--- a/internal/handler/gradle.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package handler
-
-import (
- "errors"
- "io"
- "net/http"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- gradleBuildCacheContentType = "application/vnd.gradle.build-cache-artifact.v2"
- gradleBuildCacheStorageRoot = "_gradle/http-build-cache"
- defaultGradleMaxUploadSize = 100 << 20
-)
-
-var gradleBuildCacheKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
-
-// GradleBuildCacheHandler handles Gradle HttpBuildCache GET/HEAD/PUT requests.
-//
-// This handler accepts /{key} when mounted under a base URL.
-type GradleBuildCacheHandler struct {
- proxy *Proxy
-}
-
-// NewGradleBuildCacheHandler creates a Gradle HttpBuildCache handler.
-func NewGradleBuildCacheHandler(proxy *Proxy) *GradleBuildCacheHandler {
- return &GradleBuildCacheHandler{proxy: proxy}
-}
-
-// Routes returns the HTTP handler for Gradle HttpBuildCache requests.
-func (h *GradleBuildCacheHandler) Routes() http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet, http.MethodHead, http.MethodPut:
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- key, statusCode := h.parseCacheKey(r.URL.Path)
- if statusCode != http.StatusOK {
- if statusCode == http.StatusNotFound {
- http.NotFound(w, r)
- return
- }
- http.Error(w, "invalid cache key", statusCode)
- return
- }
-
- if r.Method == http.MethodPut {
- if h.proxy.GradleReadOnly {
- http.Error(w, "gradle build cache is read-only", http.StatusMethodNotAllowed)
- return
- }
- h.handlePut(w, r, key)
- return
- }
-
- h.handleGetOrHead(w, r, key)
- })
-}
-
-func (h *GradleBuildCacheHandler) parseCacheKey(urlPath string) (string, int) {
- keyPath := strings.TrimPrefix(urlPath, "/")
- if keyPath == "" {
- return "", http.StatusNotFound
- }
-
- if containsPathTraversal(keyPath) {
- return "", http.StatusBadRequest
- }
-
- if strings.Contains(keyPath, "/") {
- return "", http.StatusNotFound
- }
-
- if !gradleBuildCacheKeyPattern.MatchString(keyPath) {
- return "", http.StatusBadRequest
- }
-
- return keyPath, http.StatusOK
-}
-
-func (h *GradleBuildCacheHandler) cacheStoragePath(key string) string {
- return gradleBuildCacheStorageRoot + "/" + key
-}
-
-func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http.Request, key string) {
- storagePath := h.cacheStoragePath(key)
- w.Header().Set("Content-Type", gradleBuildCacheContentType)
-
- if r.Method == http.MethodHead {
- existsStart := time.Now()
- exists, err := h.proxy.Storage.Exists(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(existsStart))
- if err != nil {
- metrics.RecordStorageError("read")
- h.proxy.Logger.Error("failed to check gradle build cache entry", "key", key, "error", err)
- http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
- return
- }
- if !exists {
- metrics.RecordCacheMiss("gradle")
- http.NotFound(w, r)
- return
- }
- metrics.RecordCacheHit("gradle")
-
- sizeStart := time.Now()
- size, err := h.proxy.Storage.Size(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(sizeStart))
- if err != nil {
- metrics.RecordStorageError("read")
- } else if size >= 0 {
- w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
- }
-
- w.WriteHeader(http.StatusOK)
- return
- }
-
- readStart := time.Now()
- reader, err := h.proxy.Storage.Open(r.Context(), storagePath)
- metrics.RecordStorageOperation("read", time.Since(readStart))
- if err != nil {
- if errors.Is(err, storage.ErrNotFound) {
- metrics.RecordCacheMiss("gradle")
- http.NotFound(w, r)
- return
- }
- metrics.RecordStorageError("read")
- h.proxy.Logger.Error("failed to open gradle build cache entry", "key", key, "error", err)
- http.Error(w, "failed to read cache entry", http.StatusInternalServerError)
- return
- }
- defer func() { _ = reader.Close() }()
- metrics.RecordCacheHit("gradle")
-
- w.WriteHeader(http.StatusOK)
- _, _ = io.Copy(w, reader)
-}
-
-func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Request, key string) {
- storagePath := h.cacheStoragePath(key)
- maxUploadSize := h.proxy.GradleMaxUploadSize
- if maxUploadSize <= 0 {
- maxUploadSize = defaultGradleMaxUploadSize
- }
-
- r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
-
- storeStart := time.Now()
- _, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body)
- metrics.RecordStorageOperation("write", time.Since(storeStart))
- if err != nil {
- var maxBytesErr *http.MaxBytesError
- if errors.As(err, &maxBytesErr) {
- http.Error(w, "cache entry too large", http.StatusRequestEntityTooLarge)
- return
- }
-
- metrics.RecordStorageError("write")
- h.proxy.Logger.Error("failed to store gradle build cache entry", "key", key, "error", err)
- http.Error(w, "failed to write cache entry", http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Length", "0")
- w.Header().Set("ETag", `"`+hash+`"`)
-
- w.WriteHeader(http.StatusCreated)
-}
diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go
deleted file mode 100644
index a05d07e..0000000
--- a/internal/handler/gradle_test.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package handler
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- key := "a1b2c3d4e5f6"
- payload := "cache entry content"
-
- putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- putResp, err := http.DefaultClient.Do(putReq)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- _ = putResp.Body.Close()
-
- if putResp.StatusCode != http.StatusCreated {
- t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated)
- }
-
- getResp, err := http.Get(srv.URL + "/" + key)
- if err != nil {
- t.Fatalf("GET request failed: %v", err)
- }
- defer func() { _ = getResp.Body.Close() }()
-
- if getResp.StatusCode != http.StatusOK {
- t.Fatalf("GET status = %d, want %d", getResp.StatusCode, http.StatusOK)
- }
- if getResp.Header.Get("Content-Type") != gradleBuildCacheContentType {
- t.Fatalf("GET Content-Type = %q, want %q", getResp.Header.Get("Content-Type"), gradleBuildCacheContentType)
- }
-
- body, _ := io.ReadAll(getResp.Body)
- if string(body) != payload {
- t.Fatalf("GET body = %q, want %q", body, payload)
- }
-
- headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil)
- if err != nil {
- t.Fatalf("failed to create HEAD request: %v", err)
- }
- headResp, err := http.DefaultClient.Do(headReq)
- if err != nil {
- t.Fatalf("HEAD request failed: %v", err)
- }
- defer func() { _ = headResp.Body.Close() }()
-
- if headResp.StatusCode != http.StatusOK {
- t.Fatalf("HEAD status = %d, want %d", headResp.StatusCode, http.StatusOK)
- }
- body, _ = io.ReadAll(headResp.Body)
- if len(body) != 0 {
- t.Fatalf("HEAD body length = %d, want 0", len(body))
- }
-}
-
-func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- key := "rootpathkey"
- putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader("root"))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- putResp, err := http.DefaultClient.Do(putReq)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- _ = putResp.Body.Close()
-
- if putResp.StatusCode != http.StatusCreated {
- t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated)
- }
-
- getResp, err := http.Get(srv.URL + "/" + key)
- if err != nil {
- t.Fatalf("GET request failed: %v", err)
- }
- defer func() { _ = getResp.Body.Close() }()
-
- if getResp.StatusCode != http.StatusOK {
- t.Fatalf("GET status = %d, want %d", getResp.StatusCode, http.StatusOK)
- }
-}
-
-func TestGradleBuildCacheHandler_GetMiss(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/missing-key")
- if err != nil {
- t.Fatalf("GET request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusNotFound {
- t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
- }
-}
-
-func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
-
- req := httptest.NewRequest(http.MethodPost, "/key", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusMethodNotAllowed {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
- }
-}
-
-func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
-
- req := httptest.NewRequest(http.MethodGet, "/../secret", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestGradleBuildCacheHandler_CachePrefixRejected(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
-
- req := httptest.NewRequest(http.MethodGet, "/cache/key", nil)
- w := httptest.NewRecorder()
- h.Routes().ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- key := "overwrite-key"
-
- for i, payload := range []string{"first", "second"} {
- req, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- _ = resp.Body.Close()
-
- want := http.StatusCreated
- if resp.StatusCode != want {
- t.Fatalf("PUT #%d status = %d, want %d", i+1, resp.StatusCode, want)
- }
- }
-}
-
-func TestGradleBuildCacheHandler_PutReadOnly(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.GradleReadOnly = true
-
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- req, err := http.NewRequest(http.MethodPut, srv.URL+"/readonly-key", strings.NewReader("payload"))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusMethodNotAllowed {
- t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
- }
-}
-
-func TestGradleBuildCacheHandler_PutTooLarge(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- proxy.GradleMaxUploadSize = 4
-
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- req, err := http.NewRequest(http.MethodPut, srv.URL+"/oversized-key", strings.NewReader("12345"))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusRequestEntityTooLarge {
- t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusRequestEntityTooLarge)
- }
-}
-
-func TestGradleBuildCacheHandler_RecordsMetrics(t *testing.T) {
- proxy, _, _, _ := setupTestProxy(t)
- h := NewGradleBuildCacheHandler(proxy)
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- hitsBefore := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
- missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
-
- key := "metrics-key"
- putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader("payload"))
- if err != nil {
- t.Fatalf("failed to create PUT request: %v", err)
- }
- putResp, err := http.DefaultClient.Do(putReq)
- if err != nil {
- t.Fatalf("PUT request failed: %v", err)
- }
- _ = putResp.Body.Close()
-
- getResp, err := http.Get(srv.URL + "/" + key)
- if err != nil {
- t.Fatalf("GET request failed: %v", err)
- }
- _ = getResp.Body.Close()
-
- headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil)
- if err != nil {
- t.Fatalf("failed to create HEAD request: %v", err)
- }
- headResp, err := http.DefaultClient.Do(headReq)
- if err != nil {
- t.Fatalf("HEAD request failed: %v", err)
- }
- _ = headResp.Body.Close()
-
- missResp, err := http.Get(srv.URL + "/missing-key")
- if err != nil {
- t.Fatalf("GET miss request failed: %v", err)
- }
- _ = missResp.Body.Close()
-
- hitsAfter := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("gradle"))
- missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("gradle"))
-
- if diff := hitsAfter - hitsBefore; diff != 2 {
- t.Fatalf("cache hits delta = %.0f, want 2", diff)
- }
- if diff := missesAfter - missesBefore; diff != 1 {
- t.Fatalf("cache misses delta = %.0f, want 1", diff)
- }
-}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 3df2777..2d305ea 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -2,20 +2,14 @@
package handler
import (
- "bytes"
"context"
"database/sql"
- "errors"
"fmt"
"io"
"log/slog"
"net/http"
- "net/url"
- "strconv"
- "strings"
"time"
- "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/storage"
@@ -23,76 +17,13 @@ import (
"github.com/git-pkgs/registries/fetch"
)
-// containsPathTraversal returns true if the path contains ".." segments
-// that could be used to escape the intended directory. It checks the path
-// as given and after URL-decoding, and treats backslashes as separators.
-func containsPathTraversal(path string) bool {
- if hasDotDotSegment(path) {
- return true
- }
- if decoded, err := url.PathUnescape(path); err == nil && decoded != path {
- return hasDotDotSegment(decoded)
- }
- return false
-}
-
-func hasDotDotSegment(path string) bool {
- path = strings.ReplaceAll(path, "\\", "/")
- for segment := range strings.SplitSeq(path, "/") {
- if segment == ".." {
- return true
- }
- }
- return false
-}
-
-const defaultHTTPTimeout = 30 * time.Second
-
-const contentTypeJSON = "application/json"
-
-const headerAcceptEncoding = "Accept-Encoding"
-
-// maxMetadataSize is the maximum size of upstream metadata responses (100 MB).
-// Package metadata (e.g. npm with many versions) can be large, but unbounded
-// reads risk OOM if an upstream misbehaves.
-const maxMetadataSize = 100 << 20
-
-// ErrMetadataTooLarge is returned when upstream metadata exceeds maxMetadataSize.
-var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
-
-// ReadMetadata reads an upstream response body with a size limit to prevent OOM
-// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
-// is truncated by the limit.
-func ReadMetadata(r io.Reader) ([]byte, error) {
- data, err := io.ReadAll(io.LimitReader(r, maxMetadataSize+1))
- if err != nil {
- return nil, err
- }
- if int64(len(data)) > maxMetadataSize {
- return nil, ErrMetadataTooLarge
- }
- return data, nil
-}
-
// Proxy provides shared functionality for protocol handlers.
type Proxy struct {
- DB *database.DB
- Storage storage.Storage
- Fetcher fetch.FetcherInterface
- Resolver *fetch.Resolver
- Logger *slog.Logger
- Cooldown *cooldown.Config
- CacheMetadata bool
- MetadataTTL time.Duration
- GradleReadOnly bool
- GradleMaxUploadSize int64
- DirectServe bool
- DirectServeTTL time.Duration
- // DirectServeBaseURL, if set, replaces the scheme and host of presigned
- // URLs so clients receive a public address even when the proxy reaches
- // storage at an internal one.
- DirectServeBaseURL string
- HTTPClient *http.Client
+ DB *database.DB
+ Storage storage.Storage
+ Fetcher fetch.FetcherInterface
+ Resolver *fetch.Resolver
+ Logger *slog.Logger
}
// NewProxy creates a new Proxy with the given dependencies.
@@ -106,16 +37,12 @@ func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInter
Fetcher: fetcher,
Resolver: resolver,
Logger: logger,
- HTTPClient: &http.Client{
- Timeout: defaultHTTPTimeout,
- },
}
}
// CacheResult contains information about a cached or fetched artifact.
type CacheResult struct {
Reader io.ReadCloser
- RedirectURL string
Size int64
ContentType string
Hash string
@@ -162,26 +89,6 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
return nil, nil
}
- result := &CacheResult{
- Size: artifact.Size.Int64,
- ContentType: artifact.ContentType.String,
- Hash: artifact.ContentHash.String,
- Cached: true,
- }
-
- if p.DirectServe {
- signed, err := p.Storage.SignedURL(ctx, artifact.StoragePath.String, p.DirectServeTTL)
- if err == nil {
- result.RedirectURL = rewriteSignedURLHost(signed, p.DirectServeBaseURL)
- p.recordCacheHit(pkgPURL, versionPURL, filename)
- return result, nil
- }
- if !errors.Is(err, storage.ErrSignedURLUnsupported) {
- p.Logger.Warn("failed to sign storage URL, falling back to streaming",
- "path", artifact.StoragePath.String, "error", err)
- }
- }
-
start := time.Now()
reader, err := p.Storage.Open(ctx, artifact.StoragePath.String)
metrics.RecordStorageOperation("read", time.Since(start))
@@ -192,45 +99,20 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
return nil, nil
}
- result.Reader = newVerifyingReader(reader, artifact.ContentHash.String, ver.Integrity.String,
- func(reason string) {
- p.Logger.Error("cached artifact failed integrity check",
- "purl", versionPURL, "filename", filename,
- "path", artifact.StoragePath.String, "reason", reason)
- metrics.RecordIntegrityFailure(pkg.Ecosystem)
- if err := p.DB.ClearArtifactCache(versionPURL, filename); err != nil {
- p.Logger.Warn("failed to clear corrupt artifact from cache", "error", err)
- }
- })
- p.recordCacheHit(pkgPURL, versionPURL, filename)
- return result, nil
-}
-
-// rewriteSignedURLHost replaces the scheme and host of a signed URL with those
-// from baseURL, preserving the path and query (which carry the signature).
-// Returns signed unchanged if baseURL is empty or either URL fails to parse.
-func rewriteSignedURLHost(signed, baseURL string) string {
- if baseURL == "" {
- return signed
- }
- s, err := url.Parse(signed)
- if err != nil {
- return signed
- }
- b, err := url.Parse(baseURL)
- if err != nil || b.Scheme == "" || b.Host == "" {
- return signed
- }
- s.Scheme = b.Scheme
- s.Host = b.Host
- return s.String()
-}
-
-func (p *Proxy) recordCacheHit(pkgPURL, versionPURL, filename string) {
_ = p.DB.RecordArtifactHit(versionPURL, filename)
- if parsed, err := purl.Parse(pkgPURL); err == nil {
- metrics.RecordCacheHit(purl.PURLTypeToEcosystem(parsed.Type))
+
+ // Extract ecosystem from pkgPURL for metrics
+ if p, err := purl.Parse(pkgPURL); err == nil {
+ metrics.RecordCacheHit(purl.PURLTypeToEcosystem(p.Type))
}
+
+ return &CacheResult{
+ Reader: reader,
+ Size: artifact.Size.Int64,
+ ContentType: artifact.ContentType.String,
+ Hash: artifact.ContentHash.String,
+ Cached: true,
+ }, nil
}
func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL string) (*CacheResult, error) {
@@ -276,7 +158,7 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
}
// Update database
- if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, info.URL, storagePath, hash, size, artifact.ContentType); err != nil {
+ if err := p.updateCacheDB(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, storagePath, hash, size, artifact.ContentType); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
// Continue anyway - we have the file
}
@@ -300,16 +182,16 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
}, nil
}
-func (p *Proxy) updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, upstreamURL, storagePath, hash string, size int64, contentType string) error {
+func (p *Proxy) updateCacheDB(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, storagePath, hash string, size int64, contentType string) error {
now := time.Now()
// Upsert package
pkg := &database.Package{
- PURL: pkgPURL,
- Ecosystem: ecosystem,
- Name: name,
+ PURL: pkgPURL,
+ Ecosystem: ecosystem,
+ Name: name,
RegistryURL: sql.NullString{String: upstreamURL, Valid: true},
- EnrichedAt: sql.NullTime{Time: now, Valid: true},
+ EnrichedAt: sql.NullTime{Time: now, Valid: true},
}
if err := p.DB.UpsertPackage(pkg); err != nil {
return fmt.Errorf("upserting package: %w", err)
@@ -345,15 +227,6 @@ func (p *Proxy) updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, u
// ServeArtifact writes a CacheResult to an HTTP response.
func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
- if result.RedirectURL != "" {
- if result.Hash != "" {
- w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash))
- }
- w.Header().Set("Location", result.RedirectURL)
- w.WriteHeader(http.StatusFound)
- return
- }
-
defer func() { _ = result.Reader.Close() }()
if result.ContentType != "" {
@@ -370,402 +243,16 @@ func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
_, _ = io.Copy(w, result.Reader)
}
-// ProxyUpstream forwards a request to an upstream URL without caching.
-// It copies the request, forwards specified headers, and streams the response back.
-// If forwardHeaders is nil, all response headers are copied.
-func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, forwardHeaders []string) {
- p.Logger.Debug("proxying to upstream", "url", upstreamURL)
-
- req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
- if err != nil {
- http.Error(w, "failed to create request", http.StatusInternalServerError)
- return
- }
-
- // Copy request headers that affect content negotiation / caching
- for _, header := range forwardHeaders {
- if v := r.Header.Get(header); v != "" {
- req.Header.Set(header, v)
- }
- }
-
- resp, err := p.HTTPClient.Do(req)
- if err != nil {
- p.Logger.Error("upstream request failed", "error", err)
- http.Error(w, "upstream request failed", http.StatusBadGateway)
- return
- }
- defer func() { _ = resp.Body.Close() }()
-
- for k, vv := range resp.Header {
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
-
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, resp.Body)
-}
-
-// ProxyFile forwards a file request to upstream, copying all response headers.
-func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL string) {
- req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
- if err != nil {
- http.Error(w, "failed to create request", http.StatusInternalServerError)
- return
- }
-
- resp, err := p.HTTPClient.Do(req)
- if err != nil {
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
- return
- }
- defer func() { _ = resp.Body.Close() }()
-
- for key, values := range resp.Header {
- for _, v := range values {
- w.Header().Add(key, v)
- }
- }
-
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, resp.Body)
-}
-
// JSONError writes a JSON error response.
func JSONError(w http.ResponseWriter, status int, message string) {
- w.Header().Set("Content-Type", contentTypeJSON)
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"error":%q}`, message)
}
-// ErrUpstreamNotFound indicates the upstream returned 404.
-var ErrUpstreamNotFound = fmt.Errorf("upstream: not found")
-
-// errStale304 is returned when upstream sends 304 but the cached file is missing.
-var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing")
-
-// metadataStoragePath builds a storage path for cached metadata.
-func metadataStoragePath(ecosystem, cacheKey string) string {
- return "_metadata/" + ecosystem + "/" + cacheKey + "/metadata"
-}
-
-// FetchOrCacheMetadata fetches metadata from upstream with caching.
-// On success it returns the raw response bytes and content type.
-// If upstream fails and a cached copy exists, the cached version is returned.
-// cacheKey is typically the package name but can include subpath components.
-// Optional acceptHeaders specify the Accept header(s) to send; defaults to application/json.
-func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, acceptHeaders ...string) ([]byte, string, error) {
- if containsPathTraversal(cacheKey) {
- return nil, "", fmt.Errorf("invalid cache key: %q", cacheKey)
- }
-
- storagePath := metadataStoragePath(ecosystem, cacheKey)
-
- // Check for existing cache entry (for ETag revalidation and TTL)
- var entry *database.MetadataCacheEntry
- if p.CacheMetadata && p.DB != nil {
- entry, _ = p.DB.GetMetadataCache(ecosystem, cacheKey)
- }
-
- // Serve from cache if within TTL (skip upstream entirely)
- if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid {
- if time.Since(entry.FetchedAt.Time) < p.MetadataTTL {
- cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
- if readErr == nil {
- defer func() { _ = cached.Close() }()
- data, readErr := ReadMetadata(cached)
- if readErr == nil {
- ct := contentTypeJSON
- if entry.ContentType.Valid {
- ct = entry.ContentType.String
- }
- return data, ct, nil
- }
- }
- // Cache file missing/unreadable, fall through to upstream
- }
- }
-
- accept := contentTypeJSON
- if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
- accept = acceptHeaders[0]
- }
-
- // Try upstream
- body, contentType, etag, lastModified, err := p.fetchUpstreamMetadata(ctx, upstreamURL, entry, accept)
- if errors.Is(err, errStale304) {
- // 304 but cached file is gone; retry without ETag
- body, contentType, etag, lastModified, err = p.fetchUpstreamMetadata(ctx, upstreamURL, nil, accept)
- }
- if err == nil {
- if p.CacheMetadata {
- p.cacheMetadataBlob(ctx, ecosystem, cacheKey, storagePath, body, contentType, etag, lastModified)
- }
- return body, contentType, nil
- }
-
- // Upstream failed -- fall back to cache if available
- if !p.CacheMetadata || entry == nil {
- return nil, "", fmt.Errorf("upstream failed and no cached metadata: %w", err)
- }
-
- p.Logger.Warn("upstream metadata fetch failed, checking cache",
- "ecosystem", ecosystem, "key", cacheKey, "error", err)
-
- cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
- if readErr != nil {
- return nil, "", fmt.Errorf("upstream failed and cached file missing: %w", err)
- }
- defer func() { _ = cached.Close() }()
-
- data, readErr := ReadMetadata(cached)
- if readErr != nil {
- return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err)
- }
-
- ct := contentTypeJSON
- if entry.ContentType.Valid {
- ct = entry.ContentType.String
- }
- p.Logger.Info("serving metadata from cache",
- "ecosystem", ecosystem, "key", cacheKey)
- return data, ct, nil
-}
-
-// fetchUpstreamMetadata fetches metadata from upstream, using ETag for conditional revalidation.
-// Returns the body, content type, ETag, upstream Last-Modified time, and any error.
-func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, entry *database.MetadataCacheEntry, accept string) ([]byte, string, string, time.Time, error) {
- var zeroTime time.Time
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
- if err != nil {
- return nil, "", "", zeroTime, fmt.Errorf("creating request: %w", err)
- }
- req.Header.Set("Accept", accept)
-
- if entry != nil && entry.ETag.Valid {
- req.Header.Set("If-None-Match", entry.ETag.String)
- }
-
- resp, err := p.HTTPClient.Do(req)
- if err != nil {
- return nil, "", "", zeroTime, fmt.Errorf("fetching metadata: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- // 304 Not Modified -- our cached copy is still good
- if resp.StatusCode == http.StatusNotModified && entry != nil {
- cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
- if readErr != nil {
- return nil, "", "", zeroTime, errStale304
- }
- defer func() { _ = cached.Close() }()
- data, readErr := ReadMetadata(cached)
- if readErr != nil {
- return nil, "", "", zeroTime, errStale304
- }
- ct := contentTypeJSON
- if entry.ContentType.Valid {
- ct = entry.ContentType.String
- }
- lm := zeroTime
- if entry.LastModified.Valid {
- lm = entry.LastModified.Time
- }
- return data, ct, entry.ETag.String, lm, nil
- }
-
- if resp.StatusCode == http.StatusNotFound {
- return nil, "", "", zeroTime, ErrUpstreamNotFound
- }
- if resp.StatusCode != http.StatusOK {
- return nil, "", "", zeroTime, fmt.Errorf("upstream returned %d", resp.StatusCode)
- }
-
- body, err := ReadMetadata(resp.Body)
- if err != nil {
- return nil, "", "", zeroTime, fmt.Errorf("reading response: %w", err)
- }
-
- contentType := resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = contentTypeJSON
- }
-
- etag := resp.Header.Get("ETag")
-
- var lastModified time.Time
- if lm := resp.Header.Get("Last-Modified"); lm != "" {
- lastModified, _ = http.ParseTime(lm)
- }
-
- return body, contentType, etag, lastModified, nil
-}
-
-// cacheMetadataBlob stores metadata bytes in storage and updates the database.
-func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, storagePath string, data []byte, contentType, etag string, lastModified time.Time) {
- if p.DB == nil || p.Storage == nil {
- return
- }
-
- size, _, err := p.Storage.Store(ctx, storagePath, bytes.NewReader(data))
- if err != nil {
- p.Logger.Warn("failed to cache metadata", "ecosystem", ecosystem, "key", cacheKey, "error", err)
- return
- }
-
- _ = p.DB.UpsertMetadataCache(&database.MetadataCacheEntry{
- Ecosystem: ecosystem,
- Name: cacheKey,
- StoragePath: storagePath,
- ETag: sql.NullString{String: etag, Valid: etag != ""},
- ContentType: sql.NullString{String: contentType, Valid: contentType != ""},
- Size: sql.NullInt64{Int64: size, Valid: true},
- LastModified: sql.NullTime{Time: lastModified, Valid: !lastModified.IsZero()},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- })
-}
-
-// cachedMeta holds cache validators and freshness state from a metadata cache entry.
-type cachedMeta struct {
- etag string
- lastModified time.Time
- stale bool
-}
-
-// lookupCachedMeta retrieves cache validators for a metadata entry.
-func (p *Proxy) lookupCachedMeta(ecosystem, cacheKey string) cachedMeta {
- if p.DB == nil {
- return cachedMeta{}
- }
- entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
- if err != nil || entry == nil {
- return cachedMeta{}
- }
- var cm cachedMeta
- if entry.ETag.Valid {
- cm.etag = entry.ETag.String
- }
- if entry.LastModified.Valid {
- cm.lastModified = entry.LastModified.Time
- }
- // If FetchedAt is older than TTL, upstream must have failed and
- // we served from stale cache (successful fetches update FetchedAt).
- if p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) > p.MetadataTTL {
- cm.stale = true
- }
- return cm
-}
-
-// ProxyCached fetches metadata from upstream (with optional caching for offline fallback)
-// and writes it to the response. Optional acceptHeaders specify the Accept header to send.
-// When metadata caching is disabled, the response is streamed directly to avoid buffering
-// large metadata responses (e.g. npm packages with many versions) in memory.
-func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey string, acceptHeaders ...string) {
- if !p.CacheMetadata {
- // Stream directly without buffering when caching is off.
- p.proxyMetadataStream(w, r, upstreamURL, acceptHeaders...)
- return
- }
-
- body, contentType, err := p.FetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, acceptHeaders...)
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- p.Logger.Error("metadata fetch failed", "error", err)
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
- return
- }
-
- p.writeMetadataCachedResponse(w, r, ecosystem, cacheKey, body, contentType)
-}
-
-// writeMetadataCachedResponse writes a cached metadata response and handles
-// conditional request headers using metadata cache validators.
-func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
- cm := p.lookupCachedMeta(ecosystem, cacheKey)
-
- if cm.etag != "" {
- if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- }
- if !cm.lastModified.IsZero() {
- if ims := r.Header.Get("If-Modified-Since"); ims != "" {
- if t, err := http.ParseTime(ims); err == nil && !cm.lastModified.After(t) {
- w.WriteHeader(http.StatusNotModified)
- return
- }
- }
- }
-
- w.Header().Set("Content-Type", contentType)
- w.Header().Set("Content-Length", strconv.Itoa(len(body)))
- if cm.etag != "" {
- w.Header().Set("ETag", cm.etag)
- }
- if !cm.lastModified.IsZero() {
- w.Header().Set("Last-Modified", cm.lastModified.UTC().Format(http.TimeFormat))
- }
- if cm.stale {
- w.Header().Set("Warning", `110 - "Response is Stale"`)
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(body)
-}
-
-// proxyMetadataStream forwards an upstream metadata response by streaming it to the client
-// without buffering the full body in memory.
-func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL string, acceptHeaders ...string) {
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
- if err != nil {
- http.Error(w, "failed to create request", http.StatusInternalServerError)
- return
- }
-
- accept := contentTypeJSON
- if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
- accept = acceptHeaders[0]
- }
- req.Header.Set("Accept", accept)
-
- for _, header := range []string{headerAcceptEncoding, "If-Modified-Since", "If-None-Match"} {
- if v := r.Header.Get(header); v != "" {
- req.Header.Set(header, v)
- }
- }
-
- resp, err := p.HTTPClient.Do(req)
- if err != nil {
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
- return
- }
- defer func() { _ = resp.Body.Close() }()
-
- for _, header := range []string{"Content-Type", "Content-Length", "Last-Modified", "ETag"} {
- if v := resp.Header.Get(header); v != "" {
- w.Header().Set(header, v)
- }
- }
-
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, resp.Body)
-}
-
// GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
// This is useful for registries where download URLs are determined from metadata.
func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
- return p.GetOrFetchArtifactFromURLWithHeaders(ctx, ecosystem, name, version, filename, downloadURL, nil)
-}
-
-// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
-// with additional HTTP headers. This is needed for registries that require authentication
-// (e.g. Docker Hub requires a Bearer token even for public images).
-func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
@@ -775,14 +262,14 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys
return cached, nil
}
- return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers)
+ return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL)
}
-func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) {
+func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string) (*CacheResult, error) {
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
- artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers)
+ artifact, err := p.Fetcher.Fetch(ctx, downloadURL)
if err != nil {
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
@@ -794,7 +281,7 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
return nil, fmt.Errorf("storing artifact: %w", err)
}
- if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
+ if err := p.updateCacheDB(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
}
@@ -811,3 +298,4 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
Cached: false,
}, nil
}
+
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
deleted file mode 100644
index bbcab72..0000000
--- a/internal/handler/handler_test.go
+++ /dev/null
@@ -1,1012 +0,0 @@
-package handler
-
-import (
- "bytes"
- "context"
- "database/sql"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// mockStorage implements storage.Storage for testing.
-type mockStorage struct {
- files map[string][]byte
- storeErr error
- openErr error
- signedURL string
- signErr error
-}
-
-func newMockStorage() *mockStorage {
- return &mockStorage{files: make(map[string][]byte)}
-}
-
-func (s *mockStorage) Store(_ context.Context, path string, r io.Reader) (int64, string, error) {
- if s.storeErr != nil {
- return 0, "", s.storeErr
- }
- data, err := io.ReadAll(r)
- if err != nil {
- return 0, "", err
- }
- s.files[path] = data
- return int64(len(data)), "fakehash123", nil
-}
-
-func (s *mockStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
- if s.openErr != nil {
- return nil, s.openErr
- }
- data, ok := s.files[path]
- if !ok {
- return nil, storage.ErrNotFound
- }
- return io.NopCloser(bytes.NewReader(data)), nil
-}
-
-func (s *mockStorage) Exists(_ context.Context, path string) (bool, error) {
- _, ok := s.files[path]
- return ok, nil
-}
-
-func (s *mockStorage) Delete(_ context.Context, path string) error {
- delete(s.files, path)
- return nil
-}
-
-func (s *mockStorage) Size(_ context.Context, path string) (int64, error) {
- data, ok := s.files[path]
- if !ok {
- return 0, storage.ErrNotFound
- }
- return int64(len(data)), nil
-}
-
-func (s *mockStorage) UsedSpace(_ context.Context) (int64, error) {
- var total int64
- for _, data := range s.files {
- total += int64(len(data))
- }
- return total, nil
-}
-
-func (s *mockStorage) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
- if s.signErr != nil {
- return "", s.signErr
- }
- if s.signedURL == "" {
- return "", storage.ErrSignedURLUnsupported
- }
- return s.signedURL, nil
-}
-
-func (s *mockStorage) URL() string { return "mem://" }
-
-func (s *mockStorage) Close() error { return nil }
-
-// mockFetcher implements fetch.FetcherInterface for testing.
-type mockFetcher struct {
- artifact *fetch.Artifact
- fetchErr error
- fetchErrByURL map[string]error
- fetchCalled bool
- fetchedURL string
-}
-
-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) {
- f.fetchCalled = true
- f.fetchedURL = url
- if f.fetchErrByURL != nil {
- if err, ok := f.fetchErrByURL[url]; ok {
- return nil, err
- }
- }
- if f.fetchErr != nil {
- return nil, f.fetchErr
- }
- return f.artifact, nil
-}
-
-func (f *mockFetcher) Head(_ context.Context, _ string) (int64, string, error) {
- return 0, "", nil
-}
-
-// setupTestProxy creates a Proxy with a real DB (SQLite in temp dir) and mock storage/fetcher.
-func setupTestProxy(t *testing.T) (*Proxy, *database.DB, *mockStorage, *mockFetcher) {
- t.Helper()
-
- dir := t.TempDir()
- db, err := database.Create(dir + "/test.db")
- if err != nil {
- t.Fatalf("failed to create test database: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
-
- store := newMockStorage()
- fetcher := &mockFetcher{}
- resolver := fetch.NewResolver()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- proxy := NewProxy(db, store, fetcher, resolver, logger)
- return proxy, db, store, fetcher
-}
-
-// seedPackage creates a package, version, and cached artifact in the test DB and storage.
-func seedPackage(t *testing.T, db *database.DB, store *mockStorage, ecosystem, name, version, filename, content string) {
- t.Helper()
-
- pkg := &database.Package{
- PURL: fmt.Sprintf("pkg:%s/%s", ecosystem, name),
- Ecosystem: ecosystem,
- Name: name,
- }
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- versionPURL := fmt.Sprintf("pkg:%s/%s@%s", ecosystem, name, version)
- ver := &database.Version{
- PURL: versionPURL,
- PackagePURL: pkg.PURL,
- }
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- storagePath := storage.ArtifactPath(ecosystem, "", name, version, filename)
- store.files[storagePath] = []byte(content)
-
- art := &database.Artifact{
- VersionPURL: versionPURL,
- Filename: filename,
- UpstreamURL: "https://example.com/" + filename,
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- ContentHash: sql.NullString{String: "abc123", Valid: true},
- Size: sql.NullInt64{Int64: int64(len(content)), Valid: true},
- ContentType: sql.NullString{String: "application/octet-stream", Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatalf("failed to upsert artifact: %v", err)
- }
-}
-
-// pathParseCase holds a single test case for path parsing functions that return
-// (name, version, arch).
-type pathParseCase struct {
- path string
- wantName string
- wantVersion string
- wantArch string
-}
-
-// assertPathParser runs table-driven tests for a path parser function that returns
-// three strings (name, version, arch).
-func assertPathParser(t *testing.T, funcName string, parse func(string) (string, string, string), cases []pathParseCase) {
- t.Helper()
- for _, tt := range cases {
- t.Run(tt.path, func(t *testing.T) {
- name, version, arch := parse(tt.path)
- if name != tt.wantName {
- t.Errorf("%s() name = %q, want %q", funcName, name, tt.wantName)
- }
- if version != tt.wantVersion {
- t.Errorf("%s() version = %q, want %q", funcName, version, tt.wantVersion)
- }
- if arch != tt.wantArch {
- t.Errorf("%s() arch = %q, want %q", funcName, arch, tt.wantArch)
- }
- })
- }
-}
-
-// assertRoutesBasics checks that a handler's Routes() returns a non-nil handler,
-// rejects POST requests with 405, and rejects path traversal with 400.
-func assertRoutesBasics(t *testing.T, handler http.Handler, postPath, traversalPath string) {
- t.Helper()
-
- if handler == nil {
- t.Fatal("Routes() returned nil")
- }
-
- req := httptest.NewRequest(http.MethodPost, postPath, nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusMethodNotAllowed {
- t.Errorf("POST request: got status %d, want %d", w.Code, http.StatusMethodNotAllowed)
- }
-
- req = httptest.NewRequest(http.MethodGet, traversalPath, nil)
- w = httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("path traversal: got status %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestGetOrFetchArtifact_CacheHit(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")
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if !result.Cached {
- t.Error("expected result to be cached")
- }
- if fetcher.fetchCalled {
- t.Error("fetcher should not be called on cache hit")
- }
-
- body, _ := io.ReadAll(result.Reader)
- if string(body) != "cached content" {
- t.Errorf("got body %q, want %q", body, "cached content")
- }
- if result.ContentType != "application/octet-stream" {
- t.Errorf("got content type %q, want %q", result.ContentType, "application/octet-stream")
- }
- if result.Hash != "abc123" {
- t.Errorf("got hash %q, want %q", result.Hash, "abc123")
- }
-}
-
-func TestGetOrFetchArtifact_CacheMiss_NoPackage(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
-
- // The resolver will fail because "nonexistent" isn't a real package,
- // but we're testing that it tries to fetch (doesn't return from cache).
- fetcher.fetchErr = errors.New("upstream unavailable")
-
- _, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "nonexistent", "1.0.0", "nonexistent-1.0.0.tgz")
- if err == nil {
- t.Fatal("expected error for uncached package")
- }
-}
-
-func TestGetOrFetchArtifactFromURL_CacheMiss_StorageMissing(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
-
- // Seed DB but don't put the file in storage
- pkg := &database.Package{PURL: "pkg:npm/missing", Ecosystem: "npm", Name: "missing"}
- _ = db.UpsertPackage(pkg)
- ver := &database.Version{PURL: "pkg:npm/missing@1.0.0", PackagePURL: pkg.PURL}
- _ = db.UpsertVersion(ver)
- art := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: "missing-1.0.0.tgz",
- UpstreamURL: "https://example.com/missing.tgz",
- StoragePath: sql.NullString{String: "nonexistent/path.tgz", Valid: true},
- ContentHash: sql.NullString{String: "hash", Valid: true},
- Size: sql.NullInt64{Int64: 100, Valid: true},
- ContentType: sql.NullString{String: "application/octet-stream", Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- }
- _ = db.UpsertArtifact(art)
-
- // Storage doesn't have the file, so checkCache should return nil and trigger a refetch.
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("refetched content")),
- ContentType: "application/gzip",
- }
-
- result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "npm", "missing", "1.0.0", "missing-1.0.0.tgz", "https://example.com/missing.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if result.Cached {
- t.Error("expected cache miss (storage file was missing)")
- }
- if !fetcher.fetchCalled {
- t.Error("fetcher should be called when storage file is missing")
- }
-
- // Verify the new content was stored
- storagePath := storage.ArtifactPath("npm", "", "missing", "1.0.0", "missing-1.0.0.tgz")
- if _, ok := store.files[storagePath]; !ok {
- t.Error("refetched artifact should be stored")
- }
-}
-
-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")
-
- proxy.DirectServe = true
- proxy.DirectServeTTL = 15 * time.Minute
- store.signedURL = "https://bucket.s3.amazonaws.com/npm/lodash?X-Amz-Signature=abc"
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if !result.Cached {
- t.Error("expected result to be cached")
- }
- if result.RedirectURL != store.signedURL {
- t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, store.signedURL)
- }
- if result.Reader != nil {
- t.Error("Reader should be nil when redirecting")
- }
- if fetcher.fetchCalled {
- t.Error("fetcher should not be called on cache hit")
- }
-
- // Hit count should still be recorded on the redirect path.
- art, _ := db.GetArtifact("pkg:npm/lodash@4.17.21", "lodash-4.17.21.tgz")
- if art == nil || art.HitCount != 1 {
- t.Errorf("artifact hit count not recorded: %+v", art)
- }
-}
-
-func TestGetOrFetchArtifact_DirectServe_BaseURLRewrite(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
-
- proxy.DirectServe = true
- proxy.DirectServeBaseURL = "https://cdn.example.com"
- store.signedURL = "http://127.0.0.1:9000/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- want := "https://cdn.example.com/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
- if result.RedirectURL != want {
- t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, want)
- }
-}
-
-func TestRewriteSignedURLHost(t *testing.T) {
- tests := []struct {
- name string
- signed string
- baseURL string
- want string
- }{
- {
- "empty base url is no-op",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- "",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- },
- {
- "replaces scheme and host",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- "https://cdn.example.com",
- "https://cdn.example.com/bucket/key?sig=abc",
- },
- {
- "preserves path and query",
- "http://minio:9000/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
- "https://files.example.com",
- "https://files.example.com/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
- },
- {
- "ignores base url path",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- "https://cdn.example.com/ignored",
- "https://cdn.example.com/bucket/key?sig=abc",
- },
- {
- "invalid base url is no-op",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- "://bad",
- "http://127.0.0.1:9000/bucket/key?sig=abc",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := rewriteSignedURLHost(tt.signed, tt.baseURL)
- if got != tt.want {
- t.Errorf("rewriteSignedURLHost(%q, %q) = %q, want %q", tt.signed, tt.baseURL, got, tt.want)
- }
- })
- }
-}
-
-func TestGetOrFetchArtifact_DirectServe_FallbackOnUnsupported(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
-
- proxy.DirectServe = true
- // store.signedURL is empty so SignedURL returns ErrSignedURLUnsupported.
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if result.RedirectURL != "" {
- t.Errorf("RedirectURL should be empty, got %q", result.RedirectURL)
- }
- if result.Reader == nil {
- t.Fatal("Reader should be set when signing is unsupported")
- }
- body, _ := io.ReadAll(result.Reader)
- if string(body) != "cached content" {
- t.Errorf("got body %q, want %q", body, "cached content")
- }
-}
-
-func TestGetOrFetchArtifact_DirectServe_FallbackOnError(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
-
- proxy.DirectServe = true
- store.signErr = errors.New("signing failed")
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if result.RedirectURL != "" {
- t.Errorf("RedirectURL should be empty on signing error, got %q", result.RedirectURL)
- }
- if result.Reader == nil {
- t.Fatal("Reader should be set when signing fails")
- }
-}
-
-func TestGetOrFetchArtifact_DirectServe_DisabledIgnoresSigning(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
-
- proxy.DirectServe = false
- store.signedURL = "https://bucket.example/should-not-be-used"
-
- result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if result.RedirectURL != "" {
- t.Errorf("RedirectURL should be empty when DirectServe is off, got %q", result.RedirectURL)
- }
-}
-
-func TestServeArtifact_Redirect(t *testing.T) {
- w := httptest.NewRecorder()
- ServeArtifact(w, &CacheResult{
- RedirectURL: "https://bucket.s3.amazonaws.com/file?sig=abc",
- Hash: "abc123",
- Cached: true,
- })
-
- if w.Code != http.StatusFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusFound)
- }
- if loc := w.Header().Get("Location"); loc != "https://bucket.s3.amazonaws.com/file?sig=abc" {
- t.Errorf("Location = %q", loc)
- }
- if etag := w.Header().Get("ETag"); etag != `"abc123"` {
- t.Errorf("ETag = %q, want %q", etag, `"abc123"`)
- }
- if cl := w.Header().Get("Content-Length"); cl != "" {
- t.Errorf("Content-Length should not be set on redirect, got %q", cl)
- }
-}
-
-func TestServeArtifact_Stream(t *testing.T) {
- w := httptest.NewRecorder()
- ServeArtifact(w, &CacheResult{
- Reader: io.NopCloser(strings.NewReader("payload")),
- Size: 7,
- ContentType: "application/octet-stream",
- Hash: "abc123",
- })
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Body.String() != "payload" {
- t.Errorf("body = %q, want %q", w.Body.String(), "payload")
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/octet-stream" {
- t.Errorf("Content-Type = %q", ct)
- }
-}
-
-func TestGetOrFetchArtifactFromURL_CacheHit(t *testing.T) {
- proxy, db, store, fetcher := setupTestProxy(t)
- seedPackage(t, db, store, "pypi", "requests", "2.28.0", "requests-2.28.0.tar.gz", "pypi content")
-
- result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "requests", "2.28.0", "requests-2.28.0.tar.gz", "https://pypi.org/files/requests-2.28.0.tar.gz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if !result.Cached {
- t.Error("expected cache hit")
- }
- if fetcher.fetchCalled {
- t.Error("fetcher should not be called on cache hit")
- }
-}
-
-func TestGetOrFetchArtifactFromURL_CacheMiss(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
-
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched content")),
- ContentType: "application/gzip",
- }
-
- result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", "https://pypi.org/files/newpkg-1.0.0.tar.gz")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- defer func() { _ = result.Reader.Close() }()
-
- if result.Cached {
- t.Error("expected cache miss")
- }
- if !fetcher.fetchCalled {
- t.Error("fetcher should be called on cache miss")
- }
- if fetcher.fetchedURL != "https://pypi.org/files/newpkg-1.0.0.tar.gz" {
- t.Errorf("fetcher called with wrong URL: %s", fetcher.fetchedURL)
- }
-
- body, _ := io.ReadAll(result.Reader)
- if string(body) != "fetched content" {
- t.Errorf("got body %q, want %q", body, "fetched content")
- }
-
- // Verify it was stored
- storagePath := storage.ArtifactPath("pypi", "", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz")
- if _, ok := store.files[storagePath]; !ok {
- t.Error("artifact was not stored in storage")
- }
-}
-
-func TestGetOrFetchArtifactFromURL_FetchError(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.fetchErr = errors.New("connection refused")
-
- _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "fail", "1.0.0", "fail-1.0.0.tar.gz", "https://pypi.org/files/fail-1.0.0.tar.gz")
- if err == nil {
- t.Fatal("expected error on fetch failure")
- }
- if !strings.Contains(err.Error(), "fetching from upstream") {
- t.Errorf("expected upstream error, got: %v", err)
- }
-}
-
-func TestGetOrFetchArtifactFromURL_StoreError(t *testing.T) {
- proxy, _, store, fetcher := setupTestProxy(t)
- store.storeErr = errors.New("disk full")
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("data")),
- ContentType: "application/gzip",
- }
-
- _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "fail", "1.0.0", "fail-1.0.0.tar.gz", "https://pypi.org/files/fail.tar.gz")
- if err == nil {
- t.Fatal("expected error on store failure")
- }
- if !strings.Contains(err.Error(), "storing artifact") {
- t.Errorf("expected storage error, got: %v", err)
- }
-}
-
-func TestServeArtifact(t *testing.T) {
- result := &CacheResult{
- Reader: io.NopCloser(strings.NewReader("file contents")),
- Size: 13,
- ContentType: "application/gzip",
- Hash: "sha256abc",
- Cached: true,
- }
-
- w := httptest.NewRecorder()
- ServeArtifact(w, result)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Header().Get("Content-Type") != "application/gzip" {
- t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "application/gzip")
- }
- if w.Header().Get("Content-Length") != "13" {
- t.Errorf("Content-Length = %q, want %q", w.Header().Get("Content-Length"), "13")
- }
- if w.Header().Get("ETag") != `"sha256abc"` {
- t.Errorf("ETag = %q, want %q", w.Header().Get("ETag"), `"sha256abc"`)
- }
- if w.Body.String() != "file contents" {
- t.Errorf("body = %q, want %q", w.Body.String(), "file contents")
- }
-}
-
-func TestServeArtifact_EmptyFields(t *testing.T) {
- result := &CacheResult{
- Reader: io.NopCloser(strings.NewReader("data")),
- }
-
- w := httptest.NewRecorder()
- ServeArtifact(w, result)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Header().Get("Content-Type") != "" {
- t.Errorf("Content-Type should be empty, got %q", w.Header().Get("Content-Type"))
- }
- if w.Header().Get("Content-Length") != "" {
- t.Errorf("Content-Length should be empty, got %q", w.Header().Get("Content-Length"))
- }
- if w.Header().Get("ETag") != "" {
- t.Errorf("ETag should be empty, got %q", w.Header().Get("ETag"))
- }
-}
-
-func TestJSONError(t *testing.T) {
- tests := []struct {
- status int
- message string
- }{
- {http.StatusBadRequest, "bad request"},
- {http.StatusNotFound, "not found"},
- {http.StatusInternalServerError, "internal error"},
- }
-
- for _, tt := range tests {
- w := httptest.NewRecorder()
- JSONError(w, tt.status, tt.message)
-
- if w.Code != tt.status {
- t.Errorf("status = %d, want %d", w.Code, tt.status)
- }
- if w.Header().Get("Content-Type") != "application/json" {
- t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "application/json")
- }
- body := w.Body.String()
- if !strings.Contains(body, tt.message) {
- t.Errorf("body %q should contain %q", body, tt.message)
- }
- }
-}
-
-func TestNewProxy_NilLogger(t *testing.T) {
- dir := t.TempDir()
- db, err := database.Create(dir + "/test.db")
- if err != nil {
- t.Fatalf("failed to create test database: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- proxy := NewProxy(db, newMockStorage(), &mockFetcher{}, fetch.NewResolver(), nil)
- if proxy.Logger == nil {
- t.Error("Logger should be set to default when nil is passed")
- }
-}
-
-const testLastModified = "Wed, 01 Jan 2025 12:00:00 GMT"
-
-// setupCachedProxy creates a Proxy with CacheMetadata enabled and an upstream
-// test server that returns JSON with ETag and Last-Modified headers.
-func setupCachedProxy(t *testing.T, upstreamETag, upstreamLastModified string) (*Proxy, *httptest.Server) {
- t.Helper()
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if upstreamETag != "" {
- w.Header().Set("ETag", upstreamETag)
- }
- if upstreamLastModified != "" {
- w.Header().Set("Last-Modified", upstreamLastModified)
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.HTTPClient = upstream.Client()
-
- return proxy, upstream
-}
-
-func TestProxyCached_SetsETagAndLastModified(t *testing.T) {
- lm := testLastModified
- proxy, upstream := setupCachedProxy(t, `"abc123"`, lm)
-
- // First request populates the cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "test-key")
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- if got := w.Header().Get("ETag"); got != `"abc123"` {
- t.Errorf("ETag = %q, want %q", got, `"abc123"`)
- }
- if got := w.Header().Get("Last-Modified"); got != lm {
- t.Errorf("Last-Modified = %q, want %q", got, lm)
- }
- if got := w.Header().Get("Content-Length"); got != "11" {
- t.Errorf("Content-Length = %q, want %q", got, "11")
- }
- if w.Body.String() != `{"ok":true}` {
- t.Errorf("body = %q, want %q", w.Body.String(), `{"ok":true}`)
- }
-}
-
-func TestProxyCached_IfNoneMatch_Returns304(t *testing.T) {
- proxy, upstream := setupCachedProxy(t, `"abc123"`, "")
-
- // Populate cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-key")
- if w.Code != http.StatusOK {
- t.Fatalf("initial request: status = %d, want 200", w.Code)
- }
-
- // Conditional request with matching ETag
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
- req.Header.Set("If-None-Match", `"abc123"`)
- w = httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-key")
-
- if w.Code != http.StatusNotModified {
- t.Errorf("conditional request: status = %d, want 304", w.Code)
- }
- if w.Body.Len() != 0 {
- t.Errorf("304 response should have empty body, got %d bytes", w.Body.Len())
- }
-}
-
-func TestProxyCached_IfNoneMatch_NonMatching_Returns200(t *testing.T) {
- proxy, upstream := setupCachedProxy(t, `"abc123"`, "")
-
- // Populate cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-nm-key")
- if w.Code != http.StatusOK {
- t.Fatalf("initial request: status = %d, want 200", w.Code)
- }
-
- // Conditional request with non-matching ETag
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
- req.Header.Set("If-None-Match", `"different"`)
- w = httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-nm-key")
-
- if w.Code != http.StatusOK {
- t.Errorf("non-matching ETag: status = %d, want 200", w.Code)
- }
-}
-
-func TestProxyCached_IfModifiedSince_Returns304(t *testing.T) {
- lm := testLastModified
- proxy, upstream := setupCachedProxy(t, "", lm)
-
- // Populate cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-key")
- if w.Code != http.StatusOK {
- t.Fatalf("initial request: status = %d, want 200", w.Code)
- }
-
- // Conditional request with If-Modified-Since equal to Last-Modified
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
- req.Header.Set("If-Modified-Since", lm)
- w = httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-key")
-
- if w.Code != http.StatusNotModified {
- t.Errorf("conditional request: status = %d, want 304", w.Code)
- }
-}
-
-func TestProxyCached_IfModifiedSince_OlderDate_Returns200(t *testing.T) {
- lm := testLastModified
- proxy, upstream := setupCachedProxy(t, "", lm)
-
- // Populate cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-old-key")
- if w.Code != http.StatusOK {
- t.Fatalf("initial request: status = %d, want 200", w.Code)
- }
-
- // Conditional request with If-Modified-Since older than Last-Modified
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
- req.Header.Set("If-Modified-Since", "Mon, 01 Dec 2024 12:00:00 GMT")
- w = httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-old-key")
-
- if w.Code != http.StatusOK {
- t.Errorf("older If-Modified-Since: status = %d, want 200", w.Code)
- }
-}
-
-func TestProxyCached_NoValidators_OmitsHeaders(t *testing.T) {
- proxy, upstream := setupCachedProxy(t, "", "")
-
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "no-val-key")
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- if got := w.Header().Get("ETag"); got != "" {
- t.Errorf("ETag should be empty when upstream has none, got %q", got)
- }
- if got := w.Header().Get("Last-Modified"); got != "" {
- t.Errorf("Last-Modified should be empty when upstream has none, got %q", got)
- }
-}
-
-func TestFetchOrCacheMetadata_TTL_ServesFreshFromCache(t *testing.T) {
- upstreamHits := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- upstreamHits++
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"v":1}`))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 1 * time.Hour
- proxy.HTTPClient = upstream.Client()
-
- ctx := context.Background()
-
- // First request populates cache
- body, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "ttl-pkg", upstream.URL+"/pkg")
- if err != nil {
- t.Fatalf("first fetch: %v", err)
- }
- if string(body) != `{"v":1}` {
- t.Errorf("body = %q, want %q", body, `{"v":1}`)
- }
- if upstreamHits != 1 {
- t.Fatalf("expected 1 upstream hit, got %d", upstreamHits)
- }
-
- // Second request within TTL should serve from cache without hitting upstream
- body, _, err = proxy.FetchOrCacheMetadata(ctx, "test", "ttl-pkg", upstream.URL+"/pkg")
- if err != nil {
- t.Fatalf("second fetch: %v", err)
- }
- if string(body) != `{"v":1}` {
- t.Errorf("body = %q, want %q", body, `{"v":1}`)
- }
- if upstreamHits != 1 {
- t.Errorf("expected upstream to still be hit only once, got %d", upstreamHits)
- }
-}
-
-func TestFetchOrCacheMetadata_TTL_Zero_AlwaysRevalidates(t *testing.T) {
- upstreamHits := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- upstreamHits++
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"v":1}`))
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 0 // always revalidate
- proxy.HTTPClient = upstream.Client()
-
- ctx := context.Background()
-
- _, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "ttl0-pkg", upstream.URL+"/pkg")
- if err != nil {
- t.Fatalf("first fetch: %v", err)
- }
-
- _, _, err = proxy.FetchOrCacheMetadata(ctx, "test", "ttl0-pkg", upstream.URL+"/pkg")
- if err != nil {
- t.Fatalf("second fetch: %v", err)
- }
-
- if upstreamHits != 2 {
- t.Errorf("expected 2 upstream hits with TTL=0, got %d", upstreamHits)
- }
-}
-
-func TestProxyCached_StaleWarningHeader(t *testing.T) {
- requestCount := 0
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestCount++
- if requestCount == 1 {
- // First request succeeds to populate cache
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"cached":true}`))
- return
- }
- // Subsequent requests fail to simulate upstream outage
- w.WriteHeader(http.StatusBadGateway)
- }))
- t.Cleanup(upstream.Close)
-
- proxy, _, _, _ := setupTestProxy(t)
- proxy.CacheMetadata = true
- proxy.MetadataTTL = 1 * time.Millisecond // very short TTL so it expires immediately
- proxy.HTTPClient = upstream.Client()
-
- // First request populates cache
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "stale-key")
- if w.Code != http.StatusOK {
- t.Fatalf("initial request: status = %d, want 200", w.Code)
- }
-
- // Wait for TTL to expire
- time.Sleep(5 * time.Millisecond)
-
- // Second request: upstream fails, should serve stale cache with Warning header
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
- w = httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "stale-key")
-
- if w.Code != http.StatusOK {
- t.Fatalf("stale request: status = %d, want 200", w.Code)
- }
- if w.Body.String() != `{"cached":true}` {
- t.Errorf("body = %q, want %q", w.Body.String(), `{"cached":true}`)
- }
- if got := w.Header().Get("Warning"); got != `110 - "Response is Stale"` {
- t.Errorf("Warning = %q, want %q", got, `110 - "Response is Stale"`)
- }
-}
-
-func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
- proxy, upstream := setupCachedProxy(t, "", "")
- proxy.MetadataTTL = 1 * time.Hour
-
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
- w := httptest.NewRecorder()
- proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "fresh-key")
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- if got := w.Header().Get("Warning"); got != "" {
- t.Errorf("Warning should be empty for fresh response, got %q", got)
- }
-}
diff --git a/internal/handler/hex.go b/internal/handler/hex.go
index 0f0c72e..7a34795 100644
--- a/internal/handler/hex.go
+++ b/internal/handler/hex.go
@@ -1,17 +1,10 @@
package handler
import (
- "bytes"
- "compress/gzip"
- "encoding/json"
"fmt"
"io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
- "google.golang.org/protobuf/encoding/protowire"
)
const (
@@ -41,10 +34,10 @@ func (h *HexHandler) Routes() http.Handler {
// Package tarballs (cache these)
mux.HandleFunc("GET /tarballs/{filename}", h.handleDownload)
- // Registry resources (cached for offline)
- mux.HandleFunc("GET /names", h.proxyCached)
- mux.HandleFunc("GET /versions", h.proxyCached)
- mux.HandleFunc("GET /packages/{name}", h.handlePackages)
+ // Registry resources (proxy without caching)
+ mux.HandleFunc("GET /names", h.proxyUpstream)
+ mux.HandleFunc("GET /versions", h.proxyUpstream)
+ mux.HandleFunc("GET /packages/{name}", h.proxyUpstream)
// Public keys
mux.HandleFunc("GET /public_key", h.proxyUpstream)
@@ -94,336 +87,42 @@ func (h *HexHandler) parseTarballFilename(filename string) (name, version string
return "", ""
}
-// hexAPIURL is the Hex HTTP API base URL for fetching package metadata with timestamps.
-const hexAPIURL = "https://hex.pm"
+// proxyUpstream forwards a request to hex.pm without caching.
+func (h *HexHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
+ upstreamURL := h.upstreamURL + r.URL.Path
-// handlePackages proxies the /packages/{name} endpoint, applying cooldown filtering
-// when enabled. Since the protobuf format has no timestamps, we fetch them from the
-// Hex HTTP API concurrently.
-func (h *HexHandler) handlePackages(w http.ResponseWriter, r *http.Request) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- h.proxyCached(w, r)
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
- name := r.PathValue("name")
- if name == "" {
- h.proxyCached(w, r)
- return
+ // Copy accept header for content negotiation
+ if accept := r.Header.Get("Accept"); accept != "" {
+ req.Header.Set("Accept", accept)
}
- h.proxy.Logger.Info("hex package request with cooldown", "name", name)
-
- protoResp, filteredVersions, err := h.fetchPackageAndVersions(r, name)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
- defer func() { _ = protoResp.Body.Close() }()
-
- if protoResp.StatusCode != http.StatusOK {
- for k, vv := range protoResp.Header {
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
- w.WriteHeader(protoResp.StatusCode)
- _, _ = io.Copy(w, protoResp.Body)
- return
- }
-
- body, err := io.ReadAll(protoResp.Body)
- if err != nil {
- http.Error(w, "failed to read response", http.StatusInternalServerError)
- return
- }
-
- if len(filteredVersions) == 0 {
- // No versions to filter or couldn't get timestamps, pass through
- w.Header().Set("Content-Type", protoResp.Header.Get("Content-Type"))
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(body)
- return
- }
-
- filtered, err := h.filterSignedPackage(body, filteredVersions)
- if err != nil {
- h.proxy.Logger.Warn("failed to filter hex package, proxying original", "error", err)
- w.Header().Set("Content-Type", protoResp.Header.Get("Content-Type"))
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(body)
- return
- }
-
- w.Header().Set("Content-Type", "application/octet-stream")
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(filtered)
-}
-
-// fetchPackageAndVersions fetches the protobuf package and version timestamps concurrently.
-func (h *HexHandler) fetchPackageAndVersions(r *http.Request, name string) (*http.Response, map[string]bool, error) {
- type versionsResult struct {
- filtered map[string]bool
- err error
- }
-
- versionsCh := make(chan versionsResult, 1)
- go func() {
- filtered, err := h.fetchFilteredVersions(r, name)
- versionsCh <- versionsResult{filtered: filtered, err: err}
- }()
-
- protoResp, err := h.fetchUpstreamPackage(r, name)
-
- versionsRes := <-versionsCh
-
- if err != nil {
- return nil, nil, err
- }
-
- if versionsRes.err != nil {
- h.proxy.Logger.Warn("failed to fetch hex version timestamps, proxying unfiltered",
- "name", name, "error", versionsRes.err)
- return protoResp, nil, nil
- }
-
- return protoResp, versionsRes.filtered, nil
-}
-
-// fetchUpstreamPackage fetches the protobuf package from upstream.
-func (h *HexHandler) fetchUpstreamPackage(r *http.Request, name string) (*http.Response, error) {
- upstreamURL := h.upstreamURL + "/packages/" + name
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
- if err != nil {
- return nil, err
- }
- return h.proxy.HTTPClient.Do(req)
-}
-
-// hexRelease represents a version entry from the Hex API.
-type hexRelease struct {
- Version string `json:"version"`
- InsertedAt string `json:"inserted_at"`
-}
-
-// hexPackageAPI represents the Hex API response for a package.
-type hexPackageAPI struct {
- Releases []hexRelease `json:"releases"`
-}
-
-// fetchFilteredVersions fetches the Hex API and returns a set of version
-// strings that should be filtered out by cooldown.
-func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) {
- apiURL := fmt.Sprintf("%s/api/packages/%s", hexAPIURL, name)
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, apiURL, nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Accept", "application/json")
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil, err
- }
defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("hex API returned %d", resp.StatusCode)
- }
-
- var pkg hexPackageAPI
- if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil {
- return nil, err
- }
-
- packagePURL := purl.MakePURLString("hex", name, "")
- filtered := make(map[string]bool)
-
- for _, release := range pkg.Releases {
- insertedAt, err := time.Parse(time.RFC3339Nano, release.InsertedAt)
- if err != nil {
- continue
- }
-
- if !h.proxy.Cooldown.IsAllowed("hex", packagePURL, insertedAt) {
- filtered[release.Version] = true
- h.proxy.Logger.Info("cooldown: filtering hex version",
- "package", name, "version", release.Version,
- "published", release.InsertedAt)
+ // Copy response headers
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
}
}
- return filtered, nil
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
-// filterSignedPackage decompresses gzipped data, decodes the Signed protobuf wrapper,
-// filters releases from the Package payload, and re-encodes as gzipped protobuf
-// (without the original signature since the payload has changed).
-func (h *HexHandler) filterSignedPackage(gzippedData []byte, filteredVersions map[string]bool) ([]byte, error) {
- // Decompress gzip
- gr, err := gzip.NewReader(bytes.NewReader(gzippedData))
- if err != nil {
- return nil, err
- }
- signed, err := io.ReadAll(gr)
- if err != nil {
- return nil, err
- }
- _ = gr.Close()
-
- // Parse Signed message: field 1 = payload (bytes), field 2 = signature (bytes)
- payload, err := extractProtobufBytes(signed, 1)
- if err != nil {
- return nil, fmt.Errorf("extracting payload: %w", err)
- }
-
- // Filter releases from the Package message
- filteredPayload, err := filterPackageReleases(payload, filteredVersions)
- if err != nil {
- return nil, fmt.Errorf("filtering releases: %w", err)
- }
-
- // Re-encode Signed message with modified payload and no signature
- var newSigned []byte
- newSigned = protowire.AppendTag(newSigned, 1, protowire.BytesType)
- newSigned = protowire.AppendBytes(newSigned, filteredPayload)
-
- // Gzip compress
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- if _, err := gw.Write(newSigned); err != nil {
- return nil, err
- }
- if err := gw.Close(); err != nil {
- return nil, err
- }
-
- return buf.Bytes(), nil
-}
-
-// filterPackageReleases filters releases from a Package protobuf message.
-// Package: field 1 = releases (repeated), field 2 = name, field 3 = repository
-func filterPackageReleases(payload []byte, filteredVersions map[string]bool) ([]byte, error) {
- var result []byte
- data := payload
-
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return nil, fmt.Errorf("invalid protobuf tag")
- }
-
- tagBytes := data[:n]
- data = data[n:]
-
- var fieldBytes []byte
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf bytes field")
- }
- fieldBytes = data[:vn]
- data = data[vn:]
-
- if num == 1 { // releases field
- version := extractReleaseVersion(v)
- if filteredVersions[version] {
- continue // skip this release
- }
- }
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf varint")
- }
- fieldBytes = data[:vn]
- data = data[vn:]
- default:
- return nil, fmt.Errorf("unexpected wire type %d", wtype)
- }
-
- result = append(result, tagBytes...)
- result = append(result, fieldBytes...)
- }
-
- return result, nil
-}
-
-// extractReleaseVersion extracts the version string from a Release protobuf message.
-// Release: field 1 = version (string)
-func extractReleaseVersion(release []byte) string {
- data := release
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return ""
- }
- data = data[n:]
-
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return ""
- }
- if num == 1 {
- return string(v)
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return ""
- }
- data = data[vn:]
- default:
- return ""
- }
- }
- return ""
-}
-
-// extractProtobufBytes extracts a bytes field from a protobuf message by field number.
-func extractProtobufBytes(data []byte, fieldNum protowire.Number) ([]byte, error) {
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- return nil, fmt.Errorf("invalid protobuf tag")
- }
- data = data[n:]
-
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf bytes")
- }
- if num == fieldNum {
- return v, nil
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- return nil, fmt.Errorf("invalid protobuf varint")
- }
- data = data[vn:]
- default:
- return nil, fmt.Errorf("unexpected wire type %d", wtype)
- }
- }
- return nil, fmt.Errorf("field %d not found", fieldNum)
-}
-
-// proxyCached forwards a request with metadata caching.
-func (h *HexHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "hex", cacheKey, "*/*")
-}
-
-// proxyUpstream forwards a request to hex.pm without caching.
-func (h *HexHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{"Accept"})
+func init() {
+ _ = fmt.Sprintf // silence import if unused
}
diff --git a/internal/handler/hex_test.go b/internal/handler/hex_test.go
index b02540a..f8516bd 100644
--- a/internal/handler/hex_test.go
+++ b/internal/handler/hex_test.go
@@ -1,18 +1,8 @@
package handler
import (
- "bytes"
- "compress/gzip"
- "encoding/json"
- "io"
"log/slog"
- "net/http"
- "net/http/httptest"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "google.golang.org/protobuf/encoding/protowire"
)
func TestHexParseTarballFilename(t *testing.T) {
@@ -37,290 +27,3 @@ func TestHexParseTarballFilename(t *testing.T) {
}
}
}
-
-// buildHexRelease encodes a Release protobuf message.
-func buildHexRelease(version string) []byte {
- var release []byte
- // field 1 = version (string)
- release = protowire.AppendTag(release, 1, protowire.BytesType)
- release = protowire.AppendString(release, version)
- // field 2 = inner_checksum (bytes) - required
- release = protowire.AppendTag(release, 2, protowire.BytesType)
- release = protowire.AppendBytes(release, []byte("fakechecksum1234567890123456789012"))
- // field 5 = outer_checksum (bytes)
- release = protowire.AppendTag(release, 5, protowire.BytesType)
- release = protowire.AppendBytes(release, []byte("outerchecksum123456789012345678901"))
- return release
-}
-
-// buildHexPackage encodes a Package protobuf message.
-func buildHexPackage(name string, versions []string) []byte {
- var pkg []byte
- for _, v := range versions {
- release := buildHexRelease(v)
- pkg = protowire.AppendTag(pkg, 1, protowire.BytesType)
- pkg = protowire.AppendBytes(pkg, release)
- }
- // field 2 = name
- pkg = protowire.AppendTag(pkg, 2, protowire.BytesType)
- pkg = protowire.AppendString(pkg, name)
- // field 3 = repository
- pkg = protowire.AppendTag(pkg, 3, protowire.BytesType)
- pkg = protowire.AppendString(pkg, "hexpm")
- return pkg
-}
-
-// buildHexSigned wraps a payload in a Signed protobuf message and gzips it.
-func buildHexSigned(payload []byte) []byte {
- var signed []byte
- signed = protowire.AppendTag(signed, 1, protowire.BytesType)
- signed = protowire.AppendBytes(signed, payload)
- // field 2 = signature (optional, add a fake one)
- signed = protowire.AppendTag(signed, 2, protowire.BytesType)
- signed = protowire.AppendBytes(signed, []byte("fakesignature"))
-
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- _, _ = gw.Write(signed)
- _ = gw.Close()
- return buf.Bytes()
-}
-
-func TestHexFilterPackageReleases(t *testing.T) {
- pkg := buildHexPackage("phoenix", []string{testVersion100, "2.0.0", "3.0.0"})
-
- filtered, err := filterPackageReleases(pkg, map[string]bool{"2.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
-
- // Extract remaining versions
- var versions []string
- data := filtered
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- break
- }
- data = data[n:]
- switch wtype {
- case protowire.BytesType:
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- break
- }
- if num == 1 { // release field
- version := extractReleaseVersion(v)
- if version != "" {
- versions = append(versions, version)
- }
- }
- data = data[vn:]
- case protowire.VarintType:
- _, vn := protowire.ConsumeVarint(data)
- if vn < 0 {
- break
- }
- data = data[vn:]
- }
- }
-
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d: %v", len(versions), versions)
- }
- if versions[0] != testVersion100 || versions[1] != "3.0.0" {
- t.Errorf("expected [1.0.0, 3.0.0], got %v", versions)
- }
-}
-
-func TestHexFilterSignedPackage(t *testing.T) {
- pkg := buildHexPackage("phoenix", []string{testVersion100, "2.0.0"})
- gzipped := buildHexSigned(pkg)
-
- h := &HexHandler{
- proxy: testProxy(),
- proxyURL: "http://proxy.local",
- }
-
- filtered, err := h.filterSignedPackage(gzipped, map[string]bool{"2.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
-
- // Decompress and check
- gr, err := gzip.NewReader(bytes.NewReader(filtered))
- if err != nil {
- t.Fatal(err)
- }
- signed, err := io.ReadAll(gr)
- if err != nil {
- t.Fatal(err)
- }
-
- payload, err := extractProtobufBytes(signed, 1)
- if err != nil {
- t.Fatal(err)
- }
-
- // Check that only version 1.0.0 remains
- version := extractReleaseVersion(mustExtractFirstRelease(t, payload))
- if version != testVersion100 {
- t.Errorf("expected version 1.0.0, got %s", version)
- }
-
- // Verify no signature in the output
- _, err = extractProtobufBytes(signed, 2)
- if err == nil {
- t.Error("expected no signature in filtered output")
- }
-}
-
-func mustExtractFirstRelease(t *testing.T, payload []byte) []byte {
- t.Helper()
- data := payload
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- t.Fatal("invalid protobuf")
- }
- data = data[n:]
- if wtype == protowire.BytesType {
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- t.Fatal("invalid bytes")
- }
- if num == 1 {
- return v
- }
- data = data[vn:]
- }
- }
- t.Fatal("no release found")
- return nil
-}
-
-func TestHexExtractReleaseVersion(t *testing.T) {
- release := buildHexRelease("1.2.3")
- version := extractReleaseVersion(release)
- if version != "1.2.3" {
- t.Errorf("expected 1.2.3, got %s", version)
- }
-}
-
-func TestHexHandlePackagesWithCooldown(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339Nano)
-
- pkg := buildHexPackage("testpkg", []string{testVersion100, "2.0.0"})
- gzippedProto := buildHexSigned(pkg)
-
- apiJSON, _ := json.Marshal(hexPackageAPI{
- Releases: []hexRelease{
- {Version: testVersion100, InsertedAt: oldTime},
- {Version: "2.0.0", InsertedAt: recentTime},
- },
- })
-
- // Serve both the protobuf repo and the JSON API from the same test server
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/packages/testpkg":
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(gzippedProto)
- case "/api/packages/testpkg":
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(apiJSON)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- // Override hexAPIURL for testing by using the upstream URL
- h := &HexHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- // We need to override the API URL - but it's a const. Let's test via the lower-level methods instead.
- // Test fetchFilteredVersions by making a request to the API endpoint
- // Actually, let me test the full flow through handlePackages
-
- req := httptest.NewRequest(http.MethodGet, "/packages/testpkg", nil)
- req.SetPathValue("name", "testpkg")
- w := httptest.NewRecorder()
-
- // Since hexAPIURL is a const pointing to hex.pm, we can't easily override it in tests.
- // Instead test the protobuf filtering directly which is the core logic.
- filtered, err := h.filterSignedPackage(gzippedProto, map[string]bool{"2.0.0": true})
- if err != nil {
- t.Fatal(err)
- }
-
- // Verify only version 1.0.0 survives
- gr, _ := gzip.NewReader(bytes.NewReader(filtered))
- signed, _ := io.ReadAll(gr)
- payload, _ := extractProtobufBytes(signed, 1)
-
- var versions []string
- data := payload
- for len(data) > 0 {
- num, wtype, n := protowire.ConsumeTag(data)
- if n < 0 {
- break
- }
- data = data[n:]
- if wtype == protowire.BytesType {
- v, vn := protowire.ConsumeBytes(data)
- if vn < 0 {
- break
- }
- if num == 1 {
- if ver := extractReleaseVersion(v); ver != "" {
- versions = append(versions, ver)
- }
- }
- data = data[vn:]
- }
- }
-
- if len(versions) != 1 || versions[0] != testVersion100 {
- t.Errorf("expected [1.0.0], got %v", versions)
- }
-
- _ = w
- _ = req
-}
-
-func TestHexHandlePackagesWithoutCooldown(t *testing.T) {
- pkg := buildHexPackage("testpkg", []string{testVersion100})
- gzipped := buildHexSigned(pkg)
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Encoding", "gzip")
- _, _ = w.Write(gzipped)
- }))
- defer upstream.Close()
-
- h := &HexHandler{
- proxy: testProxy(), // no cooldown
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/packages/testpkg", nil)
- req.SetPathValue("name", "testpkg")
- w := httptest.NewRecorder()
- h.handlePackages(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
diff --git a/internal/handler/integrity.go b/internal/handler/integrity.go
deleted file mode 100644
index bb29a21..0000000
--- a/internal/handler/integrity.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package handler
-
-import (
- "crypto/sha256"
- "crypto/sha512"
- "crypto/subtle"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "hash"
- "io"
- "strings"
-)
-
-// parseSRI parses a Subresource Integrity string (e.g. "sha512-abc==") into
-// an algorithm name and raw digest bytes. Returns ok=false for empty,
-// malformed, or unsupported entries. Only the first hash in a multi-hash
-// SRI string is considered.
-func parseSRI(s string) (algo string, digest []byte, ok bool) {
- s = strings.TrimSpace(s)
- if s == "" {
- return "", nil, false
- }
- if i := strings.IndexByte(s, ' '); i >= 0 {
- s = s[:i]
- }
- algo, b64, found := strings.Cut(s, "-")
- if !found {
- return "", nil, false
- }
- d, err := base64.StdEncoding.DecodeString(b64)
- if err != nil {
- return "", nil, false
- }
- switch algo {
- case "sha256", "sha384", "sha512":
- return algo, d, true
- default:
- return "", nil, false
- }
-}
-
-func newSRIHash(algo string) hash.Hash {
- switch algo {
- case "sha256":
- return sha256.New()
- case "sha384":
- return sha512.New384()
- case "sha512":
- return sha512.New()
- }
- return nil
-}
-
-// verifyingReader wraps an io.ReadCloser and computes SHA256 (and optionally
-// a second SRI hash) as bytes are read. When the underlying reader reaches
-// EOF it compares the digests against the expected values and calls
-// onMismatch for each failure. Verification is skipped if the stream was
-// not fully consumed (e.g. client disconnect) to avoid false positives.
-type verifyingReader struct {
- r io.ReadCloser
- sha256 hash.Hash
- wantSHA256 string
- sri hash.Hash
- sriAlgo string
- wantSRI []byte
- onMismatch func(reason string)
- eof bool
- verified bool
-}
-
-func newVerifyingReader(r io.ReadCloser, contentHash, sri string, onMismatch func(string)) io.ReadCloser {
- if contentHash == "" && sri == "" {
- return r
- }
- v := &verifyingReader{
- r: r,
- onMismatch: onMismatch,
- }
- if contentHash != "" {
- v.sha256 = sha256.New()
- v.wantSHA256 = contentHash
- }
- if algo, digest, ok := parseSRI(sri); ok {
- v.sri = newSRIHash(algo)
- v.sriAlgo = algo
- v.wantSRI = digest
- }
- if v.sha256 == nil && v.sri == nil {
- return r
- }
- return v
-}
-
-func (v *verifyingReader) Read(p []byte) (int, error) {
- n, err := v.r.Read(p)
- if n > 0 {
- if v.sha256 != nil {
- v.sha256.Write(p[:n])
- }
- if v.sri != nil {
- v.sri.Write(p[:n])
- }
- }
- if err == io.EOF {
- v.eof = true
- v.verify()
- }
- return n, err
-}
-
-func (v *verifyingReader) Close() error {
- if v.eof {
- v.verify()
- }
- return v.r.Close()
-}
-
-func (v *verifyingReader) verify() {
- if v.verified {
- return
- }
- v.verified = true
-
- if v.sha256 != nil {
- got := hex.EncodeToString(v.sha256.Sum(nil))
- if subtle.ConstantTimeCompare([]byte(got), []byte(v.wantSHA256)) != 1 {
- v.onMismatch(fmt.Sprintf("content_hash mismatch: stored=%s computed=%s", v.wantSHA256, got))
- }
- }
- if v.sri != nil {
- got := v.sri.Sum(nil)
- if subtle.ConstantTimeCompare(got, v.wantSRI) != 1 {
- v.onMismatch(fmt.Sprintf("integrity mismatch: %s expected=%s computed=%s",
- v.sriAlgo,
- base64.StdEncoding.EncodeToString(v.wantSRI),
- base64.StdEncoding.EncodeToString(got)))
- }
- }
-}
diff --git a/internal/handler/integrity_test.go b/internal/handler/integrity_test.go
deleted file mode 100644
index 93c448c..0000000
--- a/internal/handler/integrity_test.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package handler
-
-import (
- "crypto/sha256"
- "crypto/sha512"
- "encoding/base64"
- "encoding/hex"
- "io"
- "strings"
- "testing"
-)
-
-func sha256Hex(data string) string {
- sum := sha256.Sum256([]byte(data))
- return hex.EncodeToString(sum[:])
-}
-
-func sha512SRI(data string) string {
- sum := sha512.Sum512([]byte(data))
- return "sha512-" + base64.StdEncoding.EncodeToString(sum[:])
-}
-
-func TestParseSRI(t *testing.T) {
- tests := []struct {
- name string
- input string
- algo string
- ok bool
- }{
- {"sha512", sha512SRI("hello"), "sha512", true},
- {"sha256", "sha256-" + base64.StdEncoding.EncodeToString([]byte("0123456789012345678901234567890123456789")), "sha256", true},
- {"empty", "", "", false},
- {"no dash", "sha512abc", "", false},
- {"bad base64", "sha512-not!base64", "", false},
- {"unsupported algo", "md5-" + base64.StdEncoding.EncodeToString([]byte("x")), "", false},
- {"multi hash takes first", sha512SRI("a") + " " + sha512SRI("b"), "sha512", true},
- {"whitespace", " " + sha512SRI("x") + " ", "sha512", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- algo, digest, ok := parseSRI(tt.input)
- if ok != tt.ok {
- t.Fatalf("ok = %v, want %v", ok, tt.ok)
- }
- if !tt.ok {
- return
- }
- if algo != tt.algo {
- t.Errorf("algo = %q, want %q", algo, tt.algo)
- }
- if len(digest) == 0 {
- t.Error("digest is empty")
- }
- })
- }
-}
-
-func TestVerifyingReader(t *testing.T) {
- const data = "hello world"
- goodSHA := sha256Hex(data)
- goodSRI := sha512SRI(data)
-
- tests := []struct {
- name string
- hash string
- sri string
- wantCalls int
- }{
- {"both match", goodSHA, goodSRI, 0},
- {"sha256 only match", goodSHA, "", 0},
- {"sri only match", "", goodSRI, 0},
- {"sha256 mismatch", sha256Hex("other"), "", 1},
- {"sri mismatch", "", sha512SRI("other"), 1},
- {"both mismatch", sha256Hex("other"), sha512SRI("other"), 2},
- {"no checks", "", "", 0},
- {"unparseable sri ignored", goodSHA, "garbage", 0},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var calls []string
- r := newVerifyingReader(io.NopCloser(strings.NewReader(data)), tt.hash, tt.sri,
- func(reason string) { calls = append(calls, reason) })
-
- got, err := io.ReadAll(r)
- if err != nil {
- t.Fatalf("ReadAll: %v", err)
- }
- if string(got) != data {
- t.Errorf("data corrupted: got %q", got)
- }
- if err := r.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- if len(calls) != tt.wantCalls {
- t.Errorf("onMismatch called %d times, want %d: %v", len(calls), tt.wantCalls, calls)
- }
- })
- }
-}
-
-func TestVerifyingReaderPassthrough(t *testing.T) {
- src := io.NopCloser(strings.NewReader("x"))
- r := newVerifyingReader(src, "", "", func(string) { t.Fatal("should not be called") })
- if r != src {
- t.Error("expected passthrough when no hashes provided")
- }
-}
-
-func TestVerifyingReaderPartialRead(t *testing.T) {
- var calls int
- r := newVerifyingReader(io.NopCloser(strings.NewReader("hello world")),
- sha256Hex("hello world"), "", func(string) { calls++ })
-
- buf := make([]byte, 5)
- _, _ = r.Read(buf)
- _ = r.Close()
-
- if calls != 0 {
- t.Errorf("onMismatch called %d times for partial read, want 0", calls)
- }
-}
-
-func TestVerifyingReaderVerifyOnce(t *testing.T) {
- var calls int
- r := newVerifyingReader(io.NopCloser(strings.NewReader("x")), sha256Hex("y"), "",
- func(string) { calls++ })
- _, _ = io.ReadAll(r)
- _ = r.Close()
- _ = r.Close()
- if calls != 1 {
- t.Errorf("onMismatch called %d times, want 1", calls)
- }
-}
diff --git a/internal/handler/julia.go b/internal/handler/julia.go
deleted file mode 100644
index 08b1fdf..0000000
--- a/internal/handler/julia.go
+++ /dev/null
@@ -1,347 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "bufio"
- "bytes"
- "compress/gzip"
- "context"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strings"
- "sync"
-
- "github.com/BurntSushi/toml"
-)
-
-const (
- juliaUpstream = "https://pkg.julialang.org"
- juliaGeneralRegistryUUID = "23338594-aafe-5451-b93e-139f81909106"
- juliaArtifactName = "_artifact"
- juliaRegistryName = "_registry"
-)
-
-var (
- juliaHexPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`)
- juliaUUIDPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
-)
-
-// JuliaHandler handles Julia Pkg server protocol requests.
-//
-// See https://pkgdocs.julialang.org/v1/registries/ and the PkgServer.jl
-// reference implementation. The protocol is content-addressed: registry,
-// package and artifact resources are all identified by git tree hashes
-// and are immutable once published.
-type JuliaHandler struct {
- proxy *Proxy
- upstreamURL string
-
- mu sync.RWMutex
- names map[string]string
- namesHash string
- loadMu sync.Mutex
-}
-
-// NewJuliaHandler creates a new Julia Pkg server handler.
-func NewJuliaHandler(proxy *Proxy, _ string) *JuliaHandler {
- return &JuliaHandler{
- proxy: proxy,
- upstreamURL: juliaUpstream,
- names: make(map[string]string),
- }
-}
-
-// Routes returns the HTTP handler for Julia requests.
-func (h *JuliaHandler) Routes() http.Handler {
- mux := http.NewServeMux()
-
- mux.HandleFunc("GET /registries", h.handleRegistries)
- mux.HandleFunc("GET /registries.eager", h.handleRegistries)
- mux.HandleFunc("GET /registries.conservative", h.handleRegistries)
- mux.HandleFunc("GET /registry/{uuid}/{hash}", h.handleRegistry)
- mux.HandleFunc("GET /package/{uuid}/{hash}", h.handlePackage)
- mux.HandleFunc("GET /artifact/{hash}", h.handleArtifact)
- mux.HandleFunc("GET /meta", h.proxyUpstream)
-
- return mux
-}
-
-// handleRegistries serves the list of available registries. This is the only
-// mutable endpoint in the protocol so it goes through the metadata cache.
-func (h *JuliaHandler) handleRegistries(w http.ResponseWriter, r *http.Request) {
- cacheKey := strings.TrimPrefix(r.URL.Path, "/")
- h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "julia", cacheKey, "*/*")
-}
-
-// handleRegistry serves an immutable registry tarball and refreshes the
-// UUID→name map from its Registry.toml.
-func (h *JuliaHandler) handleRegistry(w http.ResponseWriter, r *http.Request) {
- uuid := r.PathValue("uuid")
- hash := r.PathValue("hash")
- if !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid registry reference", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Info("julia registry request", "uuid", uuid, "hash", hash)
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaRegistryName, hash, hash+".tar.gz", upstreamURL)
- if err != nil {
- h.proxy.Logger.Error("failed to get registry", "error", err)
- http.Error(w, "failed to fetch registry", http.StatusBadGateway)
- return
- }
-
- go h.refreshNamesFromRegistry(uuid, hash)
-
- ServeArtifact(w, result)
-}
-
-// handlePackage serves an immutable package source tarball.
-func (h *JuliaHandler) handlePackage(w http.ResponseWriter, r *http.Request) {
- uuid := r.PathValue("uuid")
- hash := r.PathValue("hash")
- if !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid package reference", http.StatusBadRequest)
- return
- }
-
- if err := h.ensureNames(r.Context()); err != nil {
- h.proxy.Logger.Warn("julia name map unavailable, using uuid", "error", err)
- }
- name := h.resolveName(uuid)
-
- h.proxy.Logger.Info("julia package request", "name", name, "uuid", uuid, "hash", hash)
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", name, hash, hash+".tar.gz", upstreamURL)
- if err != nil {
- h.proxy.Logger.Error("failed to get package", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
- return
- }
-
- ServeArtifact(w, result)
-}
-
-// handleArtifact serves an immutable binary artifact tarball. Artifacts are
-// anonymous content-addressed blobs with no associated package name.
-func (h *JuliaHandler) handleArtifact(w http.ResponseWriter, r *http.Request) {
- hash := r.PathValue("hash")
- if !juliaHexPattern.MatchString(hash) {
- http.Error(w, "invalid artifact hash", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Info("julia artifact request", "hash", hash)
-
- upstreamURL := h.upstreamURL + r.URL.Path
- result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaArtifactName, hash, hash+".tar.gz", upstreamURL)
- if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
- return
- }
-
- ServeArtifact(w, result)
-}
-
-// proxyUpstream forwards a request to the upstream Pkg server without caching.
-func (h *JuliaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, nil)
-}
-
-// resolveName returns the human-readable package name for a UUID, falling
-// back to the UUID itself if it is not present in the loaded registry.
-func (h *JuliaHandler) resolveName(uuid string) string {
- h.mu.RLock()
- defer h.mu.RUnlock()
- if name, ok := h.names[uuid]; ok {
- return name
- }
- return uuid
-}
-
-// ensureNames lazily populates the UUID→name map from the General registry.
-// Returns immediately if the map is already populated; otherwise blocks until
-// a single in-flight load completes. Failed loads are retried on the next call.
-func (h *JuliaHandler) ensureNames(ctx context.Context) error {
- if h.namesLoaded() {
- return nil
- }
-
- h.loadMu.Lock()
- defer h.loadMu.Unlock()
-
- if h.namesLoaded() {
- return nil
- }
- return h.loadNamesFromUpstream(ctx)
-}
-
-func (h *JuliaHandler) namesLoaded() bool {
- h.mu.RLock()
- defer h.mu.RUnlock()
- return len(h.names) > 0
-}
-
-// loadNamesFromUpstream fetches the current /registries listing, downloads the
-// General registry tarball at its current hash, and parses Registry.toml.
-func (h *JuliaHandler) loadNamesFromUpstream(ctx context.Context) error {
- hash, err := h.fetchGeneralRegistryHash(ctx)
- if err != nil {
- return err
- }
- return h.loadRegistryTarball(ctx, juliaGeneralRegistryUUID, hash)
-}
-
-// fetchGeneralRegistryHash reads /registries and returns the current tree hash
-// for the General registry.
-func (h *JuliaHandler) fetchGeneralRegistryHash(ctx context.Context) (string, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.upstreamURL+"/registries", nil)
- if err != nil {
- return "", err
- }
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return "", err
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("upstream /registries returned %d", resp.StatusCode)
- }
-
- scanner := bufio.NewScanner(resp.Body)
- for scanner.Scan() {
- uuid, hash, ok := parseRegistryLine(scanner.Text())
- if ok && uuid == juliaGeneralRegistryUUID {
- return hash, nil
- }
- }
- if err := scanner.Err(); err != nil {
- return "", err
- }
- return "", fmt.Errorf("general registry not listed in /registries")
-}
-
-// refreshNamesFromRegistry reloads the UUID→name map from a registry tarball
-// that has just been cached. Errors are logged but do not affect the response.
-func (h *JuliaHandler) refreshNamesFromRegistry(uuid, hash string) {
- if uuid != juliaGeneralRegistryUUID {
- return
- }
- h.mu.RLock()
- current := h.namesHash
- h.mu.RUnlock()
- if current == hash {
- return
- }
- if err := h.loadRegistryTarball(context.Background(), uuid, hash); err != nil {
- h.proxy.Logger.Warn("failed to refresh julia name map", "error", err)
- }
-}
-
-// loadRegistryTarball downloads a registry tarball and replaces the name map
-// with the contents of its Registry.toml.
-func (h *JuliaHandler) loadRegistryTarball(ctx context.Context, uuid, hash string) error {
- url := fmt.Sprintf("%s/registry/%s/%s", h.upstreamURL, uuid, hash)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return err
- }
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return err
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("upstream registry returned %d", resp.StatusCode)
- }
-
- names, err := extractRegistryNames(resp.Body)
- if err != nil {
- return err
- }
-
- h.mu.Lock()
- h.names = names
- h.namesHash = hash
- h.mu.Unlock()
-
- h.proxy.Logger.Info("loaded julia registry name map", "packages", len(names), "hash", hash)
- return nil
-}
-
-// extractRegistryNames reads a gzipped registry tarball, finds Registry.toml
-// at the root, and returns its [packages] table as a UUID→name map.
-func extractRegistryNames(r io.Reader) (map[string]string, error) {
- gz, err := gzip.NewReader(r)
- if err != nil {
- return nil, fmt.Errorf("opening gzip stream: %w", err)
- }
- defer func() { _ = gz.Close() }()
-
- tr := tar.NewReader(gz)
- for {
- hdr, err := tr.Next()
- if err == io.EOF {
- return nil, fmt.Errorf("no Registry.toml in tarball")
- }
- if err != nil {
- return nil, err
- }
- if strings.TrimPrefix(hdr.Name, "./") != "Registry.toml" {
- continue
- }
-
- data, err := io.ReadAll(tr)
- if err != nil {
- return nil, err
- }
- return parseRegistryToml(data)
- }
-}
-
-type juliaRegistryFile struct {
- Packages map[string]struct {
- Name string `toml:"name"`
- } `toml:"packages"`
-}
-
-// parseRegistryToml decodes the [packages] table of a Registry.toml file.
-func parseRegistryToml(data []byte) (map[string]string, error) {
- var reg juliaRegistryFile
- if _, err := toml.NewDecoder(bytes.NewReader(data)).Decode(®); err != nil {
- return nil, fmt.Errorf("parsing Registry.toml: %w", err)
- }
-
- names := make(map[string]string, len(reg.Packages))
- for uuid, pkg := range reg.Packages {
- if pkg.Name != "" {
- names[uuid] = pkg.Name
- }
- }
- return names, nil
-}
-
-// parseRegistryLine parses a single line from /registries of the form
-// "/registry/{uuid}/{hash}" and returns the uuid and hash.
-func parseRegistryLine(line string) (uuid, hash string, ok bool) {
- line = strings.TrimSpace(line)
- line = strings.TrimPrefix(line, "/registry/")
- uuid, hash, found := strings.Cut(line, "/")
- if !found || !validJuliaUUID(uuid) || !juliaHexPattern.MatchString(hash) {
- return "", "", false
- }
- return uuid, hash, true
-}
-
-// validJuliaUUID reports whether s looks like a lowercase RFC 4122 UUID.
-func validJuliaUUID(s string) bool {
- return juliaUUIDPattern.MatchString(s)
-}
diff --git a/internal/handler/julia_test.go b/internal/handler/julia_test.go
deleted file mode 100644
index 68fb975..0000000
--- a/internal/handler/julia_test.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package handler
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestJuliaParseRegistryLine(t *testing.T) {
- tests := []struct {
- line string
- wantUUID string
- wantHash string
- wantOK bool
- }{
- {
- "/registry/23338594-aafe-5451-b93e-139f81909106/342327538ed6c1ec54c69fa145e7b6bf5934201e",
- "23338594-aafe-5451-b93e-139f81909106",
- "342327538ed6c1ec54c69fa145e7b6bf5934201e",
- true,
- },
- {
- " /registry/23338594-aafe-5451-b93e-139f81909106/342327538ed6c1ec54c69fa145e7b6bf5934201e\n",
- "23338594-aafe-5451-b93e-139f81909106",
- "342327538ed6c1ec54c69fa145e7b6bf5934201e",
- true,
- },
- {"/registry/not-a-uuid/0000", "", "", false},
- {"junk", "", "", false},
- {"", "", "", false},
- }
-
- for _, tt := range tests {
- uuid, hash, ok := parseRegistryLine(tt.line)
- if uuid != tt.wantUUID || hash != tt.wantHash || ok != tt.wantOK {
- t.Errorf("parseRegistryLine(%q) = (%q, %q, %v), want (%q, %q, %v)",
- tt.line, uuid, hash, ok, tt.wantUUID, tt.wantHash, tt.wantOK)
- }
- }
-}
-
-func TestJuliaValidUUID(t *testing.T) {
- tests := []struct {
- s string
- want bool
- }{
- {"23338594-aafe-5451-b93e-139f81909106", true},
- {"295af30f-e4ad-537b-8983-00126c2a3abe", true},
- {"23338594-AAFE-5451-b93e-139f81909106", false},
- {"23338594aafe5451b93e139f81909106", false},
- {"23338594-aafe-5451-b93e-139f8190910", false},
- {"23338594-aafe-5451-b93e-139f81909106-", false},
- {"23338594-gafe-5451-b93e-139f81909106", false},
- {"", false},
- }
-
- for _, tt := range tests {
- if got := validJuliaUUID(tt.s); got != tt.want {
- t.Errorf("validJuliaUUID(%q) = %v, want %v", tt.s, got, tt.want)
- }
- }
-}
-
-func TestJuliaParseRegistryToml(t *testing.T) {
- data := []byte(`name = "General"
-uuid = "23338594-aafe-5451-b93e-139f81909106"
-
-[packages]
-295af30f-e4ad-537b-8983-00126c2a3abe = { name = "Revise", path = "R/Revise" }
-91a5bcdd-55d7-5caf-9e0b-520d859cae80 = { name = "Plots", path = "P/Plots" }
-`)
-
- names, err := parseRegistryToml(data)
- if err != nil {
- t.Fatalf("parseRegistryToml: %v", err)
- }
- if got := names["295af30f-e4ad-537b-8983-00126c2a3abe"]; got != "Revise" {
- t.Errorf("names[Revise uuid] = %q, want Revise", got)
- }
- if got := names["91a5bcdd-55d7-5caf-9e0b-520d859cae80"]; got != "Plots" {
- t.Errorf("names[Plots uuid] = %q, want Plots", got)
- }
- if len(names) != 2 {
- t.Errorf("len(names) = %d, want 2", len(names))
- }
-}
-
-func TestJuliaExtractRegistryNames(t *testing.T) {
- registryToml := `name = "General"
-[packages]
-295af30f-e4ad-537b-8983-00126c2a3abe = { name = "Revise", path = "R/Revise" }
-`
- var buf bytes.Buffer
- gw := gzip.NewWriter(&buf)
- tw := tar.NewWriter(gw)
-
- for _, f := range []struct{ name, body string }{
- {"R/Revise/Package.toml", "name = \"Revise\"\n"},
- {"Registry.toml", registryToml},
- } {
- if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o644, Size: int64(len(f.body))}); err != nil {
- t.Fatalf("WriteHeader: %v", err)
- }
- if _, err := tw.Write([]byte(f.body)); err != nil {
- t.Fatalf("Write: %v", err)
- }
- }
- if err := tw.Close(); err != nil {
- t.Fatalf("tar Close: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("gzip Close: %v", err)
- }
-
- names, err := extractRegistryNames(bytes.NewReader(buf.Bytes()))
- if err != nil {
- t.Fatalf("extractRegistryNames: %v", err)
- }
- if got := names["295af30f-e4ad-537b-8983-00126c2a3abe"]; got != "Revise" {
- t.Errorf("names[Revise uuid] = %q, want Revise", got)
- }
-}
-
-func TestJuliaResolveName(t *testing.T) {
- h := &JuliaHandler{
- proxy: &Proxy{Logger: slog.Default()},
- names: map[string]string{
- "295af30f-e4ad-537b-8983-00126c2a3abe": "Revise",
- },
- }
-
- if got := h.resolveName("295af30f-e4ad-537b-8983-00126c2a3abe"); got != "Revise" {
- t.Errorf("resolveName(known) = %q, want Revise", got)
- }
- if got := h.resolveName("00000000-0000-0000-0000-000000000000"); got != "00000000-0000-0000-0000-000000000000" {
- t.Errorf("resolveName(unknown) = %q, want uuid fallback", got)
- }
-}
-
-func TestJuliaRoutesValidation(t *testing.T) {
- h := NewJuliaHandler(&Proxy{Logger: slog.Default()}, "")
- routes := h.Routes()
-
- tests := []struct {
- path string
- want int
- }{
- {"/package/not-a-uuid/342327538ed6c1ec54c69fa145e7b6bf5934201e", http.StatusBadRequest},
- {"/package/295af30f-e4ad-537b-8983-00126c2a3abe/short", http.StatusBadRequest},
- {"/registry/295af30f-e4ad-537b-8983-00126c2a3abe/zzzz", http.StatusBadRequest},
- {"/artifact/nothex", http.StatusBadRequest},
- {"/nope", http.StatusNotFound},
- }
-
- for _, tt := range tests {
- req := httptest.NewRequest(http.MethodGet, tt.path, nil)
- rr := httptest.NewRecorder()
- routes.ServeHTTP(rr, req)
- if rr.Code != tt.want {
- t.Errorf("GET %s = %d, want %d", tt.path, rr.Code, tt.want)
- }
- }
-}
diff --git a/internal/handler/maven.go b/internal/handler/maven.go
index c423645..5c8f949 100644
--- a/internal/handler/maven.go
+++ b/internal/handler/maven.go
@@ -1,41 +1,30 @@
package handler
import (
- "errors"
"fmt"
+ "io"
"net/http"
"path"
"strings"
)
const (
- mavenCentralUpstream = "https://repo1.maven.org/maven2"
- gradlePluginPortalUpstream = "https://plugins.gradle.org/m2"
- minMavenParts = 4 // group path segments + artifact + version + filename
+ mavenUpstream = "https://repo1.maven.org/maven2"
)
// MavenHandler handles Maven repository protocol requests.
type MavenHandler struct {
- proxy *Proxy
- upstreamURL string
- pluginPortalUpstreamURL string
- proxyURL string
+ proxy *Proxy
+ upstreamURL string
+ proxyURL string
}
// NewMavenHandler creates a new Maven repository handler.
-func NewMavenHandler(proxy *Proxy, proxyURL, upstreamURL, pluginPortalUpstreamURL string) *MavenHandler {
- if strings.TrimSpace(upstreamURL) == "" {
- upstreamURL = mavenCentralUpstream
- }
- if strings.TrimSpace(pluginPortalUpstreamURL) == "" {
- pluginPortalUpstreamURL = gradlePluginPortalUpstream
- }
-
+func NewMavenHandler(proxy *Proxy, proxyURL string) *MavenHandler {
return &MavenHandler{
- proxy: proxy,
- upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
- pluginPortalUpstreamURL: strings.TrimSuffix(pluginPortalUpstreamURL, "/"),
- proxyURL: strings.TrimSuffix(proxyURL, "/"),
+ proxy: proxy,
+ upstreamURL: mavenUpstream,
+ proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
@@ -62,7 +51,8 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
filename := path.Base(urlPath)
if h.isMetadataFile(filename) {
- h.handleMetadata(w, r, urlPath)
+ // Proxy metadata without caching
+ h.proxyUpstream(w, r)
return
}
@@ -76,32 +66,6 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
h.proxyUpstream(w, r)
}
-func (h *MavenHandler) handleMetadata(w http.ResponseWriter, r *http.Request, urlPath string) {
- cacheKey := strings.ReplaceAll(urlPath, "/", "_")
- upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, urlPath)
-
- body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, upstreamURL, "*/*")
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
- h.proxy.Logger.Info("maven metadata unavailable in primary upstream, trying Gradle Plugin Portal",
- "path", urlPath)
- body, contentType, err = h.proxy.FetchOrCacheMetadata(r.Context(), "maven", cacheKey, pluginPortalURL, "*/*")
- }
- }
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- h.proxy.Logger.Error("metadata fetch failed", "error", err)
- http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
- return
- }
-
- h.proxy.writeMetadataCachedResponse(w, r, "maven", cacheKey, body, contentType)
-}
-
// handleDownload serves an artifact file, fetching and caching from upstream if needed.
func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, urlPath string) {
// Parse Maven path: group/artifact/version/filename
@@ -122,18 +86,6 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, upstreamURL)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- pluginPortalURL := fmt.Sprintf("%s/%s", h.pluginPortalUpstreamURL, urlPath)
- h.proxy.Logger.Info("maven artifact not found in primary upstream, trying Gradle Plugin Portal",
- "group", group, "artifact", artifact, "version", version, "filename", filename)
- result, err = h.proxy.GetOrFetchArtifactFromURL(r.Context(), "maven", name, version, filename, pluginPortalURL)
- }
- }
- if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
return
@@ -147,7 +99,7 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
// -> ("com.google.guava", "guava", "32.1.3-jre", "guava-32.1.3-jre.jar")
func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, filename string) {
parts := strings.Split(urlPath, "/")
- if len(parts) < minMavenParts {
+ if len(parts) < 4 {
return "", "", "", ""
}
@@ -163,7 +115,7 @@ func (h *MavenHandler) parsePath(urlPath string) (group, artifact, version, file
// isArtifactFile returns true if the filename looks like a Maven artifact.
func (h *MavenHandler) isArtifactFile(filename string) bool {
// Common artifact extensions
- extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib", ".module"}
+ extensions := []string{".jar", ".war", ".ear", ".pom", ".aar", ".klib"}
for _, ext := range extensions {
if strings.HasSuffix(filename, ext) {
return true
@@ -184,5 +136,30 @@ func (h *MavenHandler) isMetadataFile(filename string) bool {
// proxyUpstream forwards a request to Maven Central without caching.
func (h *MavenHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
- h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, nil)
+ upstreamURL := h.upstreamURL + r.URL.Path
+
+ h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("upstream request failed", "error", err)
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
diff --git a/internal/handler/maven_test.go b/internal/handler/maven_test.go
index 9ca5eb6..df6917c 100644
--- a/internal/handler/maven_test.go
+++ b/internal/handler/maven_test.go
@@ -52,7 +52,6 @@ func TestMavenIsArtifactFile(t *testing.T) {
}{
{"guava-32.1.3-jre.jar", true},
{"guava-32.1.3-jre.pom", true},
- {"guava-32.1.3-jre.module", true},
{"app-1.0.war", true},
{"lib-1.0.aar", true},
{"maven-metadata.xml", false},
@@ -66,63 +65,3 @@ func TestMavenIsArtifactFile(t *testing.T) {
}
}
}
-
-func TestMavenIsMetadataFile(t *testing.T) {
- h := &MavenHandler{}
-
- tests := []struct {
- name string
- filename string
- want bool
- }{
- {
- name: "pom is artifact, not metadata",
- filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom",
- want: false,
- },
- {
- name: "pom checksum is metadata",
- filename: "com.diffplug.spotless.gradle.plugin-8.4.0.pom.sha1",
- want: true,
- },
- {
- name: "metadata file",
- filename: "maven-metadata.xml",
- want: true,
- },
- {
- name: "metadata checksum",
- filename: "maven-metadata.xml.sha256",
- want: true,
- },
- {
- name: "jar checksum is metadata",
- filename: "guava-32.1.3-jre.jar.sha1",
- want: true,
- },
- {
- name: "asc signature is metadata",
- filename: "guava-32.1.3-jre.jar.asc",
- want: true,
- },
- {
- name: "regular jar is not metadata",
- filename: "guava-32.1.3-jre.jar",
- want: false,
- },
- {
- name: "pom checksum is metadata",
- filename: "guava-32.1.3-jre.pom.sha1",
- want: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := h.isMetadataFile(tt.filename)
- if got != tt.want {
- t.Errorf("isMetadataFile(%q) = %v, want %v", tt.filename, got, tt.want)
- }
- })
- }
-}
diff --git a/internal/handler/npm.go b/internal/handler/npm.go
index 0585eda..b2625f1 100644
--- a/internal/handler/npm.go
+++ b/internal/handler/npm.go
@@ -2,21 +2,15 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
+ "io"
"net/http"
"net/url"
- "sort"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
- npmUpstream = "https://registry.npmjs.org"
- npmAbbreviatedCT = "application/vnd.npm.install-v1+json"
- scopedParts = 2 // scope + name in scoped packages
+ npmUpstream = "https://registry.npmjs.org"
)
// NPMHandler handles npm registry protocol requests.
@@ -67,23 +61,37 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
h.proxy.Logger.Info("npm metadata request", "package", packageName)
+ // Fetch metadata from upstream
upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, url.PathEscape(packageName))
- // Use abbreviated metadata when cooldown is disabled — it's much smaller
- // (e.g. drizzle-orm: 4MB vs 92MB) but lacks the time map needed for cooldown.
- accept := npmAbbreviatedCT
- if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
- accept = contentTypeJSON
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "failed to create request")
+ return
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("failed to fetch upstream metadata", "error", err)
+ JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusNotFound {
+ JSONError(w, http.StatusNotFound, "package not found")
+ return
+ }
+ if resp.StatusCode != http.StatusOK {
+ JSONError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode))
+ return
}
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", packageName, upstreamURL, accept)
+ // Parse and rewrite tarball URLs
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- JSONError(w, http.StatusNotFound, "package not found")
- return
- }
- h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
- JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
+ JSONError(w, http.StatusInternalServerError, "failed to read response")
return
}
@@ -91,19 +99,18 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
if err != nil {
// If rewriting fails, just proxy the original
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
- w.Header().Set("Content-Type", contentTypeJSON)
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return
}
- w.Header().Set("Content-Type", contentTypeJSON)
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
// rewriteMetadata rewrites tarball URLs in npm package metadata to point at this proxy.
-// If cooldown is enabled, versions published too recently are filtered out.
func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
@@ -116,71 +123,6 @@ func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, e
return body, nil // No versions to rewrite
}
- h.applyCooldownFiltering(metadata, versions, packageName)
- h.rewriteTarballURLs(versions, packageName)
-
- return json.Marshal(metadata)
-}
-
-// applyCooldownFiltering removes versions that are too recently published,
-// and updates dist-tags.latest if the current latest was filtered out.
-func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions map[string]any, packageName string) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return
- }
-
- timeMap, _ := metadata["time"].(map[string]any)
- if timeMap == nil {
- return
- }
-
- packagePURL := purl.MakePURLString("npm", packageName, "")
-
- for version := range versions {
- publishedStr, ok := timeMap[version].(string)
- if !ok {
- continue
- }
- publishedAt, err := time.Parse(time.RFC3339, publishedStr)
- if err != nil {
- continue
- }
- if !h.proxy.Cooldown.IsAllowed("npm", packagePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering npm version",
- "package", packageName, "version", version,
- "published", publishedStr)
- delete(versions, version)
- delete(timeMap, version)
- }
- }
-
- h.updateDistTagsLatest(metadata, versions, timeMap)
-}
-
-// updateDistTagsLatest updates the dist-tags.latest field if the current latest
-// version was removed by cooldown filtering.
-func (h *NPMHandler) updateDistTagsLatest(metadata, versions, timeMap map[string]any) {
- distTags, ok := metadata["dist-tags"].(map[string]any)
- if !ok {
- return
- }
-
- latest, ok := distTags["latest"].(string)
- if !ok {
- return
- }
-
- if _, exists := versions[latest]; exists {
- return
- }
-
- if newLatest := h.findNewestVersion(versions, timeMap); newLatest != "" {
- distTags["latest"] = newLatest
- }
-}
-
-// rewriteTarballURLs rewrites all tarball URLs in version entries to point at this proxy.
-func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName string) {
for version, vdata := range versions {
vmap, ok := vdata.(map[string]any)
if !ok {
@@ -192,56 +134,25 @@ func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName str
continue
}
- tarball, ok := dist["tarball"].(string)
- if !ok {
- continue
- }
-
- filename := tarball
- if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
- filename = tarball[idx+1:]
- }
-
- escapedName := url.PathEscape(packageName)
- newTarball := fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, escapedName, filename)
- dist["tarball"] = newTarball
-
- h.proxy.Logger.Debug("rewrote tarball URL",
- "package", packageName, "version", version,
- "old", tarball, "new", newTarball)
- }
-}
-
-// findNewestVersion returns the version string with the most recent timestamp
-// from the remaining versions, using the time map.
-func (h *NPMHandler) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
- if timeMap == nil {
- return ""
- }
-
- type versionTime struct {
- version string
- t time.Time
- }
-
- var vts []versionTime
- for v := range versions {
- if ts, ok := timeMap[v].(string); ok {
- if t, err := time.Parse(time.RFC3339, ts); err == nil {
- vts = append(vts, versionTime{v, t})
+ if tarball, ok := dist["tarball"].(string); ok {
+ // Extract filename from tarball URL
+ filename := tarball
+ if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
+ filename = tarball[idx+1:]
}
+
+ // Rewrite to our proxy URL
+ escapedName := url.PathEscape(packageName)
+ newTarball := fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, escapedName, filename)
+ dist["tarball"] = newTarball
+
+ h.proxy.Logger.Debug("rewrote tarball URL",
+ "package", packageName, "version", version,
+ "old", tarball, "new", newTarball)
}
}
- if len(vts) == 0 {
- return ""
- }
-
- sort.Slice(vts, func(i, j int) bool {
- return vts[i].t.After(vts[j].t)
- })
-
- return vts[0].version
+ return json.Marshal(metadata)
}
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
@@ -326,7 +237,7 @@ func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) st
// For scoped packages, the filename uses the short name
shortName := packageName
if strings.Contains(packageName, "/") {
- parts := strings.SplitN(packageName, "/", scopedParts)
+ parts := strings.SplitN(packageName, "/", 2)
shortName = parts[1]
}
diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go
index bc1edde..7d2db00 100644
--- a/internal/handler/npm_test.go
+++ b/internal/handler/npm_test.go
@@ -6,17 +6,11 @@ import (
"net/http"
"net/http/httptest"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
)
-const testVersion100 = "1.0.0"
-
func testProxy() *Proxy {
return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
+ Logger: slog.Default(),
}
}
@@ -32,9 +26,9 @@ func TestNPMExtractVersionFromFilename(t *testing.T) {
{"@babel/core", "core-7.23.0.tgz", "7.23.0"},
{"@types/node", "node-20.10.0.tgz", "20.10.0"},
{"express", "express-4.18.2.tgz", "4.18.2"},
- {"lodash", "lodash.tgz", ""}, // no version
- {"lodash", "lodash-4.17.21.zip", ""}, // wrong extension
- {"lodash", "other-4.17.21.tgz", ""}, // wrong package name
+ {"lodash", "lodash.tgz", ""}, // no version
+ {"lodash", "lodash-4.17.21.zip", ""}, // wrong extension
+ {"lodash", "other-4.17.21.tgz", ""}, // wrong package name
}
for _, tt := range tests {
@@ -174,7 +168,7 @@ func TestNPMHandlerMetadataProxy(t *testing.T) {
// Check that tarball URL was rewritten
versions := result["versions"].(map[string]any)
- v := versions[testVersion100].(map[string]any)
+ v := versions["1.0.0"].(map[string]any)
dist := v["dist"].(map[string]any)
tarball := dist["tarball"].(string)
@@ -183,172 +177,6 @@ func TestNPMHandlerMetadataProxy(t *testing.T) {
}
}
-func TestNPMRewriteMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &NPMHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "testpkg",
- "dist-tags": {"latest": "2.0.0"},
- "time": {
- "1.0.0": "` + old + `",
- "2.0.0": "` + recent + `"
- },
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
- }
- },
- "2.0.0": {
- "name": "testpkg",
- "version": "2.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-2.0.0.tgz"
- }
- }
- }
- }`
-
- output, err := h.rewriteMetadata("testpkg", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].(map[string]any)
-
- // Old version should remain
- if _, ok := versions[testVersion100]; !ok {
- t.Error("version 1.0.0 should not be filtered")
- }
-
- // Recent version should be filtered
- if _, ok := versions["2.0.0"]; ok {
- t.Error("version 2.0.0 should be filtered by cooldown")
- }
-
- // dist-tags.latest should be updated to 1.0.0
- distTags := result["dist-tags"].(map[string]any)
- if distTags["latest"] != testVersion100 {
- t.Errorf("dist-tags.latest = %q, want %q", distTags["latest"], testVersion100)
- }
-}
-
-func TestNPMRewriteMetadataCooldownExemptPackage(t *testing.T) {
- now := time.Now()
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:npm/testpkg": "0"},
- }
-
- h := &NPMHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "testpkg",
- "time": {"1.0.0": "` + recent + `"},
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"}
- }
- }
- }`
-
- output, err := h.rewriteMetadata("testpkg", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].(map[string]any)
- if _, ok := versions[testVersion100]; !ok {
- t.Error("exempt package version should not be filtered")
- }
-}
-
-func TestNPMHandlerUsesAbbreviatedMetadata(t *testing.T) {
- var gotAccept string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAccept = r.Header.Get("Accept")
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "name": "testpkg",
- "versions": {
- "1.0.0": {
- "name": "testpkg",
- "version": "1.0.0",
- "dist": {
- "tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
- }
- }
- }
- }`))
- }))
- defer upstream.Close()
-
- t.Run("no cooldown uses abbreviated metadata", func(t *testing.T) {
- h := &NPMHandler{
- proxy: testProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if gotAccept != npmAbbreviatedCT {
- t.Errorf("Accept = %q, want abbreviated metadata header", gotAccept)
- }
- })
-
- t.Run("cooldown enabled uses full metadata", func(t *testing.T) {
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &NPMHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
- w := httptest.NewRecorder()
- h.handlePackageMetadata(w, req)
-
- if gotAccept == npmAbbreviatedCT {
- t.Error("cooldown enabled should use full metadata, not abbreviated")
- }
- })
-}
-
func TestNPMHandlerMetadataNotFound(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go
index 3cce7f8..1b0360a 100644
--- a/internal/handler/nuget.go
+++ b/internal/handler/nuget.go
@@ -2,14 +2,10 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
"io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -44,7 +40,7 @@ func (h *NuGetHandler) Routes() http.Handler {
mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.proxyUpstream)
// Registration (package metadata) - use prefix matching since {version}.json isn't allowed
- mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.handleRegistration)
+ mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.proxyUpstream)
// Search
mux.HandleFunc("GET /query", h.proxyUpstream)
@@ -61,16 +57,31 @@ func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request
upstreamURL := h.upstreamURL + "/v3/index.json"
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "nuget", "_service_index", upstreamURL)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteServiceIndex(body)
if err != nil {
@@ -141,10 +152,10 @@ func (h *NuGetHandler) shouldRewriteService(serviceType string) bool {
func (h *NuGetHandler) rewriteNuGetURL(origURL string) string {
// Map known NuGet API endpoints to our proxy paths
replacements := map[string]string{
- "https://api.nuget.org/v3-flatcontainer/": h.proxyURL + "/nuget/v3-flatcontainer/",
+ "https://api.nuget.org/v3-flatcontainer/": h.proxyURL + "/nuget/v3-flatcontainer/",
"https://api.nuget.org/v3/registration5-gz-semver2/": h.proxyURL + "/nuget/v3/registration5-gz-semver2/",
- "https://azuresearch-usnc.nuget.org/query": h.proxyURL + "/nuget/query",
- "https://azuresearch-usnc.nuget.org/autocomplete": h.proxyURL + "/nuget/autocomplete",
+ "https://azuresearch-usnc.nuget.org/query": h.proxyURL + "/nuget/query",
+ "https://azuresearch-usnc.nuget.org/autocomplete": h.proxyURL + "/nuget/autocomplete",
}
for old, new := range replacements {
@@ -156,140 +167,6 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL string) string {
return origURL
}
-// handleRegistration proxies NuGet registration pages, applying cooldown filtering.
-func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- h.proxyUpstream(w, r)
- return
- }
-
- upstreamURL := h.buildUpstreamURL(r)
-
- h.proxy.Logger.Debug("fetching registration for cooldown filtering", "url", upstreamURL)
-
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
- if err != nil {
- http.Error(w, "failed to create request", http.StatusInternalServerError)
- return
- }
- req.Header.Set(headerAcceptEncoding, "gzip")
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- h.proxy.Logger.Error("upstream request failed", "error", err)
- http.Error(w, "upstream request failed", http.StatusBadGateway)
- return
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- for k, vv := range resp.Header {
- for _, v := range vv {
- w.Header().Add(k, v)
- }
- }
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, resp.Body)
- return
- }
-
- body, err := ReadMetadata(resp.Body)
- if err != nil {
- http.Error(w, "failed to read response", http.StatusInternalServerError)
- return
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- h.proxy.Logger.Warn("failed to filter registration, proxying original", "error", err)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(body)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(filtered)
-}
-
-// applyCooldownFiltering filters versions from NuGet registration pages
-// that are too recently published.
-func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return body, nil
- }
-
- var registration map[string]any
- if err := json.Unmarshal(body, ®istration); err != nil {
- return nil, err
- }
-
- pages, ok := registration["items"].([]any)
- if !ok {
- return body, nil
- }
-
- for _, page := range pages {
- pageMap, ok := page.(map[string]any)
- if !ok {
- continue
- }
-
- items, ok := pageMap["items"].([]any)
- if !ok {
- continue
- }
-
- filtered := items[:0]
- for _, item := range items {
- itemMap, ok := item.(map[string]any)
- if !ok {
- continue
- }
-
- catalogEntry, ok := itemMap["catalogEntry"].(map[string]any)
- if !ok {
- filtered = append(filtered, item)
- continue
- }
-
- version, _ := catalogEntry["version"].(string)
- id, _ := catalogEntry["id"].(string)
- publishedStr, _ := catalogEntry["published"].(string)
-
- if publishedStr == "" {
- filtered = append(filtered, item)
- continue
- }
-
- publishedAt, err := time.Parse(time.RFC3339, publishedStr)
- if err != nil {
- // NuGet uses a slightly non-standard format, try parsing with fractional seconds
- publishedAt, err = time.Parse("2006-01-02T15:04:05.999-07:00", publishedStr)
- if err != nil {
- filtered = append(filtered, item)
- continue
- }
- }
-
- packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
-
- if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering nuget version",
- "package", id, "version", version,
- "published", publishedStr)
- continue
- }
-
- filtered = append(filtered, item)
- }
-
- pageMap["items"] = filtered
- pageMap["count"] = len(filtered)
- }
-
- return json.Marshal(registration)
-}
-
// handleDownload serves a package file, fetching and caching from upstream if needed.
func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
@@ -338,11 +215,11 @@ func (h *NuGetHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
}
// Copy accept-encoding for compression
- if ae := r.Header.Get(headerAcceptEncoding); ae != "" {
- req.Header.Set(headerAcceptEncoding, ae)
+ if ae := r.Header.Get("Accept-Encoding"); ae != "" {
+ req.Header.Set("Accept-Encoding", ae)
}
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
diff --git a/internal/handler/nuget_test.go b/internal/handler/nuget_test.go
deleted file mode 100644
index b2164e5..0000000
--- a/internal/handler/nuget_test.go
+++ /dev/null
@@ -1,1104 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-func nugetTestProxy() *Proxy {
- return &Proxy{
- Logger: slog.Default(),
- HTTPClient: http.DefaultClient,
- }
-}
-
-func TestNuGetRewriteServiceIndex(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: nugetUpstream,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "version": "3.0.0",
- "resources": [
- {
- "@id": "https://api.nuget.org/v3-flatcontainer/",
- "@type": "PackageBaseAddress/3.0.0"
- },
- {
- "@id": "https://api.nuget.org/v3/registration5-gz-semver2/",
- "@type": "RegistrationsBaseUrl/3.6.0"
- },
- {
- "@id": "https://azuresearch-usnc.nuget.org/query",
- "@type": "SearchQueryService/3.5.0"
- },
- {
- "@id": "https://azuresearch-usnc.nuget.org/autocomplete",
- "@type": "SearchAutocompleteService/3.5.0"
- },
- {
- "@id": "https://example.com/other-service",
- "@type": "SomeOtherService/1.0.0"
- }
- ]
- }`
-
- output, err := h.rewriteServiceIndex([]byte(input))
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- resources := result["resources"].([]any)
- if len(resources) != 5 {
- t.Fatalf("expected 5 resources, got %d", len(resources))
- }
-
- expectations := map[string]string{
- "PackageBaseAddress/3.0.0": "http://localhost:8080/nuget/v3-flatcontainer/",
- "RegistrationsBaseUrl/3.6.0": "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
- "SearchQueryService/3.5.0": "http://localhost:8080/nuget/query",
- "SearchAutocompleteService/3.5.0": "http://localhost:8080/nuget/autocomplete",
- "SomeOtherService/1.0.0": "https://example.com/other-service",
- }
-
- for _, res := range resources {
- rmap := res.(map[string]any)
- rtype := rmap["@type"].(string)
- id := rmap["@id"].(string)
- expected, ok := expectations[rtype]
- if !ok {
- t.Errorf("unexpected resource type: %s", rtype)
- continue
- }
- if id != expected {
- t.Errorf("resource %s: @id = %q, want %q", rtype, id, expected)
- }
- }
-}
-
-func TestNuGetShouldRewriteService(t *testing.T) {
- h := &NuGetHandler{}
-
- rewriteTypes := []string{
- "PackageBaseAddress/3.0.0",
- "RegistrationsBaseUrl/3.6.0",
- "RegistrationsBaseUrl/Versioned",
- "SearchQueryService",
- "SearchQueryService/3.0.0-rc",
- "SearchQueryService/3.5.0",
- "SearchAutocompleteService",
- "SearchAutocompleteService/3.5.0",
- }
-
- for _, stype := range rewriteTypes {
- if !h.shouldRewriteService(stype) {
- t.Errorf("shouldRewriteService(%q) = false, want true", stype)
- }
- }
-
- noRewriteTypes := []string{
- "SomeOtherService/1.0.0",
- "PackagePublish/2.0.0",
- "",
- "SearchQueryService/99.0.0",
- }
-
- for _, stype := range noRewriteTypes {
- if h.shouldRewriteService(stype) {
- t.Errorf("shouldRewriteService(%q) = true, want false", stype)
- }
- }
-}
-
-func TestNuGetRewriteURL(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- tests := []struct {
- input string
- want string
- }{
- {
- "https://api.nuget.org/v3-flatcontainer/",
- "http://localhost:8080/nuget/v3-flatcontainer/",
- },
- {
- "https://api.nuget.org/v3/registration5-gz-semver2/",
- "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
- },
- {
- "https://azuresearch-usnc.nuget.org/query",
- "http://localhost:8080/nuget/query",
- },
- {
- "https://azuresearch-usnc.nuget.org/autocomplete",
- "http://localhost:8080/nuget/autocomplete",
- },
- {
- "https://example.com/unknown",
- "https://example.com/unknown",
- },
- }
-
- for _, tt := range tests {
- got := h.rewriteNuGetURL(tt.input)
- if got != tt.want {
- t.Errorf("rewriteNuGetURL(%q) = %q, want %q", tt.input, got, tt.want)
- }
- }
-}
-
-func TestNuGetHandleServiceIndex(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v3/index.json" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "version": "3.0.0",
- "resources": [
- {
- "@id": "https://api.nuget.org/v3-flatcontainer/",
- "@type": "PackageBaseAddress/3.0.0"
- }
- ]
- }`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want %q", ct, "application/json")
- }
-
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatalf("failed to parse response: %v", err)
- }
-
- resources := result["resources"].([]any)
- r0 := resources[0].(map[string]any)
- if r0["@id"] != "http://proxy.local/nuget/v3-flatcontainer/" {
- t.Errorf("resource @id = %q, want rewritten URL", r0["@id"])
- }
-}
-
-func TestNuGetHandleServiceIndexUpstreamError(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte("internal error"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- // With metadata caching, upstream 500 is reported as 502 (bad gateway)
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetHandleServiceIndexUpstreamUnreachable(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://127.0.0.1:1", // unreachable
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetHandleServiceIndexInvalidJSON(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte("not valid json"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- h.handleServiceIndex(w, req)
-
- // When rewrite fails, the handler falls back to proxying the original body
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d (should fall back to original)", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "not valid json" {
- t.Errorf("body = %q, want original body passed through", body)
- }
-}
-
-func TestNuGetHandleDownloadEmptyParams(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- // Missing path values
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer///", nil)
- req.SetPathValue("id", "")
- req.SetPathValue("version", "")
- req.SetPathValue("filename", "")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadNonNupkg(t *testing.T) {
- // Non-.nupkg files should be proxied upstream
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/xml")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("test"))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.nuspec", nil)
- req.SetPathValue("id", "newtonsoft.json")
- req.SetPathValue("version", "13.0.1")
- req.SetPathValue("filename", "newtonsoft.json.nuspec")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if body != "test" {
- t.Errorf("body = %q, want nuspec content", body)
- }
-}
-
-func TestNuGetProxyUpstream(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v3-flatcontainer/newtonsoft.json/index.json" {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"versions":["13.0.1","13.0.2"]}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "13.0.1") {
- t.Errorf("response body does not contain expected version: %s", body)
- }
-}
-
-func TestNuGetProxyUpstreamNotFound(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/nonexistent/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestNuGetProxyUpstreamBadUpstream(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://127.0.0.1:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
- }
-}
-
-func TestNuGetProxyUpstreamCopiesHeaders(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Custom", "value")
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Header().Get("X-Custom") != "value" {
- t.Errorf("X-Custom = %q, want %q", w.Header().Get("X-Custom"), "value")
- }
-}
-
-func TestNuGetProxyUpstreamForwardsAcceptEncoding(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ae := r.Header.Get("Accept-Encoding")
- if ae != "gzip" {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("expected Accept-Encoding: gzip"))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- req.Header.Set("Accept-Encoding", "gzip")
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestNuGetBuildUpstreamURL(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- }
-
- tests := []struct {
- path string
- query string
- want string
- }{
- {
- "/v3-flatcontainer/newtonsoft.json/index.json",
- "",
- "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.json",
- },
- {
- "/v3/registration5-gz-semver2/newtonsoft.json/index.json",
- "",
- "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json",
- },
- {
- "/query",
- "q=json&take=20",
- "https://azuresearch-usnc.nuget.org/query?q=json&take=20",
- },
- {
- "/autocomplete",
- "q=new&take=10",
- "https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10",
- },
- }
-
- for _, tt := range tests {
- req := httptest.NewRequest(http.MethodGet, tt.path+"?"+tt.query, nil)
- got := h.buildUpstreamURL(req)
- if got != tt.want {
- t.Errorf("buildUpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
- }
- }
-}
-
-func TestNuGetRoutes(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/v3/index.json" {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"version":"3.0.0","resources":[]}`))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- routes := h.Routes()
-
- req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
- w := httptest.NewRecorder()
- routes.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("GET /v3/index.json: status = %d, want %d", w.Code, http.StatusOK)
- }
-}
-
-func TestNewNuGetHandler(t *testing.T) {
- proxy := nugetTestProxy()
- h := NewNuGetHandler(proxy, "http://localhost:8080/")
-
- if h.proxy != proxy {
- t.Error("proxy not set correctly")
- }
- if h.upstreamURL != nugetUpstream {
- t.Errorf("upstreamURL = %q, want %q", h.upstreamURL, nugetUpstream)
- }
- if h.proxyURL != "http://localhost:8080" {
- t.Errorf("proxyURL = %q, want %q (trailing slash should be trimmed)", h.proxyURL, "http://localhost:8080")
- }
-}
-
-func TestNewNuGetHandlerNoTrailingSlash(t *testing.T) {
- proxy := nugetTestProxy()
- h := NewNuGetHandler(proxy, "http://localhost:8080")
-
- if h.proxyURL != "http://localhost:8080" {
- t.Errorf("proxyURL = %q, want %q", h.proxyURL, "http://localhost:8080")
- }
-}
-
-func TestNuGetRewriteServiceIndexNoResources(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- input := `{"version":"3.0.0"}`
- output, err := h.rewriteServiceIndex([]byte(input))
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- // Should return the body unchanged when no resources key
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
- if result["version"] != "3.0.0" {
- t.Errorf("version = %v, want 3.0.0", result["version"])
- }
-}
-
-func TestNuGetRewriteServiceIndexAllTypes(t *testing.T) {
- h := &NuGetHandler{
- proxyURL: "http://localhost:8080",
- }
-
- // Test every rewritable service type
- resources := []map[string]string{
- {"@id": "https://api.nuget.org/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"},
- {"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/3.6.0"},
- {"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/Versioned"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.0.0-rc"},
- {"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.5.0"},
- {"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService"},
- {"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService/3.5.0"},
- }
-
- inputResources := make([]any, len(resources))
- for i, r := range resources {
- inputResources[i] = r
- }
-
- input, _ := json.Marshal(map[string]any{
- "version": "3.0.0",
- "resources": inputResources,
- })
-
- output, err := h.rewriteServiceIndex(input)
- if err != nil {
- t.Fatalf("rewriteServiceIndex failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- outputResources := result["resources"].([]any)
- for _, res := range outputResources {
- rmap := res.(map[string]any)
- id := rmap["@id"].(string)
- // All should be rewritten to proxy URL
- if strings.HasPrefix(id, "https://api.nuget.org") || strings.HasPrefix(id, "https://azuresearch-usnc.nuget.org") {
- t.Errorf("resource %s was not rewritten: %s", rmap["@type"], id)
- }
- }
-}
-
-func TestNuGetProxyUpstreamPreservesStatusCodes(t *testing.T) {
- codes := []int{
- http.StatusOK,
- http.StatusNotFound,
- http.StatusForbidden,
- http.StatusInternalServerError,
- }
-
- for _, code := range codes {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(code)
- }))
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- if w.Code != code {
- t.Errorf("status = %d, want %d", w.Code, code)
- }
-
- upstream.Close()
- }
-}
-
-func TestNuGetProxyUpstreamCopiesBody(t *testing.T) {
- expected := `{"versions":["1.0.0","2.0.0"]}`
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(expected))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
- w := httptest.NewRecorder()
- h.proxyUpstream(w, req)
-
- got, _ := io.ReadAll(w.Body)
- if string(got) != expected {
- t.Errorf("body = %q, want %q", string(got), expected)
- }
-}
-
-func TestNuGetHandleDownloadMissingID(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer//1.0.0/test.nupkg", nil)
- req.SetPathValue("id", "")
- req.SetPathValue("version", "1.0.0")
- req.SetPathValue("filename", "test.nupkg")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadMissingVersion(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test//test.nupkg", nil)
- req.SetPathValue("id", "test")
- req.SetPathValue("version", "")
- req.SetPathValue("filename", "test.nupkg")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetHandleDownloadMissingFilename(t *testing.T) {
- h := &NuGetHandler{
- proxy: nugetTestProxy(),
- upstreamURL: "http://localhost:1",
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/1.0.0/", nil)
- req.SetPathValue("id", "test")
- req.SetPathValue("version", "1.0.0")
- req.SetPathValue("filename", "")
-
- w := httptest.NewRecorder()
- h.handleDownload(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestNuGetBuildUpstreamURLQueryPath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- }
-
- // Query endpoint should go to azuresearch
- req := httptest.NewRequest(http.MethodGet, "/query?q=json&skip=0&take=20", nil)
- got := h.buildUpstreamURL(req)
- want := "https://azuresearch-usnc.nuget.org/query?q=json&skip=0&take=20"
- if got != want {
- t.Errorf("buildUpstreamURL for /query = %q, want %q", got, want)
- }
-}
-
-func TestNuGetBuildUpstreamURLAutocompletePath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/autocomplete?q=new&take=10", nil)
- got := h.buildUpstreamURL(req)
- want := "https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10"
- if got != want {
- t.Errorf("buildUpstreamURL for /autocomplete = %q, want %q", got, want)
- }
-}
-
-func TestNuGetBuildUpstreamURLRegularPath(t *testing.T) {
- h := &NuGetHandler{
- upstreamURL: "https://api.nuget.org",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/newtonsoft.json/index.json", nil)
- got := h.buildUpstreamURL(req)
- want := "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json"
- if got != want {
- t.Errorf("buildUpstreamURL for registration = %q, want %q", got, want)
- }
-}
-
-func TestNuGetCooldownFiltering(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 2,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPackage",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPackage",
- "version": "2.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- }
-
- body, err := json.Marshal(registration)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item after filtering, got %d", len(items))
- }
-
- entry := items[0].(map[string]any)["catalogEntry"].(map[string]any)
- if entry["version"] != testVersion100 {
- t.Errorf("expected version 1.0.0 to survive, got %s", entry["version"])
- }
-
- count := page["count"]
- if count != float64(1) {
- t.Errorf("expected page count to be 1, got %v", count)
- }
-}
-
-func TestNuGetCooldownFilteringWithPackageOverride(t *testing.T) {
- now := time.Now()
- recentTime := now.Add(-2 * time.Hour).Format(time.RFC3339)
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "SpecialPackage",
- "version": "1.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- }
-
- body, err := json.Marshal(registration)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- Packages: map[string]string{"pkg:nuget/specialpackage": "1h"},
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item (package override allows it), got %d", len(items))
- }
-}
-
-func TestNuGetCooldownNoCooldownConfig(t *testing.T) {
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "Test",
- "version": "1.0.0",
- "published": time.Now().Format(time.RFC3339),
- },
- },
- },
- },
- },
- }
-
- body, err := json.Marshal(registration)
- if err != nil {
- t.Fatal(err)
- }
-
- // No cooldown - applyCooldownFiltering still works, just doesn't filter
- h := &NuGetHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- // Without cooldown config on the handler, applyCooldownFiltering
- // is called but proxy.Cooldown is nil, so IsAllowed is never called
- // Actually, applyCooldownFiltering always runs the filter logic -
- // but the caller (handleRegistration) short-circuits when cooldown is disabled.
- // The function itself should still work fine with a nil Cooldown.
- if len(items) != 1 {
- t.Fatalf("expected 1 item, got %d", len(items))
- }
-}
-
-func TestNuGetCooldownFilteringNuGetTimestamp(t *testing.T) {
- // NuGet uses timestamps like "2024-09-07T01:37:52.233+00:00" which
- // have fractional seconds - verify these parse correctly
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format("2006-01-02T15:04:05.000-07:00")
-
- registration := map[string]any{
- "items": []any{
- map[string]any{
- "count": 1,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "Test",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- },
- },
- },
- }
-
- body, err := json.Marshal(registration)
- if err != nil {
- t.Fatal(err)
- }
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- filtered, err := h.applyCooldownFiltering(body)
- if err != nil {
- t.Fatal(err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(filtered, &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item (old enough to pass cooldown), got %d", len(items))
- }
-}
-
-func TestNuGetHandleRegistrationWithCooldown(t *testing.T) {
- now := time.Now()
- oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
- recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- registrationJSON, _ := json.Marshal(map[string]any{
- "items": []any{
- map[string]any{
- "count": 2,
- "items": []any{
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPkg",
- "version": "1.0.0",
- "published": oldTime,
- },
- },
- map[string]any{
- "catalogEntry": map[string]any{
- "id": "TestPkg",
- "version": "2.0.0",
- "published": recentTime,
- },
- },
- },
- },
- },
- })
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write(registrationJSON)
- }))
- defer upstream.Close()
-
- proxy := testProxy()
- proxy.Cooldown = &cooldown.Config{
- Default: "3d",
- }
-
- h := &NuGetHandler{
- proxy: proxy,
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
- w := httptest.NewRecorder()
- h.handleRegistration(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-
- var result map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
-
- pages := result["items"].([]any)
- page := pages[0].(map[string]any)
- items := page["items"].([]any)
-
- if len(items) != 1 {
- t.Fatalf("expected 1 item after cooldown filtering, got %d", len(items))
- }
-}
-
-func TestNuGetHandleRegistrationWithoutCooldown(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"items":[]}`))
- }))
- defer upstream.Close()
-
- h := &NuGetHandler{
- proxy: nugetTestProxy(), // no cooldown configured
- upstreamURL: upstream.URL,
- proxyURL: "http://proxy.local",
- }
-
- req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
- w := httptest.NewRecorder()
- h.handleRegistration(w, req)
-
- // Without cooldown, should proxy directly
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
- }
-}
diff --git a/internal/handler/path_traversal_test.go b/internal/handler/path_traversal_test.go
deleted file mode 100644
index 5ad68a5..0000000
--- a/internal/handler/path_traversal_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package handler
-
-import "testing"
-
-func TestContainsPathTraversal(t *testing.T) {
- tests := []struct {
- path string
- want bool
- }{
- {"pool/main/n/nginx/nginx_1.0.deb", false},
- {"releases/39/Packages/test.rpm", false},
- {"../etc/passwd", true},
- {"pool/../../etc/passwd", true},
- {"pool/main/../../../etc/shadow", true},
- {"pool/..hidden/file", false}, // ".." as a segment, not "..hidden"
- {"", false},
- {"%2e%2e/etc/passwd", true},
- {"%2e%2e%2fetc%2fpasswd", true},
- {"pool/%2e%2e/%2e%2e/etc/shadow", true},
- {"%2E%2E%2Fetc", true},
- {`..\\etc\\passwd`, true},
- {`pool\\..\\..\\etc`, true},
- {"%2e%2e%5cetc%5cpasswd", true},
- {"pool/%2e%2ehidden/file", false},
- {"pool/%zz/bad-encoding", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.path, func(t *testing.T) {
- got := containsPathTraversal(tt.path)
- if got != tt.want {
- t.Errorf("containsPathTraversal(%q) = %v, want %v", tt.path, got, tt.want)
- }
- })
- }
-}
diff --git a/internal/handler/pub.go b/internal/handler/pub.go
index 60bbbad..154dc45 100644
--- a/internal/handler/pub.go
+++ b/internal/handler/pub.go
@@ -2,18 +2,14 @@ package handler
import (
"encoding/json"
- "errors"
"fmt"
+ "io"
"net/http"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
- pubUpstream = "https://pub.dev"
- pubPathParts = 2 // name + version in path split by /versions/
+ pubUpstream = "https://pub.dev"
)
// PubHandler handles pub.dev registry protocol requests.
@@ -50,7 +46,7 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
// Parse path: /packages/{name}/versions/{version}.tar.gz
path := strings.TrimPrefix(r.URL.Path, "/packages/")
parts := strings.Split(path, "/versions/")
- if len(parts) != pubPathParts {
+ if len(parts) != 2 {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
@@ -89,16 +85,32 @@ func (h *PubHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
upstreamURL := fmt.Sprintf("%s/api/packages/%s", h.upstreamURL, name)
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pub", name, upstreamURL)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteMetadata(name, body)
if err != nil {
@@ -115,31 +127,18 @@ func (h *PubHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
}
// rewriteMetadata rewrites archive_url fields to point at this proxy.
-// If cooldown is enabled, versions published too recently are filtered out.
func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
+ // Rewrite archive URLs in versions
versions, ok := metadata["versions"].([]any)
if !ok {
return body, nil
}
- packagePURL := purl.MakePURLString("pub", name, "")
- filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
- metadata["versions"] = filtered
-
- h.updateLatestVersion(metadata, filtered)
-
- return json.Marshal(metadata)
-}
-
-// filterAndRewriteVersions applies cooldown filtering and rewrites archive URLs
-// for a package's version list.
-func (h *PubHandler) filterAndRewriteVersions(name, packagePURL string, versions []any) []any {
- filtered := versions[:0]
for _, vdata := range versions {
vmap, ok := vdata.(map[string]any)
if !ok {
@@ -151,72 +150,13 @@ func (h *PubHandler) filterAndRewriteVersions(name, packagePURL string, versions
continue
}
- if h.shouldFilterVersion(packagePURL, name, version, vmap) {
- continue
- }
-
+ // Rewrite archive_url
newURL := fmt.Sprintf("%s/pub/packages/%s/versions/%s.tar.gz", h.proxyURL, name, version)
vmap["archive_url"] = newURL
- filtered = append(filtered, vdata)
h.proxy.Logger.Debug("rewrote archive URL",
"package", name, "version", version, "new", newURL)
}
- return filtered
-}
-
-// shouldFilterVersion returns true if the version should be excluded due to cooldown.
-func (h *PubHandler) shouldFilterVersion(packagePURL, name, version string, vmap map[string]any) bool {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return false
- }
-
- publishedStr, ok := vmap["published"].(string)
- if !ok {
- return false
- }
-
- publishedAt, err := time.Parse(time.RFC3339, publishedStr)
- if err != nil {
- return false
- }
-
- if !h.proxy.Cooldown.IsAllowed("pub", packagePURL, publishedAt) {
- h.proxy.Logger.Info("cooldown: filtering pub version",
- "package", name, "version", version)
- return true
- }
-
- return false
-}
-
-// updateLatestVersion updates the latest field if the current latest version
-// was removed by cooldown filtering.
-func (h *PubHandler) updateLatestVersion(metadata map[string]any, filtered []any) {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
- return
- }
-
- latest, ok := metadata["latest"].(map[string]any)
- if !ok {
- return
- }
-
- latestVer, ok := latest["version"].(string)
- if !ok {
- return
- }
-
- for _, vdata := range filtered {
- if vmap, ok := vdata.(map[string]any); ok {
- if vmap["version"] == latestVer {
- return
- }
- }
- }
-
- if len(filtered) > 0 {
- metadata["latest"] = filtered[len(filtered)-1]
- }
+ return json.Marshal(metadata)
}
diff --git a/internal/handler/pub_test.go b/internal/handler/pub_test.go
deleted file mode 100644
index 8a4c098..0000000
--- a/internal/handler/pub_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "log/slog"
- "testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
-)
-
-func TestPubRewriteMetadata(t *testing.T) {
- h := &PubHandler{
- proxy: testProxy(),
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "flutter_bloc",
- "versions": [
- {"version": "1.0.0", "archive_url": "https://pub.dev/packages/flutter_bloc/versions/1.0.0.tar.gz"},
- {"version": "2.0.0", "archive_url": "https://pub.dev/packages/flutter_bloc/versions/2.0.0.tar.gz"}
- ]
- }`
-
- output, err := h.rewriteMetadata("flutter_bloc", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].([]any)
- if len(versions) != 2 {
- t.Fatalf("expected 2 versions, got %d", len(versions))
- }
-
- v1 := versions[0].(map[string]any)
- if v1["archive_url"] != "http://localhost:8080/pub/packages/flutter_bloc/versions/1.0.0.tar.gz" {
- t.Errorf("unexpected archive_url: %s", v1["archive_url"])
- }
-}
-
-func TestPubRewriteMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &PubHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "name": "flutter_bloc",
- "latest": {"version": "2.0.0"},
- "versions": [
- {"version": "1.0.0", "published": "` + old + `", "archive_url": "https://pub.dev/1.0.0.tar.gz"},
- {"version": "2.0.0", "published": "` + recent + `", "archive_url": "https://pub.dev/2.0.0.tar.gz"}
- ]
- }`
-
- output, err := h.rewriteMetadata("flutter_bloc", []byte(input))
- if err != nil {
- t.Fatalf("rewriteMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- versions := result["versions"].([]any)
- if len(versions) != 1 {
- t.Fatalf("expected 1 version after cooldown, got %d", len(versions))
- }
-
- v := versions[0].(map[string]any)
- if v["version"] != "1.0.0" {
- t.Errorf("expected version 1.0.0, got %v", v["version"])
- }
-
- // latest should be updated
- latest := result["latest"].(map[string]any)
- if latest["version"] != "1.0.0" {
- t.Errorf("latest version = %v, want 1.0.0", latest["version"])
- }
-}
diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go
index 3021d2b..cafbb52 100644
--- a/internal/handler/pypi.go
+++ b/internal/handler/pypi.go
@@ -4,24 +4,16 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
- "errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
- "time"
-
- "github.com/git-pkgs/purl"
)
const (
- pypiUpstream = "https://pypi.org"
- minWheelParts = 5 // name + version + python + abi + platform
- minSubmatchParts = 2 // full match + first capture group
- minPyPIPathParts = 3 // hash_prefix + hash + filename
- minPythonTagLen = 2 // minimum length for a python tag (e.g., "py")
+ pypiUpstream = "https://pypi.org"
)
// PyPIHandler handles PyPI registry protocol requests.
@@ -75,122 +67,63 @@ func (h *PyPIHandler) handleSimplePackage(w http.ResponseWriter, r *http.Request
h.proxy.Logger.Info("pypi simple request", "package", name)
upstreamURL := fmt.Sprintf("%s/simple/%s/", h.upstreamURL, name)
- cacheKey := name + "/simple"
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL, "text/html")
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Accept", "text/html")
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
- // When cooldown is enabled, fetch JSON metadata to get version timestamps
- var filteredVersions map[string]bool
- if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
- filteredVersions = h.fetchFilteredVersions(r, name)
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
}
- rewritten := h.rewriteSimpleHTML(body, filteredVersions)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
+
+ rewritten := h.rewriteSimpleHTML(body)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
-// fetchFilteredVersions fetches JSON metadata and returns a set of version strings
-// that should be filtered out due to cooldown.
-func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[string]bool {
- jsonURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, jsonURL, nil)
- if err != nil {
- return nil
- }
- req.Header.Set("Accept", "application/json")
-
- resp, err := h.proxy.HTTPClient.Do(req)
- if err != nil {
- return nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return nil
- }
-
- var metadata map[string]any
- if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
- return nil
- }
-
- releases, ok := metadata["releases"].(map[string]any)
- if !ok {
- return nil
- }
-
- packagePURL := purl.MakePURLString("pypi", name, "")
- filtered := make(map[string]bool)
-
- for version, files := range releases {
- filesArr, ok := files.([]any)
- if !ok {
- continue
- }
- publishedAt := h.newestUploadTime(filesArr)
- if !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt) {
- filtered[version] = true
- }
- }
-
- if len(filtered) == 0 {
- return nil
- }
- return filtered
-}
-
// rewriteSimpleHTML rewrites package URLs in simple API HTML to point at this proxy.
-// If filteredVersions is non-nil, links for those versions are removed entirely.
-func (h *PyPIHandler) rewriteSimpleHTML(body []byte, filteredVersions map[string]bool) []byte {
- // If cooldown filtering is active, remove entire tags for filtered versions
- if len(filteredVersions) > 0 {
- // Match full anchor tags: filename
- linkRe := regexp.MustCompile(`]+href="[^"]*"[^>]*>[^<]+`)
- body = linkRe.ReplaceAllFunc(body, func(match []byte) []byte {
- // Extract filename from between tags
- innerRe := regexp.MustCompile(`>([^<]+)`)
- innerMatch := innerRe.FindSubmatch(match)
- if len(innerMatch) < minSubmatchParts {
- return match
- }
- filename := string(innerMatch[1])
- _, version := h.parseFilename(strings.TrimSpace(filename))
- if version != "" && filteredVersions[version] {
- return nil
- }
- return match
- })
- }
-
+func (h *PyPIHandler) rewriteSimpleHTML(body []byte) []byte {
// Match href attributes pointing to packages
// PyPI URLs look like: https://files.pythonhosted.org/packages/...
re := regexp.MustCompile(`href="(https://files\.pythonhosted\.org/packages/[^"]+)"`)
return re.ReplaceAllFunc(body, func(match []byte) []byte {
+ // Extract the URL
submatch := re.FindSubmatch(match)
- if len(submatch) < minSubmatchParts {
+ if len(submatch) < 2 {
return match
}
origURL := string(submatch[1])
+ // Parse the URL to get the path
u, err := url.Parse(origURL)
if err != nil {
return match
}
+ // Rewrite to our proxy
newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
return []byte(fmt.Sprintf(`href="%s"`, newURL))
})
@@ -207,7 +140,7 @@ func (h *PyPIHandler) handleJSON(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("pypi json request", "package", name)
upstreamURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
- h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/json")
+ h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// handleVersionJSON serves the JSON API version metadata.
@@ -223,21 +156,37 @@ func (h *PyPIHandler) handleVersionJSON(w http.ResponseWriter, r *http.Request)
h.proxy.Logger.Info("pypi version json request", "package", name, "version", version)
upstreamURL := fmt.Sprintf("%s/pypi/%s/%s/json", h.upstreamURL, name, version)
- h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/"+version)
+ h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// proxyAndRewriteJSON fetches JSON metadata and rewrites download URLs.
-func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL, cacheKey string) {
- body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL)
+func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL string) {
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- if errors.Is(err, ErrUpstreamNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read response", http.StatusInternalServerError)
+ return
+ }
rewritten, err := h.rewriteJSONMetadata(body)
if err != nil {
@@ -252,86 +201,13 @@ func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request
}
// rewriteJSONMetadata rewrites download URLs in PyPI JSON metadata.
-// If cooldown is enabled, versions published too recently are filtered out.
func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
- packageName, _ := extractPyPIName(metadata)
- packagePURL := ""
- if packageName != "" {
- packagePURL = purl.MakePURLString("pypi", packageName, "")
- }
-
- h.filterAndRewriteReleases(metadata, packageName, packagePURL)
- h.filterAndRewriteURLs(metadata, packagePURL)
-
- return json.Marshal(metadata)
-}
-
-// filterAndRewriteReleases applies cooldown filtering and URL rewriting to the
-// releases map in PyPI metadata.
-func (h *PyPIHandler) filterAndRewriteReleases(metadata map[string]any, packageName, packagePURL string) {
- releases, ok := metadata["releases"].(map[string]any)
- if !ok {
- return
- }
-
- for version, files := range releases {
- if h.shouldFilterRelease(packagePURL, files) {
- h.proxy.Logger.Info("cooldown: filtering pypi version",
- "package", packageName, "version", version)
- delete(releases, version)
- continue
- }
-
- h.rewriteFileEntries(files)
- }
-}
-
-// shouldFilterRelease returns true if a release should be excluded due to cooldown.
-func (h *PyPIHandler) shouldFilterRelease(packagePURL string, files any) bool {
- if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() || packagePURL == "" {
- return false
- }
-
- filesArr, ok := files.([]any)
- if !ok {
- return false
- }
-
- publishedAt := h.newestUploadTime(filesArr)
- return !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt)
-}
-
-// rewriteFileEntries rewrites URLs in a list of file entries.
-func (h *PyPIHandler) rewriteFileEntries(files any) {
- filesArr, ok := files.([]any)
- if !ok {
- return
- }
-
- for _, f := range filesArr {
- if fmap, ok := f.(map[string]any); ok {
- h.rewriteURLEntry(fmap)
- }
- }
-}
-
-// filterAndRewriteURLs applies cooldown filtering and URL rewriting to the
-// urls array (current version files) in PyPI metadata.
-func (h *PyPIHandler) filterAndRewriteURLs(metadata map[string]any, packagePURL string) {
- urls, ok := metadata["urls"].([]any)
- if !ok {
- return
- }
-
- if h.shouldFilterRelease(packagePURL, urls) {
- metadata["urls"] = []any{}
- }
-
+ // Rewrite URLs in urls array
if urls, ok := metadata["urls"].([]any); ok {
for _, u := range urls {
if umap, ok := u.(map[string]any); ok {
@@ -339,39 +215,21 @@ func (h *PyPIHandler) filterAndRewriteURLs(metadata map[string]any, packagePURL
}
}
}
-}
-// extractPyPIName extracts the package name from PyPI JSON metadata.
-func extractPyPIName(metadata map[string]any) (string, bool) {
- info, ok := metadata["info"].(map[string]any)
- if !ok {
- return "", false
- }
- name, ok := info["name"].(string)
- return name, ok
-}
-
-// newestUploadTime returns the most recent upload_time_iso_8601 from a list of file entries.
-func (h *PyPIHandler) newestUploadTime(files []any) time.Time {
- var newest time.Time
- for _, f := range files {
- fmap, ok := f.(map[string]any)
- if !ok {
- continue
- }
- ts, ok := fmap["upload_time_iso_8601"].(string)
- if !ok {
- continue
- }
- t, err := time.Parse(time.RFC3339, ts)
- if err != nil {
- continue
- }
- if t.After(newest) {
- newest = t
+ // Rewrite URLs in releases map
+ if releases, ok := metadata["releases"].(map[string]any); ok {
+ for _, files := range releases {
+ if filesArr, ok := files.([]any); ok {
+ for _, f := range filesArr {
+ if fmap, ok := f.(map[string]any); ok {
+ h.rewriteURLEntry(fmap)
+ }
+ }
+ }
}
}
- return newest
+
+ return json.Marshal(metadata)
}
// rewriteURLEntry rewrites a single URL entry in PyPI metadata.
@@ -404,7 +262,7 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
// Path format: /packages/{hash_prefix}/{hash}/{filename}
// e.g., /packages/ab/cd/abc123.../requests-2.31.0.tar.gz
parts := strings.Split(path, "/")
- if len(parts) < minPyPIPathParts {
+ if len(parts) < 3 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
@@ -421,10 +279,8 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("pypi download request",
"name", name, "version", version, "filename", filename)
- // Construct upstream URL; the incoming path starts with
- // '/packages' so there is no need to include it in the format
- // string
- upstreamURL := fmt.Sprintf("https://files.pythonhosted.org/%s", path)
+ // Construct upstream URL
+ upstreamURL := fmt.Sprintf("https://files.pythonhosted.org/packages/%s", path)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
if err != nil {
@@ -445,7 +301,7 @@ func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
if strings.HasSuffix(filename, ".whl") {
base := strings.TrimSuffix(filename, ".whl")
parts := strings.Split(base, "-")
- if len(parts) >= minWheelParts {
+ if len(parts) >= 5 {
// Find where version ends (version followed by python tag)
for i := 1; i < len(parts)-2; i++ {
// Check if this looks like a python tag (py2, py3, cp39, etc)
@@ -475,7 +331,7 @@ func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
}
func isPythonTag(s string) bool {
- if len(s) < minPythonTagLen {
+ if len(s) < 2 {
return false
}
// Python tags start with py, cp, pp, ip, jy
@@ -508,7 +364,7 @@ func (h *PyPIHandler) proxySimple(w http.ResponseWriter, r *http.Request, path s
}
req.Header.Set("Accept", "text/html")
- resp, err := h.proxy.HTTPClient.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
diff --git a/internal/handler/pypi_test.go b/internal/handler/pypi_test.go
index 2b58960..70a8266 100644
--- a/internal/handler/pypi_test.go
+++ b/internal/handler/pypi_test.go
@@ -1,17 +1,8 @@
package handler
import (
- "encoding/json"
- "io"
"log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
"testing"
- "time"
-
- "github.com/git-pkgs/cooldown"
- "github.com/git-pkgs/registries/fetch"
)
func TestPyPIParseFilename(t *testing.T) {
@@ -46,54 +37,6 @@ func TestPyPIParseFilename(t *testing.T) {
}
}
-func TestPyPIRewriteJSONMetadataCooldown(t *testing.T) {
- now := time.Now()
- old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
- recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
-
- proxy := &Proxy{Logger: slog.Default()}
- proxy.Cooldown = &cooldown.Config{Default: "3d"}
-
- h := &PyPIHandler{
- proxy: proxy,
- proxyURL: "http://localhost:8080",
- }
-
- input := `{
- "info": {"name": "requests"},
- "releases": {
- "2.30.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.30.0.tar.gz", "upload_time_iso_8601": "` + old + `"}],
- "2.31.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
- },
- "urls": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
- }`
-
- output, err := h.rewriteJSONMetadata([]byte(input))
- if err != nil {
- t.Fatalf("rewriteJSONMetadata failed: %v", err)
- }
-
- var result map[string]any
- if err := json.Unmarshal(output, &result); err != nil {
- t.Fatalf("failed to parse output: %v", err)
- }
-
- releases := result["releases"].(map[string]any)
-
- if _, ok := releases["2.30.0"]; !ok {
- t.Error("version 2.30.0 should not be filtered")
- }
- if _, ok := releases["2.31.0"]; ok {
- t.Error("version 2.31.0 should be filtered by cooldown")
- }
-
- // urls array should be empty since the current version is filtered
- urls := result["urls"].([]any)
- if len(urls) != 0 {
- t.Errorf("urls should be empty, got %d entries", len(urls))
- }
-}
-
func TestIsPythonTag(t *testing.T) {
tests := []struct {
tag string
@@ -116,79 +59,3 @@ func TestIsPythonTag(t *testing.T) {
}
}
}
-
-func TestPyPIHandler_DownloadUpstreamURL(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("wheel data")),
- ContentType: "application/octet-stream",
- }
-
- h := NewPyPIHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- // The path wildcard {path...} captures everything after /packages/,
- // which includes "packages/" from the rewritten URL. The upstream URL
- // must not double the "packages" segment.
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Fatal("expected fetcher to be called on cache miss")
- }
-
- want := "https://files.pythonhosted.org/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl"
- if fetcher.fetchedURL != want {
- t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
- }
-}
-
-func TestPyPIHandler_DownloadCacheHit(t *testing.T) {
- proxy, db, store, _ := setupTestProxy(t)
- seedPackage(t, db, store, "pypi", "requests", "2.31.0",
- "requests-2.31.0-py3-none-any.whl", "wheel binary data")
-
- h := NewPyPIHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
- }
- body, _ := io.ReadAll(resp.Body)
- if string(body) != "wheel binary data" {
- t.Errorf("body = %q, want %q", body, "wheel binary data")
- }
-}
-
-func TestPyPIHandler_DownloadCacheMiss(t *testing.T) {
- proxy, _, _, fetcher := setupTestProxy(t)
- fetcher.artifact = &fetch.Artifact{
- Body: io.NopCloser(strings.NewReader("fetched wheel")),
- ContentType: "application/octet-stream",
- }
-
- h := NewPyPIHandler(proxy, "http://localhost")
- srv := httptest.NewServer(h.Routes())
- defer srv.Close()
-
- resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz")
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if !fetcher.fetchCalled {
- t.Error("expected fetcher to be called on cache miss")
- }
-}
diff --git a/internal/handler/read_metadata_test.go b/internal/handler/read_metadata_test.go
deleted file mode 100644
index 60c1cf2..0000000
--- a/internal/handler/read_metadata_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package handler
-
-import (
- "bytes"
- "errors"
- "testing"
-)
-
-func TestReadMetadata(t *testing.T) {
- t.Run("small body", func(t *testing.T) {
- data := []byte("hello world")
- got, err := ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if !bytes.Equal(got, data) {
- t.Errorf("got %q, want %q", got, data)
- }
- })
-
- t.Run("exactly at limit", func(t *testing.T) {
- data := make([]byte, maxMetadataSize)
- for i := range data {
- data[i] = 'x'
- }
- got, err := ReadMetadata(bytes.NewReader(data))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(got) != int(maxMetadataSize) {
- t.Errorf("got length %d, want %d", len(got), maxMetadataSize)
- }
- })
-
- t.Run("over limit returns error", func(t *testing.T) {
- data := make([]byte, maxMetadataSize+100)
- for i := range data {
- data[i] = 'x'
- }
- _, err := ReadMetadata(bytes.NewReader(data))
- if !errors.Is(err, ErrMetadataTooLarge) {
- t.Errorf("got error %v, want ErrMetadataTooLarge", err)
- }
- })
-}
diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go
index 6440d0f..cff5b00 100644
--- a/internal/handler/rpm.go
+++ b/internal/handler/rpm.go
@@ -2,6 +2,7 @@ package handler
import (
"fmt"
+ "io"
"net/http"
"regexp"
"strings"
@@ -10,7 +11,6 @@ import (
const (
// Default upstream for Fedora packages
defaultRPMUpstream = "https://dl.fedoraproject.org/pub/fedora/linux"
- rpmMatchCount = 5 // full match + name + version + release + arch
)
// RPMHandler handles RPM/Yum repository protocol requests.
@@ -41,11 +41,6 @@ func (h *RPMHandler) Routes() http.Handler {
path := strings.TrimPrefix(r.URL.Path, "/")
- if containsPathTraversal(path) {
- http.Error(w, "invalid path", http.StatusBadRequest)
- return
- }
-
// Route based on path type
switch {
case strings.HasSuffix(path, ".rpm"):
@@ -95,13 +90,67 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques
// handleMetadata proxies repository metadata files (repomd.xml, primary.xml.gz, etc.).
// These change frequently so we don't cache them.
func (h *RPMHandler) handleMetadata(w http.ResponseWriter, r *http.Request, path string) {
- cacheKey := strings.ReplaceAll(path, "/", "_")
- h.proxy.ProxyCached(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path), "rpm", cacheKey, "*/*")
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
+
+ h.proxy.Logger.Debug("rpm metadata request", "path", path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ // Forward relevant headers
+ for _, header := range []string{"Accept", "Accept-Encoding", "If-Modified-Since", "If-None-Match"} {
+ if v := r.Header.Get(header); v != "" {
+ req.Header.Set(header, v)
+ }
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ h.proxy.Logger.Error("failed to fetch upstream metadata", "error", err)
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Copy response headers
+ for _, header := range []string{"Content-Type", "Content-Length", "Last-Modified", "ETag"} {
+ if v := resp.Header.Get(header); v != "" {
+ w.Header().Set(header, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// proxyFile proxies any file directly without caching.
func (h *RPMHandler) proxyFile(w http.ResponseWriter, r *http.Request, path string) {
- h.proxy.ProxyFile(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path))
+ upstreamURL := fmt.Sprintf("%s/%s", h.upstreamURL, path)
+
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
+ if err != nil {
+ http.Error(w, "failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for key, values := range resp.Header {
+ for _, v := range values {
+ w.Header().Add(key, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
}
// rpmPackagePattern matches .rpm filenames to extract name, version, release, and arch.
@@ -122,7 +171,7 @@ func (h *RPMHandler) parseRPMPath(path string) (name, version, arch string) {
// Parse the filename
matches := rpmPackagePattern.FindStringSubmatch(filename)
- if len(matches) != rpmMatchCount {
+ if len(matches) != 5 {
return "", "", ""
}
diff --git a/internal/handler/rpm_test.go b/internal/handler/rpm_test.go
index 851a304..b24d8fa 100644
--- a/internal/handler/rpm_test.go
+++ b/internal/handler/rpm_test.go
@@ -1,23 +1,89 @@
package handler
import (
+ "net/http"
+ "net/http/httptest"
"testing"
)
func TestRPMHandler_parseRPMPath(t *testing.T) {
h := &RPMHandler{}
- assertPathParser(t, "parseRPMPath", h.parseRPMPath, []pathParseCase{
- {"releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm", "nginx", "1.24.0-1.fc39", "x86_64"},
- {"Packages/kernel-core-6.5.5-200.fc38.x86_64.rpm", "kernel-core", "6.5.5-200.fc38", "x86_64"},
- {"updates/39/Everything/aarch64/Packages/g/git-2.42.0-1.fc39.aarch64.rpm", "git", "2.42.0-1.fc39", "aarch64"},
- {"vim-enhanced-9.0.1000-1.fc38.noarch.rpm", "vim-enhanced", "9.0.1000-1.fc38", "noarch"},
- {"invalid.rpm", "", "", ""},
- {"not-an-rpm-file", "", "", ""},
- })
+ tests := []struct {
+ path string
+ wantName string
+ wantVersion string
+ wantArch string
+ }{
+ {
+ path: "releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm",
+ wantName: "nginx",
+ wantVersion: "1.24.0-1.fc39",
+ wantArch: "x86_64",
+ },
+ {
+ path: "Packages/kernel-core-6.5.5-200.fc38.x86_64.rpm",
+ wantName: "kernel-core",
+ wantVersion: "6.5.5-200.fc38",
+ wantArch: "x86_64",
+ },
+ {
+ path: "updates/39/Everything/aarch64/Packages/g/git-2.42.0-1.fc39.aarch64.rpm",
+ wantName: "git",
+ wantVersion: "2.42.0-1.fc39",
+ wantArch: "aarch64",
+ },
+ {
+ path: "vim-enhanced-9.0.1000-1.fc38.noarch.rpm",
+ wantName: "vim-enhanced",
+ wantVersion: "9.0.1000-1.fc38",
+ wantArch: "noarch",
+ },
+ {
+ path: "invalid.rpm",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ {
+ path: "not-an-rpm-file",
+ wantName: "",
+ wantVersion: "",
+ wantArch: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ name, version, arch := h.parseRPMPath(tt.path)
+ if name != tt.wantName {
+ t.Errorf("parseRPMPath() name = %q, want %q", name, tt.wantName)
+ }
+ if version != tt.wantVersion {
+ t.Errorf("parseRPMPath() version = %q, want %q", version, tt.wantVersion)
+ }
+ if arch != tt.wantArch {
+ t.Errorf("parseRPMPath() arch = %q, want %q", arch, tt.wantArch)
+ }
+ })
+ }
}
func TestRPMHandler_Routes(t *testing.T) {
h := NewRPMHandler(nil, "http://localhost:8080")
- assertRoutesBasics(t, h.Routes(), "/repodata/repomd.xml", "/releases/../../../etc/passwd")
+
+ // Test that handler doesn't panic on initialization
+ handler := h.Routes()
+ if handler == nil {
+ t.Fatal("Routes() returned nil")
+ }
+
+ // Test method not allowed
+ req := httptest.NewRequest(http.MethodPost, "/repodata/repomd.xml", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST request: got status %d, want %d", w.Code, http.StatusMethodNotAllowed)
+ }
}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index f23a5a9..da8bde6 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -120,22 +120,6 @@ var (
Help: "Number of currently active requests",
},
)
-
- IntegrityFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_integrity_failures_total",
- Help: "Cached artifacts that failed hash verification on read",
- },
- []string{"ecosystem"},
- )
-
- HealthProbeFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "proxy_health_probe_failures_total",
- Help: "Total number of storage health probe failures, by step (write|size|read|verify|delete).",
- },
- []string{"step"},
- )
)
func init() {
@@ -154,8 +138,6 @@ func init() {
StorageOperationDuration,
StorageErrors,
ActiveRequests,
- IntegrityFailures,
- HealthProbeFailures,
)
}
@@ -196,17 +178,6 @@ func RecordStorageOperation(operation string, duration time.Duration) {
StorageOperationDuration.WithLabelValues(operation).Observe(duration.Seconds())
}
-// RecordIntegrityFailure increments the integrity failure counter.
-func RecordIntegrityFailure(ecosystem string) {
- IntegrityFailures.WithLabelValues(ecosystem).Inc()
-}
-
-// RecordHealthProbeFailure increments the health probe failure counter.
-// step is one of: "write", "size", "read", "verify", "delete".
-func RecordHealthProbeFailure(step string) {
- HealthProbeFailures.WithLabelValues(step).Inc()
-}
-
// RecordStorageError increments storage error counter.
func RecordStorageError(operation string) {
StorageErrors.WithLabelValues(operation).Inc()
diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go
index 445a715..db7b097 100644
--- a/internal/metrics/metrics_test.go
+++ b/internal/metrics/metrics_test.go
@@ -48,7 +48,7 @@ func TestRecordStorageOperations(t *testing.T) {
func TestUpdateCacheStats(t *testing.T) {
UpdateCacheStats(1024*1024*1024, 100) // 1GB, 100 artifacts
- UpdateCacheStats(0, 0) // Empty cache
+ UpdateCacheStats(0, 0) // Empty cache
// No panics = success
}
diff --git a/internal/mirror/job.go b/internal/mirror/job.go
deleted file mode 100644
index fbae2a2..0000000
--- a/internal/mirror/job.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package mirror
-
-import (
- "context"
- "crypto/rand"
- "fmt"
- "sync"
- "time"
-)
-
-// JobState represents the current state of a mirror job.
-type JobState string
-
-const (
- JobStatePending JobState = "pending"
- JobStateRunning JobState = "running"
- JobStateComplete JobState = "complete"
- JobStateFailed JobState = "failed"
- JobStateCanceled JobState = "canceled"
-)
-
-const jobTTL = 1 * time.Hour
-const cleanupInterval = 5 * time.Minute //nolint:mnd // cleanup ticker
-
-// Job represents an async mirror operation.
-type Job struct {
- ID string `json:"id"`
- State JobState `json:"state"`
- Progress Progress `json:"progress"`
- CreatedAt time.Time `json:"created_at"`
- Error string `json:"error,omitempty"`
-
- cancel context.CancelFunc
-}
-
-// JobRequest is the JSON body for starting a mirror job via the API.
-type JobRequest struct {
- PURLs []string `json:"purls,omitempty"`
- Registry string `json:"registry,omitempty"`
-}
-
-// JobStore manages in-memory mirror jobs.
-type JobStore struct {
- mu sync.RWMutex
- jobs map[string]*Job
- mirror *Mirror
- parentCtx context.Context
-}
-
-// NewJobStore creates a new job store. The parent context is used as the base
-// for all job contexts so that jobs are canceled when the server shuts down.
-func NewJobStore(ctx context.Context, m *Mirror) *JobStore {
- return &JobStore{
- jobs: make(map[string]*Job),
- mirror: m,
- parentCtx: ctx,
- }
-}
-
-// Create starts a new mirror job and returns its ID.
-func (js *JobStore) Create(req JobRequest) (string, error) {
- source, err := js.sourceFromRequest(req)
- if err != nil {
- return "", err
- }
-
- id := newJobID()
- ctx, cancel := context.WithCancel(js.parentCtx)
-
- job := &Job{
- ID: id,
- State: JobStatePending,
- CreatedAt: time.Now(),
- cancel: cancel,
- }
-
- js.mu.Lock()
- js.jobs[id] = job
- js.mu.Unlock()
-
- go js.runJob(ctx, cancel, job, source)
-
- return id, nil
-}
-
-// Get returns a snapshot of a job by ID. The returned copy is safe to
-// serialize without holding the lock.
-func (js *JobStore) Get(id string) *Job {
- js.mu.RLock()
- defer js.mu.RUnlock()
- job := js.jobs[id]
- if job == nil {
- return nil
- }
- snapshot := *job
- snapshot.cancel = nil // don't leak cancel func
- if len(job.Progress.Errors) > 0 {
- snapshot.Progress.Errors = make([]MirrorError, len(job.Progress.Errors))
- copy(snapshot.Progress.Errors, job.Progress.Errors)
- }
- return &snapshot
-}
-
-// Cancel cancels a running job.
-func (js *JobStore) Cancel(id string) bool {
- js.mu.Lock()
- defer js.mu.Unlock()
-
- job := js.jobs[id]
- if job == nil || job.cancel == nil {
- return false
- }
-
- if job.State != JobStatePending && job.State != JobStateRunning {
- return false
- }
-
- job.cancel()
- job.State = JobStateCanceled
- return true
-}
-
-// Cleanup removes completed/failed/canceled jobs older than jobTTL.
-func (js *JobStore) Cleanup() {
- js.mu.Lock()
- defer js.mu.Unlock()
- for id, job := range js.jobs {
- if job.State == JobStateComplete || job.State == JobStateFailed || job.State == JobStateCanceled {
- if time.Since(job.CreatedAt) > jobTTL {
- delete(js.jobs, id)
- }
- }
- }
-}
-
-// StartCleanup runs periodic cleanup of old jobs until the context is canceled.
-func (js *JobStore) StartCleanup(ctx context.Context) {
- ticker := time.NewTicker(cleanupInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- js.Cleanup()
- }
- }
-}
-
-func (js *JobStore) runJob(ctx context.Context, cancel context.CancelFunc, job *Job, source Source) {
- defer cancel()
-
- js.mu.Lock()
- if job.State == JobStateCanceled {
- js.mu.Unlock()
- return
- }
- job.State = JobStateRunning
- js.mu.Unlock()
-
- progress, err := js.mirror.Run(ctx, source, func(p Progress) {
- js.mu.Lock()
- defer js.mu.Unlock()
- if job.State == JobStateRunning {
- job.Progress = p
- }
- })
-
- js.mu.Lock()
- defer js.mu.Unlock()
-
- // Cancel() may have already set the state; don't overwrite it.
- if job.State == JobStateCanceled {
- return
- }
-
- if err != nil {
- job.State = JobStateFailed
- job.Error = err.Error()
- return
- }
-
- job.Progress = *progress
- if progress.Failed > 0 && progress.Completed == 0 {
- job.State = JobStateFailed
- } else {
- job.State = JobStateComplete
- }
-}
-
-func (js *JobStore) sourceFromRequest(req JobRequest) (Source, error) { //nolint:ireturn // interface return is the design
- switch {
- case len(req.PURLs) > 0:
- return &PURLSource{PURLs: req.PURLs}, nil
- case req.Registry != "":
- return nil, fmt.Errorf("registry mirroring is not yet implemented; use purls instead")
- default:
- return nil, fmt.Errorf("request must include purls")
- }
-}
-
-// newJobID generates a random hex job ID.
-func newJobID() string {
- b := make([]byte, 16) //nolint:mnd // 128-bit ID
- _, _ = rand.Read(b)
- return fmt.Sprintf("%x", b)
-}
diff --git a/internal/mirror/job_test.go b/internal/mirror/job_test.go
deleted file mode 100644
index f7f2f1c..0000000
--- a/internal/mirror/job_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package mirror
-
-import (
- "context"
- "testing"
- "time"
-)
-
-func TestJobStoreCreateAndGet(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- id, err := js.Create(JobRequest{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- })
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- if id == "" {
- t.Fatal("expected non-empty job ID")
- }
-
- // Wait for the job to start (it runs async)
- time.Sleep(100 * time.Millisecond)
-
- job := js.Get(id)
- if job == nil {
- t.Fatal("Get() returned nil")
- }
- if job.ID != id {
- t.Errorf("job ID = %q, want %q", job.ID, id)
- }
-}
-
-func TestJobStoreGetNotFound(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- job := js.Get("nonexistent")
- if job != nil {
- t.Errorf("expected nil for nonexistent job, got %v", job)
- }
-}
-
-func TestJobStoreCancelNotFound(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- if js.Cancel("nonexistent") {
- t.Error("expected Cancel to return false for nonexistent job")
- }
-}
-
-func TestJobStoreCreateInvalidRequest(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- _, err := js.Create(JobRequest{})
- if err == nil {
- t.Fatal("expected error for empty request")
- }
-}
-
-func TestJobStoreMultipleJobs(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- id1, err := js.Create(JobRequest{PURLs: []string{"pkg:npm/lodash@4.17.21"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- id2, err := js.Create(JobRequest{PURLs: []string{"pkg:cargo/serde@1.0.0"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- if id1 == id2 {
- t.Error("expected different job IDs")
- }
-
- job1 := js.Get(id1)
- job2 := js.Get(id2)
- if job1 == nil || job2 == nil {
- t.Fatal("expected both jobs to exist")
- }
-}
-
-func TestSourceFromRequestPURLs(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- source, err := js.sourceFromRequest(JobRequest{PURLs: []string{"pkg:npm/lodash@1.0.0"}})
- if err != nil {
- t.Fatalf("sourceFromRequest() error = %v", err)
- }
- if _, ok := source.(*PURLSource); !ok {
- t.Errorf("expected *PURLSource, got %T", source)
- }
-}
-
-func TestSourceFromRequestRegistryRejected(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- _, err := js.sourceFromRequest(JobRequest{Registry: "npm"})
- if err == nil {
- t.Fatal("expected error for registry request")
- }
-}
-
-func TestJobStoreCleanup(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- // Add a completed job with old CreatedAt
- js.mu.Lock()
- js.jobs["old-job"] = &Job{
- ID: "old-job",
- State: JobStateComplete,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- }
- js.jobs["recent-job"] = &Job{
- ID: "recent-job",
- State: JobStateComplete,
- CreatedAt: time.Now(),
- }
- js.jobs["running-job"] = &Job{
- ID: "running-job",
- State: JobStateRunning,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- }
- js.mu.Unlock()
-
- js.Cleanup()
-
- if js.Get("old-job") != nil {
- t.Error("expected old completed job to be cleaned up")
- }
- if js.Get("recent-job") == nil {
- t.Error("expected recent completed job to be kept")
- }
- if js.Get("running-job") == nil {
- t.Error("expected running job to be kept regardless of age")
- }
-}
-
-func TestJobStoreCancelPreservesStateAfterRunJob(t *testing.T) {
- m := setupTestMirror(t, 1)
- js := NewJobStore(context.Background(), m)
-
- // Create a job with a PURL that will fail (no real upstream in test)
- id, err := js.Create(JobRequest{PURLs: []string{"pkg:npm/nonexistent-pkg@0.0.0"}})
- if err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- // Cancel immediately -- the job may already be running
- js.Cancel(id)
-
- // Wait for runJob goroutine to finish
- time.Sleep(200 * time.Millisecond)
-
- job := js.Get(id)
- if job == nil {
- t.Fatal("Get() returned nil")
- }
- if job.State != JobStateCanceled {
- t.Errorf("state = %q, want %q (cancel should not be overwritten by runJob)", job.State, JobStateCanceled)
- }
-}
-
-func TestNewJobIDUnique(t *testing.T) {
- ids := make(map[string]bool)
- for range 100 {
- id := newJobID()
- if ids[id] {
- t.Fatalf("duplicate job ID: %s", id)
- }
- ids[id] = true
- }
-}
diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go
deleted file mode 100644
index 26de7b8..0000000
--- a/internal/mirror/mirror.go
+++ /dev/null
@@ -1,228 +0,0 @@
-// Package mirror provides selective package mirroring for pre-populating the proxy cache.
-package mirror
-
-import (
- "context"
- "fmt"
- "log/slog"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/storage"
- "golang.org/x/sync/errgroup"
-)
-
-// Mirror pre-populates the proxy cache from various input sources.
-type Mirror struct {
- proxy *handler.Proxy
- db *database.DB
- storage storage.Storage
- logger *slog.Logger
- workers int
-}
-
-// New creates a new Mirror with the given dependencies.
-func New(proxy *handler.Proxy, db *database.DB, store storage.Storage, logger *slog.Logger, workers int) *Mirror {
- if workers < 1 {
- workers = 1
- }
- return &Mirror{
- proxy: proxy,
- db: db,
- storage: store,
- logger: logger,
- workers: workers,
- }
-}
-
-// Progress tracks the state of a mirror operation.
-type Progress struct {
- Total int64 `json:"total"`
- Completed int64 `json:"completed"`
- Skipped int64 `json:"skipped"`
- Failed int64 `json:"failed"`
- Bytes int64 `json:"bytes"`
- Errors []MirrorError `json:"errors,omitempty"`
- StartedAt time.Time `json:"started_at"`
- Phase string `json:"phase"`
-}
-
-// MirrorError records a single failed mirror attempt.
-type MirrorError struct {
- Ecosystem string `json:"ecosystem"`
- Name string `json:"name"`
- Version string `json:"version"`
- Error string `json:"error"`
-}
-
-type progressTracker struct {
- total atomic.Int64
- completed atomic.Int64
- skipped atomic.Int64
- failed atomic.Int64
- bytes atomic.Int64
- mu sync.Mutex
- errors []MirrorError
- startedAt time.Time
- phase atomic.Value // string
-}
-
-func newProgressTracker() *progressTracker {
- pt := &progressTracker{
- startedAt: time.Now(),
- }
- pt.phase.Store("resolving")
- return pt
-}
-
-const maxTrackedErrors = 1000
-const progressReportInterval = 500 * time.Millisecond //nolint:mnd // progress update frequency
-
-func (pt *progressTracker) addError(eco, name, version, err string) {
- pt.mu.Lock()
- if len(pt.errors) < maxTrackedErrors {
- pt.errors = append(pt.errors, MirrorError{
- Ecosystem: eco,
- Name: name,
- Version: version,
- Error: err,
- })
- }
- pt.mu.Unlock()
-}
-
-func (pt *progressTracker) snapshot() Progress {
- pt.mu.Lock()
- errs := make([]MirrorError, len(pt.errors))
- copy(errs, pt.errors)
- pt.mu.Unlock()
-
- phase, _ := pt.phase.Load().(string)
- return Progress{
- Total: pt.total.Load(),
- Completed: pt.completed.Load(),
- Skipped: pt.skipped.Load(),
- Failed: pt.failed.Load(),
- Bytes: pt.bytes.Load(),
- Errors: errs,
- StartedAt: pt.startedAt,
- Phase: phase,
- }
-}
-
-// ProgressFunc is called periodically with a snapshot of the current progress.
-type ProgressFunc func(Progress)
-
-// Run mirrors all packages from the source using a bounded worker pool.
-// It returns the final progress when complete. If onProgress is non-nil,
-// it is called with progress snapshots as work proceeds.
-func (m *Mirror) Run(ctx context.Context, source Source, onProgress ...ProgressFunc) (*Progress, error) {
- tracker := newProgressTracker()
-
- // Collect items from source
- var items []PackageVersion
- tracker.phase.Store("resolving")
- err := source.Enumerate(ctx, func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("enumerating packages: %w", err)
- }
-
- tracker.total.Store(int64(len(items)))
- tracker.phase.Store("downloading")
-
- // Start periodic progress reporting if a callback was provided
- var progressFn ProgressFunc
- if len(onProgress) > 0 && onProgress[0] != nil {
- progressFn = onProgress[0]
- }
- progressDone := make(chan struct{})
- if progressFn != nil {
- progressFn(tracker.snapshot()) // initial snapshot with total set
- go func() {
- ticker := time.NewTicker(progressReportInterval)
- defer ticker.Stop()
- for {
- select {
- case <-progressDone:
- return
- case <-ticker.C:
- progressFn(tracker.snapshot())
- }
- }
- }()
- }
-
- // Process items with bounded concurrency
- g, gctx := errgroup.WithContext(ctx)
- g.SetLimit(m.workers)
-
- for _, item := range items {
- g.Go(func() (err error) {
- defer func() {
- if r := recover(); r != nil {
- m.logger.Error("panic in mirror worker", "recover", r,
- "ecosystem", item.Ecosystem, "name", item.Name, "version", item.Version)
- tracker.failed.Add(1)
- tracker.addError(item.Ecosystem, item.Name, item.Version, fmt.Sprintf("panic: %v", r))
- }
- }()
- m.mirrorOne(gctx, item, tracker)
- return nil // never fail the group; errors are tracked
- })
- }
-
- _ = g.Wait()
-
- close(progressDone) // stop the progress reporter goroutine
-
- tracker.phase.Store("complete")
- p := tracker.snapshot()
-
- // Send final snapshot
- if progressFn != nil {
- progressFn(p)
- }
-
- return &p, nil
-}
-
-// RunDryRun enumerates what would be mirrored without downloading.
-func (m *Mirror) RunDryRun(ctx context.Context, source Source) ([]PackageVersion, error) {
- var items []PackageVersion
- err := source.Enumerate(ctx, func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- return items, err
-}
-
-func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
- result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "")
- if err != nil {
- tracker.failed.Add(1)
- tracker.addError(pv.Ecosystem, pv.Name, pv.Version, err.Error())
- m.logger.Warn("mirror failed",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "error", err)
- return
- }
-
- _ = result.Reader.Close()
-
- if result.Cached {
- tracker.skipped.Add(1)
- m.logger.Debug("already cached",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version)
- } else {
- tracker.completed.Add(1)
- tracker.bytes.Add(result.Size)
- m.logger.Info("mirrored",
- "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version,
- "size", result.Size)
- }
-}
diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go
deleted file mode 100644
index 1d7d30d..0000000
--- a/internal/mirror/mirror_test.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package mirror
-
-import (
- "context"
- "log/slog"
- "os"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
-)
-
-// setupTestMirror creates a Mirror with real DB and filesystem storage for integration tests.
-func setupTestMirror(t *testing.T, workers int) *Mirror {
- t.Helper()
-
- dbPath := t.TempDir() + "/test.db"
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("creating database: %v", err)
- }
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("migrating schema: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
-
- storeDir := t.TempDir()
- store, err := storage.OpenBucket(context.Background(), "file://"+storeDir)
- if err != nil {
- t.Fatalf("opening storage: %v", err)
- }
-
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
- fetcher := fetch.NewFetcher()
- resolver := fetch.NewResolver()
- proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
-
- return New(proxy, db, store, logger, workers)
-}
-
-const testPackageLodash = "lodash"
-
-func TestMirrorRunEmptySource(t *testing.T) {
- m := setupTestMirror(t, 2)
-
- source := &PURLSource{PURLs: []string{}}
- progress, err := m.Run(context.Background(), source)
- if err != nil {
- t.Fatalf("Run() error = %v", err)
- }
-
- if progress.Total != 0 {
- t.Errorf("total = %d, want 0", progress.Total)
- }
- if progress.Phase != "complete" {
- t.Errorf("phase = %q, want %q", progress.Phase, "complete")
- }
-}
-
-func TestMirrorRunDryRun(t *testing.T) {
- m := setupTestMirror(t, 1)
-
- source := &PURLSource{
- PURLs: []string{
- "pkg:npm/lodash@4.17.21",
- "pkg:cargo/serde@1.0.0",
- },
- }
-
- items, err := m.RunDryRun(context.Background(), source)
- if err != nil {
- t.Fatalf("RunDryRun() error = %v", err)
- }
-
- if len(items) != 2 {
- t.Fatalf("got %d items, want 2", len(items))
- }
-
- // Dry run should not modify the database
- stats, err := m.db.GetCacheStats()
- if err != nil {
- t.Fatalf("GetCacheStats() error = %v", err)
- }
- if stats.TotalArtifacts != 0 {
- t.Errorf("artifacts = %d, want 0 (dry run should not cache)", stats.TotalArtifacts)
- }
-}
-
-func TestMirrorRunCanceled(t *testing.T) {
- m := setupTestMirror(t, 1)
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel() // cancel immediately
-
- // Use a source that produces items but they'll all fail due to canceled context
- source := &PURLSource{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- }
-
- progress, err := m.Run(ctx, source)
- if err != nil {
- t.Fatalf("Run() error = %v", err)
- }
-
- // With a canceled context, the fetch should fail
- if progress.Failed != 1 {
- t.Errorf("failed = %d, want 1", progress.Failed)
- }
-}
-
-func TestProgressTrackerSnapshot(t *testing.T) {
- pt := newProgressTracker()
- pt.total.Store(10)
- pt.completed.Store(5)
- pt.skipped.Store(3)
- pt.failed.Store(2)
- pt.bytes.Store(1024)
- pt.phase.Store("downloading")
- pt.addError("npm", testPackageLodash, "4.17.21", "fetch failed")
-
- snap := pt.snapshot()
- if snap.Total != 10 {
- t.Errorf("total = %d, want 10", snap.Total)
- }
- if snap.Completed != 5 {
- t.Errorf("completed = %d, want 5", snap.Completed)
- }
- if snap.Skipped != 3 {
- t.Errorf("skipped = %d, want 3", snap.Skipped)
- }
- if snap.Failed != 2 {
- t.Errorf("failed = %d, want 2", snap.Failed)
- }
- if snap.Bytes != 1024 {
- t.Errorf("bytes = %d, want 1024", snap.Bytes)
- }
- if snap.Phase != "downloading" {
- t.Errorf("phase = %q, want %q", snap.Phase, "downloading")
- }
- if len(snap.Errors) != 1 {
- t.Fatalf("errors = %d, want 1", len(snap.Errors))
- }
- if snap.Errors[0].Name != testPackageLodash {
- t.Errorf("error name = %q, want %q", snap.Errors[0].Name, testPackageLodash)
- }
- if snap.StartedAt.IsZero() {
- t.Error("started_at should not be zero")
- }
-}
-
-func TestProgressTrackerConcurrentAccess(t *testing.T) {
- pt := newProgressTracker()
- done := make(chan struct{})
-
- for range 10 {
- go func() {
- pt.completed.Add(1)
- pt.addError("npm", "test", "1.0.0", "error")
- _ = pt.snapshot()
- done <- struct{}{}
- }()
- }
-
- timeout := time.After(5 * time.Second)
- for range 10 {
- select {
- case <-done:
- case <-timeout:
- t.Fatal("timed out waiting for goroutines")
- }
- }
-
- snap := pt.snapshot()
- if snap.Completed != 10 {
- t.Errorf("completed = %d, want 10", snap.Completed)
- }
- if len(snap.Errors) != 10 {
- t.Errorf("errors = %d, want 10", len(snap.Errors))
- }
-}
-
-func TestNewMirrorDefaultWorkers(t *testing.T) {
- m := New(nil, nil, nil, slog.Default(), 0)
- if m.workers != 1 {
- t.Errorf("workers = %d, want 1 (minimum)", m.workers)
- }
-
- m = New(nil, nil, nil, slog.Default(), -5)
- if m.workers != 1 {
- t.Errorf("workers = %d, want 1 (minimum)", m.workers)
- }
-}
diff --git a/internal/mirror/registry.go b/internal/mirror/registry.go
deleted file mode 100644
index 6b2c449..0000000
--- a/internal/mirror/registry.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package mirror
-
-import (
- "context"
- "fmt"
-)
-
-// RegistrySource enumerates all packages in a registry for full mirroring.
-// Registry enumeration is not yet implemented for any ecosystem.
-type RegistrySource struct {
- Ecosystem string
-}
-
-func (s *RegistrySource) Enumerate(_ context.Context, _ func(PackageVersion) error) error {
- return fmt.Errorf("registry enumeration is not yet implemented for ecosystem %q", s.Ecosystem)
-}
diff --git a/internal/mirror/registry_test.go b/internal/mirror/registry_test.go
deleted file mode 100644
index 363bfea..0000000
--- a/internal/mirror/registry_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package mirror
-
-import (
- "context"
- "testing"
-)
-
-func TestRegistrySourceUnsupported(t *testing.T) {
- source := &RegistrySource{Ecosystem: "golang"}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for unsupported ecosystem")
- }
-}
-
-func TestRegistrySourceNPMNotImplemented(t *testing.T) {
- source := &RegistrySource{Ecosystem: "npm"}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected not-implemented error")
- }
-}
-
-func TestRegistrySourcePyPINotImplemented(t *testing.T) {
- source := &RegistrySource{Ecosystem: "pypi"}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected not-implemented error")
- }
-}
-
-func TestRegistrySourceCargoNotImplemented(t *testing.T) {
- source := &RegistrySource{Ecosystem: "cargo"}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected not-implemented error")
- }
-}
diff --git a/internal/mirror/source.go b/internal/mirror/source.go
deleted file mode 100644
index c583fb7..0000000
--- a/internal/mirror/source.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package mirror
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "os"
-
- cdx "github.com/CycloneDX/cyclonedx-go"
- "github.com/git-pkgs/purl"
- "github.com/git-pkgs/registries"
- _ "github.com/git-pkgs/registries/all"
- spdxjson "github.com/spdx/tools-golang/json"
- "github.com/spdx/tools-golang/spdx"
- spdxtv "github.com/spdx/tools-golang/tagvalue"
-)
-
-// PackageVersion identifies a specific package version to mirror.
-type PackageVersion struct {
- Ecosystem string
- Name string
- Version string
-}
-
-func (pv PackageVersion) String() string {
- return fmt.Sprintf("pkg:%s/%s@%s", pv.Ecosystem, pv.Name, pv.Version)
-}
-
-// Source produces PackageVersion items for mirroring.
-type Source interface {
- Enumerate(ctx context.Context, fn func(PackageVersion) error) error
-}
-
-// PURLSource yields packages from PURL strings.
-// Versioned PURLs produce a single item. Unversioned PURLs look up all versions from the registry.
-type PURLSource struct {
- PURLs []string
- RegClient *registries.Client
-}
-
-func (s *PURLSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error {
- client := s.RegClient
- if client == nil {
- client = registries.DefaultClient()
- }
-
- for _, purlStr := range s.PURLs {
- p, err := purl.Parse(purlStr)
- if err != nil {
- return fmt.Errorf("parsing PURL %q: %w", purlStr, err)
- }
-
- ecosystem := purl.PURLTypeToEcosystem(p.Type)
- name := p.Name
- if p.Namespace != "" {
- name = p.Namespace + "/" + p.Name
- }
-
- if p.Version != "" {
- if err := fn(PackageVersion{Ecosystem: ecosystem, Name: name, Version: p.Version}); err != nil {
- return err
- }
- continue
- }
-
- // Unversioned: enumerate all versions
- versions, err := s.fetchVersions(ctx, client, ecosystem, name)
- if err != nil {
- return fmt.Errorf("fetching versions for %s/%s: %w", ecosystem, name, err)
- }
- for _, v := range versions {
- if err := fn(PackageVersion{Ecosystem: ecosystem, Name: name, Version: v}); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-func (s *PURLSource) fetchVersions(ctx context.Context, client *registries.Client, ecosystem, name string) ([]string, error) {
- reg, err := registries.New(purl.EcosystemToPURLType(ecosystem), "", client)
- if err != nil {
- return nil, err
- }
- versions, err := reg.FetchVersions(ctx, name)
- if err != nil {
- return nil, err
- }
- result := make([]string, len(versions))
- for i, v := range versions {
- result[i] = v.Number
- }
- return result, nil
-}
-
-// SBOMSource extracts package versions from a CycloneDX or SPDX SBOM file.
-type SBOMSource struct {
- Path string
- RegClient *registries.Client
-}
-
-func (s *SBOMSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error {
- purls, err := s.extractPURLs()
- if err != nil {
- return fmt.Errorf("reading SBOM %s: %w", s.Path, err)
- }
-
- inner := &PURLSource{PURLs: purls, RegClient: s.RegClient}
- return inner.Enumerate(ctx, fn)
-}
-
-func (s *SBOMSource) extractPURLs() ([]string, error) {
- data, err := os.ReadFile(s.Path)
- if err != nil {
- return nil, err
- }
-
- // Try CycloneDX first
- if purls, err := extractCycloneDXPURLs(data); err == nil && len(purls) > 0 {
- return purls, nil
- }
-
- // Try SPDX JSON
- if purls, err := extractSPDXJSONPURLs(data); err == nil && len(purls) > 0 {
- return purls, nil
- }
-
- // Try SPDX tag-value
- if purls, err := extractSPDXTVPURLs(data); err == nil && len(purls) > 0 {
- return purls, nil
- }
-
- return nil, fmt.Errorf("could not parse SBOM as CycloneDX or SPDX")
-}
-
-func extractCycloneDXPURLs(data []byte) ([]string, error) {
- bom := new(cdx.BOM)
- if err := json.Unmarshal(data, bom); err != nil {
- // Try XML
- decoder := cdx.NewBOMDecoder(bytes.NewReader(data), cdx.BOMFileFormatXML)
- bom = new(cdx.BOM)
- if err := decoder.Decode(bom); err != nil {
- return nil, err
- }
- }
-
- if bom.Components == nil {
- return nil, nil
- }
-
- var purls []string
- for _, c := range *bom.Components {
- if c.PackageURL != "" {
- purls = append(purls, c.PackageURL)
- }
- }
- return purls, nil
-}
-
-func extractSPDXJSONPURLs(data []byte) ([]string, error) {
- doc, err := spdxjson.Read(bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- return extractSPDXDocPURLs(doc), nil
-}
-
-func extractSPDXTVPURLs(data []byte) ([]string, error) {
- doc, err := spdxtv.Read(bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- return extractSPDXDocPURLs(doc), nil
-}
-
-func extractSPDXDocPURLs(doc *spdx.Document) []string {
- if doc == nil {
- return nil
- }
- var purls []string
- for _, pkg := range doc.Packages {
- for _, ref := range pkg.PackageExternalReferences {
- if ref.RefType == "purl" {
- purls = append(purls, ref.Locator)
- }
- }
- }
- return purls
-}
diff --git a/internal/mirror/source_test.go b/internal/mirror/source_test.go
deleted file mode 100644
index b0bb1be..0000000
--- a/internal/mirror/source_test.go
+++ /dev/null
@@ -1,243 +0,0 @@
-package mirror
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestPURLSourceVersioned(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{
- "pkg:npm/lodash@4.17.21",
- "pkg:cargo/serde@1.0.0",
- "pkg:pypi/requests@2.31.0",
- },
- }
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 3 {
- t.Fatalf("got %d items, want 3", len(items))
- }
-
- expected := []PackageVersion{
- {Ecosystem: "npm", Name: "lodash", Version: "4.17.21"},
- {Ecosystem: "cargo", Name: "serde", Version: "1.0.0"},
- {Ecosystem: "pypi", Name: "requests", Version: "2.31.0"},
- }
-
- for i, want := range expected {
- got := items[i]
- if got.Ecosystem != want.Ecosystem || got.Name != want.Name || got.Version != want.Version {
- t.Errorf("items[%d] = %v, want %v", i, got, want)
- }
- }
-}
-
-func TestPURLSourceScopedPackage(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"pkg:npm/%40babel/core@7.23.0"},
- }
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 1 {
- t.Fatalf("got %d items, want 1", len(items))
- }
-
- if items[0].Name != "@babel/core" {
- t.Errorf("name = %q, want %q", items[0].Name, "@babel/core")
- }
- if items[0].Version != "7.23.0" {
- t.Errorf("version = %q, want %q", items[0].Version, "7.23.0")
- }
-}
-
-func TestPURLSourceInvalid(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"not-a-purl"},
- }
-
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for invalid PURL")
- }
-}
-
-func TestPURLSourceCallbackError(t *testing.T) {
- source := &PURLSource{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- }
-
- wantErr := context.Canceled
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return wantErr
- })
- if err != wantErr {
- t.Fatalf("got error %v, want %v", err, wantErr)
- }
-}
-
-func TestPackageVersionString(t *testing.T) {
- pv := PackageVersion{Ecosystem: "npm", Name: "lodash", Version: "4.17.21"}
- got := pv.String()
- want := "pkg:npm/lodash@4.17.21"
- if got != want {
- t.Errorf("String() = %q, want %q", got, want)
- }
-}
-
-func TestSBOMSourceCycloneDXJSON(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- "components": []map[string]any{
- {"type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21"},
- {"type": "library", "name": "serde", "version": "1.0.0", "purl": "pkg:cargo/serde@1.0.0"},
- },
- }
-
- path := writeTempJSON(t, bom)
- source := &SBOMSource{Path: path}
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 2 {
- t.Fatalf("got %d items, want 2", len(items))
- }
-
- if items[0].Ecosystem != "npm" || items[0].Name != "lodash" || items[0].Version != "4.17.21" {
- t.Errorf("items[0] = %v", items[0])
- }
- if items[1].Ecosystem != "cargo" || items[1].Name != "serde" || items[1].Version != "1.0.0" {
- t.Errorf("items[1] = %v", items[1])
- }
-}
-
-func TestSBOMSourceSPDXJSON(t *testing.T) {
- doc := map[string]any{
- "spdxVersion": "SPDX-2.3",
- "dataLicense": "CC0-1.0",
- "SPDXID": "SPDXRef-DOCUMENT",
- "name": "test",
- "documentNamespace": "https://example.com/test",
- "packages": []map[string]any{
- {
- "SPDXID": "SPDXRef-Package",
- "name": "lodash",
- "version": "4.17.21",
- "downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "externalRefs": []map[string]any{
- {
- "referenceCategory": "PACKAGE-MANAGER",
- "referenceType": "purl",
- "referenceLocator": "pkg:npm/lodash@4.17.21",
- },
- },
- },
- },
- }
-
- path := writeTempJSON(t, doc)
- source := &SBOMSource{Path: path}
-
- var items []PackageVersion
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- items = append(items, pv)
- return nil
- })
- if err != nil {
- t.Fatalf("Enumerate() error = %v", err)
- }
-
- if len(items) != 1 {
- t.Fatalf("got %d items, want 1", len(items))
- }
-
- if items[0].Name != "lodash" || items[0].Version != "4.17.21" {
- t.Errorf("items[0] = %v", items[0])
- }
-}
-
-func TestSBOMSourceNonexistentFile(t *testing.T) {
- source := &SBOMSource{Path: "/nonexistent/sbom.json"}
-
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for nonexistent file")
- }
-}
-
-func TestSBOMSourceInvalidFormat(t *testing.T) {
- path := filepath.Join(t.TempDir(), "invalid.txt")
- if err := os.WriteFile(path, []byte("this is not an SBOM"), 0644); err != nil {
- t.Fatal(err)
- }
-
- source := &SBOMSource{Path: path}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for invalid SBOM")
- }
-}
-
-func TestSBOMSourceEmptyCycloneDX(t *testing.T) {
- bom := map[string]any{
- "bomFormat": "CycloneDX",
- "specVersion": "1.4",
- }
- path := writeTempJSON(t, bom)
-
- // This should fall through to SPDX parsing, which will also fail,
- // resulting in an error about not being able to parse
- source := &SBOMSource{Path: path}
- err := source.Enumerate(context.Background(), func(pv PackageVersion) error {
- return nil
- })
- if err == nil {
- t.Fatal("expected error for empty SBOM")
- }
-}
-
-func writeTempJSON(t *testing.T, v any) string {
- t.Helper()
- data, err := json.Marshal(v)
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(t.TempDir(), "sbom.json")
- if err := os.WriteFile(path, data, 0644); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/server/api.go b/internal/server/api.go
index ddb9ca7..a3ed9d8 100644
--- a/internal/server/api.go
+++ b/internal/server/api.go
@@ -12,12 +12,6 @@ import (
"github.com/go-chi/chi/v5"
)
-const (
- maxBodySize = 1 << 20 // 1 MB
- licenseCategoryUnknown = "unknown"
- defaultSortBy = "hits"
-)
-
// APIHandler provides REST endpoints for package enrichment data.
type APIHandler struct {
enrichment *enrichment.Service
@@ -135,71 +129,33 @@ type BulkResponse struct {
Packages map[string]*PackageResponse `json:"packages"`
}
-// HandlePackagePath dispatches /api/package/{ecosystem}/* to the appropriate handler.
-// Resolves namespaced package names (Composer vendor/name, npm @scope/name) from the path.
-func (h *APIHandler) HandlePackagePath(w http.ResponseWriter, r *http.Request) {
+// HandleGetPackage handles GET /api/package/{ecosystem}/{name}
+func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- badRequest(w, err.Error())
- return
- }
- segments := splitWildcardPath(wildcard)
+ name := chi.URLParam(r, "name")
- if ecosystem == "" || len(segments) == 0 {
- badRequest(w, "ecosystem and name are required")
+ if ecosystem == "" || name == "" {
+ http.Error(w, "ecosystem and name are required", http.StatusBadRequest)
return
}
- // For the API, we don't have a DB to resolve names, so we use a heuristic:
- // the last segment that looks like a version (contains a digit) is the version,
- // everything before it is the name. If no version-like segment, it's all name.
- //
- // With 1 segment: package lookup (name only)
- // With 2+ segments: last segment is version, rest is name
- // Exception: if this is a namespaced ecosystem and we have exactly 2 segments,
- // it could be vendor/name with no version. The enrichment service handles
- // both cases (it will try to look up the package either way).
- if len(segments) == 1 {
- h.getPackage(w, r, ecosystem, segments[0])
- return
- }
-
- // Try the full path as a package name first via enrichment.
- // If it resolves, this is a package-only lookup.
- fullName := strings.Join(segments, "/")
- info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, fullName)
- if err == nil && info != nil {
- resp := &PackageResponse{
- Ecosystem: info.Ecosystem,
- Name: info.Name,
- LatestVersion: info.LatestVersion,
- License: info.License,
- LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)),
- Description: info.Description,
- Homepage: info.Homepage,
- Repository: info.Repository,
- RegistryURL: info.RegistryURL,
+ // Handle scoped npm packages (e.g., @scope/name)
+ if strings.HasPrefix(name, "@") {
+ // The path is split, so we need to get the rest
+ rest := chi.URLParam(r, "rest")
+ if rest != "" {
+ name = name + "/" + rest
}
- writeJSON(w, resp)
- return
}
- // Otherwise, last segment is the version.
- name := strings.Join(segments[:len(segments)-1], "/")
- version := segments[len(segments)-1]
- h.getVersion(w, r, ecosystem, name, version)
-}
-
-func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosystem, name string) {
info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name)
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to enrich package")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if info == nil {
- notFound(w, "package not found")
+ http.Error(w, "package not found", http.StatusNotFound)
return
}
@@ -218,10 +174,20 @@ func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosyste
writeJSON(w, resp)
}
-func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
+// HandleGetVersion handles GET /api/package/{ecosystem}/{name}/{version}
+func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ if ecosystem == "" || name == "" || version == "" {
+ http.Error(w, "ecosystem, name, and version are required", http.StatusBadRequest)
+ return
+ }
+
result, err := h.enrichment.EnrichFull(r.Context(), ecosystem, name, version)
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to enrich version")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -273,40 +239,25 @@ func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosyste
writeJSON(w, resp)
}
-// HandleVulnsPath dispatches /api/vulns/{ecosystem}/* to the vulns handler.
-// Supports both {name} and {name}/{version} paths with namespaced package names.
-func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) {
+// HandleGetVulns handles GET /api/vulns/{ecosystem}/{name}
+func (h *APIHandler) HandleGetVulns(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- badRequest(w, err.Error())
- return
- }
- segments := splitWildcardPath(wildcard)
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
- if ecosystem == "" || len(segments) == 0 {
- badRequest(w, "ecosystem and name are required")
+ if ecosystem == "" || name == "" {
+ http.Error(w, "ecosystem and name are required", http.StatusBadRequest)
return
}
- // Last segment could be a version. Try full path as name first,
- // then split off the last segment as version.
- name := strings.Join(segments, "/")
- version := "0"
-
- if len(segments) > 1 {
- // Try enrichment with the full path as name.
- // If it doesn't resolve, assume last segment is version.
- info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name)
- if err != nil || info == nil {
- name = strings.Join(segments[:len(segments)-1], "/")
- version = segments[len(segments)-1]
- }
+ // If no version specified, use "0" to get all vulnerabilities
+ if version == "" {
+ version = "0"
}
vulns, err := h.enrichment.CheckVulnerabilities(r.Context(), ecosystem, name, version)
if err != nil {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "failed to check vulnerabilities")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -332,25 +283,15 @@ func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) {
}
// HandleOutdated handles POST /api/outdated
-// @Summary Check outdated packages
-// @Tags api
-// @Accept json
-// @Produce json
-// @Param request body OutdatedRequest true "Packages to check"
-// @Success 200 {object} OutdatedResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/outdated [post]
func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req OutdatedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
+ http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.Packages) == 0 {
- badRequest(w, "packages list is required")
+ http.Error(w, "packages list is required", http.StatusBadRequest)
return
}
@@ -378,25 +319,15 @@ func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) {
}
// HandleBulkLookup handles POST /api/bulk
-// @Summary Bulk package lookup by PURL
-// @Tags api
-// @Accept json
-// @Produce json
-// @Param request body BulkRequest true "PURLs"
-// @Success 200 {object} BulkResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/bulk [post]
func (h *APIHandler) HandleBulkLookup(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req BulkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
+ http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.PURLs) == 0 {
- badRequest(w, "purls list is required")
+ http.Error(w, "purls list is required", http.StatusBadRequest)
return
}
@@ -478,21 +409,12 @@ type SearchPackageResult struct {
}
// HandleSearch handles GET /api/search
-// @Summary Search cached packages
-// @Tags api
-// @Produce json
-// @Param q query string true "Query"
-// @Param ecosystem query string false "Ecosystem"
-// @Success 200 {object} SearchResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/search [get]
func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
ecosystem := r.URL.Query().Get("ecosystem")
if query == "" {
- badRequest(w, "query parameter 'q' is required")
+ http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
@@ -502,7 +424,7 @@ func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
// Search in database
results, err := h.db.SearchPackages(query, ecosystem, limit, (page-1)*limit)
if err != nil {
- internalError(w, "search failed")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -546,7 +468,7 @@ func (h *APIHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
- internalError(w, "failed to encode response")
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
@@ -575,32 +497,23 @@ type PackageListResult struct {
}
// HandlePackagesList handles GET /api/packages
-// @Summary List cached packages
-// @Tags api
-// @Produce json
-// @Param ecosystem query string false "Ecosystem"
-// @Param sort query string false "Sort" Enums(hits,name,size,cached_at,ecosystem,vulns)
-// @Success 200 {object} PackagesListResponse
-// @Failure 400 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/packages [get]
func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem")
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
- sortBy = defaultSortBy
+ sortBy = "hits"
}
validSorts := map[string]bool{
- defaultSortBy: true,
- "name": true,
- "size": true,
- "cached_at": true,
- "ecosystem": true,
- "vulns": true,
+ "hits": true,
+ "name": true,
+ "size": true,
+ "cached_at": true,
+ "ecosystem": true,
+ "vulns": true,
}
if !validSorts[sortBy] {
- badRequest(w, "invalid sort parameter")
+ http.Error(w, "invalid sort parameter", http.StatusBadRequest)
return
}
@@ -609,7 +522,7 @@ func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request)
packages, err := h.db.ListCachedPackages(ecosystem, sortBy, limit, (page-1)*limit)
if err != nil {
- internalError(w, "failed to list packages")
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -634,7 +547,7 @@ func (h *APIHandler) HandlePackagesList(w http.ResponseWriter, r *http.Request)
latestVersion = pkg.LatestVersion.String
}
license := ""
- licenseCategory := licenseCategoryUnknown
+ licenseCategory := "unknown"
if pkg.License.Valid {
license = pkg.License.String
if h.enrichment != nil {
diff --git a/internal/server/api_test.go b/internal/server/api_test.go
index 0494b2f..832f31a 100644
--- a/internal/server/api_test.go
+++ b/internal/server/api_test.go
@@ -9,16 +9,12 @@ import (
"net/http/httptest"
"os"
"path/filepath"
- "strings"
"testing"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
- "github.com/go-chi/chi/v5"
)
-const testEcosystemNPM = "npm"
-
func TestNewAPIHandler(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
@@ -32,66 +28,55 @@ func TestNewAPIHandler(t *testing.T) {
}
}
-func TestHandlePackagePath_MissingParams(t *testing.T) {
+func TestHandleGetPackage_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath)
-
req := httptest.NewRequest("GET", "/api/package//", nil)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
+
w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
+ h.HandleGetPackage(w, req)
- if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
- t.Errorf("expected status 400 or 404, got %d", w.Code)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
-func TestHandlePackagePath_InvalidName(t *testing.T) {
+func TestHandleGetVersion_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath)
+ req := httptest.NewRequest("GET", "/api/package///", nil)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
+ req.SetPathValue("version", "")
- tests := []struct {
- name string
- path string
- }{
- {"null byte", "/api/package/npm/lodash%00"},
- {"too long", "/api/package/npm/" + strings.Repeat("a", maxPackagePathLen+1)},
- }
+ w := httptest.NewRecorder()
+ h.HandleGetVersion(w, req)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest("GET", tt.path, nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status 400, got %d", w.Code)
- }
- })
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
-func TestHandleVulnsPath_MissingParams(t *testing.T) {
+func TestHandleGetVulns_MissingParams(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
h := NewAPIHandler(svc, nil)
- r := chi.NewRouter()
- r.Get("/api/vulns/{ecosystem}/*", h.HandleVulnsPath)
-
req := httptest.NewRequest("GET", "/api/vulns//", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
+ req.SetPathValue("ecosystem", "")
+ req.SetPathValue("name", "")
- if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
- t.Errorf("expected status 400 or 404, got %d", w.Code)
+ w := httptest.NewRecorder()
+ h.HandleGetVulns(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
@@ -109,25 +94,6 @@ func TestHandleOutdated_EmptyBody(t *testing.T) {
}
}
-func TestHandleOutdated_OversizedBody(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
- h := NewAPIHandler(svc, nil)
-
- // Send a body larger than 1 MB
- body := make([]byte, 2<<20)
- for i := range body {
- body[i] = 'x'
- }
- req := httptest.NewRequest("POST", "/api/outdated", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleOutdated(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status %d for oversized body, got %d", http.StatusBadRequest, w.Code)
- }
-}
-
func TestHandleOutdated_InvalidJSON(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
svc := enrichment.New(logger)
@@ -192,7 +158,7 @@ func TestWriteJSON(t *testing.T) {
func TestPackageResponseJSON(t *testing.T) {
resp := &PackageResponse{
- Ecosystem: testEcosystemNPM,
+ Ecosystem: "npm",
Name: "lodash",
LatestVersion: "4.17.21",
License: "MIT",
@@ -213,7 +179,7 @@ func TestPackageResponseJSON(t *testing.T) {
t.Fatalf("failed to unmarshal: %v", err)
}
- if decoded.Ecosystem != testEcosystemNPM {
+ if decoded.Ecosystem != "npm" {
t.Errorf("expected ecosystem npm, got %s", decoded.Ecosystem)
}
if decoded.Name != "lodash" {
@@ -324,7 +290,7 @@ func TestHandleSearch_WithNullValues(t *testing.T) {
pkg := &database.Package{
PURL: "pkg:npm/api-test",
- Ecosystem: testEcosystemNPM,
+ Ecosystem: "npm",
Name: "api-test",
}
if err := db.UpsertPackage(pkg); err != nil {
@@ -383,109 +349,3 @@ func TestHandleSearch_WithNullValues(t *testing.T) {
t.Errorf("expected 3 hits, got %d", result.Hits)
}
}
-
-func TestHandlePackagesListAPI(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
-
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "test.db")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- // Seed two packages
- for _, name := range []string{"api-list-one", "api-list-two"} {
- pkg := &database.Package{
- PURL: "pkg:npm/" + name,
- Ecosystem: testEcosystemNPM,
- Name: name,
- }
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatalf("UpsertPackage failed: %v", err)
- }
- ver := &database.Version{
- PURL: "pkg:npm/" + name + "@1.0.0",
- PackagePURL: pkg.PURL,
- }
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatalf("UpsertVersion failed: %v", err)
- }
- art := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: name + "-1.0.0.tgz",
- UpstreamURL: "https://registry.npmjs.org/" + name + "/-/" + name + "-1.0.0.tgz",
- StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
- }
- if err := db.UpsertArtifact(art); err != nil {
- t.Fatalf("UpsertArtifact failed: %v", err)
- }
- }
-
- h := NewAPIHandler(svc, db)
-
- r := chi.NewRouter()
- r.Get("/api/packages", h.HandlePackagesList)
-
- req := httptest.NewRequest("GET", "/api/packages", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- var resp PackagesListResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
-
- if len(resp.Results) < 2 {
- t.Fatalf("expected at least 2 results, got %d", len(resp.Results))
- }
-
- if resp.SortBy != defaultSortBy {
- t.Errorf("expected default sort by hits, got %q", resp.SortBy)
- }
-
- found := false
- for _, pkg := range resp.Results {
- if pkg.Name == "api-list-one" || pkg.Name == "api-list-two" {
- found = true
- break
- }
- }
- if !found {
- t.Error("expected seeded packages in results")
- }
-}
-
-func TestHandlePackagesListAPI_InvalidSort(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
- svc := enrichment.New(logger)
-
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "test.db")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("Create failed: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- h := NewAPIHandler(svc, db)
-
- r := chi.NewRouter()
- r.Get("/api/packages", h.HandlePackagesList)
-
- req := httptest.NewRequest("GET", "/api/packages?sort=invalid", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("expected status 400 for invalid sort, got %d", w.Code)
- }
-}
diff --git a/internal/server/browse.go b/internal/server/browse.go
index be2b04a..21f973a 100644
--- a/internal/server/browse.go
+++ b/internal/server/browse.go
@@ -15,80 +15,15 @@ import (
"github.com/go-chi/chi/v5"
)
-const contentTypePlainText = "text/plain; charset=utf-8"
-
-// maxBrowseArchiveSize caps how much data openArchive will buffer for
-// prefix detection. Artifacts larger than this are rejected to prevent
-// memory exhaustion from a single request.
-const maxBrowseArchiveSize = 512 << 20 // 512 MB
-
-// archiveFilename returns a filename suitable for archive format detection.
-// Some ecosystems (e.g. composer) store artifacts with bare hash filenames
-// that have no extension. This adds .zip when the original has no extension
-// and the content is likely a zip archive.
-func archiveFilename(filename string) string {
- if path.Ext(filename) == "" {
- return filename + ".zip"
- }
- return filename
-}
-
-// detectSingleRootDir returns the single top-level directory name if all files
-// in the archive live under one common directory (e.g. GitHub zipballs use
-// "repo-hash/"). Returns "" if there's no single root or the archive is flat.
-func detectSingleRootDir(reader archives.Reader) string {
- files, err := reader.List()
- if err != nil || len(files) == 0 {
+// getStripPrefix returns the path prefix to strip for a given ecosystem.
+// npm packages wrap content in a "package/" directory.
+func getStripPrefix(ecosystem string) string {
+ switch ecosystem {
+ case "npm":
+ return "package/"
+ default:
return ""
}
-
- var root string
- for _, f := range files {
- parts := strings.SplitN(f.Path, "/", 2) //nolint:mnd // split into dir + rest
- if len(parts) == 0 {
- continue
- }
- dir := parts[0]
- if root == "" {
- root = dir
- } else if dir != root {
- return ""
- }
- }
-
- if root == "" {
- return ""
- }
- return root + "/"
-}
-
-// openArchive opens a cached artifact as an archive reader, auto-detecting
-// and stripping a single top-level directory prefix (like GitHub zipballs).
-// For npm, the hardcoded "package/" prefix takes precedence.
-func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations
- fname := archiveFilename(filename)
-
- limited := io.LimitReader(content, maxBrowseArchiveSize+1)
- data, err := io.ReadAll(limited)
- if err != nil {
- return nil, fmt.Errorf("reading artifact: %w", err)
- }
- if int64(len(data)) > maxBrowseArchiveSize {
- return nil, fmt.Errorf("artifact too large for browsing (%d bytes)", len(data))
- }
-
- if ecosystem == "npm" {
- return archives.OpenBytesWithPrefix(fname, data, "package/")
- }
-
- probe, err := archives.OpenBytes(fname, data)
- if err != nil {
- return nil, err
- }
- prefix := detectSingleRootDir(probe)
- _ = probe.Close()
-
- return archives.OpenBytesWithPrefix(fname, data, prefix)
}
// BrowseListResponse contains the file listing for a directory in an archives.
@@ -108,117 +43,22 @@ type BrowseFileInfo struct {
// handleBrowseList returns a list of files in a directory within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}?path=/some/dir
-// @Summary List files inside a cached artifact
-// @Description Lists files from the first cached artifact for a package version.
-// @Tags browse
-// @Produce json
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param version path string true "Version"
-// @Param path query string false "Directory path inside the archive"
-// @Success 200 {object} BrowseListResponse
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/browse/{ecosystem}/{name}/{version} [get]
-// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler.
-// It resolves namespaced package names by consulting the database.
-//
-// Supported paths:
-//
-// {name}/{version} -> browse list
-// {name}/{version}/file/{path} -> browse file
-func (s *Server) handleBrowsePath(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- badRequest(w, err.Error())
- return
- }
- segments := splitWildcardPath(wildcard)
-
- if ecosystem == "" || len(segments) < 2 {
- badRequest(w, "ecosystem, name, and version required")
- return
- }
-
- // Check for /file/ in the path for browse file requests.
- fileIdx := -1
- for i, seg := range segments {
- if seg == "file" && i > 0 {
- fileIdx = i
- break
- }
- }
-
- if fileIdx >= 0 {
- // Everything before "file" is name+version, everything after is the file path.
- nameVersionSegments := segments[:fileIdx]
- filePath := strings.Join(segments[fileIdx+1:], "/")
-
- name, rest := resolvePackageName(s.db, ecosystem, nameVersionSegments)
- if name == "" && len(nameVersionSegments) >= 2 {
- name = strings.Join(nameVersionSegments[:len(nameVersionSegments)-1], "/")
- rest = nameVersionSegments[len(nameVersionSegments)-1:]
- }
- if len(rest) != 1 {
- notFound(w, "not found")
- return
- }
- s.browseFile(w, r, ecosystem, name, rest[0], filePath)
- return
- }
-
- // No /file/ segment: this is a browse list.
- name, rest := resolvePackageName(s.db, ecosystem, segments)
- if name == "" && len(segments) >= 2 {
- name = strings.Join(segments[:len(segments)-1], "/")
- rest = segments[len(segments)-1:]
- }
- if len(rest) != 1 {
- notFound(w, "not found")
- return
- }
- s.browseList(w, r, ecosystem, name, rest[0])
-}
-
-// handleComparePath dispatches /api/compare/{ecosystem}/* to the compare handler.
-// Supported paths: {name}/{fromVersion}/{toVersion}
-func (s *Server) handleComparePath(w http.ResponseWriter, r *http.Request) {
- ecosystem := chi.URLParam(r, "ecosystem")
- wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- badRequest(w, err.Error())
- return
- }
- segments := splitWildcardPath(wildcard)
-
- if ecosystem == "" || len(segments) < 3 {
- badRequest(w, "ecosystem, name, fromVersion, and toVersion required")
- return
- }
-
- // The last two segments are fromVersion and toVersion.
- // Everything before that is the package name.
- name := strings.Join(segments[:len(segments)-2], "/")
- fromVersion := segments[len(segments)-2]
- toVersion := segments[len(segments)-1]
-
- s.compareDiff(w, r, ecosystem, name, fromVersion, toVersion)
-}
-
-func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
dirPath := r.URL.Query().Get("path")
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
- notFound(w, "version not found")
+ http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
- notFound(w, "no artifacts cached")
+ http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
@@ -232,7 +72,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
}
if cachedArtifact == nil {
- notFound(w, "artifact not cached")
+ http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
@@ -240,16 +80,17 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
- internalError(w, "failed to read artifact")
+ http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
- // Open archive with auto-detected prefix stripping
- archiveReader, err := openArchive(cachedArtifact.Filename, artifactReader, ecosystem)
+ // Open archive with appropriate prefix stripping
+ stripPrefix := getStripPrefix(ecosystem)
+ archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
- internalError(w, "failed to open archive")
+ http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
defer func() { _ = archiveReader.Close() }()
@@ -258,7 +99,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
files, err := archiveReader.ListDir(dirPath)
if err != nil {
s.logger.Error("failed to list directory", "error", err, "path", dirPath)
- internalError(w, "failed to list directory")
+ http.Error(w, "failed to list directory", http.StatusInternalServerError)
return
}
@@ -284,22 +125,15 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
// handleBrowseFile returns the contents of a specific file within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}/file/{filepath...}
-// @Summary Fetch a file inside a cached artifact
-// @Description Streams a single file from the cached artifact. The file path may contain slashes.
-// @Tags browse
-// @Produce application/octet-stream
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param version path string true "Version"
-// @Param filepath path string true "File path inside the archive"
-// @Success 200 {file} file
-// @Failure 400 {object} ErrorResponse
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get]
-func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) {
+func (s *Server) handleBrowseFile(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ // Get the wildcard path
+ filePath := chi.URLParam(r, "*")
if filePath == "" {
- badRequest(w, "file path required")
+ http.Error(w, "file path required", http.StatusBadRequest)
return
}
@@ -307,12 +141,12 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
- notFound(w, "version not found")
+ http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
- notFound(w, "no artifacts cached")
+ http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
@@ -326,7 +160,7 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
}
if cachedArtifact == nil {
- notFound(w, "artifact not cached")
+ http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
@@ -334,16 +168,17 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
- internalError(w, "failed to read artifact")
+ http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
- // Open archive with auto-detected prefix stripping
- archiveReader, err := openArchive(cachedArtifact.Filename, artifactReader, ecosystem)
+ // Open archive with appropriate prefix stripping
+ stripPrefix := getStripPrefix(ecosystem)
+ archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
- internalError(w, "failed to open archive")
+ http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
defer func() { _ = archiveReader.Close() }()
@@ -352,20 +187,20 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
fileReader, err := archiveReader.Extract(filePath)
if err != nil {
if strings.Contains(err.Error(), "not found") {
- notFound(w, "file not found")
+ http.Error(w, "file not found", http.StatusNotFound)
return
}
s.logger.Error("failed to extract file", "error", err, "path", filePath)
- internalError(w, "failed to extract file")
+ http.Error(w, "failed to extract file", http.StatusInternalServerError)
return
}
defer func() { _ = fileReader.Close() }()
+ // Set content type based on file extension
contentType := detectContentType(filePath)
w.Header().Set("Content-Type", contentType)
- w.Header().Set("Content-Security-Policy", "sandbox")
- w.Header().Set("X-Content-Type-Options", "nosniff")
+ // Set filename for download
_, filename := path.Split(filePath)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
@@ -380,9 +215,9 @@ func detectContentType(filename string) string {
switch ext {
// Text formats
case ".txt", ".md", ".markdown":
- return contentTypePlainText
- case ".html", ".htm", ".xhtml":
- return contentTypePlainText
+ return "text/plain; charset=utf-8"
+ case ".html", ".htm":
+ return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js", ".mjs":
@@ -422,7 +257,7 @@ func detectContentType(filename string) string {
// Config files
case ".conf", ".config", ".ini":
- return contentTypePlainText
+ return "text/plain; charset=utf-8"
case ".sh", ".bash":
return "text/x-shellscript; charset=utf-8"
case ".dockerfile":
@@ -436,7 +271,7 @@ func detectContentType(filename string) string {
case ".gif":
return "image/gif"
case ".svg":
- return contentTypePlainText
+ return "image/svg+xml"
case ".ico":
return "image/x-icon"
@@ -447,7 +282,7 @@ func detectContentType(filename string) string {
default:
// Try to detect if it looks like text
if isLikelyText(filename) {
- return contentTypePlainText
+ return "text/plain; charset=utf-8"
}
return "application/octet-stream"
}
@@ -483,36 +318,46 @@ type BrowseSourceData struct {
Version string
}
-// handleBrowseSource is now showBrowseSource in server.go, dispatched via handlePackagePath.
+// handleBrowseSource renders the source code browser UI.
+// GET /package/{ecosystem}/{name}/{version}/browse
+func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ data := BrowseSourceData{
+ Ecosystem: ecosystem,
+ PackageName: name,
+ Version: version,
+ }
+
+ if err := s.templates.Render(w, "browse_source", data); err != nil {
+ s.logger.Error("failed to render browse source page", "error", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+}
// handleCompareDiff compares two versions and returns a diff.
// GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}
-// @Summary Compare two cached versions
-// @Description Returns a structured diff for two cached versions.
-// @Tags browse
-// @Produce json
-// @Param ecosystem path string true "Ecosystem"
-// @Param name path string true "Package name"
-// @Param fromVersion path string true "From version"
-// @Param toVersion path string true "To version"
-// @Success 200 {object} map[string]any
-// @Failure 404 {object} ErrorResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
-func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
+func (s *Server) handleCompareDiff(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ fromVersion := chi.URLParam(r, "fromVersion")
+ toVersion := chi.URLParam(r, "toVersion")
+
// Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
toPURL := purl.MakePURLString(ecosystem, name, toVersion)
fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL)
if err != nil || len(fromArtifacts) == 0 {
- notFound(w, "from version not found or not cached")
+ http.Error(w, "from version not found or not cached", http.StatusNotFound)
return
}
toArtifacts, err := s.db.GetArtifactsByVersionPURL(toPURL)
if err != nil || len(toArtifacts) == 0 {
- notFound(w, "to version not found or not cached")
+ http.Error(w, "to version not found or not cached", http.StatusNotFound)
return
}
@@ -532,7 +377,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
}
if fromArtifact == nil || toArtifact == nil {
- notFound(w, "one or both versions not cached")
+ http.Error(w, "one or both versions not cached", http.StatusNotFound)
return
}
@@ -540,7 +385,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
fromReader, err := s.storage.Open(r.Context(), fromArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open from artifact", "error", err)
- internalError(w, "failed to read from version")
+ http.Error(w, "failed to read from version", http.StatusInternalServerError)
return
}
defer func() { _ = fromReader.Close() }()
@@ -548,23 +393,25 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
toReader, err := s.storage.Open(r.Context(), toArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open to artifact", "error", err)
- internalError(w, "failed to read to version")
+ http.Error(w, "failed to read to version", http.StatusInternalServerError)
return
}
defer func() { _ = toReader.Close() }()
- fromArchive, err := openArchive(fromArtifact.Filename, fromReader, ecosystem)
+ stripPrefix := getStripPrefix(ecosystem)
+
+ fromArchive, err := archives.OpenWithPrefix(fromArtifact.Filename, fromReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open from archive", "error", err)
- internalError(w, "failed to open from archive")
+ http.Error(w, "failed to open from archive", http.StatusInternalServerError)
return
}
defer func() { _ = fromArchive.Close() }()
- toArchive, err := openArchive(toArtifact.Filename, toReader, ecosystem)
+ toArchive, err := archives.OpenWithPrefix(toArtifact.Filename, toReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open to archive", "error", err)
- internalError(w, "failed to open to archive")
+ http.Error(w, "failed to open to archive", http.StatusInternalServerError)
return
}
defer func() { _ = toArchive.Close() }()
@@ -573,7 +420,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
result, err := diff.Compare(fromArchive, toArchive)
if err != nil {
s.logger.Error("failed to generate diff", "error", err)
- internalError(w, "failed to generate diff")
+ http.Error(w, "failed to generate diff", http.StatusInternalServerError)
return
}
@@ -589,4 +436,33 @@ type ComparePageData struct {
ToVersion string
}
-// handleComparePage is now showComparePage in server.go, dispatched via handlePackagePath.
+// handleComparePage renders the version comparison UI.
+// GET /package/{ecosystem}/{name}/compare/{versions}
+// where {versions} is in format "fromVersion...toVersion"
+func (s *Server) handleComparePage(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ versions := chi.URLParam(r, "versions")
+
+ // Parse versions (format: "1.0.0...2.0.0")
+ parts := strings.Split(versions, "...")
+ if len(parts) != 2 {
+ http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest)
+ return
+ }
+
+ fromVersion := parts[0]
+ toVersion := parts[1]
+
+ data := ComparePageData{
+ Ecosystem: ecosystem,
+ PackageName: name,
+ FromVersion: fromVersion,
+ ToVersion: toVersion,
+ }
+
+ if err := s.templates.Render(w, "compare_versions", data); err != nil {
+ s.logger.Error("failed to render compare page", "error", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+}
diff --git a/internal/server/browse_bench_test.go b/internal/server/browse_bench_test.go
deleted file mode 100644
index 03f3f02..0000000
--- a/internal/server/browse_bench_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package server
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "fmt"
- "math/rand"
- "testing"
-)
-
-func createBenchTarGz(prefix string, fileCount, fileSize int) []byte {
- rnd := rand.New(rand.NewSource(1)) //nolint:gosec
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
-
- payload := make([]byte, fileSize)
- for i := range fileCount {
- rnd.Read(payload)
- _ = tw.WriteHeader(&tar.Header{
- Name: fmt.Sprintf("%sfile%04d.dat", prefix, i),
- Size: int64(fileSize),
- Mode: 0644,
- })
- _, _ = tw.Write(payload)
- }
- _ = tw.Close()
- _ = gw.Close()
- return buf.Bytes()
-}
-
-func BenchmarkOpenArchive(b *testing.B) {
- cases := []struct {
- name string
- ecosystem string
- filename string
- data []byte
- }{
- {"npm", "npm", "pkg.tgz", createBenchTarGz("package/", 64, 16*1024)},
- {"go", "go", "v1.2.3.tar.gz", createBenchTarGz("repo-abc123/", 64, 16*1024)},
- }
-
- for _, tc := range cases {
- b.Run(tc.name, func(b *testing.B) {
- b.SetBytes(int64(len(tc.data)))
- b.ReportAllocs()
- for b.Loop() {
- r, err := openArchive(tc.filename, bytes.NewReader(tc.data), tc.ecosystem)
- if err != nil {
- b.Fatal(err)
- }
- _ = r.Close()
- }
- })
- }
-}
diff --git a/internal/server/browse_test.go b/internal/server/browse_test.go
index 28f08da..ed7af57 100644
--- a/internal/server/browse_test.go
+++ b/internal/server/browse_test.go
@@ -2,7 +2,6 @@ package server
import (
"archive/tar"
- "archive/zip"
"bytes"
"compress/gzip"
"database/sql"
@@ -17,8 +16,6 @@ import (
"github.com/git-pkgs/proxy/internal/database"
)
-const testArchiveName = "test.tar.gz"
-
func TestHandleBrowseList(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
@@ -29,12 +26,12 @@ func TestHandleBrowseList(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
// Storage path relative to artifacts directory
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -102,12 +99,12 @@ func TestHandleBrowseFile(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
// Storage path relative to artifacts directory
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -153,7 +150,7 @@ func TestHandleBrowseFile(t *testing.T) {
// Check content type
contentType := w.Header().Get("Content-Type")
- if contentType != contentTypePlainText {
+ if contentType != "text/plain; charset=utf-8" {
t.Errorf("expected text/plain content type, got %q", contentType)
}
@@ -169,26 +166,22 @@ func TestHandleBrowseFile(t *testing.T) {
func TestDetectContentType(t *testing.T) {
tests := []struct {
- filename string
- expectedCT string
+ filename string
+ expectedCT string
}{
- {"file.txt", contentTypePlainText},
- {"file.md", contentTypePlainText},
+ {"file.txt", "text/plain; charset=utf-8"},
+ {"file.md", "text/plain; charset=utf-8"},
{"file.json", "application/json; charset=utf-8"},
{"file.js", "application/javascript; charset=utf-8"},
{"file.go", "text/x-go; charset=utf-8"},
{"file.py", "text/x-python; charset=utf-8"},
{"file.rs", "text/x-rust; charset=utf-8"},
- {"file.html", contentTypePlainText},
- {"file.htm", contentTypePlainText},
- {"file.xhtml", contentTypePlainText},
- {"file.svg", contentTypePlainText},
{"file.png", "image/png"},
{"file.jpg", "image/jpeg"},
- {"README", contentTypePlainText},
- {"LICENSE", contentTypePlainText},
- {"Makefile", contentTypePlainText},
- {".gitignore", contentTypePlainText},
+ {"README", "text/plain; charset=utf-8"},
+ {"LICENSE", "text/plain; charset=utf-8"},
+ {"Makefile", "text/plain; charset=utf-8"},
+ {".gitignore", "text/plain; charset=utf-8"},
{"file.bin", "application/octet-stream"},
}
@@ -202,19 +195,6 @@ func TestDetectContentType(t *testing.T) {
}
}
-func TestOpenArchiveSizeLimit(t *testing.T) {
- huge := bytes.Repeat([]byte("x"), int(maxBrowseArchiveSize)+1)
- for _, eco := range []string{"npm", "go"} {
- _, err := openArchive("test.tar.gz", bytes.NewReader(huge), eco)
- if err == nil {
- t.Fatalf("%s: expected error for oversized archive, got nil", eco)
- }
- if !strings.Contains(err.Error(), "too large") {
- t.Fatalf("%s: expected 'too large' error, got: %v", eco, err)
- }
- }
-}
-
func TestIsLikelyText(t *testing.T) {
tests := []struct {
filename string
@@ -333,11 +313,11 @@ func TestHandleBrowseSourcePage(t *testing.T) {
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
t.Fatalf("failed to create artifacts dir: %v", err)
}
- storagePath := filepath.Join(artifactsDir, testArchiveName)
+ storagePath := filepath.Join(artifactsDir, "test.tar.gz")
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
t.Fatalf("failed to write test archive: %v", err)
}
- relPath := testArchiveName
+ relPath := "test.tar.gz"
// Setup test package and artifact
pkg := &database.Package{
@@ -395,19 +375,6 @@ func TestHandleBrowseSourcePage(t *testing.T) {
}
}
- // Check that the escapeHTML function is present for XSS protection
- if !strings.Contains(body, "function escapeHTML(str)") {
- t.Error("browse source page missing escapeHTML function for XSS protection")
- }
-
- // Check that onclick handlers use escapeHTML
- if strings.Contains(body, "onclick=\"loadFileTree('${file.path}')") {
- t.Error("browse source page has unescaped file.path in onclick handler")
- }
- if strings.Contains(body, "onclick=\"loadFile('${file.path}')") {
- t.Error("browse source page has unescaped file.path in onclick handler")
- }
-
// Check that ecosystem, package name, and version are set in JavaScript
if !strings.Contains(body, "const ecosystem = 'npm'") {
t.Error("browse source page missing ecosystem variable")
@@ -608,195 +575,3 @@ func TestHandleComparePage(t *testing.T) {
t.Errorf("expected status 400 for invalid separator, got %d", w.Code)
}
}
-
-func TestArchiveFilename(t *testing.T) {
- tests := []struct {
- input string
- want string
- }{
- {"package.tar.gz", "package.tar.gz"},
- {"d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856.zip"},
- {"file.zip", "file.zip"},
- {"archive.tgz", "archive.tgz"},
- {"noext", "noext.zip"},
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- got := archiveFilename(tt.input)
- if got != tt.want {
- t.Errorf("archiveFilename(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestOpenArchiveStripsSingleRootDir(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "repo-abc123/README.md": "hello",
- "repo-abc123/src/main.go": "package main",
- "repo-abc123/go.mod": "module test",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "repo-abc123/") {
- t.Errorf("file %q still has root prefix after stripping", f.Path)
- }
- }
-}
-
-func TestOpenArchiveMultipleRootDirs(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "src/main.go": "package main",
- "docs/README.md": "hello",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- paths := make(map[string]bool)
- for _, f := range files {
- paths[f.Path] = true
- }
- if !paths["src/main.go"] {
- t.Error("expected src/main.go to remain unchanged")
- }
- if !paths["docs/README.md"] {
- t.Error("expected docs/README.md to remain unchanged")
- }
-}
-
-func TestOpenArchiveFlatNoSubdirs(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "README.md": "hello",
- "main.go": "package main",
- })
- reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- paths := make(map[string]bool)
- for _, f := range files {
- paths[f.Path] = true
- }
- if !paths["README.md"] {
- t.Error("expected README.md at root")
- }
-}
-
-func TestOpenArchiveNpmUsesPackagePrefix(t *testing.T) {
- data := createTarGzArchive(t, map[string]string{
- "package/README.md": "hello",
- "package/index.js": "module.exports = {}",
- })
- reader, err := openArchive("pkg.tgz", bytes.NewReader(data), "npm")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "package/") {
- t.Errorf("file %q still has package/ prefix", f.Path)
- }
- }
-}
-
-func TestOpenArchiveExtensionlessFilename(t *testing.T) {
- data := createZipArchive(t, map[string]string{
- "repo-hash/README.md": "hello",
- })
- reader, err := openArchive("d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", bytes.NewReader(data), "composer")
- if err != nil {
- t.Fatalf("openArchive failed: %v", err)
- }
- defer func() { _ = reader.Close() }()
-
- files, err := reader.List()
- if err != nil {
- t.Fatalf("List failed: %v", err)
- }
- if len(files) == 0 {
- t.Fatal("expected files in archive")
- }
- for _, f := range files {
- if strings.HasPrefix(f.Path, "repo-hash/") {
- t.Errorf("file %q still has root prefix", f.Path)
- }
- }
-}
-
-func createZipArchive(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- w := zip.NewWriter(buf)
-
- for name, content := range files {
- f, err := w.Create(name)
- if err != nil {
- t.Fatalf("failed to create zip entry: %v", err)
- }
- if _, err := f.Write([]byte(content)); err != nil {
- t.Fatalf("failed to write zip content: %v", err)
- }
- }
-
- if err := w.Close(); err != nil {
- t.Fatalf("failed to close zip writer: %v", err)
- }
- return buf.Bytes()
-}
-
-func createTarGzArchive(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := new(bytes.Buffer)
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
-
- for name, content := range files {
- header := &tar.Header{
- Name: name,
- Size: int64(len(content)),
- Mode: 0644,
- }
- if err := tw.WriteHeader(header); err != nil {
- t.Fatalf("failed to write tar header: %v", err)
- }
- if _, err := tw.Write([]byte(content)); err != nil {
- t.Fatalf("failed to write tar content: %v", err)
- }
- }
-
- if err := tw.Close(); err != nil {
- t.Fatalf("failed to close tar writer: %v", err)
- }
- if err := gw.Close(); err != nil {
- t.Fatalf("failed to close gzip writer: %v", err)
- }
- return buf.Bytes()
-}
diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go
index 1de294c..d6550bd 100644
--- a/internal/server/dashboard.go
+++ b/internal/server/dashboard.go
@@ -113,83 +113,6 @@ type PackagesListPageData struct {
TotalPages int
}
-func supportedEcosystems() []string {
- // this list should be kept sorted in lexicographic order so
- // that the 'select' list in the UI will be in the expected
- // order
- return []string{
- "cargo",
- "composer",
- "conan",
- "conda",
- "cran",
- "deb",
- "gem",
- "golang",
- "hex",
- "julia",
- "maven",
- "npm",
- "nuget",
- "oci",
- "pub",
- "pypi",
- "rpm",
- }
-}
-
-func ecosystemBadgeLabel(ecosystem string) string {
- switch ecosystem {
- case "oci":
- return "container"
- case "deb":
- return "debian"
- default:
- return ecosystem
- }
-}
-
-func ecosystemBadgeClasses(ecosystem string) string {
- base := "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
-
- switch ecosystem {
- case "npm", "maven":
- return base + " bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
- case "cargo":
- return base + " bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
- case "gem":
- return base + " bg-pink-100 text-pink-700 dark:bg-pink-900/50 dark:text-pink-300"
- case "go":
- return base + " bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
- case "hex":
- return base + " bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
- case "pub":
- return base + " bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
- case "pypi":
- return base + " bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
- case "nuget":
- return base + " bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300"
- case "composer":
- return base + " bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
- case "conan":
- return base + " bg-teal-100 text-teal-700 dark:bg-teal-900/50 dark:text-teal-300"
- case "conda":
- return base + " bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
- case "cran":
- 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 "oci":
- return base + " bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
- case "deb":
- return base + " bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300"
- case "rpm":
- return base + " bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300"
- default:
- return base + " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
- }
-}
-
func getRegistryConfigs(baseURL string) []RegistryConfig {
return []RegistryConfig{
{
@@ -289,20 +212,6 @@ index-url = ` + baseURL + `/pypi/simple/`),
</mirror>
</mirrors>
</settings>`),
- },
- {
- ID: "gradle",
- Name: "Gradle Build Cache",
- Language: "Java/Kotlin",
- Endpoint: "/gradle/",
- Instructions: template.HTML(`Configure Gradle to use the proxy for HttpBuildCache:
-// In settings.gradle(.kts)
-buildCache {
- remote<HttpBuildCache> {
- url = uri("` + baseURL + `/gradle/")
- push = true
- }
-}
`),
},
{
ID: "nuget",
@@ -380,17 +289,6 @@ local({
r["CRAN"] <- "` + baseURL + `/cran"
options(repos = r)
})`),
- },
- {
- ID: "julia",
- Name: "Julia",
- Language: "Julia",
- Endpoint: "/julia/",
- Instructions: template.HTML(`Set the Pkg server before starting Julia:
-export JULIA_PKG_SERVER=` + baseURL + `/julia
-Or inside a running session:
-ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
-using Pkg; Pkg.update()
`),
},
{
ID: "oci",
diff --git a/internal/server/errors.go b/internal/server/errors.go
deleted file mode 100644
index 474ecd7..0000000
--- a/internal/server/errors.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
-)
-
-// Error codes returned in API error responses. These are stable identifiers
-// that clients can match on; the message text is for humans and may change.
-const (
- ErrCodeBadRequest = "BAD_REQUEST"
- ErrCodeNotFound = "NOT_FOUND"
- ErrCodeUpstream = "UPSTREAM_ERROR"
- ErrCodeInternal = "INTERNAL_ERROR"
-)
-
-// ErrorResponse is the JSON body returned for API errors.
-type ErrorResponse struct {
- Code string `json:"code"`
- Message string `json:"message"`
-}
-
-// writeError sends a JSON error response with the given status, code and
-// user-facing message. Internal error details should be logged separately
-// by the caller, never passed as the message.
-func writeError(w http.ResponseWriter, status int, code, message string) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(ErrorResponse{Code: code, Message: message})
-}
-
-func badRequest(w http.ResponseWriter, message string) {
- writeError(w, http.StatusBadRequest, ErrCodeBadRequest, message)
-}
-
-func notFound(w http.ResponseWriter, message string) {
- writeError(w, http.StatusNotFound, ErrCodeNotFound, message)
-}
-
-func internalError(w http.ResponseWriter, message string) {
- writeError(w, http.StatusInternalServerError, ErrCodeInternal, message)
-}
diff --git a/internal/server/errors_test.go b/internal/server/errors_test.go
deleted file mode 100644
index c660ae2..0000000
--- a/internal/server/errors_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestWriteError(t *testing.T) {
- tests := []struct {
- name string
- fn func(w http.ResponseWriter)
- status int
- code string
- message string
- }{
- {
- name: "badRequest",
- fn: func(w http.ResponseWriter) { badRequest(w, "missing field") },
- status: http.StatusBadRequest,
- code: ErrCodeBadRequest,
- message: "missing field",
- },
- {
- name: "notFound",
- fn: func(w http.ResponseWriter) { notFound(w, "package not found") },
- status: http.StatusNotFound,
- code: ErrCodeNotFound,
- message: "package not found",
- },
- {
- name: "internalError",
- fn: func(w http.ResponseWriter) { internalError(w, "boom") },
- status: http.StatusInternalServerError,
- code: ErrCodeInternal,
- message: "boom",
- },
- {
- name: "upstream",
- fn: func(w http.ResponseWriter) {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "registry unreachable")
- },
- status: http.StatusBadGateway,
- code: ErrCodeUpstream,
- message: "registry unreachable",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- w := httptest.NewRecorder()
- tt.fn(w)
-
- if w.Code != tt.status {
- t.Errorf("status = %d, want %d", w.Code, tt.status)
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", ct)
- }
-
- var resp ErrorResponse
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("response body is not valid JSON: %v (body: %q)", err, w.Body.String())
- }
- if resp.Code != tt.code {
- t.Errorf("code = %q, want %q", resp.Code, tt.code)
- }
- if resp.Message != tt.message {
- t.Errorf("message = %q, want %q", resp.Message, tt.message)
- }
- })
- }
-}
-
-func TestAPIErrorResponseShape(t *testing.T) {
- w := httptest.NewRecorder()
- badRequest(w, "x")
-
- var raw map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
- t.Fatalf("invalid JSON: %v", err)
- }
- if _, ok := raw["code"]; !ok {
- t.Error("response missing 'code' field")
- }
- if _, ok := raw["message"]; !ok {
- t.Error("response missing 'message' field")
- }
- if len(raw) != 2 {
- t.Errorf("response has unexpected fields: %v", raw)
- }
-}
diff --git a/internal/server/eviction.go b/internal/server/eviction.go
deleted file mode 100644
index 4173bd5..0000000
--- a/internal/server/eviction.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package server
-
-import (
- "context"
- "log/slog"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- evictionInterval = 1 * time.Minute
- evictionBatch = 50
-)
-
-func (s *Server) startEvictionLoop(ctx context.Context) {
- maxSize := s.cfg.ParseMaxSize()
- if maxSize <= 0 {
- return
- }
-
- s.logger.Info("cache eviction enabled", "max_size", s.cfg.Storage.MaxSize)
-
- ticker := time.NewTicker(evictionInterval)
- defer ticker.Stop()
-
- s.runEviction(ctx, maxSize)
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- s.runEviction(ctx, maxSize)
- }
- }
-}
-
-func (s *Server) runEviction(ctx context.Context, maxSize int64) {
- evictLRU(ctx, s.db, s.storage, s.logger, maxSize)
-}
-
-func evictLRU(ctx context.Context, db *database.DB, store storage.Storage, logger *slog.Logger, maxSize int64) {
- totalSize, err := db.GetTotalCacheSize()
- if err != nil {
- logger.Warn("eviction: failed to get cache size", "error", err)
- return
- }
-
- if totalSize <= maxSize {
- return
- }
-
- logger.Info("eviction: cache size exceeds limit, evicting",
- "current_size", totalSize, "max_size", maxSize)
-
- evicted := 0
- freedBytes := int64(0)
-
- for totalSize-freedBytes > maxSize {
- artifacts, err := db.GetLeastRecentlyUsedArtifacts(evictionBatch)
- if err != nil {
- logger.Warn("eviction: failed to get LRU artifacts", "error", err)
- return
- }
- if len(artifacts) == 0 {
- break
- }
-
- for _, art := range artifacts {
- if totalSize-freedBytes <= maxSize {
- break
- }
-
- if !art.StoragePath.Valid {
- continue
- }
-
- if err := store.Delete(ctx, art.StoragePath.String); err != nil {
- logger.Warn("eviction: failed to delete from storage",
- "path", art.StoragePath.String, "error", err)
- continue
- }
-
- if err := db.ClearArtifactCache(art.VersionPURL, art.Filename); err != nil {
- logger.Warn("eviction: failed to clear artifact record",
- "version_purl", art.VersionPURL, "filename", art.Filename, "error", err)
- continue
- }
-
- size := int64(0)
- if art.Size.Valid {
- size = art.Size.Int64
- }
- freedBytes += size
- evicted++
- }
- }
-
- if evicted > 0 {
- logger.Info("eviction: completed",
- "evicted", evicted, "freed_bytes", freedBytes)
- }
-}
diff --git a/internal/server/eviction_test.go b/internal/server/eviction_test.go
deleted file mode 100644
index 9fa9e6b..0000000
--- a/internal/server/eviction_test.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package server
-
-import (
- "context"
- "database/sql"
- "io"
- "log/slog"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-func setupEvictionTest(t *testing.T) (*database.DB, *storage.Filesystem) {
- t.Helper()
-
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
- storagePath := filepath.Join(tempDir, "artifacts")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("failed to create database: %v", err)
- }
-
- store, err := storage.NewFilesystem(storagePath)
- if err != nil {
- _ = db.Close()
- t.Fatalf("failed to create storage: %v", err)
- }
-
- t.Cleanup(func() {
- _ = db.Close()
- })
-
- return db, store
-}
-
-func seedArtifact(t *testing.T, ctx context.Context, db *database.DB, store storage.Storage, name string, dataSize int, accessedAt time.Time) {
- t.Helper()
-
- pkgPURL := "pkg:npm/" + name
- versionPURL := pkgPURL + "@1.0.0"
- filename := name + "-1.0.0.tgz"
-
- if err := db.UpsertPackage(&database.Package{
- PURL: pkgPURL,
- Ecosystem: "npm",
- Name: name,
- }); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- if err := db.UpsertVersion(&database.Version{
- PURL: versionPURL,
- PackagePURL: pkgPURL,
- }); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- storagePath := storage.ArtifactPath("npm", "", name, "1.0.0", filename)
- data := strings.NewReader(strings.Repeat("x", dataSize))
- size, hash, err := store.Store(ctx, storagePath, data)
- if err != nil {
- t.Fatalf("failed to store artifact: %v", err)
- }
-
- if err := db.UpsertArtifact(&database.Artifact{
- VersionPURL: versionPURL,
- Filename: filename,
- UpstreamURL: "https://example.com/" + filename,
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- ContentHash: sql.NullString{String: hash, Valid: true},
- Size: sql.NullInt64{Int64: size, Valid: true},
- ContentType: sql.NullString{String: "application/gzip", Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- LastAccessedAt: sql.NullTime{Time: accessedAt, Valid: true},
- }); err != nil {
- t.Fatalf("failed to upsert artifact: %v", err)
- }
-}
-
-func TestEvictLRU_NoEvictionWhenUnderLimit(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- seedArtifact(t, ctx, db, store, "pkg-a", 100, time.Now())
-
- evictLRU(ctx, db, store, logger, 1024)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 1 {
- t.Errorf("expected 1 cached artifact, got %d", count)
- }
-}
-
-func TestEvictLRU_EvictsOldestFirst(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- now := time.Now()
- seedArtifact(t, ctx, db, store, "old-pkg", 500, now.Add(-3*time.Hour))
- seedArtifact(t, ctx, db, store, "mid-pkg", 500, now.Add(-1*time.Hour))
- seedArtifact(t, ctx, db, store, "new-pkg", 500, now)
-
- // Total is 1500 bytes, limit to 1100 so only the oldest gets evicted
- evictLRU(ctx, db, store, logger, 1100)
-
- // old-pkg should be evicted
- art, err := db.GetArtifact("pkg:npm/old-pkg@1.0.0", "old-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if art.StoragePath.Valid {
- t.Error("expected old-pkg to be evicted (storage_path should be NULL)")
- }
-
- // mid-pkg and new-pkg should remain
- art, err = db.GetArtifact("pkg:npm/mid-pkg@1.0.0", "mid-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if !art.StoragePath.Valid {
- t.Error("expected mid-pkg to remain cached")
- }
-
- art, err = db.GetArtifact("pkg:npm/new-pkg@1.0.0", "new-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if !art.StoragePath.Valid {
- t.Error("expected new-pkg to remain cached")
- }
-
- // Storage file should be removed for old-pkg
- storagePath := storage.ArtifactPath("npm", "", "old-pkg", "1.0.0", "old-pkg-1.0.0.tgz")
- exists, _ := store.Exists(ctx, storagePath)
- if exists {
- t.Error("expected old-pkg file to be deleted from storage")
- }
-}
-
-func TestEvictLRU_EvictsMultipleToGetUnderLimit(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- now := time.Now()
- seedArtifact(t, ctx, db, store, "pkg-1", 400, now.Add(-4*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-2", 400, now.Add(-3*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-3", 400, now.Add(-2*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-4", 400, now)
-
- // Total is 1600 bytes, limit to 900 so pkg-1 and pkg-2 get evicted
- evictLRU(ctx, db, store, logger, 900)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 2 {
- t.Errorf("expected 2 cached artifacts remaining, got %d", count)
- }
-
- // Verify the right ones remain
- for _, name := range []string{"pkg-3", "pkg-4"} {
- art, err := db.GetArtifact("pkg:npm/"+name+"@1.0.0", name+"-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact %s: %v", name, err)
- }
- if !art.StoragePath.Valid {
- t.Errorf("expected %s to remain cached", name)
- }
- }
-}
-
-func TestEvictLRU_NothingToEvictWhenEmpty(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- // Should not panic or error with no artifacts
- evictLRU(ctx, db, store, logger, 1024)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 0 {
- t.Errorf("expected 0 cached artifacts, got %d", count)
- }
-}
-
-func TestEvictLRU_StorageFileDeleted(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- seedArtifact(t, ctx, db, store, "delete-me", 1000, time.Now().Add(-1*time.Hour))
-
- storagePath := storage.ArtifactPath("npm", "", "delete-me", "1.0.0", "delete-me-1.0.0.tgz")
- exists, _ := store.Exists(ctx, storagePath)
- if !exists {
- t.Fatal("expected artifact file to exist before eviction")
- }
-
- evictLRU(ctx, db, store, logger, 500)
-
- exists, _ = store.Exists(ctx, storagePath)
- if exists {
- t.Error("expected artifact file to be deleted after eviction")
- }
-
- art, err := db.GetArtifact("pkg:npm/delete-me@1.0.0", "delete-me-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if art.StoragePath.Valid {
- t.Error("expected storage_path to be NULL after eviction")
- }
- if art.Size.Valid {
- t.Error("expected size to be NULL after eviction")
- }
-}
-
-func TestStartEvictionLoop_UnlimitedSkips(t *testing.T) {
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
- storagePath := filepath.Join(tempDir, "artifacts")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("failed to create database: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- store, err := storage.NewFilesystem(storagePath)
- if err != nil {
- t.Fatalf("failed to create storage: %v", err)
- }
-
- cfg := defaultTestConfig(storagePath, dbPath)
-
- s := &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cancel()
-
- // Should return immediately since max_size is empty (unlimited)
- done := make(chan struct{})
- go func() {
- s.startEvictionLoop(ctx)
- close(done)
- }()
-
- select {
- case <-done:
- // Good, returned immediately
- case <-time.After(1 * time.Second):
- t.Error("startEvictionLoop should return immediately when max_size is unlimited")
- cancel()
- }
-}
-
-func defaultTestConfig(storagePath, dbPath string) *config.Config {
- return &config.Config{
- Listen: ":8080",
- BaseURL: "http://localhost:8080",
- Storage: config.StorageConfig{Path: storagePath, MaxSize: ""},
- Database: config.DatabaseConfig{
- Driver: "sqlite",
- Path: dbPath,
- },
- Log: config.LogConfig{Level: "info", Format: "text"},
- }
-}
diff --git a/internal/server/gradle_cache_eviction.go b/internal/server/gradle_cache_eviction.go
deleted file mode 100644
index 7f546d1..0000000
--- a/internal/server/gradle_cache_eviction.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package server
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const gradleBuildCacheStoragePrefix = "_gradle/http-build-cache/"
-
-type gradleBuildCacheLister interface {
- ListPrefix(ctx context.Context, prefix string) ([]storage.ObjectInfo, error)
-}
-
-func (s *Server) startGradleBuildCacheEviction(ctx context.Context) {
- maxAge := s.cfg.ParseGradleBuildCacheMaxAge()
- maxSize := s.cfg.ParseGradleBuildCacheMaxSize()
- if maxAge <= 0 && maxSize <= 0 {
- return
- }
-
- lister, ok := s.storage.(gradleBuildCacheLister)
- if !ok {
- s.logger.Warn("gradle cache eviction is enabled, but storage backend cannot list objects")
- return
- }
-
- interval := s.cfg.ParseGradleBuildCacheSweepInterval()
- s.logger.Info("gradle cache eviction enabled",
- "max_age", maxAge,
- "max_size_bytes", maxSize,
- "interval", interval)
-
- sweep := func() {
- deletedCount, freedBytes, err := sweepGradleBuildCache(ctx, s.storage, lister, maxAge, maxSize, time.Now())
- if err != nil {
- s.logger.Warn("gradle cache eviction sweep failed", "error", err)
- return
- }
- if deletedCount > 0 {
- s.logger.Info("gradle cache eviction sweep completed",
- "deleted_entries", deletedCount,
- "freed_bytes", freedBytes)
- }
- }
-
- sweep()
-
- go func() {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- sweep()
- }
- }
- }()
-}
-
-func sweepGradleBuildCache(
- ctx context.Context,
- store storage.Storage,
- lister gradleBuildCacheLister,
- maxAge time.Duration,
- maxSize int64,
- now time.Time,
-) (int, int64, error) {
- entries, err := lister.ListPrefix(ctx, gradleBuildCacheStoragePrefix)
- if err != nil {
- return 0, 0, fmt.Errorf("listing gradle cache entries: %w", err)
- }
-
- if len(entries) == 0 {
- return 0, 0, nil
- }
-
- sortOldestFirst(entries)
-
- deletedCount := 0
- freedBytes := int64(0)
- var firstDeleteErr error
-
- deleteEntry := func(entry storage.ObjectInfo) bool {
- if err := store.Delete(ctx, entry.Path); err != nil {
- if firstDeleteErr == nil {
- firstDeleteErr = err
- }
- return false
- }
- deletedCount++
- freedBytes += entry.Size
- return true
- }
-
- remaining := entries
- if maxAge > 0 {
- cutoff := now.Add(-maxAge)
- kept := make([]storage.ObjectInfo, 0, len(entries))
-
- for _, entry := range entries {
- if !entry.ModTime.IsZero() && entry.ModTime.Before(cutoff) {
- if deleteEntry(entry) {
- continue
- }
- }
- kept = append(kept, entry)
- }
-
- remaining = kept
- }
-
- if maxSize > 0 {
- totalSize := int64(0)
- for _, entry := range remaining {
- totalSize += entry.Size
- }
-
- for _, entry := range remaining {
- if totalSize <= maxSize {
- break
- }
- if deleteEntry(entry) {
- totalSize -= entry.Size
- }
- }
- }
-
- if firstDeleteErr != nil {
- return deletedCount, freedBytes, fmt.Errorf("deleting gradle cache entries: %w", firstDeleteErr)
- }
-
- return deletedCount, freedBytes, nil
-}
-
-func sortOldestFirst(entries []storage.ObjectInfo) {
- sort.Slice(entries, func(i, j int) bool {
- if entries[i].ModTime.Equal(entries[j].ModTime) {
- return entries[i].Path < entries[j].Path
- }
- return entries[i].ModTime.Before(entries[j].ModTime)
- })
-}
diff --git a/internal/server/gradle_cache_eviction_test.go b/internal/server/gradle_cache_eviction_test.go
deleted file mode 100644
index 4e97507..0000000
--- a/internal/server/gradle_cache_eviction_test.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "io"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-type fakeGradleCacheStore struct {
- objects map[string]storage.ObjectInfo
-}
-
-func newFakeGradleCacheStore(objects []storage.ObjectInfo) *fakeGradleCacheStore {
- m := make(map[string]storage.ObjectInfo, len(objects))
- for _, obj := range objects {
- m[obj.Path] = obj
- }
- return &fakeGradleCacheStore{objects: m}
-}
-
-func (s *fakeGradleCacheStore) Store(_ context.Context, path string, r io.Reader) (int64, string, error) {
- data, _ := io.ReadAll(r)
- s.objects[path] = storage.ObjectInfo{Path: path, Size: int64(len(data)), ModTime: time.Now()}
- return int64(len(data)), "", nil
-}
-
-func (s *fakeGradleCacheStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
- obj, ok := s.objects[path]
- if !ok {
- return nil, storage.ErrNotFound
- }
- return io.NopCloser(bytes.NewReader(make([]byte, obj.Size))), nil
-}
-
-func (s *fakeGradleCacheStore) Exists(_ context.Context, path string) (bool, error) {
- _, ok := s.objects[path]
- return ok, nil
-}
-
-func (s *fakeGradleCacheStore) Delete(_ context.Context, path string) error {
- delete(s.objects, path)
- return nil
-}
-
-func (s *fakeGradleCacheStore) Size(_ context.Context, path string) (int64, error) {
- obj, ok := s.objects[path]
- if !ok {
- return 0, storage.ErrNotFound
- }
- return obj.Size, nil
-}
-
-func (s *fakeGradleCacheStore) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
- return "", storage.ErrSignedURLUnsupported
-}
-
-func (s *fakeGradleCacheStore) UsedSpace(_ context.Context) (int64, error) {
- var total int64
- for _, obj := range s.objects {
- total += obj.Size
- }
- return total, nil
-}
-
-func (s *fakeGradleCacheStore) URL() string { return "mem://" }
-
-func (s *fakeGradleCacheStore) Close() error { return nil }
-
-func (s *fakeGradleCacheStore) ListPrefix(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
- objects := make([]storage.ObjectInfo, 0)
- for _, obj := range s.objects {
- if strings.HasPrefix(obj.Path, prefix) {
- objects = append(objects, obj)
- }
- }
- return objects, nil
-}
-
-func TestSweepGradleBuildCache_MaxAge(t *testing.T) {
- now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
- store := newFakeGradleCacheStore([]storage.ObjectInfo{
- {Path: "_gradle/http-build-cache/old", Size: 10, ModTime: now.Add(-48 * time.Hour)},
- {Path: "_gradle/http-build-cache/new", Size: 10, ModTime: now.Add(-2 * time.Hour)},
- })
-
- deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 24*time.Hour, 0, now)
- if err != nil {
- t.Fatalf("sweepGradleBuildCache() error = %v", err)
- }
- if deleted != 1 {
- t.Fatalf("deleted entries = %d, want 1", deleted)
- }
- if freed != 10 {
- t.Fatalf("freed bytes = %d, want 10", freed)
- }
-
- if _, ok := store.objects["_gradle/http-build-cache/old"]; ok {
- t.Fatal("old entry was not deleted")
- }
- if _, ok := store.objects["_gradle/http-build-cache/new"]; !ok {
- t.Fatal("new entry should remain")
- }
-}
-
-func TestSweepGradleBuildCache_MaxSizeOldestFirst(t *testing.T) {
- now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
- store := newFakeGradleCacheStore([]storage.ObjectInfo{
- {Path: "_gradle/http-build-cache/a", Size: 5, ModTime: now.Add(-3 * time.Hour)},
- {Path: "_gradle/http-build-cache/b", Size: 5, ModTime: now.Add(-2 * time.Hour)},
- {Path: "_gradle/http-build-cache/c", Size: 5, ModTime: now.Add(-1 * time.Hour)},
- })
-
- deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 0, 10, now)
- if err != nil {
- t.Fatalf("sweepGradleBuildCache() error = %v", err)
- }
- if deleted != 1 {
- t.Fatalf("deleted entries = %d, want 1", deleted)
- }
- if freed != 5 {
- t.Fatalf("freed bytes = %d, want 5", freed)
- }
-
- if _, ok := store.objects["_gradle/http-build-cache/a"]; ok {
- t.Fatal("oldest entry was not deleted")
- }
- if _, ok := store.objects["_gradle/http-build-cache/b"]; !ok {
- t.Fatal("middle entry should remain")
- }
- if _, ok := store.objects["_gradle/http-build-cache/c"]; !ok {
- t.Fatal("newest entry should remain")
- }
-}
diff --git a/internal/server/health.go b/internal/server/health.go
deleted file mode 100644
index f4e4847..0000000
--- a/internal/server/health.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Package server implements the proxy HTTP server.
-package server
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "strconv"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- probePathPrefix = ".healthcheck/"
- probeMarker = "proxy-healthcheck:"
- probeSuffixBytes = 8
- defaultProbeTTL = 30 * time.Second
- defaultProbeTimeout = 10 * time.Second
-)
-
-// HealthResponse is the JSON payload returned by /health.
-type HealthResponse struct {
- Status string `json:"status"`
- Checks map[string]HealthCheck `json:"checks"`
-}
-
-// HealthCheck reports the status of a single subsystem check.
-type HealthCheck struct {
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
- Step string `json:"step,omitempty"`
-}
-
-// probeError tags a storage probe failure with the step that failed.
-type probeError struct {
- step string
- err error
-}
-
-func (e *probeError) Error() string { return e.step + ": " + e.err.Error() }
-func (e *probeError) Unwrap() error { return e.err }
-
-// storageProbe runs a write → size-check → read → verify → delete round-trip
-// against the storage backend. Returns nil on success or a *probeError on failure.
-func storageProbe(ctx context.Context, s storage.Storage) (err error) {
- suffix, suffixErr := randomSuffix()
- if suffixErr != nil {
- return &probeError{step: "write", err: fmt.Errorf("generating random suffix: %w", suffixErr)}
- }
- path := probePathPrefix + strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + suffix
- payload := []byte(probeMarker + suffix)
-
- // 1. Store
- size, _, storeErr := s.Store(ctx, path, bytes.NewReader(payload))
- if storeErr != nil {
- return &probeError{step: "write", err: storeErr}
- }
- // After Store succeeds, always attempt to delete on the way out so probe
- // objects don't accumulate when a later step (size/open/read/verify) fails.
- // Delete is reported as the primary error only if no earlier failure
- // already set one.
- defer func() {
- if delErr := s.Delete(ctx, path); delErr != nil && err == nil {
- err = &probeError{step: "delete", err: delErr}
- }
- }()
- // 2. Size check
- if size != int64(len(payload)) {
- return &probeError{step: "size", err: fmt.Errorf("wrote %d bytes, expected %d", size, len(payload))}
- }
- // 3. Open
- rc, openErr := s.Open(ctx, path)
- if openErr != nil {
- return &probeError{step: "read", err: openErr}
- }
- // 4. Read all (classify mid-stream errors as read, not verify).
- // Close explicitly (not deferred) so the file handle is released before
- // Delete — on Windows, an open handle prevents deletion.
- data, readErr := io.ReadAll(rc)
- _ = rc.Close()
- if readErr != nil {
- return &probeError{step: "read", err: readErr}
- }
- // 5. Verify
- if !bytes.Equal(data, payload) {
- return &probeError{step: "verify", err: fmt.Errorf("content mismatch")}
- }
- // 6. Delete is handled via the deferred cleanup above.
- return nil
-}
-
-// randomSuffix returns 8 cryptographically random bytes hex-encoded.
-func randomSuffix() (string, error) {
- b := make([]byte, probeSuffixBytes)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- return hex.EncodeToString(b), nil
-}
-
-// healthCache memoizes the result of storageProbe for a configurable TTL.
-// It is safe for concurrent use.
-type healthCache struct {
- storage storage.Storage
- interval time.Duration
- probeTimeout time.Duration
- logger *slog.Logger
-
- mu sync.Mutex
- lastAt time.Time
- lastErr error
-}
-
-// newHealthCache builds a cache, parsing the interval from a duration string.
-// Empty interval string defaults to 30s. "0" or "0s" disables caching.
-func newHealthCache(s storage.Storage, intervalStr string, logger *slog.Logger) (*healthCache, error) {
- interval := defaultProbeTTL
- if intervalStr != "" {
- d, err := time.ParseDuration(intervalStr)
- if err != nil {
- return nil, fmt.Errorf("parsing storage_probe_interval %q: %w", intervalStr, err)
- }
- interval = d
- }
- return &healthCache{
- storage: s,
- interval: interval,
- probeTimeout: defaultProbeTimeout,
- logger: logger,
- }, nil
-}
-
-// Check returns the cached probe result if still fresh, otherwise runs a fresh probe.
-// The probe runs under a context derived from context.Background() with a fixed
-// timeout so that caller cancellation (e.g. client disconnect) cannot poison the
-// cache with context.Canceled.
-func (c *healthCache) Check() error {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- // Cache hit
- if c.interval > 0 && !c.lastAt.IsZero() && time.Since(c.lastAt) < c.interval {
- return c.lastErr
- }
-
- // Fresh probe under a detached context
- probeCtx, cancel := context.WithTimeout(context.Background(), c.probeTimeout)
- defer cancel()
- err := storageProbe(probeCtx, c.storage)
-
- // Transition logging and metric increment happen only on the fresh-probe path.
- c.logTransition(c.lastErr, err)
- if err != nil {
- var pe *probeError
- if errors.As(err, &pe) {
- metrics.RecordHealthProbeFailure(pe.step)
- } else {
- metrics.RecordHealthProbeFailure("unknown")
- }
- }
-
- c.lastErr = err
- c.lastAt = time.Now()
- return err
-}
-
-func (c *healthCache) logTransition(prev, curr error) {
- switch {
- case prev != nil && curr == nil:
- c.logger.Info("storage probe recovered")
- case prev == nil && curr != nil:
- c.logger.Error("storage probe failed", "error", curr.Error())
- }
-}
diff --git a/internal/server/health_test.go b/internal/server/health_test.go
deleted file mode 100644
index c0f70c9..0000000
--- a/internal/server/health_test.go
+++ /dev/null
@@ -1,448 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "log/slog"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// fakeStorage is a minimal storage.Storage for probe tests with per-step failure injection.
-type fakeStorage struct {
- mu sync.Mutex
-
- storeCalls atomic.Int64
- openCalls atomic.Int64
- closeCalls atomic.Int64
- deleteCalls atomic.Int64
-
- paths []string
- payloads [][]byte
-
- // Failure injection.
- storeErr error
- openErr error
- readErr error // returned by the io.ReadCloser.Read after partial bytes
- deleteErr error
-
- // Misbehavior knobs.
- sizeDelta int64 // added to the reported size from Store
- readOverride []byte // if non-nil, Open returns a reader yielding these bytes instead of stored content
-
- // storeBlock, if non-nil, causes Store to block until the channel is closed or ctx is done.
- storeBlock chan struct{}
-
- stored map[string][]byte
-}
-
-func newFakeStorage() *fakeStorage { return &fakeStorage{stored: map[string][]byte{}} }
-
-func (f *fakeStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- f.storeCalls.Add(1)
- if f.storeErr != nil {
- return 0, "", f.storeErr
- }
- if f.storeBlock != nil {
- select {
- case <-f.storeBlock:
- case <-ctx.Done():
- return 0, "", ctx.Err()
- }
- }
- data, err := io.ReadAll(r)
- if err != nil {
- return 0, "", err
- }
- f.mu.Lock()
- f.stored[path] = data
- f.paths = append(f.paths, path)
- f.payloads = append(f.payloads, data)
- f.mu.Unlock()
- return int64(len(data)) + f.sizeDelta, "fakehash", nil
-}
-
-type fakeReadCloser struct {
- data []byte
- pos int
- readErr error
- closed *atomic.Int64
-}
-
-func (rc *fakeReadCloser) Read(p []byte) (int, error) {
- if rc.pos >= len(rc.data) {
- if rc.readErr != nil {
- return 0, rc.readErr
- }
- return 0, io.EOF
- }
- n := copy(p, rc.data[rc.pos:])
- rc.pos += n
- if rc.pos >= len(rc.data) && rc.readErr != nil {
- return n, rc.readErr
- }
- return n, nil
-}
-
-func (rc *fakeReadCloser) Close() error { rc.closed.Add(1); return nil }
-
-func (f *fakeStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- f.openCalls.Add(1)
- if f.openErr != nil {
- return nil, f.openErr
- }
- f.mu.Lock()
- data := f.stored[path]
- f.mu.Unlock()
- if f.readOverride != nil {
- data = f.readOverride
- }
- return &fakeReadCloser{data: data, readErr: f.readErr, closed: &f.closeCalls}, nil
-}
-
-func (f *fakeStorage) Exists(ctx context.Context, path string) (bool, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- _, ok := f.stored[path]
- return ok, nil
-}
-
-func (f *fakeStorage) Delete(ctx context.Context, path string) error {
- f.deleteCalls.Add(1)
- if f.deleteErr != nil {
- return f.deleteErr
- }
- f.mu.Lock()
- delete(f.stored, path)
- f.mu.Unlock()
- return nil
-}
-
-func (f *fakeStorage) Size(ctx context.Context, path string) (int64, error) { return 0, nil }
-func (f *fakeStorage) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
- return "", storage.ErrSignedURLUnsupported
-}
-func (f *fakeStorage) UsedSpace(ctx context.Context) (int64, error) { return 0, nil }
-func (f *fakeStorage) URL() string { return "fake://" }
-func (f *fakeStorage) Close() error { return nil }
-
-// --- Tests follow. First test: happy path ---
-
-func TestStorageProbe_HappyPath(t *testing.T) {
- fs := newFakeStorage()
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("Store calls = %d, want 1", got)
- }
- if got := fs.openCalls.Load(); got != 1 {
- t.Errorf("Open calls = %d, want 1", got)
- }
- if got := fs.closeCalls.Load(); got != 1 {
- t.Errorf("Close calls = %d, want 1", got)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("Delete calls = %d, want 1", got)
- }
- if len(fs.paths) != 1 || !strings.HasPrefix(fs.paths[0], ".healthcheck/") {
- t.Errorf("unexpected probe path: %v", fs.paths)
- }
-}
-
-func TestStorageProbe_WriteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("disk full")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) {
- t.Fatalf("expected *probeError, got %T: %v", err, err)
- }
- if pe.step != "write" {
- t.Errorf("step = %q, want write", pe.step)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after write failure")
- }
-}
-
-func TestStorageProbe_SizeMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.sizeDelta = -1 // Report 1 byte fewer than actually written
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "size" {
- t.Fatalf("step = %v, want size; err = %v", pe, err)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after size mismatch")
- }
-}
-
-func TestStorageProbe_OpenFails(t *testing.T) {
- fs := newFakeStorage()
- fs.openErr = errors.New("access denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ReadMidStreamFails(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("connection reset")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read (NOT verify); err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ContentMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.readOverride = []byte("wrong content")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "verify" {
- t.Fatalf("step = %v, want verify; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_DeleteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.deleteErr = errors.New("permission denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "delete" {
- t.Fatalf("step = %v, want delete; err = %v", pe, err)
- }
-}
-
-// TestStorageProbe_CleanupOnNonDeleteFailure asserts that the probe object is
-// deleted even when a step after Store (size/open/read/verify) fails, so
-// probe artifacts don't accumulate in the storage backend.
-func TestStorageProbe_CleanupOnNonDeleteFailure(t *testing.T) {
- cases := []struct {
- name string
- inject func(*fakeStorage)
- wantErr string
- }{
- {"size mismatch", func(fs *fakeStorage) { fs.sizeDelta = -1 }, "size"},
- {"open fails", func(fs *fakeStorage) { fs.openErr = errors.New("open boom") }, "read"},
- {"read mid-stream", func(fs *fakeStorage) { fs.readErr = errors.New("mid-stream boom") }, "read"},
- {"content mismatch", func(fs *fakeStorage) { fs.readOverride = []byte("wrong") }, "verify"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- fs := newFakeStorage()
- tc.inject(fs)
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != tc.wantErr {
- t.Fatalf("step = %v, want %q; err = %v", pe, tc.wantErr, err)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("deleteCalls = %d, want 1 (cleanup should run on non-delete failures)", got)
- }
- })
- }
-}
-
-func TestStorageProbe_ReaderClosedOnReadFailure(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("read error")
- _ = storageProbe(context.Background(), fs)
- if got := fs.closeCalls.Load(); got != fs.openCalls.Load() {
- t.Errorf("closeCalls = %d, openCalls = %d (should match)", got, fs.openCalls.Load())
- }
-}
-
-func TestStorageProbe_PathUniqueness(t *testing.T) {
- fs := newFakeStorage()
- for i := 0; i < 100; i++ {
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("probe %d: %v", i, err)
- }
- }
- seen := make(map[string]bool)
- for _, p := range fs.paths {
- if !strings.HasPrefix(p, ".healthcheck/") {
- t.Errorf("path missing prefix: %q", p)
- }
- if seen[p] {
- t.Errorf("duplicate path: %q", p)
- }
- seen[p] = true
- }
-}
-
-// helper: a healthCache wired to a fakeStorage and a discard logger.
-func newTestCache(fs *fakeStorage, interval time.Duration) *healthCache {
- return &healthCache{
- storage: fs,
- interval: interval,
- probeTimeout: 5 * time.Second,
- logger: discardLogger(),
- }
-}
-
-func discardLogger() *slog.Logger {
- return slog.New(slog.NewTextHandler(io.Discard, nil))
-}
-
-func TestHealthCache_CacheHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- if err := c.Check(); err != nil {
- t.Fatalf("first check: %v", err)
- }
- if err := c.Check(); err != nil {
- t.Fatalf("second check: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 (second call should be cached)", got)
- }
-}
-
-func TestHealthCache_MissAfterTTL(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 10*time.Millisecond)
- _ = c.Check()
- time.Sleep(20 * time.Millisecond)
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_Disabled(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 0) // interval = 0 means probe every call
- _ = c.Check()
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_LastAtNotAdvancedOnHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- for i := 0; i < 100; i++ {
- _ = c.Check()
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 across 100 hits", got)
- }
-}
-
-func TestHealthCache_ConcurrentSingleFlight(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- var wg sync.WaitGroup
- for i := 0; i < 20; i++ {
- wg.Add(1)
- go func() { defer wg.Done(); _ = c.Check() }()
- }
- wg.Wait()
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 with 20 concurrent callers", got)
- }
-}
-
-func TestHealthCache_FailureCounterIncrement(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("boom")
- c := newTestCache(fs, 30*time.Second)
-
- before := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
-
- // First call: fresh probe → counter +1
- _ = c.Check()
- afterFirst := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterFirst-before != 1 {
- t.Errorf("counter delta after first call = %v, want 1", afterFirst-before)
- }
-
- // Second call: cache hit → counter NOT re-incremented
- _ = c.Check()
- afterSecond := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterSecond != afterFirst {
- t.Errorf("counter changed on cache hit: %v → %v", afterFirst, afterSecond)
- }
-}
-
-func TestHealthCache_ProbeTimeout(t *testing.T) {
- fs := newFakeStorage()
- fs.storeBlock = make(chan struct{}) // Store will block until channel is closed (or never)
- t.Cleanup(func() { close(fs.storeBlock) })
-
- c := &healthCache{
- storage: fs,
- interval: 30 * time.Second,
- probeTimeout: 50 * time.Millisecond,
- logger: discardLogger(),
- }
- start := time.Now()
- err := c.Check()
- elapsed := time.Since(start)
-
- if err == nil {
- t.Fatal("expected timeout error, got nil")
- }
- if elapsed > 500*time.Millisecond {
- t.Errorf("probe took %v, expected ~50ms (timeout not respected)", elapsed)
- }
-}
-
-func TestHealthCache_TransitionLogging(t *testing.T) {
- fs := newFakeStorage()
- var buf bytes.Buffer
- logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
- c := &healthCache{
- storage: fs,
- interval: 0, // probe every call
- probeTimeout: 5 * time.Second,
- logger: logger,
- }
-
- // Steady ok state — should not log
- _ = c.Check()
- _ = c.Check()
- if got := strings.Count(buf.String(), "storage probe"); got != 0 {
- t.Errorf("steady-state logs = %d, want 0; output: %s", got, buf.String())
- }
-
- // ok → err transition: exactly one Error log
- buf.Reset()
- fs.storeErr = errors.New("boom")
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe failed") {
- t.Errorf("missing failure log on transition; output: %s", buf.String())
- }
-
- // err steady state — should not log again
- buf.Reset()
- _ = c.Check()
- if buf.Len() != 0 {
- t.Errorf("steady-err logs = %q, want empty", buf.String())
- }
-
- // err → ok transition: exactly one Info log
- buf.Reset()
- fs.storeErr = nil
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe recovered") {
- t.Errorf("missing recovery log on transition; output: %s", buf.String())
- }
-}
diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go
index 75c6ccd..22a1167 100644
--- a/internal/server/middleware_test.go
+++ b/internal/server/middleware_test.go
@@ -2,8 +2,6 @@ package server
import (
"context"
- "io"
- "log/slog"
"net/http"
"net/http/httptest"
"testing"
@@ -94,56 +92,3 @@ func TestActiveRequestsMiddleware_SkipsMetricsEndpoint(t *testing.T) {
t.Errorf("expected status 200, got %d", rec.Code)
}
}
-
-func TestLoggerMiddleware(t *testing.T) {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- s := &Server{logger: logger}
-
- called := false
- next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- called = true
- w.WriteHeader(http.StatusCreated)
- })
-
- handler := s.LoggerMiddleware(next)
-
- req := httptest.NewRequest(http.MethodGet, "/test-path", nil)
- rec := httptest.NewRecorder()
-
- handler.ServeHTTP(rec, req)
-
- if !called {
- t.Error("expected next handler to be called")
- }
-
- if rec.Code != http.StatusCreated {
- t.Errorf("expected status 201, got %d", rec.Code)
- }
-}
-
-func TestResponseWriter_WriteHeader(t *testing.T) {
- tests := []struct {
- name string
- status int
- }{
- {"ok", http.StatusOK},
- {"not found", http.StatusNotFound},
- {"internal error", http.StatusInternalServerError},
- {"created", http.StatusCreated},
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- rec := httptest.NewRecorder()
- rw := &responseWriter{ResponseWriter: rec, status: http.StatusOK}
- rw.WriteHeader(tc.status)
-
- if rw.status != tc.status {
- t.Errorf("expected status %d, got %d", tc.status, rw.status)
- }
- if rec.Code != tc.status {
- t.Errorf("expected underlying recorder status %d, got %d", tc.status, rec.Code)
- }
- })
- }
-}
diff --git a/internal/server/mirror_api.go b/internal/server/mirror_api.go
deleted file mode 100644
index 028d4e0..0000000
--- a/internal/server/mirror_api.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
-
- "github.com/git-pkgs/proxy/internal/mirror"
- "github.com/go-chi/chi/v5"
-)
-
-// MirrorAPIHandler handles mirror API requests.
-type MirrorAPIHandler struct {
- jobs *mirror.JobStore
-}
-
-// NewMirrorAPIHandler creates a new mirror API handler.
-func NewMirrorAPIHandler(jobs *mirror.JobStore) *MirrorAPIHandler {
- return &MirrorAPIHandler{jobs: jobs}
-}
-
-// HandleCreate starts a new mirror job.
-func (h *MirrorAPIHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
- var req mirror.JobRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
- return
- }
-
- id, err := h.jobs.Create(req)
- if err != nil {
- badRequest(w, "invalid mirror job request")
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusAccepted)
- writeJSON(w, map[string]string{"id": id})
-}
-
-// HandleGet returns the status of a mirror job.
-func (h *MirrorAPIHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- job := h.jobs.Get(id)
- if job == nil {
- notFound(w, "job not found")
- return
- }
-
- writeJSON(w, job)
-}
-
-// HandleCancel cancels a running mirror job.
-func (h *MirrorAPIHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- if h.jobs.Cancel(id) {
- writeJSON(w, map[string]string{"status": "canceled"})
- } else {
- notFound(w, "job not found or not running")
- }
-}
diff --git a/internal/server/mirror_api_test.go b/internal/server/mirror_api_test.go
deleted file mode 100644
index 73b8731..0000000
--- a/internal/server/mirror_api_test.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "os"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/handler"
- "github.com/git-pkgs/proxy/internal/mirror"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/git-pkgs/registries/fetch"
- "github.com/go-chi/chi/v5"
-)
-
-func setupMirrorAPI(t *testing.T) *MirrorAPIHandler {
- t.Helper()
-
- dbPath := t.TempDir() + "/test.db"
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("creating database: %v", err)
- }
- if err := db.MigrateSchema(); err != nil {
- t.Fatalf("migrating schema: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
-
- storeDir := t.TempDir()
- store, err := storage.OpenBucket(context.Background(), "file://"+storeDir)
- if err != nil {
- t.Fatalf("opening storage: %v", err)
- }
-
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
- fetcher := fetch.NewFetcher()
- resolver := fetch.NewResolver()
- proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
-
- m := mirror.New(proxy, db, store, logger, 1)
- js := mirror.NewJobStore(context.Background(), m)
- return NewMirrorAPIHandler(js)
-}
-
-func TestMirrorAPICreateJob(t *testing.T) {
- h := setupMirrorAPI(t)
-
- body, _ := json.Marshal(mirror.JobRequest{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- })
-
- req := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleCreate(w, req)
-
- if w.Code != http.StatusAccepted {
- t.Errorf("status = %d, want %d", w.Code, http.StatusAccepted)
- }
-
- var resp map[string]string
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp["id"] == "" {
- t.Error("expected non-empty job ID")
- }
-}
-
-func TestMirrorAPICreateOversizedBody(t *testing.T) {
- h := setupMirrorAPI(t)
-
- body := bytes.Repeat([]byte("x"), int(maxBodySize)+1)
- req := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleCreate(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestMirrorAPICreateInvalidBody(t *testing.T) {
- h := setupMirrorAPI(t)
-
- req := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader([]byte("not json")))
- w := httptest.NewRecorder()
- h.HandleCreate(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestMirrorAPICreateEmptyRequest(t *testing.T) {
- h := setupMirrorAPI(t)
-
- body, _ := json.Marshal(mirror.JobRequest{})
- req := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleCreate(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
-func TestMirrorAPIGetNotFound(t *testing.T) {
- h := setupMirrorAPI(t)
-
- r := chi.NewRouter()
- r.Get("/api/mirror/{id}", h.HandleGet)
-
- req := httptest.NewRequest("GET", "/api/mirror/nonexistent", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestMirrorAPICancelNotFound(t *testing.T) {
- h := setupMirrorAPI(t)
-
- r := chi.NewRouter()
- r.Delete("/api/mirror/{id}", h.HandleCancel)
-
- req := httptest.NewRequest("DELETE", "/api/mirror/nonexistent", nil)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
- }
-}
-
-func TestMirrorAPICreateAndGetJob(t *testing.T) {
- h := setupMirrorAPI(t)
-
- // Create a job
- body, _ := json.Marshal(mirror.JobRequest{
- PURLs: []string{"pkg:npm/lodash@4.17.21"},
- })
- createReq := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader(body))
- createW := httptest.NewRecorder()
- h.HandleCreate(createW, createReq)
-
- var createResp map[string]string
- _ = json.NewDecoder(createW.Body).Decode(&createResp)
- jobID := createResp["id"]
-
- // Get the job
- r := chi.NewRouter()
- r.Get("/api/mirror/{id}", h.HandleGet)
-
- getReq := httptest.NewRequest("GET", "/api/mirror/"+jobID, nil)
- getW := httptest.NewRecorder()
- r.ServeHTTP(getW, getReq)
-
- if getW.Code != http.StatusOK {
- t.Errorf("status = %d, want %d", getW.Code, http.StatusOK)
- }
-
- var job mirror.Job
- if err := json.NewDecoder(getW.Body).Decode(&job); err != nil {
- t.Fatalf("decoding job: %v", err)
- }
- if job.ID != jobID {
- t.Errorf("job ID = %q, want %q", job.ID, jobID)
- }
-}
diff --git a/internal/server/packages_list_test.go b/internal/server/packages_list_test.go
index ac57d74..1b915ad 100644
--- a/internal/server/packages_list_test.go
+++ b/internal/server/packages_list_test.go
@@ -28,7 +28,7 @@ func TestHandlePackagesList(t *testing.T) {
// Create test data
pkg1 := &database.Package{
PURL: "pkg:npm/lodash",
- Ecosystem: testEcosystemNPM,
+ Ecosystem: "npm",
Name: "lodash",
LatestVersion: sql.NullString{String: "4.17.21", Valid: true},
License: sql.NullString{String: "MIT", Valid: true},
@@ -103,7 +103,7 @@ func TestHandlePackagesList(t *testing.T) {
if len(resp.Results) != 2 {
t.Errorf("expected 2 results, got %d", len(resp.Results))
}
- if resp.SortBy != defaultSortBy {
+ if resp.SortBy != "hits" {
t.Errorf("expected default sort to be hits, got %s", resp.SortBy)
}
})
@@ -123,7 +123,7 @@ func TestHandlePackagesList(t *testing.T) {
t.Fatal(err)
}
- if resp.Ecosystem != testEcosystemNPM {
+ if resp.Ecosystem != "npm" {
t.Errorf("expected ecosystem npm, got %s", resp.Ecosystem)
}
if resp.Count != 1 {
diff --git a/internal/server/resolve.go b/internal/server/resolve.go
deleted file mode 100644
index 51f203d..0000000
--- a/internal/server/resolve.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package server
-
-import (
- "fmt"
- "strings"
- "unicode"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-// maxPackagePathLen bounds the wildcard portion of package routes (name plus
-// version and any suffix). npm caps names at 214 and Maven coordinates can be
-// longer, so 512 leaves room without admitting pathological inputs.
-const maxPackagePathLen = 512
-
-// validatePackagePath rejects wildcard package paths that cannot be valid in
-// any supported ecosystem. It is a coarse filter applied before database or
-// enrichment lookups; ecosystem-specific name rules are layered on top.
-func validatePackagePath(path string) error {
- if path == "" {
- return fmt.Errorf("package name required")
- }
- if len(path) > maxPackagePathLen {
- return fmt.Errorf("package path exceeds %d bytes", maxPackagePathLen)
- }
- for _, r := range path {
- if r == 0 {
- return fmt.Errorf("package path contains null byte")
- }
- if unicode.IsControl(r) {
- return fmt.Errorf("package path contains control character %#U", r)
- }
- }
- return nil
-}
-
-// resolvePackageName determines the package name from a wildcard path by
-// checking the database. This handles namespaced packages like Composer's
-// vendor/name format where the package name contains a slash.
-//
-// It tries the full path as a package name first. If not found, it splits
-// off the last segment as a non-name suffix (version, action, etc.) and
-// tries again, working backwards until a match is found or segments run out.
-//
-// Returns the package name and the remaining path segments after the name.
-// If no package is found, returns empty name and the original segments.
-func resolvePackageName(db *database.DB, ecosystem string, segments []string) (name string, rest []string) {
- // Try increasingly longer prefixes as the package name.
- // Start with the longest possible name (all segments) and work down.
- for i := len(segments); i >= 1; i-- {
- candidate := strings.Join(segments[:i], "/")
- pkg, err := db.GetPackageByEcosystemName(ecosystem, candidate)
- if err == nil && pkg != nil {
- return candidate, segments[i:]
- }
- }
-
- return "", segments
-}
-
-// splitWildcardPath splits a chi wildcard path value into segments,
-// trimming any leading/trailing slashes.
-func splitWildcardPath(path string) []string {
- path = strings.Trim(path, "/")
- if path == "" {
- return nil
- }
- return strings.Split(path, "/")
-}
diff --git a/internal/server/resolve_test.go b/internal/server/resolve_test.go
deleted file mode 100644
index dd7d2dc..0000000
--- a/internal/server/resolve_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package server
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/git-pkgs/proxy/internal/database"
-)
-
-func newTestDB(t *testing.T) (*database.DB, func()) {
- t.Helper()
- dir, err := os.MkdirTemp("", "resolve-test-*")
- if err != nil {
- t.Fatal(err)
- }
- db, err := database.Create(filepath.Join(dir, "test.db"))
- if err != nil {
- _ = os.RemoveAll(dir)
- t.Fatal(err)
- }
- return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }
-}
-
-func seedPackage(t *testing.T, db *database.DB, ecosystem, name, purl string) {
- t.Helper()
- if err := db.UpsertPackage(&database.Package{
- PURL: purl, Ecosystem: ecosystem, Name: name,
- }); err != nil {
- t.Fatalf("failed to upsert package %s: %v", name, err)
- }
-}
-
-func TestResolvePackageName(t *testing.T) {
- db, cleanup := newTestDB(t)
- defer cleanup()
-
- seedPackage(t, db, "npm", "lodash", "pkg:npm/lodash")
- seedPackage(t, db, "composer", "monolog/monolog", "pkg:composer/monolog/monolog")
- seedPackage(t, db, "composer", "symfony/console", "pkg:composer/symfony/console")
-
- tests := []struct {
- name string
- ecosystem string
- segments []string
- wantName string
- wantRest []string
- }{
- {
- name: "simple package", ecosystem: "npm",
- segments: []string{"lodash"}, wantName: "lodash", wantRest: nil,
- },
- {
- name: "simple package with version", ecosystem: "npm",
- segments: []string{"lodash", "4.17.21"}, wantName: "lodash", wantRest: []string{"4.17.21"},
- },
- {
- name: "namespaced package", ecosystem: "composer",
- segments: []string{"monolog", "monolog"}, wantName: "monolog/monolog", wantRest: nil,
- },
- {
- name: "namespaced package with version", ecosystem: "composer",
- segments: []string{"symfony", "console", "6.0.0"}, wantName: "symfony/console", wantRest: []string{"6.0.0"},
- },
- {
- name: "namespaced with version and action", ecosystem: "composer",
- segments: []string{"symfony", "console", "6.0.0", "browse"},
- wantName: "symfony/console", wantRest: []string{"6.0.0", "browse"},
- },
- {
- name: "not found", ecosystem: "npm",
- segments: []string{"nonexistent"}, wantName: "", wantRest: []string{"nonexistent"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- name, rest := resolvePackageName(db, tt.ecosystem, tt.segments)
- if name != tt.wantName {
- t.Errorf("name = %q, want %q", name, tt.wantName)
- }
- if len(rest) != len(tt.wantRest) {
- t.Errorf("rest = %v, want %v", rest, tt.wantRest)
- } else {
- for i := range rest {
- if rest[i] != tt.wantRest[i] {
- t.Errorf("rest[%d] = %q, want %q", i, rest[i], tt.wantRest[i])
- }
- }
- }
- })
- }
-}
-
-func TestSplitWildcardPath(t *testing.T) {
- tests := []struct {
- input string
- want []string
- }{
- {"lodash", []string{"lodash"}},
- {"lodash/4.17.21", []string{"lodash", "4.17.21"}},
- {"monolog/monolog", []string{"monolog", "monolog"}},
- {"symfony/console/6.0.0/browse", []string{"symfony", "console", "6.0.0", "browse"}},
- {"", nil},
- {"/", nil},
- }
-
- for _, tt := range tests {
- got := splitWildcardPath(tt.input)
- if len(got) != len(tt.want) {
- t.Errorf("splitWildcardPath(%q) = %v, want %v", tt.input, got, tt.want)
- continue
- }
- for i := range got {
- if got[i] != tt.want[i] {
- t.Errorf("splitWildcardPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
- }
- }
- }
-}
-
-func TestValidatePackagePath(t *testing.T) {
- tests := []struct {
- name string
- path string
- wantErr bool
- }{
- {"simple", "lodash", false},
- {"with version", "lodash/4.17.21", false},
- {"npm scoped", "@babel/core/7.0.0", false},
- {"composer namespaced", "symfony/console/6.0.0", false},
- {"maven coordinates", "org.apache.commons/commons-lang3/3.12.0", false},
- {"unicode", "café/1.0.0", false},
- {"empty", "", true},
- {"null byte", "lodash\x00/4.17.21", true},
- {"null byte suffix", "lodash\x00", true},
- {"newline", "lodash\n4.17.21", true},
- {"carriage return", "lodash\r", true},
- {"escape", "lodash\x1b[31m", true},
- {"delete", "lodash\x7f", true},
- {"too long", strings.Repeat("a", maxPackagePathLen+1), true},
- {"at limit", strings.Repeat("a", maxPackagePathLen), false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := validatePackagePath(tt.path)
- if (err != nil) != tt.wantErr {
- t.Errorf("validatePackagePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
- }
- })
- }
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index 251386e..957b1d1 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -9,13 +9,11 @@
// - /pub/* - pub.dev registry protocol
// - /pypi/* - PyPI registry protocol
// - /maven/* - Maven repository protocol
-// - /gradle/* - Gradle HttpBuildCache protocol
// - /nuget/* - NuGet V3 API protocol
// - /composer/* - Composer/Packagist protocol
// - /conan/* - Conan C/C++ protocol
// - /conda/* - Conda/Anaconda protocol
// - /cran/* - CRAN (R) protocol
-// - /julia/* - Julia Pkg server protocol
// - /v2/* - OCI/Docker container registry protocol
// - /debian/* - Debian/APT repository protocol
// - /rpm/* - RPM/Yum repository protocol
@@ -23,7 +21,6 @@
// Additional endpoints:
// - /health - Health check endpoint
// - /stats - Cache statistics (JSON)
-// - /openapi.json - OpenAPI spec (JSON)
// - /packages - List all cached packages (HTML)
// - /search - Search packages (HTML)
//
@@ -41,22 +38,17 @@ import (
"context"
"database/sql"
"encoding/json"
- "errors"
"fmt"
"log/slog"
"net/http"
"strconv"
- "strings"
"time"
- swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
"github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/mirror"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
@@ -65,14 +57,6 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
-const (
- serverReadTimeout = 30 * time.Second
- serverWriteTimeout = 5 * time.Minute
- serverIdleTimeout = 60 * time.Second
- dashboardTopN = 10
- hoursPerDay = 24
-)
-
// Server is the main proxy server.
type Server struct {
cfg *config.Config
@@ -81,8 +65,6 @@ type Server struct {
logger *slog.Logger
http *http.Server
templates *Templates
- cancel context.CancelFunc
- healthCache *healthCache
}
// New creates a new Server with the given configuration.
@@ -111,7 +93,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
storageURL := cfg.Storage.URL
if storageURL == "" {
// Fall back to file:// with Path
- storageURL = "file://" + cfg.Storage.Path //nolint:staticcheck // backwards compat
+ storageURL = "file://" + cfg.Storage.Path
}
store, err := storage.OpenBucket(context.Background(), storageURL)
if err != nil {
@@ -119,29 +101,19 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("initializing storage: %w", err)
}
- // Verify storage is accessible (catches bad S3 credentials/endpoints early).
- // Exists returns (false, nil) for a missing key, so only real connectivity
- // or permission errors surface here.
- if _, err := store.Exists(context.Background(), ".health-check"); err != nil {
- _ = store.Close()
- _ = db.Close()
- return nil, fmt.Errorf("verifying storage connectivity: %w", err)
- }
-
- hc, err := newHealthCache(store, cfg.Health.StorageProbeInterval, logger)
+ // Load templates
+ templates, err := NewTemplates()
if err != nil {
- _ = store.Close()
_ = db.Close()
- return nil, fmt.Errorf("initializing health cache: %w", err)
+ return nil, fmt.Errorf("loading templates: %w", err)
}
return &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: templates,
}, nil
}
@@ -151,20 +123,7 @@ func (s *Server) Start() error {
baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL))
fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher)
resolver := fetch.NewResolver()
- cd := &cooldown.Config{
- Default: s.cfg.Cooldown.Default,
- Ecosystems: s.cfg.Cooldown.Ecosystems,
- Packages: s.cfg.Cooldown.Packages,
- }
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
- proxy.Cooldown = cd
- proxy.CacheMetadata = s.cfg.CacheMetadata
- proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
- proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly
- proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()
- proxy.DirectServe = s.cfg.Storage.DirectServe
- proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL()
- proxy.DirectServeBaseURL = s.cfg.Storage.DirectServeBaseURL
// Create router with Chi
r := chi.NewRouter()
@@ -193,19 +152,12 @@ func (s *Server) Start() error {
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL)
- mavenHandler := handler.NewMavenHandler(
- proxy,
- s.cfg.BaseURL,
- s.cfg.Upstream.Maven,
- s.cfg.Upstream.GradlePluginPortal,
- )
- gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
+ mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL)
nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL)
composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL)
conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL)
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
- juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL)
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL)
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL)
@@ -218,13 +170,11 @@ func (s *Server) Start() error {
r.Mount("/pub", http.StripPrefix("/pub", pubHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
r.Mount("/maven", http.StripPrefix("/maven", mavenHandler.Routes()))
- r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
r.Mount("/nuget", http.StripPrefix("/nuget", nugetHandler.Routes()))
r.Mount("/composer", http.StripPrefix("/composer", composerHandler.Routes()))
r.Mount("/conan", http.StripPrefix("/conan", conanHandler.Routes()))
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("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
@@ -232,7 +182,6 @@ func (s *Server) Start() error {
// Health, stats, and static endpoints
r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats)
- r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
metrics.Handler().ServeHTTP(w, r)
})
@@ -241,54 +190,47 @@ func (s *Server) Start() error {
r.Get("/install", s.handleInstall)
r.Get("/search", s.handleSearch)
r.Get("/packages", s.handlePackagesList)
- r.Get("/package/{ecosystem}/*", s.handlePackagePath)
+ r.Get("/package/{ecosystem}/{name}", s.handlePackageShow)
+ r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow)
+ r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource)
// API endpoints for enrichment data
enrichSvc := enrichment.New(s.logger)
apiHandler := NewAPIHandler(enrichSvc, s.db)
- r.Get("/api/package/{ecosystem}/*", apiHandler.HandlePackagePath)
- r.Get("/api/vulns/{ecosystem}/*", apiHandler.HandleVulnsPath)
+ r.Get("/api/package/{ecosystem}/{name}", apiHandler.HandleGetPackage)
+ r.Get("/api/package/{ecosystem}/{name}/{version}", apiHandler.HandleGetVersion)
+ r.Get("/api/vulns/{ecosystem}/{name}", apiHandler.HandleGetVulns)
+ r.Get("/api/vulns/{ecosystem}/{name}/{version}", apiHandler.HandleGetVulns)
r.Post("/api/outdated", apiHandler.HandleOutdated)
r.Post("/api/bulk", apiHandler.HandleBulkLookup)
r.Get("/api/search", apiHandler.HandleSearch)
r.Get("/api/packages", apiHandler.HandlePackagesList)
- // Archive browsing and comparison endpoints also use wildcard for namespaced packages
- r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
- r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
+ // Archive browsing endpoints
+ r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList)
+ r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile)
- // Start background context (used by mirror jobs and cleanup)
- bgCtx, bgCancel := context.WithCancel(context.Background())
- s.cancel = bgCancel
- s.startGradleBuildCacheEviction(bgCtx)
-
- // Mirror API endpoints (opt-in via mirror_api config or PROXY_MIRROR_API env)
- if s.cfg.MirrorAPI {
- mirrorSvc := mirror.New(proxy, s.db, s.storage, s.logger, 4) //nolint:mnd // default concurrency
- jobStore := mirror.NewJobStore(bgCtx, mirrorSvc)
- mirrorAPI := NewMirrorAPIHandler(jobStore)
- r.Post("/api/mirror", mirrorAPI.HandleCreate)
- r.Get("/api/mirror/{id}", mirrorAPI.HandleGet)
- r.Delete("/api/mirror/{id}", mirrorAPI.HandleCancel)
- go jobStore.StartCleanup(bgCtx)
- }
+ // Version comparison endpoints
+ r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff)
+ r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage)
s.http = &http.Server{
Addr: s.cfg.Listen,
Handler: r,
- ReadTimeout: serverReadTimeout,
- WriteTimeout: serverWriteTimeout, // Large artifacts need time
- IdleTimeout: serverIdleTimeout,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 5 * time.Minute, // Large artifacts need time
+ IdleTimeout: 60 * time.Second,
}
s.logger.Info("starting server",
"listen", s.cfg.Listen,
"base_url", s.cfg.BaseURL,
- "storage", s.storage.URL(),
+ "storage", s.cfg.Storage.Path,
"database", s.cfg.Database.Path)
+
+ // Start background goroutine to update cache stats metrics
go s.updateCacheStatsMetrics()
- go s.startEvictionLoop(bgCtx)
return s.http.ListenAndServe()
}
@@ -319,10 +261,6 @@ func (s *Server) updateCacheStats() {
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("shutting down server")
- if s.cancel != nil {
- s.cancel()
- }
-
var errs []error
if s.http != nil {
@@ -331,12 +269,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
}
- if s.storage != nil {
- if err := s.storage.Close(); err != nil {
- errs = append(errs, fmt.Errorf("storage close: %w", err))
- }
- }
-
if s.db != nil {
if err := s.db.Close(); err != nil {
errs = append(errs, fmt.Errorf("database close: %w", err))
@@ -374,13 +306,13 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
}
// Get popular packages
- popular, err := s.db.GetMostPopularPackages(dashboardTopN)
+ popular, err := s.db.GetMostPopularPackages(10)
if err != nil {
s.logger.Error("failed to get popular packages", "error", err)
}
// Get recent packages
- recent, err := s.db.GetRecentlyCachedPackages(dashboardTopN)
+ recent, err := s.db.GetRecentlyCachedPackages(10)
if err != nil {
s.logger.Error("failed to get recent packages", "error", err)
}
@@ -466,11 +398,6 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
}
}
-func (s *Server) handleOpenAPIJSON(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- _, _ = w.Write([]byte(swaggerdoc.SwaggerInfo.ReadDoc()))
-}
-
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
data := struct {
Registries []RegistryConfig
@@ -555,7 +482,7 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem")
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
- sortBy = defaultSortBy
+ sortBy = "hits"
}
page := 1
@@ -596,16 +523,16 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {
}
}
items[i] = SearchResultItem{
- Ecosystem: pkg.Ecosystem,
- Name: pkg.Name,
- LatestVersion: latestVersion,
- License: license,
+ Ecosystem: pkg.Ecosystem,
+ Name: pkg.Name,
+ LatestVersion: latestVersion,
+ License: license,
LicenseCategory: categorizeLicenseCSS(license),
- Hits: pkg.Hits,
- Size: pkg.Size,
- SizeFormatted: formatSize(pkg.Size),
- CachedAt: cachedAt,
- VulnCount: pkg.VulnCount,
+ Hits: pkg.Hits,
+ Size: pkg.Size,
+ SizeFormatted: formatSize(pkg.Size),
+ CachedAt: cachedAt,
+ VulnCount: pkg.VulnCount,
}
}
@@ -626,75 +553,15 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {
}
}
-// handlePackagePath dispatches wildcard package routes to the appropriate handler.
-// It resolves namespaced package names (e.g., Composer vendor/name) by consulting
-// the database to determine which path segments are part of the package name.
-//
-// Supported paths:
-//
-// {name} -> package show
-// {name}/{version} -> version show
-// {name}/{version}/browse -> browse source
-// {name}/compare/{v1}...{v2} -> compare versions
-func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handlePackageShow(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
- wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- segments := splitWildcardPath(wildcard)
+ name := chi.URLParam(r, "name")
- if ecosystem == "" || len(segments) == 0 {
- http.Error(w, "ecosystem and package name required", http.StatusBadRequest)
+ if ecosystem == "" || name == "" {
+ http.Error(w, "ecosystem and name required", http.StatusBadRequest)
return
}
- // Check for compare route: {name}/compare/{versions}
- for i, seg := range segments {
- if seg == "compare" && i > 0 && i < len(segments)-1 {
- name := strings.Join(segments[:i], "/")
- versions := strings.Join(segments[i+1:], "/")
- s.showComparePage(w, ecosystem, name, versions)
- return
- }
- }
-
- // Check for browse suffix
- browse := false
- if len(segments) > 1 && segments[len(segments)-1] == "browse" {
- browse = true
- segments = segments[:len(segments)-1]
- }
-
- // Resolve package name from the remaining segments using DB lookup.
- name, rest := resolvePackageName(s.db, ecosystem, segments)
-
- if name == "" {
- // No package found in DB. Fall back to heuristic: assume the last
- // segment is a version (if present) and everything else is the name.
- if len(segments) == 1 {
- // Single segment, no DB match: try package show (will 404).
- s.showPackage(w, ecosystem, segments[0])
- return
- }
- name = strings.Join(segments[:len(segments)-1], "/")
- rest = segments[len(segments)-1:]
- }
-
- switch {
- case len(rest) == 0 && !browse:
- s.showPackage(w, ecosystem, name)
- case len(rest) == 1 && browse:
- s.showBrowseSource(w, ecosystem, name, rest[0])
- case len(rest) == 1:
- s.showVersion(w, ecosystem, name, rest[0])
- default:
- http.Error(w, "not found", http.StatusNotFound)
- }
-}
-
-func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) {
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
if err != nil {
s.logger.Error("failed to get package", "error", err, "ecosystem", ecosystem, "name", name)
@@ -730,7 +597,16 @@ func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) {
}
}
-func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version string) {
+func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) {
+ ecosystem := chi.URLParam(r, "ecosystem")
+ name := chi.URLParam(r, "name")
+ version := chi.URLParam(r, "version")
+
+ if ecosystem == "" || name == "" || version == "" {
+ http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest)
+ return
+ }
+
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
if err != nil || pkg == nil {
s.logger.Error("failed to get package", "error", err)
@@ -760,6 +636,7 @@ func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version str
isOutdated := pkg.LatestVersion.Valid && pkg.LatestVersion.String != version
+ // Check if any artifact is cached
hasCached := false
for _, art := range artifacts {
if art.StoragePath.Valid {
@@ -783,83 +660,16 @@ func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version str
}
}
-func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, version string) {
- data := BrowseSourceData{
- Ecosystem: ecosystem,
- PackageName: name,
- Version: version,
- }
-
- if err := s.templates.Render(w, "browse_source", data); err != nil {
- s.logger.Error("failed to render browse source page", "error", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- }
-}
-
-func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, versions string) {
- const compareVersionParts = 2
- parts := strings.Split(versions, "...")
- if len(parts) != compareVersionParts {
- http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest)
- return
- }
-
- data := ComparePageData{
- Ecosystem: ecosystem,
- PackageName: name,
- FromVersion: parts[0],
- ToVersion: parts[1],
- }
-
- if err := s.templates.Render(w, "compare_versions", data); err != nil {
- s.logger.Error("failed to render compare page", "error", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- }
-}
-
-// handleHealth responds with a structured JSON health report.
-//
-// @Summary Health check
-// @Tags meta
-// @Produce json
-// @Success 200 {object} HealthResponse
-// @Failure 503 {object} HealthResponse
-// @Router /health [get]
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
-
- resp := HealthResponse{Status: "ok", Checks: map[string]HealthCheck{}}
-
- // Database check (short-circuit; do not waste a storage probe call when DB is down).
- // On DB failure the storage entry reports "skipped" rather than being omitted so
- // the response always carries the same key set for monitors that expect it.
+ // Check database connectivity
if _, err := s.db.SchemaVersion(); err != nil {
- resp.Status = "error"
- resp.Checks["database"] = HealthCheck{Status: "error", Error: err.Error()}
- resp.Checks["storage"] = HealthCheck{Status: "skipped"}
w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprintf(w, "database error: %v", err)
return
}
- resp.Checks["database"] = HealthCheck{Status: "ok"}
-
- // Storage probe (via cache).
- if err := s.healthCache.Check(); err != nil {
- resp.Status = "error"
- sc := HealthCheck{Status: "error", Error: err.Error()}
- var pe *probeError
- if errors.As(err, &pe) {
- sc.Step = pe.step
- }
- resp.Checks["storage"] = sc
- w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
- return
- }
- resp.Checks["storage"] = HealthCheck{Status: "ok"}
w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprint(w, "ok")
}
// StatsResponse contains cache statistics.
@@ -867,29 +677,22 @@ type StatsResponse struct {
CachedArtifacts int64 `json:"cached_artifacts"`
TotalSize int64 `json:"total_size_bytes"`
TotalSizeHuman string `json:"total_size"`
- StorageURL string `json:"storage_url"`
+ StoragePath string `json:"storage_path"`
DatabasePath string `json:"database_path"`
}
-// handleStats returns cache statistics.
-// @Summary Cache statistics
-// @Tags meta
-// @Produce json
-// @Success 200 {object} StatsResponse
-// @Failure 500 {object} ErrorResponse
-// @Router /stats [get]
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
count, err := s.db.GetCachedArtifactCount()
if err != nil {
- internalError(w, "failed to get artifact count")
+ http.Error(w, "failed to get artifact count", http.StatusInternalServerError)
return
}
size, err := s.db.GetTotalCacheSize()
if err != nil {
- internalError(w, "failed to get cache size")
+ http.Error(w, "failed to get cache size", http.StatusInternalServerError)
return
}
@@ -899,7 +702,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
CachedArtifacts: count,
TotalSize: size,
TotalSizeHuman: formatSize(size),
- StorageURL: s.storage.URL(),
+ StoragePath: s.cfg.Storage.Path,
DatabasePath: s.cfg.Database.Path,
}
@@ -934,14 +737,14 @@ func formatTimeAgo(t time.Time) string {
return "1 min ago"
}
return fmt.Sprintf("%d mins ago", m)
- case d < hoursPerDay*time.Hour:
+ case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
- case d < 7*hoursPerDay*time.Hour:
- days := int(d.Hours() / hoursPerDay)
+ case d < 7*24*time.Hour:
+ days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
@@ -954,7 +757,7 @@ func formatTimeAgo(t time.Time) string {
// categorizeLicenseCSS returns the CSS class suffix for a license category using the spdx module.
func categorizeLicenseCSS(license string) string {
if license == "" {
- return licenseCategoryUnknown
+ return "unknown"
}
if spdx.HasCopyleft(license) {
@@ -965,13 +768,13 @@ func categorizeLicenseCSS(license string) string {
return "permissive"
}
- return licenseCategoryUnknown
+ return "unknown"
}
// categorizeLicense is a helper that handles sql.NullString.
func categorizeLicense(license sql.NullString) string {
if !license.Valid {
- return licenseCategoryUnknown
+ return "unknown"
}
return categorizeLicenseCSS(license.String)
}
@@ -986,3 +789,4 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
+
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index e2dc1c2..44498f9 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -3,7 +3,6 @@ package server
import (
"database/sql"
"encoding/json"
- "fmt"
"io"
"log/slog"
"net/http"
@@ -12,7 +11,6 @@ import (
"path/filepath"
"strings"
"testing"
- "time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
@@ -59,8 +57,8 @@ func newTestServer(t *testing.T) *testServer {
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
cfg := &config.Config{
- BaseURL: "http://localhost:8080",
- Storage: config.StorageConfig{Path: storagePath},
+ BaseURL: "http://localhost:8080",
+ Storage: config.StorageConfig{Path: storagePath},
Database: config.DatabaseConfig{Path: dbPath},
}
@@ -72,43 +70,42 @@ func newTestServer(t *testing.T) *testServer {
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
- gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes()))
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
- r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
- hc, err := newHealthCache(store, "30s", logger)
+ // Load templates
+ templates, err := NewTemplates()
if err != nil {
_ = db.Close()
_ = os.RemoveAll(tempDir)
- t.Fatalf("failed to create health cache: %v", err)
+ t.Fatalf("failed to load templates: %v", err)
}
// Create a minimal server struct for the handlers
s := &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: templates,
}
r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats)
- r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/search", s.handleSearch)
- r.Get("/package/{ecosystem}/*", s.handlePackagePath)
- r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
- r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
+ r.Get("/package/{ecosystem}/{name}", s.handlePackageShow)
+ r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow)
+ r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource)
+ r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList)
+ r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile)
+ r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff)
+ r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage)
r.Get("/", s.handleRoot)
- r.Get("/install", s.handleInstall)
- r.Get("/packages", s.handlePackagesList)
return &testServer{
handler: r,
@@ -123,61 +120,6 @@ func (ts *testServer) close() {
_ = os.RemoveAll(ts.tempDir)
}
-// seedTestPackage creates a package, version, and artifact in the database for testing
-// page rendering. The package is created under the npm ecosystem with version 1.0.0.
-func seedTestPackage(t *testing.T, db *database.DB, name string) {
- t.Helper()
-
- pkg := &database.Package{
- PURL: "pkg:npm/" + name,
- Ecosystem: "npm",
- Name: name,
- }
- if err := db.UpsertPackage(pkg); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- ver := &database.Version{
- PURL: "pkg:npm/" + name + "@1.0.0",
- PackagePURL: pkg.PURL,
- }
- if err := db.UpsertVersion(ver); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- artifact := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: name + "-1.0.0.tgz",
- UpstreamURL: "https://registry.npmjs.org/" + name + "/-/" + name + "-1.0.0.tgz",
- StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
- }
- if err := db.UpsertArtifact(artifact); err != nil {
- t.Fatalf("failed to upsert artifact: %v", err)
- }
-}
-
-func TestHandleOpenAPIJSON(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- contentType := w.Header().Get("Content-Type")
- if !strings.Contains(contentType, "application/json") {
- t.Fatalf("expected JSON content type, got %q", contentType)
- }
-
- if !strings.Contains(w.Body.String(), `"swagger": "2.0"`) {
- t.Fatalf("expected swagger document, got %q", w.Body.String())
- }
-}
-
func TestHealthEndpoint(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
@@ -187,55 +129,12 @@ func TestHealthEndpoint(t *testing.T) {
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ t.Errorf("expected status 200, got %d", w.Code)
}
- if got := w.Header().Get("Content-Type"); got != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", got)
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp.Status != "ok" {
- t.Errorf("status = %q, want ok", resp.Status)
- }
- if resp.Checks["database"].Status != "ok" {
- t.Errorf("database check = %+v, want ok", resp.Checks["database"])
- }
- if resp.Checks["storage"].Status != "ok" {
- t.Errorf("storage check = %+v, want ok", resp.Checks["storage"])
- }
-}
-func TestHealthEndpoint_DBFailureShortCircuits(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Force DB failure by closing the connection.
- _ = ts.db.Close()
-
- req := httptest.NewRequest("GET", "/health", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String())
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding: %v", err)
- }
- if resp.Status != "error" {
- t.Errorf("status = %q, want error", resp.Status)
- }
- if resp.Checks["database"].Status != "error" {
- t.Errorf("database check = %+v, want error", resp.Checks["database"])
- }
- storage, present := resp.Checks["storage"]
- if !present {
- t.Error("storage key should be present (with status=skipped) on DB short-circuit")
- } else if storage.Status != "skipped" {
- t.Errorf("storage check = %+v, want status=skipped", storage)
+ body := w.Body.String()
+ if body != "ok" {
+ t.Errorf("expected body 'ok', got %q", body)
}
}
@@ -264,10 +163,6 @@ func TestStatsEndpoint(t *testing.T) {
if stats.CachedArtifacts != 0 {
t.Errorf("expected 0 cached artifacts, got %d", stats.CachedArtifacts)
}
-
- if !strings.HasPrefix(stats.StorageURL, "file://") {
- t.Errorf("expected storage_url to start with file://, got %q", stats.StorageURL)
- }
}
func TestDashboard(t *testing.T) {
@@ -301,21 +196,6 @@ func TestDashboard(t *testing.T) {
if !strings.Contains(body, "Popular Packages") {
t.Error("dashboard should contain popular packages section")
}
- if !strings.Contains(body, ">composer<") {
- t.Error("dashboard should show composer in supported ecosystems")
- }
- if !strings.Contains(body, ">conan<") {
- t.Error("dashboard should show conan in supported ecosystems")
- }
- if !strings.Contains(body, ">container<") {
- t.Error("dashboard should show container in supported ecosystems")
- }
- if !strings.Contains(body, ">debian<") {
- t.Error("dashboard should show debian in supported ecosystems")
- }
- if !strings.Contains(body, "/openapi.json") {
- t.Error("page should link to the OpenAPI JSON spec")
- }
}
func min(a, b int) int {
@@ -373,14 +253,10 @@ func TestGoList(t *testing.T) {
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
- // The handler is mounted if we get a response from the proxy (404 from upstream
- // or 502 from connection failure), not a chi router 404.
- // With metadata caching, upstream 404 is cleanly returned as our own 404.
- if w.Code == http.StatusNotFound {
- body := w.Body.String()
- if !strings.Contains(body, "not found") {
- t.Errorf("go handler should be mounted, got status %d, body: %s", w.Code, body)
- }
+ // The handler is mounted if we get a Go proxy error (not a generic 404)
+ body := w.Body.String()
+ if w.Code == http.StatusNotFound && !strings.Contains(body, "example.com") {
+ t.Errorf("go handler should be mounted, got status %d, body: %s", w.Code, body)
}
}
@@ -397,33 +273,6 @@ func TestPyPISimple(t *testing.T) {
}
}
-func TestGradleBuildCachePutGet(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- key := "abc123def456"
- body := "build-cache-bytes"
-
- putReq := httptest.NewRequest(http.MethodPut, "/gradle/"+key, strings.NewReader(body))
- putW := httptest.NewRecorder()
- ts.handler.ServeHTTP(putW, putReq)
-
- if putW.Code != http.StatusCreated {
- t.Fatalf("expected status 201, got %d: %s", putW.Code, putW.Body.String())
- }
-
- getReq := httptest.NewRequest(http.MethodGet, "/gradle/"+key, nil)
- getW := httptest.NewRecorder()
- ts.handler.ServeHTTP(getW, getReq)
-
- if getW.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d: %s", getW.Code, getW.Body.String())
- }
- if got := getW.Body.String(); got != body {
- t.Fatalf("expected body %q, got %q", body, got)
- }
-}
-
func TestGemSpecs(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
@@ -619,487 +468,3 @@ func TestSearchWithNullValues(t *testing.T) {
t.Error("expected search results to contain package name")
}
}
-
-func TestFormatTimeAgo_AllRanges(t *testing.T) {
- tests := []struct {
- name string
- input time.Time
- expected string
- }{
- {"zero time", time.Time{}, ""},
- {"now", time.Now(), "just now"},
- {"30 seconds ago", time.Now().Add(-30 * time.Second), "just now"},
- {"1 minute ago", time.Now().Add(-1 * time.Minute), "1 min ago"},
- {"5 minutes ago", time.Now().Add(-5 * time.Minute), "5 mins ago"},
- {"1 hour ago", time.Now().Add(-1 * time.Hour), "1 hour ago"},
- {"3 hours ago", time.Now().Add(-3 * time.Hour), "3 hours ago"},
- {"1 day ago", time.Now().Add(-24 * time.Hour), "1 day ago"},
- {"3 days ago", time.Now().Add(-3 * 24 * time.Hour), "3 days ago"},
- {"10 days ago", time.Now().Add(-10 * 24 * time.Hour), time.Now().Add(-10 * 24 * time.Hour).Format("Jan 2")},
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- got := formatTimeAgo(tc.input)
- if got != tc.expected {
- t.Errorf("formatTimeAgo() = %q, want %q", got, tc.expected)
- }
- })
- }
-}
-
-func TestFormatSize_AllUnits(t *testing.T) {
- tests := []struct {
- bytes int64
- expected string
- }{
- {0, "0 B"},
- {500, "500 B"},
- {1024, "1.0 KB"},
- {1536, "1.5 KB"},
- {1048576, "1.0 MB"},
- {1073741824, "1.0 GB"},
- }
-
- for _, tc := range tests {
- t.Run(tc.expected, func(t *testing.T) {
- got := formatSize(tc.bytes)
- if got != tc.expected {
- t.Errorf("formatSize(%d) = %q, want %q", tc.bytes, got, tc.expected)
- }
- })
- }
-}
-
-func TestCategorizeLicense_NullString(t *testing.T) {
- tests := []struct {
- name string
- license sql.NullString
- expected string
- }{
- {"invalid null string", sql.NullString{Valid: false}, "unknown"},
- {"MIT", sql.NullString{String: "MIT", Valid: true}, "permissive"},
- {"GPL-3.0", sql.NullString{String: "GPL-3.0", Valid: true}, "copyleft"},
- {"empty string", sql.NullString{String: "", Valid: true}, "unknown"},
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- got := categorizeLicense(tc.license)
- if got != tc.expected {
- t.Errorf("categorizeLicense(%v) = %q, want %q", tc.license, got, tc.expected)
- }
- })
- }
-}
-
-func TestSearchRedirectsWhenEmpty(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest("GET", "/search", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusSeeOther {
- t.Errorf("expected status 303, got %d", w.Code)
- }
-
- loc := w.Header().Get("Location")
- if loc != "/" {
- t.Errorf("expected redirect to /, got %q", loc)
- }
-}
-
-func TestPackageShowPage_NotFoundServer(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("expected status 404, got %d", w.Code)
- }
-}
-
-func TestVersionShowPage_NotFoundServer(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv/1.0.0", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("expected status 404, got %d", w.Code)
- }
-}
-
-func TestPackageShowPage_WithLicense(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- pkg := &database.Package{
- PURL: "pkg:npm/show-test-lic",
- Ecosystem: "npm",
- Name: "show-test-lic",
- License: sql.NullString{String: "MIT", Valid: true},
- }
- if err := ts.db.UpsertPackage(pkg); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- ver := &database.Version{
- PURL: "pkg:npm/show-test-lic@1.0.0",
- PackagePURL: pkg.PURL,
- }
- if err := ts.db.UpsertVersion(ver); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- req := httptest.NewRequest("GET", "/package/npm/show-test-lic", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "show-test-lic") {
- t.Error("expected page to contain the package name")
- }
-}
-
-func TestComposerNamespacedPackageRoutes(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Seed two Composer packages with vendor/name format.
- for _, p := range []struct {
- purl, name, versionPURL string
- }{
- {"pkg:composer/monolog/monolog", "monolog/monolog", "pkg:composer/monolog/monolog@3.0.0"},
- {"pkg:composer/symfony/console", "symfony/console", "pkg:composer/symfony/console@6.0.0"},
- } {
- if err := ts.db.UpsertPackage(&database.Package{
- PURL: p.purl, Ecosystem: "composer", Name: p.name,
- }); err != nil {
- t.Fatalf("failed to upsert package %s: %v", p.name, err)
- }
- if err := ts.db.UpsertVersion(&database.Version{
- PURL: p.versionPURL, PackagePURL: p.purl,
- }); err != nil {
- t.Fatalf("failed to upsert version for %s: %v", p.name, err)
- }
- }
-
- tests := []struct {
- name string
- url string
- want string
- }{
- {"package show", "/package/composer/monolog/monolog", "monolog/monolog"},
- {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest("GET", tt.url, nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("GET %s: expected status 200, got %d", tt.url, w.Code)
- }
- if !strings.Contains(w.Body.String(), tt.want) {
- t.Errorf("GET %s: expected body to contain %q", tt.url, tt.want)
- }
- })
- }
-}
-
-func TestNamespacedPackageRoutes(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Seed packages from ecosystems that use slashes in package names.
- pkgs := []struct {
- purl, ecosystem, name, versionPURL string
- }{
- // npm scoped packages
- {"pkg:npm/%40babel/core", "npm", "@babel/core", "pkg:npm/%40babel/core@7.24.0"},
- // Go modules (multi-segment paths)
- {"pkg:golang/github.com/stretchr/testify", "golang", "github.com/stretchr/testify", "pkg:golang/github.com/stretchr/testify@1.9.0"},
- // OCI/container images
- {"pkg:oci/library/nginx", "oci", "library/nginx", "pkg:oci/library/nginx@sha256:abc123"},
- // Conda (channel/name)
- {"pkg:conda/conda-forge/numpy", "conda", "conda-forge/numpy", "pkg:conda/conda-forge/numpy@1.26.4"},
- // Conan (name/version@user/channel)
- {"pkg:conan/zlib/1.2.13@demo/stable", "conan", "zlib/1.2.13@demo/stable", "pkg:conan/zlib/1.2.13@demo/stable@rev1"},
- }
-
- for _, p := range pkgs {
- if err := ts.db.UpsertPackage(&database.Package{
- PURL: p.purl, Ecosystem: p.ecosystem, Name: p.name,
- }); err != nil {
- t.Fatalf("failed to upsert package %s: %v", p.name, err)
- }
- if err := ts.db.UpsertVersion(&database.Version{
- PURL: p.versionPURL, PackagePURL: p.purl,
- }); err != nil {
- t.Fatalf("failed to upsert version for %s: %v", p.name, err)
- }
- }
-
- tests := []struct {
- name string
- url string
- want int
- }{
- {"npm scoped package show", "/package/npm/@babel/core", http.StatusOK},
- {"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK},
- {"oci image show", "/package/oci/library/nginx", http.StatusOK},
- {"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK},
- {"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest("GET", tt.url, nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != tt.want {
- t.Errorf("GET %s: expected status %d, got %d (body: %s)",
- tt.url, tt.want, w.Code, w.Body.String())
- }
- })
- }
-}
-
-func TestSearchPage_WithSeededResults(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- seedTestPackage(t, ts.db, "searchable-pkg")
-
- req := httptest.NewRequest("GET", "/search?q=searchable", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "searchable-pkg") {
- t.Error("expected search results to contain package name")
- }
-}
-
-func TestSearchPage_PaginationMultiPage(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Seed 55 packages to exceed one page (limit=50)
- for i := 0; i < 55; i++ {
- name := fmt.Sprintf("page-test-%03d", i)
- pkg := &database.Package{
- PURL: fmt.Sprintf("pkg:npm/%s", name),
- Ecosystem: "npm",
- Name: name,
- }
- if err := ts.db.UpsertPackage(pkg); err != nil {
- t.Fatalf("failed to upsert package %d: %v", i, err)
- }
- ver := &database.Version{
- PURL: fmt.Sprintf("pkg:npm/%s@1.0.0", name),
- PackagePURL: pkg.PURL,
- }
- if err := ts.db.UpsertVersion(ver); err != nil {
- t.Fatalf("failed to upsert version %d: %v", i, err)
- }
- artifact := &database.Artifact{
- VersionPURL: ver.PURL,
- Filename: fmt.Sprintf("%s-1.0.0.tgz", name),
- UpstreamURL: fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-1.0.0.tgz", name, name),
- StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
- }
- if err := ts.db.UpsertArtifact(artifact); err != nil {
- t.Fatalf("failed to upsert artifact %d: %v", i, err)
- }
- }
-
- // First page
- req := httptest.NewRequest("GET", "/search?q=page-test", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "page-test-") {
- t.Error("expected first page to contain results")
- }
-
- // Second page
- req = httptest.NewRequest("GET", "/search?q=page-test&page=2", nil)
- w = httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200 for page 2, got %d", w.Code)
- }
-}
-
-func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Seed npm package
- npmPkg := &database.Package{
- PURL: "pkg:npm/eco-filter-npm",
- Ecosystem: "npm",
- Name: "eco-filter-npm",
- }
- if err := ts.db.UpsertPackage(npmPkg); err != nil {
- t.Fatalf("failed to upsert npm package: %v", err)
- }
- npmVer := &database.Version{
- PURL: "pkg:npm/eco-filter-npm@1.0.0",
- PackagePURL: npmPkg.PURL,
- }
- if err := ts.db.UpsertVersion(npmVer); err != nil {
- t.Fatalf("failed to upsert npm version: %v", err)
- }
- npmArt := &database.Artifact{
- VersionPURL: npmVer.PURL,
- Filename: "eco-filter-npm-1.0.0.tgz",
- UpstreamURL: "https://registry.npmjs.org/eco-filter-npm/-/eco-filter-npm-1.0.0.tgz",
- StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
- }
- if err := ts.db.UpsertArtifact(npmArt); err != nil {
- t.Fatalf("failed to upsert npm artifact: %v", err)
- }
-
- // Seed pypi package
- pypiPkg := &database.Package{
- PURL: "pkg:pypi/eco-filter-pypi",
- Ecosystem: "pypi",
- Name: "eco-filter-pypi",
- }
- if err := ts.db.UpsertPackage(pypiPkg); err != nil {
- t.Fatalf("failed to upsert pypi package: %v", err)
- }
- pypiVer := &database.Version{
- PURL: "pkg:pypi/eco-filter-pypi@1.0.0",
- PackagePURL: pypiPkg.PURL,
- }
- if err := ts.db.UpsertVersion(pypiVer); err != nil {
- t.Fatalf("failed to upsert pypi version: %v", err)
- }
- pypiArt := &database.Artifact{
- VersionPURL: pypiVer.PURL,
- Filename: "eco-filter-pypi-1.0.0.tar.gz",
- UpstreamURL: "https://files.pythonhosted.org/eco-filter-pypi-1.0.0.tar.gz",
- StoragePath: sql.NullString{String: "/tmp/test.tar.gz", Valid: true},
- }
- if err := ts.db.UpsertArtifact(pypiArt); err != nil {
- t.Fatalf("failed to upsert pypi artifact: %v", err)
- }
-
- // Search with ecosystem filter for npm only
- req := httptest.NewRequest("GET", "/search?q=eco-filter&ecosystem=npm", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "eco-filter-npm") {
- t.Error("expected npm package in filtered results")
- }
- if strings.Contains(body, "eco-filter-pypi") {
- t.Error("did not expect pypi package in npm-filtered results")
- }
-}
-
-func TestHandlePackagesListPage(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- seedTestPackage(t, ts.db, "list-test")
-
- req := httptest.NewRequest("GET", "/packages", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "list-test") {
- t.Error("expected packages list to contain seeded package")
- }
-}
-
-func TestNewServer_StorageConnectivityCheck(t *testing.T) {
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
- storagePath := filepath.Join(tempDir, "artifacts")
-
- cfg := &config.Config{
- Listen: ":0",
- BaseURL: "http://localhost:8080",
- Storage: config.StorageConfig{URL: "file://" + storagePath},
- Database: config.DatabaseConfig{Path: dbPath},
- }
-
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- srv, err := New(cfg, logger)
- if err != nil {
- t.Fatalf("New() failed: %v", err)
- }
-
- // On Windows, OpenBucket normalises to file:///C:/path; on Unix the
- // absolute path already starts with /, so file:// + /path == file:///path.
- wantPrefix := "file://"
- wantPath := filepath.ToSlash(storagePath)
- got := srv.storage.URL()
- if !strings.HasPrefix(got, wantPrefix) || !strings.Contains(got, wantPath) {
- t.Errorf("expected storage URL containing %s, got %s", wantPath, got)
- }
-
- _ = srv.db.Close()
-}
-
-func TestStatsEndpoint_StorageURL(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- req := httptest.NewRequest("GET", "/stats", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d", w.Code)
- }
-
- // Verify the JSON response uses storage_url (not storage_path)
- body := w.Body.String()
- if !strings.Contains(body, `"storage_url"`) {
- t.Errorf("expected JSON key storage_url in response, got: %s", body)
- }
- if strings.Contains(body, `"storage_path"`) {
- t.Errorf("unexpected JSON key storage_path in response (should be storage_url)")
- }
-}
diff --git a/internal/server/swagger_gen.go b/internal/server/swagger_gen.go
deleted file mode 100644
index f72f32b..0000000
--- a/internal/server/swagger_gen.go
+++ /dev/null
@@ -1,3 +0,0 @@
-//go:generate swag init -g ../../cmd/proxy/main.go -o ../../docs/swagger --outputTypes go,json --parseInternal
-
-package server
diff --git a/internal/server/templates.go b/internal/server/templates.go
index 217da41..c49c480 100644
--- a/internal/server/templates.go
+++ b/internal/server/templates.go
@@ -5,70 +5,58 @@ import (
"html/template"
"net/http"
"path/filepath"
- "sync"
)
//go:embed templates/**/*.html
var templatesFS embed.FS
-// Templates holds lazily-parsed templates for each page.
+// Templates holds parsed templates for each page.
type Templates struct {
- once sync.Once
pages map[string]*template.Template
- err error
}
-// load parses all templates from the embedded filesystem on first call.
-func (t *Templates) load() error {
- t.once.Do(func() {
- pages := make(map[string]*template.Template)
+// NewTemplates loads and parses all templates from the embedded filesystem.
+func NewTemplates() (*Templates, error) {
+ pages := make(map[string]*template.Template)
- funcMap := template.FuncMap{
- "add": func(a, b int) int { return a + b },
- "sub": func(a, b int) int { return a - b },
- "supportedEcosystems": supportedEcosystems,
- "ecosystemBadgeClass": ecosystemBadgeClasses,
- "ecosystemBadgeLabel": ecosystemBadgeLabel,
+ // Define custom template functions
+ funcMap := template.FuncMap{
+ "add": func(a, b int) int { return a + b },
+ "sub": func(a, b int) int { return a - b },
+ }
+
+ // Get all page files
+ pageFiles, err := templatesFS.ReadDir("templates/pages")
+ if err != nil {
+ return nil, err
+ }
+
+ for _, pageFile := range pageFiles {
+ if pageFile.IsDir() {
+ continue
}
- pageFiles, err := templatesFS.ReadDir("templates/pages")
+ pageName := pageFile.Name()
+ pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))]
+
+ // Parse all layout files + components + this page with custom functions
+ tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS,
+ "templates/layout/*.html",
+ "templates/components/*.html",
+ "templates/pages/"+pageFile.Name(),
+ )
if err != nil {
- t.err = err
- return
+ return nil, err
}
- for _, pageFile := range pageFiles {
- if pageFile.IsDir() {
- continue
- }
+ pages[pageName] = tmpl
+ }
- pageName := pageFile.Name()
- pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))]
-
- tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS,
- "templates/layout/*.html",
- "templates/components/*.html",
- "templates/pages/"+pageFile.Name(),
- )
- if err != nil {
- t.err = err
- return
- }
-
- pages[pageName] = tmpl
- }
-
- t.pages = pages
- })
- return t.err
+ return &Templates{pages: pages}, nil
}
// Render renders a page template with the given data.
func (t *Templates) Render(w http.ResponseWriter, pageName string, data any) error {
- if err := t.load(); err != nil {
- return err
- }
-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, ok := t.pages[pageName]
diff --git a/internal/server/templates/components/ecosystem_badge.html b/internal/server/templates/components/ecosystem_badge.html
index 4c7e882..ba1c286 100644
--- a/internal/server/templates/components/ecosystem_badge.html
+++ b/internal/server/templates/components/ecosystem_badge.html
@@ -1,3 +1,3 @@
{{define "ecosystem_badge"}}
-{{ecosystemBadgeLabel .}}
+{{.}}
{{end}}
diff --git a/internal/server/templates/layout/footer.html b/internal/server/templates/layout/footer.html
index 3245d1d..2c963f0 100644
--- a/internal/server/templates/layout/footer.html
+++ b/internal/server/templates/layout/footer.html
@@ -14,15 +14,17 @@
Configuration Guide
Health Check
API Stats
- OpenAPI Spec
Supported Ecosystems
- {{range supportedEcosystems}}
- {{template "ecosystem_badge" .}}
- {{end}}
+ npm
+ cargo
+ gem
+ go
+ pypi
+ maven
diff --git a/internal/server/templates/layout/styles.html b/internal/server/templates/layout/styles.html
index 7fecaff..1d21184 100644
--- a/internal/server/templates/layout/styles.html
+++ b/internal/server/templates/layout/styles.html
@@ -1,4 +1,25 @@
{{define "styles"}}
+
+