diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c1a2abb..bb3d87d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,12 +17,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go-version }}
@@ -35,12 +35,12 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 93b0a1e..c08cfa6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -20,12 +20,12 @@ jobs:
contents: read
steps:
- name: Check out the repo
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Log in to the Container registry
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
+ uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -33,12 +33,12 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
push: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a7d8ae5..5d32181 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -22,12 +22,12 @@ jobs:
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
+ - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: "~> v2"
args: release --clean
diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
index b6e086a..625f944 100644
--- a/.github/workflows/swagger.yml
+++ b/.github/workflows/swagger.yml
@@ -12,12 +12,12 @@ jobs:
swagger:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index b404599..4df0e18 100644
--- a/.github/workflows/zizmor.yml
+++ b/.github/workflows/zizmor.yml
@@ -21,9 +21,9 @@ jobs:
security-events: write
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run zizmor
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+ uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
diff --git a/Dockerfile b/Dockerfile
index 7b2795c..c2e197f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.26.4-alpine AS builder
+FROM golang:1.26.5-alpine AS builder
WORKDIR /src
diff --git a/config.example.yaml b/config.example.yaml
index 62b4105..1176f5b 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -9,6 +9,11 @@ listen: ":8080"
# so users know what to point their package manager at.
base_url: "http://localhost:8080"
+# Timeout for individual upstream HTTP requests made by protocol handlers
+# (metadata fetches, pass-through file requests). Uses Go duration syntax.
+# Set to "0" to disable the timeout. Default: "30s".
+# http_timeout: "30s"
+
# Public URL where the web UI is reached. Defaults to base_url when unset.
# Set this separately when the UI is served on a different hostname than the
# package endpoints — for example, the UI on a public domain behind auth while
@@ -163,7 +168,8 @@ cooldown:
# npm: "7d"
# cargo: "0"
- # Per-package overrides (keyed by PURL)
+ # Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes
+ # may use either @scope or the canonical %40scope form.
# packages:
# "pkg:npm/lodash": "0"
# "pkg:npm/@babel/core": "14d"
diff --git a/docs/configuration.md b/docs/configuration.md
index f220279..8998ac2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -234,6 +234,8 @@ cooldown:
Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable.
+Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins.
+
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.
@@ -276,6 +278,18 @@ metadata_max_size: "100MB" # default
Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`.
+## Upstream HTTP timeout
+
+Protocol handlers use a shared HTTP client for upstream requests such as metadata fetches and pass-through file downloads. `http_timeout` sets that client's per-request timeout. Raise it if slow upstreams or large metadata responses cause `context deadline exceeded` errors.
+
+```yaml
+http_timeout: "30s" # default
+```
+
+Or via environment variable: `PROXY_HTTP_TIMEOUT=2m`.
+
+Set to `"0"` to disable the timeout entirely (requests then rely only on the server's write timeout).
+
## Mirror API
The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:
diff --git a/go.mod b/go.mod
index 471ab53..e6e0a08 100644
--- a/go.mod
+++ b/go.mod
@@ -5,26 +5,27 @@ go 1.25.6
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/archives v0.4.0
github.com/git-pkgs/cooldown v0.1.1
- github.com/git-pkgs/enrichment v0.4.1
- github.com/git-pkgs/purl v0.1.13
- github.com/git-pkgs/registries v0.6.2
+ github.com/git-pkgs/enrichment v0.6.4
+ github.com/git-pkgs/magic v0.1.0
+ github.com/git-pkgs/purl v0.1.15
+ github.com/git-pkgs/registries v0.6.4
github.com/git-pkgs/spdx v0.1.4
- github.com/git-pkgs/vers v0.2.6
- github.com/git-pkgs/vulns v0.1.6
- github.com/go-chi/chi/v5 v5.3.0
+ github.com/git-pkgs/vers v0.3.0
+ github.com/git-pkgs/vulns v0.2.1
+ github.com/go-chi/chi/v5 v5.3.1
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
- github.com/prometheus/client_golang v1.23.2
+ github.com/prometheus/client_golang v1.24.0
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.46.0
- golang.org/x/sync v0.21.0
+ golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
- modernc.org/sqlite v1.53.0
+ modernc.org/sqlite v1.55.0
)
require (
@@ -115,7 +116,7 @@ require (
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/ecosyste-ms/ecosystems-go v0.2.0 // indirect
+ github.com/ecosyste-ms/ecosystems-go v0.4.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -217,15 +218,16 @@ require (
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
- github.com/oapi-codegen/runtime v1.4.1 // indirect
+ github.com/oapi-codegen/nullable v1.1.0 // indirect
+ github.com/oapi-codegen/runtime v1.6.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
+ github.com/prometheus/common v0.70.0 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
@@ -265,7 +267,7 @@ require (
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
- github.com/ulikunitz/xz v0.5.15 // indirect
+ github.com/ulikunitz/xz v0.5.16 // 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
@@ -289,25 +291,25 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
- go.yaml.in/yaml/v2 v2.4.3 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/crypto v0.53.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.36.0 // indirect
- golang.org/x/net v0.54.0 // indirect
+ golang.org/x/mod v0.37.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.37.0 // indirect
- golang.org/x/tools v0.45.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
+ golang.org/x/tools v0.47.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.272.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
- google.golang.org/grpc v1.81.1 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
+ google.golang.org/grpc v1.82.1 // 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.73.4 // indirect
+ modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
diff --git a/go.sum b/go.sum
index 8d8c96d..c239240 100644
--- a/go.sum
+++ b/go.sum
@@ -66,8 +66,8 @@ github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3w
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.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
@@ -217,8 +217,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ecosyste-ms/ecosystems-go v0.2.0 h1:Nhpg54C+St8Sd/mf8bNJmQqx35ZgHw33TfFoxaXMQI8=
-github.com/ecosyste-ms/ecosystems-go v0.2.0/go.mod h1:CCdzT1iAZirbEZAbFSnWpK88eKKaIWex7gjtZ0UudXA=
+github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA=
+github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
@@ -244,30 +244,32 @@ 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/archives v0.4.0 h1:KNmmIsLiSH27lUdT27EfUkQXFaLgXV5KezE81iyOIgo=
+github.com/git-pkgs/archives v0.4.0/go.mod h1:tfio0OIuPKEBKHs/UCL5XBUvYmKpnvtnba2iDlfSd6g=
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.4.1 h1:A8BKs0XwvpF1sF5qviZy4fkJAe18qB9OgpbRnmwnT34=
-github.com/git-pkgs/enrichment v0.4.1/go.mod h1:stHqZUitV9ZkwACqHzBysLMSe6T4QZn81hxTdSroNhM=
+github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU=
+github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY=
+github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY=
+github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI=
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.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY=
github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
-github.com/git-pkgs/purl v0.1.13 h1:at8BU6vnP5oonHFHAPA064BzgRqij+SZcOUDgNT2DC8=
-github.com/git-pkgs/purl v0.1.13/go.mod h1:8oCcdcYZA/e1B33e7Ylju6azboTKjdqf3ybcbQj6I/o=
-github.com/git-pkgs/registries v0.6.2 h1:26G5zW6Q7x1CSfNkaEqEjRMJiA4JwfdKOCJ7Qm+u0a8=
-github.com/git-pkgs/registries v0.6.2/go.mod h1:GR0Bu6nC3NQe6f7lfDoEVqAnoQkMocf4M98B12a7B3E=
+github.com/git-pkgs/purl v0.1.15 h1:iQ3clh0Cw41rkM0rf24B7ShnN9Z+UtLMAFlNDUs+Qd4=
+github.com/git-pkgs/purl v0.1.15/go.mod h1:PqCLVBDeZrZgHysR803/AntMELgIr2LFZVNCcwLH2m0=
+github.com/git-pkgs/registries v0.6.4 h1:Kq/KlStjaQyE83UXT/tKuzCrIzc4keGeBjtroMqgoHA=
+github.com/git-pkgs/registries v0.6.4/go.mod h1:YkGHbxHIe2Ha/ROH6zNkS5PJUUoa9g0Ti/s2XhZnrak=
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.6 h1:8RRSgdlxp4JMU0Zykr63XTOMo5CyZKwt/PwaQxrx9Yg=
-github.com/git-pkgs/vulns v0.1.6/go.mod h1:TsZC4MjoCkKJslgmbcmRCnytwnFcjESC2N8b0a2xDWc=
+github.com/git-pkgs/vers v0.3.0 h1:xM4LLUCRmqzdDfe+/pVQUx4SRyFXRVth6tOsJ14wMKU=
+github.com/git-pkgs/vers v0.3.0/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
+github.com/git-pkgs/vulns v0.2.1 h1:tWGhOfPVDZwkM2Y9vRkMpMR+gjtlu2jhERS5JeNBoKQ=
+github.com/git-pkgs/vulns v0.2.1/go.mod h1:/0gHKHQR5SWttZVEMqgOvCXssKFwAtbac/PfkhBax9o=
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/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
-github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
+github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@@ -422,8 +424,8 @@ github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3
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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
+github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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=
@@ -512,8 +514,8 @@ github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
-github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
-github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
+github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
+github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
@@ -541,14 +543,14 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
-github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
+github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
-github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
-github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
-github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
+github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
@@ -654,8 +656,8 @@ github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVF
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
-github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
-github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
+github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
@@ -702,8 +704,8 @@ go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDoj
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
-go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
@@ -726,8 +728,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
-go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
@@ -738,8 +740,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
@@ -755,8 +757,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -771,8 +773,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -784,8 +786,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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -807,8 +809,8 @@ 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -823,8 +825,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -839,8 +841,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -857,12 +859,12 @@ google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
-google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
-google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
-google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
+google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
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=
@@ -882,20 +884,20 @@ 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.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
-modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
-modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
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=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
-modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
-modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
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=
@@ -904,8 +906,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
-modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
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=
diff --git a/internal/config/config.go b/internal/config/config.go
index 16928dc..ec510d2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -54,10 +54,12 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strconv"
"strings"
"time"
+ "github.com/git-pkgs/purl"
"gopkg.in/yaml.v3"
)
@@ -110,6 +112,12 @@ type Config struct {
// size return ErrMetadataTooLarge. Default: "100MB".
MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"`
+ // HTTPTimeout is the timeout for individual upstream HTTP requests made
+ // by protocol handlers (metadata fetches, pass-through file requests).
+ // Uses Go duration syntax (e.g. "30s", "2m"). Default: "30s".
+ // Set to "0" to disable the timeout entirely.
+ HTTPTimeout string `json:"http_timeout" yaml:"http_timeout"`
+
// 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"`
@@ -131,9 +139,38 @@ type CooldownConfig struct {
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
// Packages overrides the cooldown for specific packages (keyed by PURL).
+ // Valid PURL keys are normalized to canonical form before use.
Packages map[string]string `json:"packages" yaml:"packages"`
}
+// NormalizedPackages returns a copy of the package overrides with valid PURL
+// keys in canonical form. An explicitly canonical key wins over an equivalent
+// noncanonical key, and invalid keys are preserved unchanged.
+func (c *CooldownConfig) NormalizedPackages() map[string]string {
+ if c == nil || c.Packages == nil {
+ return nil
+ }
+
+ keys := make([]string, 0, len(c.Packages))
+ for key := range c.Packages {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ normalized := make(map[string]string, len(c.Packages))
+ for _, key := range keys {
+ canonical := key
+ if parsed, err := purl.Parse(key); err == nil {
+ canonical = parsed.String()
+ }
+ if _, exists := normalized[canonical]; exists && key != canonical {
+ continue
+ }
+ normalized[canonical] = c.Packages[key]
+ }
+ return normalized
+}
+
// StorageConfig configures artifact storage.
type StorageConfig struct {
// URL is the storage backend URL.
@@ -460,6 +497,9 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" {
c.MetadataMaxSize = v
}
+ if v := os.Getenv("PROXY_HTTP_TIMEOUT"); v != "" {
+ c.HTTPTimeout = v
+ }
if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" {
c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1"
}
@@ -567,6 +607,10 @@ func (c *Config) Validate() error {
return err
}
+ if err := validateHTTPTimeout(c.HTTPTimeout); err != nil {
+ return err
+ }
+
if err := c.Health.Validate(); err != nil {
return err
}
@@ -636,6 +680,7 @@ func (g *GradleBuildCacheConfig) Validate() error {
const (
defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
+ defaultHTTPTimeout = 30 * time.Second //nolint:mnd // sensible default
defaultMetadataMaxSize = 100 << 20
defaultGradleBuildCacheMaxUploadSize = 100 << 20
defaultGradleBuildCacheSweepInterval = 10 * time.Minute
@@ -656,6 +701,20 @@ func (c *Config) ParseMaxSize() int64 {
return size
}
+func validateHTTPTimeout(s string) error {
+ if s == "" || s == "0" {
+ return nil
+ }
+ d, err := time.ParseDuration(s)
+ if err != nil {
+ return fmt.Errorf("invalid http_timeout %q: %w", s, err)
+ }
+ if d < 0 {
+ return fmt.Errorf("invalid http_timeout %q: must be non-negative", s)
+ }
+ return nil
+}
+
func validateMetadataMaxSize(s string) error {
if s == "" {
return nil
@@ -683,6 +742,22 @@ func (c *Config) ParseMetadataMaxSize() int64 {
return size
}
+// ParseHTTPTimeout returns the upstream HTTP client timeout.
+// Returns 30s if unset, 0 (no timeout) if explicitly set to "0".
+func (c *Config) ParseHTTPTimeout() time.Duration {
+ if c.HTTPTimeout == "" {
+ return defaultHTTPTimeout
+ }
+ if c.HTTPTimeout == "0" {
+ return 0
+ }
+ d, err := time.ParseDuration(c.HTTPTimeout)
+ if err != nil || d < 0 {
+ return defaultHTTPTimeout
+ }
+ return d
+}
+
// ParseMetadataTTL returns the metadata TTL duration.
// Returns 5 minutes if unset, 0 if explicitly disabled.
func (c *Config) ParseMetadataTTL() time.Duration {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index bb3ec74..decc18f 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -363,6 +363,34 @@ cooldown:
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")
}
+ if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" {
+ t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d")
+ }
+}
+
+func TestCooldownConfigNormalizedPackages(t *testing.T) {
+ rawScoped := "pkg:npm/@typescript/typescript-darwin-arm64"
+ canonicalScoped := "pkg:npm/%40typescript/typescript-darwin-arm64"
+ cfg := CooldownConfig{Packages: map[string]string{
+ rawScoped: "2d",
+ canonicalScoped: "3d",
+ "not-a-purl": "4d",
+ }}
+
+ got := cfg.NormalizedPackages()
+
+ if got[canonicalScoped] != "3d" {
+ t.Errorf("canonical scoped package duration = %q, want %q", got[canonicalScoped], "3d")
+ }
+ if _, exists := got[rawScoped]; exists {
+ t.Errorf("raw scoped package key %q was not canonicalized", rawScoped)
+ }
+ if got["not-a-purl"] != "4d" {
+ t.Errorf("invalid PURL duration = %q, want preserved value %q", got["not-a-purl"], "4d")
+ }
+ if cfg.Packages[rawScoped] != "2d" {
+ t.Error("NormalizedPackages mutated the source map")
+ }
}
func TestLoadCooldownFromEnv(t *testing.T) {
@@ -524,6 +552,69 @@ func TestValidateHealthStorageProbeInterval(t *testing.T) {
}
}
+func TestParseHTTPTimeout(t *testing.T) {
+ tests := []struct {
+ name string
+ timeout string
+ want time.Duration
+ }{
+ {"empty defaults to 30s", "", 30 * time.Second},
+ {"explicit zero disables", "0", 0},
+ {"2 minutes", "2m", 2 * time.Minute},
+ {"90 seconds", "90s", 90 * time.Second},
+ {"invalid defaults to 30s", "not-a-duration", 30 * time.Second},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := Default()
+ cfg.HTTPTimeout = tt.timeout
+ got := cfg.ParseHTTPTimeout()
+ if got != tt.want {
+ t.Errorf("ParseHTTPTimeout() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestValidateHTTPTimeout(t *testing.T) {
+ cfg := Default()
+ cfg.HTTPTimeout = "not-a-duration"
+ if err := cfg.Validate(); err == nil {
+ t.Error("expected validation error for invalid http_timeout")
+ }
+
+ cfg.HTTPTimeout = "-5s"
+ if err := cfg.Validate(); err == nil {
+ t.Error("expected validation error for negative http_timeout")
+ }
+
+ cfg.HTTPTimeout = "2m"
+ if err := cfg.Validate(); err != nil {
+ t.Errorf("unexpected error for valid http_timeout: %v", err)
+ }
+
+ cfg.HTTPTimeout = "0"
+ if err := cfg.Validate(); err != nil {
+ t.Errorf("unexpected error for zero http_timeout: %v", err)
+ }
+
+ cfg.HTTPTimeout = ""
+ if err := cfg.Validate(); err != nil {
+ t.Errorf("unexpected error for empty http_timeout: %v", err)
+ }
+}
+
+func TestLoadHTTPTimeoutFromEnv(t *testing.T) {
+ cfg := Default()
+ t.Setenv("PROXY_HTTP_TIMEOUT", "90s")
+ cfg.LoadFromEnv()
+
+ if cfg.HTTPTimeout != "90s" {
+ t.Errorf("HTTPTimeout = %q, want %q", cfg.HTTPTimeout, "90s")
+ }
+}
+
func TestLoadMetadataTTLFromEnv(t *testing.T) {
cfg := Default()
t.Setenv("PROXY_METADATA_TTL", "10m")
diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go
index 5d7810c..79fb750 100644
--- a/internal/handler/cargo.go
+++ b/internal/handler/cargo.go
@@ -6,10 +6,9 @@ import (
"errors"
"fmt"
"net/http"
+ "net/url"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -30,11 +29,18 @@ type CargoHandler struct {
}
// NewCargoHandler creates a new cargo protocol handler.
-func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler {
+func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler {
+ if strings.TrimSpace(indexURL) == "" {
+ indexURL = cargoUpstream
+ }
+ if strings.TrimSpace(downloadURL) == "" {
+ downloadURL = cargoDownloadBase
+ }
+
return &CargoHandler{
proxy: proxy,
- indexURL: cargoUpstream,
- downloadURL: cargoDownloadBase,
+ indexURL: strings.TrimSuffix(indexURL, "/"),
+ downloadURL: strings.TrimSuffix(downloadURL, "/"),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
@@ -143,7 +149,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr
continue
}
- cratePURL := purl.MakePURLString("cargo", crate.Name, "")
+ cratePURL := canonicalPackagePURL("cargo", crate.Name)
if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering cargo version",
@@ -191,10 +197,17 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("cargo download request",
"crate", name, "version", version, "filename", filename)
- result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename)
+ downloadURL := fmt.Sprintf(
+ "%s/%s/%s",
+ h.downloadURL,
+ url.PathEscape(name),
+ url.PathEscape(filename),
+ )
+ result, err := h.proxy.GetOrFetchArtifactFromURL(
+ r.Context(), "cargo", name, version, filename, downloadURL,
+ )
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch crate", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch crate")
return
}
diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go
index 10d3faf..895ff7b 100644
--- a/internal/handler/cargo_test.go
+++ b/internal/handler/cargo_test.go
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
+ "io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/registries/fetch"
)
func cargoTestProxy() *Proxy {
@@ -70,6 +72,75 @@ func TestCargoConfigEndpoint(t *testing.T) {
}
}
+func TestCargoHandlerUsesConfiguredUpstreams(t *testing.T) {
+ t.Run("index", func(t *testing.T) {
+ var requestPath, authHeader string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestPath = r.URL.Path
+ authHeader = r.Header.Get("Authorization")
+ if authHeader != "Bearer cargo-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = io.WriteString(w, `{"name":"serde","vers":"1.0.0"}`)
+ }))
+ defer upstream.Close()
+
+ proxy, _, _, _ := setupTestProxy(t)
+ proxy.HTTPClient = upstream.Client()
+ proxy.AuthForURL = func(string) (string, string) {
+ return "Authorization", "Bearer cargo-token"
+ }
+ h := NewCargoHandler(
+ proxy,
+ "http://proxy.test",
+ upstream.URL+"/index/",
+ "https://crates.example.test/files/",
+ )
+
+ req := httptest.NewRequest(http.MethodGet, "/se/rd/serde", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ if requestPath != "/index/se/rd/serde" {
+ t.Errorf("upstream path = %q, want %q", requestPath, "/index/se/rd/serde")
+ }
+ if authHeader != "Bearer cargo-token" {
+ t.Errorf("Authorization = %q, want %q", authHeader, "Bearer cargo-token")
+ }
+ })
+
+ t.Run("download", func(t *testing.T) {
+ proxy, _, _, artifactFetcher := setupTestProxy(t)
+ artifactFetcher.artifact = &fetch.Artifact{
+ Body: io.NopCloser(strings.NewReader("crate")),
+ ContentType: "application/gzip",
+ }
+ h := NewCargoHandler(
+ proxy,
+ "http://proxy.test",
+ "https://index.example.test/root/",
+ "https://crates.example.test/files/",
+ )
+
+ req := httptest.NewRequest(http.MethodGet, "/crates/serde/1.0.0/download", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ want := "https://crates.example.test/files/serde/serde-1.0.0.crate"
+ if artifactFetcher.fetchedURL != want {
+ t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
+ }
+ })
+}
+
func TestCargoIndexProxy(t *testing.T) {
// Create a mock upstream index server
indexContent := `{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123"}
diff --git a/internal/handler/composer.go b/internal/handler/composer.go
index e4dec41..45935b7 100644
--- a/internal/handler/composer.go
+++ b/internal/handler/composer.go
@@ -10,8 +10,6 @@ import (
"path"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -216,7 +214,7 @@ func deepCopyValue(v any) any {
// 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, "")
+ packagePURL := canonicalPackagePURL("composer", packageName)
filtered := versionList[:0]
for _, v := range versionList {
@@ -316,6 +314,10 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
// stable and dev versions resolve correctly.
metaURLs := h.metadataURLsForVersion(vendor, pkg, version)
+ h.proxy.Logger.Debug("resolving download URL",
+ "package", packageName, "version", version,
+ "metadata_urls", metaURLs)
+
var downloadURL string
for _, metaURL := range metaURLs {
url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version)
@@ -331,14 +333,20 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
}
if downloadURL == "" {
+ h.proxy.Logger.Debug("version not found in any metadata source",
+ "package", packageName, "version", version,
+ "tried_urls", metaURLs)
http.Error(w, "version not found", http.StatusNotFound)
return
}
+ h.proxy.Logger.Debug("resolved download URL",
+ "package", packageName, "version", version,
+ "download_url", downloadURL)
+
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
@@ -372,6 +380,9 @@ func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []
// An error is returned only on transport failure; a missing document (non-200)
// or a missing version both yield an empty string so the caller can fall back.
func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, error) {
+ h.proxy.Logger.Debug("fetching upstream metadata for download lookup",
+ "url", metaURL, "package", packageName, "version", version)
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil)
if err != nil {
return "", err
@@ -383,6 +394,9 @@ func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaU
}
defer func() { _ = resp.Body.Close() }()
+ h.proxy.Logger.Debug("upstream metadata response",
+ "url", metaURL, "status", resp.StatusCode)
+
if resp.StatusCode != http.StatusOK {
return "", nil
}
@@ -392,7 +406,27 @@ func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaU
return "", err
}
- return h.findDownloadURL(metadata, packageName, version), nil
+ // Expand minified Composer v2 format so that inherited fields (including
+ // dist) are present on every version entry. Without this, versions that
+ // inherit dist from a previous entry will appear to have no download URL.
+ if metadata["minified"] == "composer/2.0" {
+ h.proxy.Logger.Debug("expanding minified metadata", "url", metaURL)
+ if packages, ok := metadata["packages"].(map[string]any); ok {
+ for pkgName, versions := range packages {
+ versionList, ok := versions.([]any)
+ if !ok {
+ continue
+ }
+ packages[pkgName] = expandMinifiedVersions(versionList)
+ }
+ }
+ }
+
+ url := h.findDownloadURL(metadata, packageName, version)
+ h.proxy.Logger.Debug("download URL lookup result",
+ "url", metaURL, "package", packageName, "version", version,
+ "download_url", url)
+ return url, nil
}
// findDownloadURL finds the dist URL for a specific version in metadata.
diff --git a/internal/handler/conan.go b/internal/handler/conan.go
index 53f6428..c0476f9 100644
--- a/internal/handler/conan.go
+++ b/internal/handler/conan.go
@@ -84,8 +84,7 @@ func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch file", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch file")
return
}
@@ -122,8 +121,7 @@ func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch file", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch file")
return
}
diff --git a/internal/handler/conda.go b/internal/handler/conda.go
index cfa20c8..224c25f 100644
--- a/internal/handler/conda.go
+++ b/internal/handler/conda.go
@@ -6,8 +6,6 @@ import (
"net/http"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -74,8 +72,7 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
@@ -218,7 +215,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
continue
}
- packagePURL := purl.MakePURLString("conda", name, "")
+ packagePURL := canonicalPackagePURL("conda", name)
if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
version, _ := entryMap["version"].(string)
diff --git a/internal/handler/container.go b/internal/handler/container.go
index 8ba5e97..0710ed1 100644
--- a/internal/handler/container.go
+++ b/internal/handler/container.go
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -117,8 +118,12 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
)
if err != nil {
+ if errors.Is(err, ErrUpstreamNotFound) {
+ h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry")
+ return
+ }
h.proxy.Logger.Error("failed to fetch blob", "error", err)
- h.containerError(w, http.StatusBadGateway, "BLOB_UNKNOWN", "failed to fetch blob")
+ h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob")
return
}
diff --git a/internal/handler/cran.go b/internal/handler/cran.go
index 0ecd2a3..2fc4fab 100644
--- a/internal/handler/cran.go
+++ b/internal/handler/cran.go
@@ -72,8 +72,7 @@ func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
@@ -107,8 +106,7 @@ func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
diff --git a/internal/handler/debian.go b/internal/handler/debian.go
index b767f6d..9a4aaab 100644
--- a/internal/handler/debian.go
+++ b/internal/handler/debian.go
@@ -81,8 +81,7 @@ func (h *DebianHandler) handlePackageDownload(w http.ResponseWriter, r *http.Req
result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "deb", name, version, filename, downloadURL)
if err != nil {
- h.proxy.Logger.Error("failed to get debian package", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
diff --git a/internal/handler/filename_download.go b/internal/handler/filename_download.go
new file mode 100644
index 0000000..bedec16
--- /dev/null
+++ b/internal/handler/filename_download.go
@@ -0,0 +1,39 @@
+package handler
+
+import (
+ "net/http"
+ "strings"
+)
+
+type filenameDownload struct {
+ ecosystem string
+ suffix string
+ parseErr string
+ fetchErr string
+ parse func(string) (name, version string)
+}
+
+func (p *Proxy) handleFilenameDownload(w http.ResponseWriter, r *http.Request, d filenameDownload) {
+ filename := r.PathValue("filename")
+ if filename == "" || !strings.HasSuffix(filename, d.suffix) {
+ http.Error(w, "invalid filename", http.StatusBadRequest)
+ return
+ }
+
+ name, version := d.parse(filename)
+ if name == "" || version == "" {
+ http.Error(w, d.parseErr, http.StatusBadRequest)
+ return
+ }
+
+ p.Logger.Info(d.ecosystem+" download request",
+ "name", name, "version", version, "filename", filename)
+
+ result, err := p.GetOrFetchArtifact(r.Context(), d.ecosystem, name, version, filename)
+ if err != nil {
+ p.serveArtifactError(w, err, d.fetchErr)
+ return
+ }
+
+ ServeArtifact(w, result)
+}
diff --git a/internal/handler/gem.go b/internal/handler/gem.go
index 9ec57e3..260568a 100644
--- a/internal/handler/gem.go
+++ b/internal/handler/gem.go
@@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -60,30 +58,13 @@ func (h *GemHandler) Routes() http.Handler {
// handleDownload serves a gem file, fetching and caching from upstream if needed.
func (h *GemHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
- filename := r.PathValue("filename")
- if filename == "" || !strings.HasSuffix(filename, ".gem") {
- http.Error(w, "invalid filename", http.StatusBadRequest)
- return
- }
-
- // Extract name and version from filename (e.g., "rails-7.1.0.gem")
- name, version := h.parseGemFilename(filename)
- if name == "" || version == "" {
- http.Error(w, "could not parse gem filename", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Info("gem download request",
- "name", name, "version", version, "filename", filename)
-
- result, err := h.proxy.GetOrFetchArtifact(r.Context(), "gem", name, version, filename)
- if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch gem", http.StatusBadGateway)
- return
- }
-
- ServeArtifact(w, result)
+ h.proxy.handleFilenameDownload(w, r, filenameDownload{
+ ecosystem: "gem",
+ suffix: ".gem",
+ parseErr: "could not parse gem filename",
+ fetchErr: "failed to fetch gem",
+ parse: h.parseGemFilename,
+ })
}
// parseGemFilename extracts name and version from a gem filename.
@@ -266,7 +247,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err
}
- packagePURL := purl.MakePURLString("gem", name, "")
+ packagePURL := canonicalPackagePURL("gem", name)
filtered := make(map[string]bool)
for _, v := range versions {
diff --git a/internal/handler/go.go b/internal/handler/go.go
index 955a89c..cf40aa1 100644
--- a/internal/handler/go.go
+++ b/internal/handler/go.go
@@ -1,9 +1,12 @@
package handler
import (
+ "errors"
"fmt"
"net/http"
"strings"
+
+ "github.com/git-pkgs/registries/fetch"
)
const (
@@ -100,6 +103,10 @@ func (h *GoHandler) handleDownload(w http.ResponseWriter, r *http.Request, modul
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "golang", decodedModule, version, filename)
if err != nil {
+ if errors.Is(err, fetch.ErrNotFound) {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch module", http.StatusBadGateway)
return
diff --git a/internal/handler/go_test.go b/internal/handler/go_test.go
index da4ea63..ae998c9 100644
--- a/internal/handler/go_test.go
+++ b/internal/handler/go_test.go
@@ -1,9 +1,49 @@
package handler
import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
"testing"
+
+ "github.com/git-pkgs/registries/fetch"
)
+func TestGoModuleDownloadUpstreamErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ fetchErr error
+ wantStatus int
+ }{
+ {
+ name: "module not found",
+ fetchErr: fetch.ErrNotFound,
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "upstream failure",
+ fetchErr: errors.New("connection refused"),
+ wantStatus: http.StatusBadGateway,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = tt.fetchErr
+ handler := NewGoHandler(proxy, "http://localhost:8080")
+
+ req := httptest.NewRequest(http.MethodGet, "/example.com/mod/@v/v1.0.0.zip", nil)
+ resp := httptest.NewRecorder()
+ handler.Routes().ServeHTTP(resp, req)
+
+ if resp.Code != tt.wantStatus {
+ t.Fatalf("status = %d, want %d", resp.Code, tt.wantStatus)
+ }
+ })
+ }
+}
+
func TestDecodeGoModule(t *testing.T) {
tests := []struct {
encoded string
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index d06ca83..fc78dbf 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -48,6 +48,14 @@ func hasDotDotSegment(path string) bool {
const defaultHTTPTimeout = 30 * time.Second
+// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
+// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
+func canonicalPackagePURL(ecosystem, name string) string {
+ p := purl.MakePURL(ecosystem, name, "")
+ _ = p.Normalize()
+ return p.String()
+}
+
const contentTypeJSON = "application/json"
const headerAcceptEncoding = "Accept-Encoding"
@@ -96,6 +104,7 @@ type Proxy struct {
// storage at an internal one.
DirectServeBaseURL string
HTTPClient *http.Client
+ AuthForURL func(string) (headerName, headerValue string)
}
// NewProxy creates a new Proxy with the given dependencies.
@@ -243,6 +252,9 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
// Resolve download URL
info, err := p.Resolver.Resolve(ctx, ecosystem, name, version)
if err != nil {
+ if errors.Is(err, fetch.ErrNotFound) {
+ return nil, ErrUpstreamNotFound
+ }
return nil, fmt.Errorf("resolving download URL: %w", err)
}
@@ -262,6 +274,9 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
if err != nil {
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
metrics.RecordUpstreamError(ecosystem, "fetch_failed")
+ if errors.Is(err, fetch.ErrNotFound) {
+ return nil, ErrUpstreamNotFound
+ }
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
@@ -391,6 +406,7 @@ func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamUR
req.Header.Set(header, v)
}
}
+ p.applyUpstreamAuth(req)
resp, err := p.HTTPClient.Do(req)
if err != nil {
@@ -417,6 +433,7 @@ func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL st
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
+ p.applyUpstreamAuth(req)
resp, err := p.HTTPClient.Do(req)
if err != nil {
@@ -443,7 +460,18 @@ func JSONError(w http.ResponseWriter, status int, message string) {
}
// ErrUpstreamNotFound indicates the upstream returned 404.
-var ErrUpstreamNotFound = fmt.Errorf("upstream: not found")
+var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound)
+
+// serveArtifactError writes response for a failed fetch:
+// 404 when upstream reports artifact missing, 502 otherwise.
+func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg string) {
+ if errors.Is(err, ErrUpstreamNotFound) {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ p.Logger.Error("failed to get artifact", "error", err)
+ http.Error(w, clientMsg, http.StatusBadGateway)
+}
// 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")
@@ -546,6 +574,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
return nil, "", "", zeroTime, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", accept)
+ p.applyUpstreamAuth(req)
if entry != nil && entry.ETag.Valid {
req.Header.Set("If-None-Match", entry.ETag.String)
@@ -735,6 +764,7 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
accept = acceptHeaders[0]
}
req.Header.Set("Accept", accept)
+ p.applyUpstreamAuth(req)
for _, header := range []string{headerAcceptEncoding, "If-Modified-Since", "If-None-Match"} {
if v := r.Header.Get(header); v != "" {
@@ -759,6 +789,17 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
_, _ = io.Copy(w, resp.Body)
}
+func (p *Proxy) applyUpstreamAuth(req *http.Request) {
+ if p.AuthForURL == nil {
+ return
+ }
+
+ headerName, headerValue := p.AuthForURL(req.URL.String())
+ if headerName != "" && headerValue != "" {
+ req.Header.Set(headerName, headerValue)
+ }
+}
+
// 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) {
@@ -787,6 +828,9 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers)
if err != nil {
+ if errors.Is(err, fetch.ErrNotFound) {
+ return nil, ErrUpstreamNotFound
+ }
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
index bbcab72..9a2b329 100644
--- a/internal/handler/handler_test.go
+++ b/internal/handler/handler_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
@@ -1010,3 +1011,33 @@ func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
t.Errorf("Warning should be empty for fresh response, got %q", got)
}
}
+
+// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key
+// agrees with config.CooldownConfig.NormalizedPackages for the same package,
+// so a configured override is actually found regardless of how the user wrote it.
+func TestCanonicalPackagePURLMatchesConfig(t *testing.T) {
+ tests := []struct {
+ ecosystem string
+ requestName string
+ configKey string
+ }{
+ {"npm", "@babel/core", "pkg:npm/@babel/core"},
+ {"npm", "@babel/core", "pkg:npm/%40babel/core"},
+ {"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"},
+ {"pypi", "Django", "pkg:pypi/Django"},
+ {"pypi", "django", "pkg:pypi/Django"},
+ {"composer", "symfony/console", "pkg:composer/Symfony/Console"},
+ {"cargo", "serde", "pkg:cargo/serde"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) {
+ cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}}
+ normalized := cfg.NormalizedPackages()
+
+ lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName)
+ if _, ok := normalized[lookup]; !ok {
+ t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized)
+ }
+ })
+ }
+}
diff --git a/internal/handler/hex.go b/internal/handler/hex.go
index 0f0c72e..2ff4f0f 100644
--- a/internal/handler/hex.go
+++ b/internal/handler/hex.go
@@ -10,7 +10,6 @@ import (
"strings"
"time"
- "github.com/git-pkgs/purl"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -54,30 +53,13 @@ func (h *HexHandler) Routes() http.Handler {
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
- filename := r.PathValue("filename")
- if filename == "" || !strings.HasSuffix(filename, ".tar") {
- http.Error(w, "invalid filename", http.StatusBadRequest)
- return
- }
-
- // Extract name and version from filename (e.g., "phoenix-1.7.10.tar")
- name, version := h.parseTarballFilename(filename)
- if name == "" || version == "" {
- http.Error(w, "could not parse tarball filename", http.StatusBadRequest)
- return
- }
-
- h.proxy.Logger.Info("hex download request",
- "name", name, "version", version, "filename", filename)
-
- result, err := h.proxy.GetOrFetchArtifact(r.Context(), "hex", name, version, filename)
- if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
- return
- }
-
- ServeArtifact(w, result)
+ h.proxy.handleFilenameDownload(w, r, filenameDownload{
+ ecosystem: "hex",
+ suffix: ".tar",
+ parseErr: "could not parse tarball filename",
+ fetchErr: "failed to fetch package",
+ parse: h.parseTarballFilename,
+ })
}
// parseTarballFilename extracts name and version from a hex tarball filename.
@@ -237,7 +219,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err
}
- packagePURL := purl.MakePURLString("hex", name, "")
+ packagePURL := canonicalPackagePURL("hex", name)
filtered := make(map[string]bool)
for _, release := range pkg.Releases {
diff --git a/internal/handler/julia.go b/internal/handler/julia.go
index 08b1fdf..0fed8c9 100644
--- a/internal/handler/julia.go
+++ b/internal/handler/julia.go
@@ -90,8 +90,7 @@ func (h *JuliaHandler) handleRegistry(w http.ResponseWriter, r *http.Request) {
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)
+ h.proxy.serveArtifactError(w, err, "failed to fetch registry")
return
}
@@ -119,8 +118,7 @@ func (h *JuliaHandler) handlePackage(w http.ResponseWriter, r *http.Request) {
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)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
@@ -141,8 +139,7 @@ func (h *JuliaHandler) handleArtifact(w http.ResponseWriter, r *http.Request) {
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)
+ h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
return
}
diff --git a/internal/handler/maven.go b/internal/handler/maven.go
index c423645..10e551e 100644
--- a/internal/handler/maven.go
+++ b/internal/handler/maven.go
@@ -130,12 +130,7 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
}
}
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)
+ h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
return
}
diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go
new file mode 100644
index 0000000..3c2cecf
--- /dev/null
+++ b/internal/handler/notfound_ecosystems_test.go
@@ -0,0 +1,151 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/git-pkgs/registries/fetch"
+)
+
+func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ handler func(p *Proxy) http.Handler
+ }{
+ {"debian", "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb",
+ func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost").Routes() }},
+ {"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm",
+ func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }},
+ {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg",
+ func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }},
+ {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl",
+ func(p *Proxy) http.Handler { return NewPyPIHandler(p, "http://localhost").Routes() }},
+ {"cran", "/src/contrib/ggplot2_3.4.4.tar.gz",
+ func(p *Proxy) http.Handler { return NewCRANHandler(p, "http://localhost").Routes() }},
+ {"conda", "/conda-forge/linux-64/numpy-1.26.0-py311_0.tar.bz2",
+ func(p *Proxy) http.Handler { return NewCondaHandler(p, "http://localhost").Routes() }},
+ {"conan", "/v1/files/zlib/1.3.1/_/_/0/recipe/conan_sources.tgz",
+ func(p *Proxy) http.Handler { return NewConanHandler(p, "http://localhost").Routes() }},
+ {"gem", "/gems/rails-7.1.0.gem",
+ func(p *Proxy) http.Handler { return NewGemHandler(p, "http://localhost").Routes() }},
+ {"hex", "/tarballs/phoenix-1.7.10.tar",
+ func(p *Proxy) http.Handler { return NewHexHandler(p, "http://localhost").Routes() }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = fetch.ErrNotFound
+
+ srv := httptest.NewServer(tt.handler(proxy))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + tt.path)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
+ }
+ })
+ }
+}
+
+func TestJuliaPackageUpstreamNotFoundReturns404(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = fetch.ErrNotFound
+
+ dead := httptest.NewServer(http.NotFoundHandler())
+ defer dead.Close()
+
+ h := NewJuliaHandler(proxy, "http://localhost")
+ h.upstreamURL = dead.URL
+
+ srv := httptest.NewServer(h.Routes())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL +
+ "/package/7876af07-990d-54b4-ab0e-23690620f79a/0123456789abcdef0123456789abcdef01234567")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("want 404 for missing upstream package, got %d", resp.StatusCode)
+ }
+}
+
+func TestComposerDownloadUpstreamNotFoundReturns404(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = fetch.ErrNotFound
+
+ meta := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/p2/monolog/monolog.json" {
+ _, _ = w.Write([]byte(`{
+ "packages": {
+ "monolog/monolog": [
+ {"version": "2.9.1", "dist": {"url": "https://example.com/monolog-2.9.1.zip", "type": "zip"}}
+ ]
+ }
+ }`))
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer meta.Close()
+
+ h := &ComposerHandler{proxy: proxy, repoURL: meta.URL, proxyURL: "http://localhost"}
+ srv := httptest.NewServer(h.Routes())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/files/monolog/monolog/2.9.1/monolog-2.9.1.zip")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("want 404 for missing upstream dist, got %d", resp.StatusCode)
+ }
+}
+
+func TestContainerBlobUpstreamNotFoundReturns404(t *testing.T) {
+ authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"token": "test-token-123"}`))
+ }))
+ defer authServer.Close()
+
+ proxy, _, _, _ := setupTestProxy(t)
+ proxy.Fetcher = &mockFetcherWithHeaders{
+ fetchFn: func(_ context.Context, _ string, _ http.Header) (*fetch.Artifact, error) {
+ return nil, fetch.ErrNotFound
+ },
+ }
+
+ h := &ContainerHandler{
+ proxy: proxy,
+ registryURL: "https://registry-1.docker.io",
+ authURL: authServer.URL,
+ proxyURL: "http://localhost:8080",
+ }
+
+ req := httptest.NewRequest(http.MethodGet,
+ "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404 for missing upstream blob, got %d; body: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "BLOB_UNKNOWN") {
+ t.Errorf("want BLOB_UNKNOWN error code in body, got: %s", w.Body.String())
+ }
+}
diff --git a/internal/handler/notfound_test.go b/internal/handler/notfound_test.go
new file mode 100644
index 0000000..9ea38ac
--- /dev/null
+++ b/internal/handler/notfound_test.go
@@ -0,0 +1,83 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/git-pkgs/registries/fetch"
+)
+
+func TestErrUpstreamNotFoundWrapsFetchErrNotFound(t *testing.T) {
+ if !errors.Is(ErrUpstreamNotFound, fetch.ErrNotFound) {
+ t.Fatal("ErrUpstreamNotFound does not wrap fetch.ErrNotFound")
+ }
+}
+
+func TestGetOrFetchArtifactFromURL_NotFound(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = fetch.ErrNotFound
+
+ _, err := proxy.GetOrFetchArtifactFromURL(context.Background(),
+ "maven", "org.example:missing", "1.0", "missing-1.0.jar",
+ "http://upstream.test/org/example/missing/1.0/missing-1.0.jar")
+
+ if !errors.Is(err, ErrUpstreamNotFound) {
+ t.Fatalf("want ErrUpstreamNotFound, got %v", err)
+ }
+}
+
+func TestMavenHandler_UpstreamNotFoundReturns404(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErr = fetch.ErrNotFound
+
+ h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
+ srv := httptest.NewServer(h.Routes())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/org/example/missing/1.0/missing-1.0.jar")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
+ }
+}
+
+func TestMavenHandler_PluginPortalFallback(t *testing.T) {
+ proxy, _, _, fetcher := setupTestProxy(t)
+ fetcher.fetchErrByURL = map[string]error{
+ "http://upstream.test/org/example/plugin/1.0/plugin-1.0.jar": fetch.ErrNotFound,
+ }
+ fetcher.artifact = &fetch.Artifact{
+ Body: io.NopCloser(strings.NewReader("portal artifact")),
+ ContentType: "application/java-archive",
+ }
+
+ h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
+ srv := httptest.NewServer(h.Routes())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/org/example/plugin/1.0/plugin-1.0.jar")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200 via plugin portal fallback, got %d", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if string(body) != "portal artifact" {
+ t.Errorf("want portal artifact body, got %q", body)
+ }
+ if fetcher.fetchedURL != "http://portal.test/org/example/plugin/1.0/plugin-1.0.jar" {
+ t.Errorf("fallback did not hit plugin portal, last URL: %s", fetcher.fetchedURL)
+ }
+}
diff --git a/internal/handler/npm.go b/internal/handler/npm.go
index 0585eda..ee9b59c 100644
--- a/internal/handler/npm.go
+++ b/internal/handler/npm.go
@@ -9,8 +9,6 @@ import (
"sort"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -27,10 +25,14 @@ type NPMHandler struct {
}
// NewNPMHandler creates a new npm protocol handler.
-func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler {
+func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler {
+ if strings.TrimSpace(upstreamURL) == "" {
+ upstreamURL = npmUpstream
+ }
+
return &NPMHandler{
proxy: proxy,
- upstreamURL: npmUpstream,
+ upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
@@ -134,7 +136,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma
return
}
- packagePURL := purl.MakePURLString("npm", packageName, "")
+ packagePURL := canonicalPackagePURL("npm", packageName)
for version := range versions {
publishedStr, ok := timeMap[version].(string)
@@ -263,8 +265,20 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("npm download request",
"package", packageName, "version", version, "filename", filename)
- result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename)
+ downloadURL := fmt.Sprintf(
+ "%s/%s/-/%s",
+ h.upstreamURL,
+ escapeNPMDownloadPackage(packageName),
+ url.PathEscape(filename),
+ )
+ result, err := h.proxy.GetOrFetchArtifactFromURL(
+ r.Context(), "npm", packageName, version, filename, downloadURL,
+ )
if err != nil {
+ if errors.Is(err, ErrUpstreamNotFound) {
+ JSONError(w, http.StatusNotFound, "package not found")
+ return
+ }
h.proxy.Logger.Error("failed to get artifact", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch package")
return
@@ -273,6 +287,14 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
ServeArtifact(w, result)
}
+func escapeNPMDownloadPackage(packageName string) string {
+ scope, name, scoped := strings.Cut(packageName, "/")
+ if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
+ return url.PathEscape(scope) + "/" + url.PathEscape(name)
+ }
+ return url.PathEscape(packageName)
+}
+
// extractPackageName extracts the package name from the request path.
// Handles both scoped (@scope/name) and unscoped (name) packages.
func (h *NPMHandler) extractPackageName(r *http.Request) string {
diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go
index bc1edde..e0257dd 100644
--- a/internal/handler/npm_test.go
+++ b/internal/handler/npm_test.go
@@ -2,13 +2,16 @@ 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"
)
const testVersion100 = "1.0.0"
@@ -46,6 +49,86 @@ func TestNPMExtractVersionFromFilename(t *testing.T) {
}
}
+func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) {
+ t.Run("metadata", func(t *testing.T) {
+ var requestPath, authHeader string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestPath = r.URL.Path
+ authHeader = r.Header.Get("Authorization")
+ if authHeader != "Bearer npm-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"versions":{}}`)
+ }))
+ defer upstream.Close()
+
+ proxy, _, _, _ := setupTestProxy(t)
+ proxy.HTTPClient = upstream.Client()
+ proxy.AuthForURL = func(string) (string, string) {
+ return "Authorization", "Bearer npm-token"
+ }
+ h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/")
+
+ req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ if requestPath != "/root/testpkg" {
+ t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg")
+ }
+ if authHeader != "Bearer npm-token" {
+ t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token")
+ }
+ })
+
+ t.Run("download", func(t *testing.T) {
+ proxy, _, _, artifactFetcher := setupTestProxy(t)
+ artifactFetcher.artifact = &fetch.Artifact{
+ Body: io.NopCloser(strings.NewReader("package")),
+ ContentType: "application/gzip",
+ }
+ h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
+
+ req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz"
+ if artifactFetcher.fetchedURL != want {
+ t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
+ }
+ })
+
+ t.Run("scoped download", func(t *testing.T) {
+ proxy, _, _, artifactFetcher := setupTestProxy(t)
+ artifactFetcher.artifact = &fetch.Artifact{
+ Body: io.NopCloser(strings.NewReader("package")),
+ ContentType: "application/gzip",
+ }
+ h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
+
+ req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil)
+ w := httptest.NewRecorder()
+ h.Routes().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz"
+ if artifactFetcher.fetchedURL != want {
+ t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
+ }
+ })
+}
+
func TestNPMRewriteMetadata(t *testing.T) {
h := &NPMHandler{
proxy: testProxy(),
diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go
index 40b8b5f..4785e40 100644
--- a/internal/handler/nuget.go
+++ b/internal/handler/nuget.go
@@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -271,7 +269,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
}
}
- packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
+ packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id))
if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering nuget version",
@@ -316,8 +314,7 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
diff --git a/internal/handler/pub.go b/internal/handler/pub.go
index 60bbbad..e5ca199 100644
--- a/internal/handler/pub.go
+++ b/internal/handler/pub.go
@@ -7,8 +7,6 @@ import (
"net/http"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -69,8 +67,7 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
@@ -127,7 +124,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
return body, nil
}
- packagePURL := purl.MakePURLString("pub", name, "")
+ packagePURL := canonicalPackagePURL("pub", name)
filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
metadata["versions"] = filtered
diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go
index 3021d2b..f5a0232 100644
--- a/internal/handler/pypi.go
+++ b/internal/handler/pypi.go
@@ -12,8 +12,6 @@ import (
"regexp"
"strings"
"time"
-
- "github.com/git-pkgs/purl"
)
const (
@@ -131,7 +129,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st
return nil
}
- packagePURL := purl.MakePURLString("pypi", name, "")
+ packagePURL := canonicalPackagePURL("pypi", name)
filtered := make(map[string]bool)
for version, files := range releases {
@@ -262,7 +260,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
packageName, _ := extractPyPIName(metadata)
packagePURL := ""
if packageName != "" {
- packagePURL = purl.MakePURLString("pypi", packageName, "")
+ packagePURL = canonicalPackagePURL("pypi", packageName)
}
h.filterAndRewriteReleases(metadata, packageName, packagePURL)
@@ -428,8 +426,7 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
if err != nil {
- h.proxy.Logger.Error("failed to get artifact", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go
index 6440d0f..a5752ec 100644
--- a/internal/handler/rpm.go
+++ b/internal/handler/rpm.go
@@ -83,8 +83,7 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "rpm", name, version, filename, downloadURL)
if err != nil {
- h.proxy.Logger.Error("failed to get rpm package", "error", err)
- http.Error(w, "failed to fetch package", http.StatusBadGateway)
+ h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
diff --git a/internal/server/browse.go b/internal/server/browse.go
index 504f5f1..56aa1c5 100644
--- a/internal/server/browse.go
+++ b/internal/server/browse.go
@@ -1,6 +1,7 @@
package server
import (
+ "bufio"
"encoding/json"
"fmt"
"io"
@@ -10,29 +11,22 @@ import (
"github.com/git-pkgs/archives"
"github.com/git-pkgs/archives/diff"
+ "github.com/git-pkgs/magic"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/purl"
"github.com/go-chi/chi/v5"
)
-const contentTypePlainText = "text/plain; charset=utf-8"
+const (
+ contentTypePlainText = "text/plain; charset=utf-8"
+ browseSniffSize = 512
+)
// 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.
@@ -66,8 +60,6 @@ func detectSingleRootDir(reader archives.Reader) string {
// 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 {
@@ -78,17 +70,17 @@ func openArchive(filename string, content io.Reader, ecosystem string) (archives
}
if ecosystem == "npm" {
- return archives.OpenBytesWithPrefix(fname, data, "package/")
+ return archives.OpenBytesWithPrefix(filename, data, "package/")
}
- probe, err := archives.OpenBytes(fname, data)
+ probe, err := archives.OpenBytes(filename, data)
if err != nil {
return nil, err
}
prefix := detectSingleRootDir(probe)
_ = probe.Close()
- return archives.OpenBytesWithPrefix(fname, data, prefix)
+ return archives.OpenBytesWithPrefix(filename, data, prefix)
}
// BrowseListResponse contains the file listing for a directory in an archives.
@@ -361,7 +353,14 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
}
defer func() { _ = fileReader.Close() }()
- contentType := detectContentType(filePath)
+ contentType, knownPath := detectContentTypeFromPath(filePath)
+ var content io.Reader = fileReader
+ if !knownPath {
+ bufferedFile := bufio.NewReaderSize(fileReader, browseSniffSize)
+ prefix, _ := bufferedFile.Peek(browseSniffSize)
+ contentType = detectContentTypeFromPrefix(prefix)
+ content = bufferedFile
+ }
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Security-Policy", "sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -370,85 +369,103 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
// Stream the file
- _, _ = io.Copy(w, fileReader)
+ _, _ = io.Copy(w, content)
}
-// detectContentType returns an appropriate content type based on file extension.
-func detectContentType(filename string) string {
+func detectContentTypeFromPath(filename string) (string, bool) {
ext := strings.ToLower(path.Ext(filename))
switch ext {
// Text formats
case ".txt", ".md", ".markdown":
- return contentTypePlainText
+ return contentTypePlainText, true
case ".html", ".htm", ".xhtml":
- return contentTypePlainText
+ return contentTypePlainText, true
case ".css":
- return "text/css; charset=utf-8"
+ return "text/css; charset=utf-8", true
case ".js", ".mjs":
- return "application/javascript; charset=utf-8"
+ return "application/javascript; charset=utf-8", true
case ".json":
- return "application/json; charset=utf-8"
+ return "application/json; charset=utf-8", true
case ".xml":
- return "application/xml; charset=utf-8"
+ return "application/xml; charset=utf-8", true
case ".yaml", ".yml":
- return "text/yaml; charset=utf-8"
+ return "text/yaml; charset=utf-8", true
case ".toml":
- return "text/toml; charset=utf-8"
+ return "text/toml; charset=utf-8", true
// Programming languages
case ".go":
- return "text/x-go; charset=utf-8"
+ return "text/x-go; charset=utf-8", true
case ".rs":
- return "text/x-rust; charset=utf-8"
+ return "text/x-rust; charset=utf-8", true
case ".py":
- return "text/x-python; charset=utf-8"
+ return "text/x-python; charset=utf-8", true
case ".rb":
- return "text/x-ruby; charset=utf-8"
+ return "text/x-ruby; charset=utf-8", true
case ".java":
- return "text/x-java; charset=utf-8"
+ return "text/x-java; charset=utf-8", true
case ".c", ".h":
- return "text/x-c; charset=utf-8"
+ return "text/x-c; charset=utf-8", true
case ".cpp", ".cc", ".cxx", ".hpp":
- return "text/x-c++; charset=utf-8"
+ return "text/x-c++; charset=utf-8", true
case ".ts":
- return "text/typescript; charset=utf-8"
+ return "text/typescript; charset=utf-8", true
case ".tsx":
- return "text/tsx; charset=utf-8"
+ return "text/tsx; charset=utf-8", true
case ".jsx":
- return "text/jsx; charset=utf-8"
+ return "text/jsx; charset=utf-8", true
case ".php":
- return "text/x-php; charset=utf-8"
+ return "text/x-php; charset=utf-8", true
// Config files
case ".conf", ".config", ".ini":
- return contentTypePlainText
+ return contentTypePlainText, true
case ".sh", ".bash":
- return "text/x-shellscript; charset=utf-8"
+ return "text/x-shellscript; charset=utf-8", true
case ".dockerfile":
- return "text/x-dockerfile; charset=utf-8"
+ return "text/x-dockerfile; charset=utf-8", true
// Images
case ".png":
- return "image/png"
+ return "image/png", true
case ".jpg", ".jpeg":
- return "image/jpeg"
+ return "image/jpeg", true
case ".gif":
- return "image/gif"
+ return "image/gif", true
case ".svg":
- return contentTypePlainText
+ return contentTypePlainText, true
case ".ico":
- return "image/x-icon"
+ return "image/x-icon", true
// Archives
case ".zip", ".tar", ".gz", ".bz2", ".xz":
- return "application/octet-stream"
+ return "application/octet-stream", true
default:
- // Try to detect if it looks like text
if isLikelyText(filename) {
- return contentTypePlainText
+ return contentTypePlainText, true
}
+ return "", false
+ }
+}
+
+func detectContentTypeFromPrefix(prefix []byte) string {
+ result := magic.DetectPrefix(prefix)
+ if result.Kind == magic.KindText {
+ return contentTypePlainText
+ }
+
+ switch result.Format {
+ case "png":
+ return "image/png"
+ case "jpeg":
+ return "image/jpeg"
+ case "gif":
+ return "image/gif"
+ case "pdf":
+ return "application/pdf"
+ default:
return "application/octet-stream"
}
}
diff --git a/internal/server/browse_bench_test.go b/internal/server/browse_bench_test.go
index 03f3f02..840bc75 100644
--- a/internal/server/browse_bench_test.go
+++ b/internal/server/browse_bench_test.go
@@ -55,3 +55,35 @@ func BenchmarkOpenArchive(b *testing.B) {
})
}
}
+
+func BenchmarkDetectContentType(b *testing.B) {
+ cases := []struct {
+ name string
+ filename string
+ prefix []byte
+ knownPath bool
+ }{
+ {"known-path", "README.md", nil, true},
+ {"text-prefix", "artifact", bytes.Repeat([]byte("a"), browseSniffSize), false},
+ {"png-prefix", "artifact", append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, browseSniffSize-8)...), false},
+ }
+
+ for _, tc := range cases {
+ b.Run(tc.name, func(b *testing.B) {
+ b.ReportAllocs()
+ var contentType string
+ if tc.knownPath {
+ for b.Loop() {
+ contentType, _ = detectContentTypeFromPath(tc.filename)
+ }
+ } else {
+ for b.Loop() {
+ contentType = detectContentTypeFromPrefix(tc.prefix)
+ }
+ }
+ if contentType == "" {
+ b.Fatal("empty content type")
+ }
+ })
+ }
+}
diff --git a/internal/server/browse_test.go b/internal/server/browse_test.go
index f1fb993..5240a92 100644
--- a/internal/server/browse_test.go
+++ b/internal/server/browse_test.go
@@ -137,29 +137,44 @@ func TestHandleBrowseFile(t *testing.T) {
t.Fatalf("failed to upsert artifact: %v", err)
}
- // Test fetching a file
- req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/README.md", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ files := []struct {
+ path string
+ content string
+ contentType string
+ }{
+ {"README.md", "# Test Package\n", contentTypePlainText},
+ {"notes.data", "short text\n", contentTypePlainText},
+ {"logo", "\x89PNG\r\n\x1a\nimage data", "image/png"},
+ {"page", "", contentTypePlainText},
+ {"misleading.txt", "\x89PNG\r\n\x1a\nimage data", contentTypePlainText},
}
+ for _, file := range files {
+ t.Run(file.path, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/"+file.path, nil)
+ w := httptest.NewRecorder()
+ ts.handler.ServeHTTP(w, req)
- body := w.Body.String()
- if body != "# Test Package\n" {
- t.Errorf("unexpected file content: %q", body)
- }
-
- // Check content type
- contentType := w.Header().Get("Content-Type")
- if contentType != contentTypePlainText {
- t.Errorf("expected text/plain content type, got %q", contentType)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if w.Body.String() != file.content {
+ t.Errorf("unexpected file content: %q", w.Body.String())
+ }
+ if got := w.Header().Get("Content-Type"); got != file.contentType {
+ t.Errorf("Content-Type = %q, want %q", got, file.contentType)
+ }
+ if got := w.Header().Get("Content-Security-Policy"); got != "sandbox" {
+ t.Errorf("Content-Security-Policy = %q, want sandbox", got)
+ }
+ if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
+ }
+ })
}
// Test fetching non-existent file
- req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
- w = httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
+ w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -167,36 +182,52 @@ func TestHandleBrowseFile(t *testing.T) {
}
}
-func TestDetectContentType(t *testing.T) {
+func TestBrowseContentTypePolicy(t *testing.T) {
tests := []struct {
+ name string
filename string
+ prefix []byte
expectedCT string
}{
- {"file.txt", contentTypePlainText},
- {"file.md", contentTypePlainText},
- {"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},
- {"file.bin", "application/octet-stream"},
+ {"text extension", "file.txt", nil, contentTypePlainText},
+ {"markdown extension", "file.md", nil, contentTypePlainText},
+ {"JSON extension", "file.json", nil, "application/json; charset=utf-8"},
+ {"JavaScript extension", "file.js", nil, "application/javascript; charset=utf-8"},
+ {"Go extension", "file.go", nil, "text/x-go; charset=utf-8"},
+ {"Python extension", "file.py", nil, "text/x-python; charset=utf-8"},
+ {"Rust extension", "file.rs", nil, "text/x-rust; charset=utf-8"},
+ {"HTML extension", "file.html", nil, contentTypePlainText},
+ {"HTM extension", "file.htm", nil, contentTypePlainText},
+ {"XHTML extension", "file.xhtml", nil, contentTypePlainText},
+ {"SVG extension", "file.svg", nil, contentTypePlainText},
+ {"PNG extension", "file.png", nil, "image/png"},
+ {"JPEG extension", "file.jpg", nil, "image/jpeg"},
+ {"README", "README", nil, contentTypePlainText},
+ {"LICENSE", "LICENSE", nil, contentTypePlainText},
+ {"Makefile", "Makefile", nil, contentTypePlainText},
+ {"gitignore", ".gitignore", nil, contentTypePlainText},
+ {"unknown empty", "file.bin", nil, "application/octet-stream"},
+ {"extensionless PNG", "asset", []byte("\x89PNG\r\n\x1a\n"), "image/png"},
+ {"extensionless JPEG", "asset", []byte("\xff\xd8\xff"), "image/jpeg"},
+ {"extensionless GIF", "asset", []byte("GIF89a"), "image/gif"},
+ {"extensionless PDF", "asset", []byte("%PDF-1.7"), "application/pdf"},
+ {"extensionless text", "asset", []byte("plain text\n"), contentTypePlainText},
+ {"extensionless HTML", "asset", []byte(""), contentTypePlainText},
+ {"extensionless XML", "asset", []byte(""), contentTypePlainText},
+ {"extensionless SVG", "asset", []byte(""), contentTypePlainText},
+ {"extensionless ZIP", "asset", []byte("PK\x03\x04"), "application/octet-stream"},
+ {"extensionless binary", "asset", []byte{0, 1, 2}, "application/octet-stream"},
+ {"known path wins", "file.txt", []byte("\x89PNG\r\n\x1a\n"), contentTypePlainText},
}
for _, tt := range tests {
- t.Run(tt.filename, func(t *testing.T) {
- got := detectContentType(tt.filename)
+ t.Run(tt.name, func(t *testing.T) {
+ got, knownPath := detectContentTypeFromPath(tt.filename)
+ if !knownPath {
+ got = detectContentTypeFromPrefix(tt.prefix)
+ }
if got != tt.expectedCT {
- t.Errorf("detectContentType(%q) = %q, want %q", tt.filename, got, tt.expectedCT)
+ t.Errorf("content type for %q with prefix %q = %q, want %q", tt.filename, tt.prefix, got, tt.expectedCT)
}
})
}
@@ -255,6 +286,10 @@ func createTestArchive(t *testing.T) []byte {
"package/lib/index.js": "module.exports = {};",
"package/lib/helper.js": "module.exports.help = () => {};",
"package/test/index.test.js": "// tests",
+ "package/notes.data": "short text\n",
+ "package/logo": "\x89PNG\r\n\x1a\nimage data",
+ "package/page": "",
+ "package/misleading.txt": "\x89PNG\r\n\x1a\nimage data",
}
for path, content := range files {
@@ -609,25 +644,19 @@ func TestHandleComparePage(t *testing.T) {
}
}
-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"},
+func TestOpenArchiveDetectsExtensionlessTarGz(t *testing.T) {
+ reader, err := openArchive("artifact", bytes.NewReader(createTestArchive(t)), "npm")
+ if err != nil {
+ t.Fatalf("openArchive failed: %v", err)
}
+ defer func() { _ = reader.Close() }()
- 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)
- }
- })
+ files, err := reader.List()
+ if err != nil {
+ t.Fatalf("List failed: %v", err)
+ }
+ if len(files) == 0 {
+ t.Fatal("expected files in extensionless archive")
}
}
diff --git a/internal/server/server.go b/internal/server/server.go
index 0a46a37..13c5997 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -163,9 +163,11 @@ func (s *Server) Start() error {
cd := &cooldown.Config{
Default: s.cfg.Cooldown.Default,
Ecosystems: s.cfg.Cooldown.Ecosystems,
- Packages: s.cfg.Cooldown.Packages,
+ Packages: s.cfg.Cooldown.NormalizedPackages(),
}
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
+ proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout()
+ proxy.AuthForURL = s.authForURL
proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
@@ -195,8 +197,13 @@ func (s *Server) Start() error {
})
// Mount protocol handlers
- npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL)
- cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL)
+ npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM)
+ cargoHandler := handler.NewCargoHandler(
+ proxy,
+ s.cfg.BaseURL,
+ s.cfg.Upstream.Cargo,
+ s.cfg.Upstream.CargoDownload,
+ )
gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL)
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index f1de62d..2d27147 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -67,8 +67,13 @@ func newTestServer(t *testing.T) *testServer {
r := chi.NewRouter()
// Mount handlers
- npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL)
- cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL)
+ npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM)
+ cargoHandler := handler.NewCargoHandler(
+ proxy,
+ cfg.BaseURL,
+ cfg.Upstream.Cargo,
+ cfg.Upstream.CargoDownload,
+ )
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go
index 1e5a24f..d509e9d 100644
--- a/internal/storage/filesystem.go
+++ b/internal/storage/filesystem.go
@@ -34,11 +34,31 @@ func NewFilesystem(root string) (*Filesystem, error) {
}
func (fs *Filesystem) fullPath(path string) (string, error) {
- full := filepath.Clean(filepath.Join(fs.root, filepath.FromSlash(path)))
- if full != fs.root && !strings.HasPrefix(full, fs.root+string(filepath.Separator)) {
- return "", fmt.Errorf("%w: path escapes storage root", ErrNotFound)
+ localPath, err := cleanStoragePath(path)
+ if err != nil {
+ return "", err
}
- return full, nil
+ return filepath.Join(fs.root, localPath), nil
+}
+
+func (fs *Filesystem) prefixPath(prefix string) (string, error) {
+ if prefix == "" {
+ return fs.root, nil
+ }
+ return fs.fullPath(prefix)
+}
+
+func cleanStoragePath(path string) (string, error) {
+ if path == "." || strings.Contains(path, `\`) || !filepath.IsLocal(path) {
+ return "", fmt.Errorf("%w: invalid storage path", ErrNotFound)
+ }
+
+ localPath, err := filepath.Localize(path)
+ if err != nil || localPath == "." || !filepath.IsLocal(localPath) {
+ return "", fmt.Errorf("%w: invalid storage path", ErrNotFound)
+ }
+
+ return localPath, nil
}
func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
@@ -190,7 +210,7 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) {
// ListPrefix returns object metadata for paths under a prefix.
func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) {
- searchRoot, err := fs.fullPath(prefix)
+ searchRoot, err := fs.prefixPath(prefix)
if err != nil {
return nil, err
}
diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go
index 332dfbf..de0e418 100644
--- a/internal/storage/filesystem_test.go
+++ b/internal/storage/filesystem_test.go
@@ -243,19 +243,70 @@ func TestFilesystemLargeFile(t *testing.T) {
assertLargeFileRoundTrip(t, createTestFilesystem(t))
}
-func TestFilesystemRejectsTraversal(t *testing.T) {
+func TestFilesystemRejectsInvalidPaths(t *testing.T) {
tmp := t.TempDir()
fs, err := NewFilesystem(tmp)
if err != nil {
t.Fatal(err)
}
- for _, p := range []string{"../etc/passwd", "../../etc/passwd", "a/../../etc/passwd"} {
- if _, err := fs.Open(context.Background(), p); err == nil {
- t.Errorf("Open(%q) should reject traversal", p)
- }
- if _, _, err := fs.Store(context.Background(), p, strings.NewReader("x")); err == nil {
- t.Errorf("Store(%q) should reject traversal", p)
+ for _, p := range []string{
+ "",
+ ".",
+ "../etc/passwd",
+ "../../etc/passwd",
+ "a/../../etc/passwd",
+ "/etc/passwd",
+ "test//file.txt",
+ "test/./file.txt",
+ "test/../file.txt",
+ `test\..\file.txt`,
+ } {
+ name := p
+ if name == "" {
+ name = "empty"
}
+
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+
+ if _, err := fs.FullPath(p); !errors.Is(err, ErrNotFound) {
+ t.Errorf("FullPath(%q) = %v, want ErrNotFound", p, err)
+ }
+ if _, err := fs.Open(ctx, p); err == nil {
+ t.Errorf("Open(%q) should reject invalid path", p)
+ }
+ if _, _, err := fs.Store(ctx, p, strings.NewReader("x")); err == nil {
+ t.Errorf("Store(%q) should reject invalid path", p)
+ }
+ if _, err := fs.Exists(ctx, p); err == nil {
+ t.Errorf("Exists(%q) should reject invalid path", p)
+ }
+ if err := fs.Delete(ctx, p); err == nil {
+ t.Errorf("Delete(%q) should reject invalid path", p)
+ }
+ if _, err := fs.Size(ctx, p); err == nil {
+ t.Errorf("Size(%q) should reject invalid path", p)
+ }
+ if _, err := fs.ListPrefix(ctx, p); p != "" && err == nil {
+ t.Errorf("ListPrefix(%q) should reject invalid path", p)
+ }
+ })
+ }
+}
+
+func TestFilesystemListPrefixAllowsEmptyPrefix(t *testing.T) {
+ fs := createTestFilesystem(t)
+ ctx := context.Background()
+
+ _, _, _ = fs.Store(ctx, "a.txt", strings.NewReader("aaaa"))
+ _, _, _ = fs.Store(ctx, "c/d.txt", strings.NewReader("ccccc"))
+
+ objects, err := fs.ListPrefix(ctx, "")
+ if err != nil {
+ t.Fatalf("ListPrefix empty prefix failed: %v", err)
+ }
+ if len(objects) != 2 {
+ t.Fatalf("ListPrefix empty prefix returned %d objects, want 2", len(objects))
}
}