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

Compare commits

..
Author SHA1 Message Date
Andrew Nesbitt
bc08021536 Preserve direct-serve redirects for blob HEAD requests 2026-07-13 17:20:27 -07:00
Andrew Nesbitt
0ee37c7521 container: cache manifests for offline pulls 2026-07-13 16:36:39 -07:00
Andrew Nesbitt
f8f475a65d upstream: apply authentication through shared transport 2026-07-13 16:34:07 -07:00
46 changed files with 1667 additions and 988 deletions

View file

@ -17,12 +17,12 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Go - name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
@ -35,12 +35,12 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Go - name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '1.25' go-version: '1.25'

View file

@ -20,12 +20,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with: with:
persist-credentials: false persist-credentials: false
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@ -22,7 +22,7 @@ jobs:
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Set up Go - name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: false cache: false

View file

@ -12,12 +12,12 @@ jobs:
swagger: swagger:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Go - name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '1.25' go-version: '1.25'

View file

@ -21,9 +21,9 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7

View file

@ -1,4 +1,4 @@
FROM golang:1.26.5-alpine AS builder FROM golang:1.26.4-alpine AS builder
WORKDIR /src WORKDIR /src

View file

@ -96,7 +96,8 @@ upstream:
cargo_download: "https://static.crates.io/crates" cargo_download: "https://static.crates.io/crates"
# Authentication for upstream registries # Authentication for upstream registries
# Keys are URL prefixes matched against request URLs. # Keys are absolute URL scopes. Scheme, host, effective port, and path
# segment boundaries must match; the longest matching scope wins.
# Values can reference environment variables using ${VAR_NAME} syntax. # Values can reference environment variables using ${VAR_NAME} syntax.
# #
# Supported auth types: # Supported auth types:
@ -168,8 +169,7 @@ cooldown:
# npm: "7d" # npm: "7d"
# cargo: "0" # cargo: "0"
# Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes # Per-package overrides (keyed by PURL)
# may use either @scope or the canonical %40scope form.
# packages: # packages:
# "pkg:npm/lodash": "0" # "pkg:npm/lodash": "0"
# "pkg:npm/@babel/core": "14d" # "pkg:npm/@babel/core": "14d"

View file

@ -240,6 +240,8 @@ Fetches artifacts from upstream registries.
- Exponential backoff retry on 429 (rate limit) and 5xx errors - Exponential backoff retry on 429 (rate limit) and 5xx errors
- Returns streaming reader (doesn't load into memory) - Returns streaming reader (doesn't load into memory)
- Configurable user-agent - Configurable user-agent
- Shares an authentication-aware transport with metadata requests so URL-scoped credentials apply consistently
- Discovers and caches scoped OCI Bearer tokens from registry challenges
**Resolver:** **Resolver:**
- Determines download URL for a package/version - Determines download URL for a package/version
@ -351,6 +353,7 @@ Eviction can be implemented as:
- Fresh data - new versions visible immediately - Fresh data - new versions visible immediately
- Metadata is small, upstream fetch is fast - Metadata is small, upstream fetch is fast
- Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table - Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table
- OCI manifests are the exception: they are cached automatically so previously fetched images remain pullable when the registry or token service is unavailable
**Why stream artifacts?** **Why stream artifacts?**
- Memory efficient - don't load large files into RAM - Memory efficient - don't load large files into RAM

View file

@ -123,7 +123,9 @@ upstream:
## Authentication ## Authentication
Configure authentication for private upstream registries. Auth is matched by URL prefix, and credentials can reference environment variables using `${VAR_NAME}` syntax. Configure authentication for private upstream registries. The same authentication-aware client is used for metadata and artifact downloads, and credentials can reference environment variables using `${VAR_NAME}` syntax.
OCI registries that return a Bearer challenge from a `/v2/{repository}/…` endpoint are handled automatically. The proxy discovers the token realm from `WWW-Authenticate`, applies any configured credentials for the token URL, and reuses the scoped token until shortly before it expires.
### Bearer Token ### Bearer Token
@ -172,7 +174,7 @@ upstream:
### URL Matching ### URL Matching
Auth configs are matched by URL prefix. The longest matching prefix wins, so you can configure different credentials for different paths: Auth keys must be absolute URLs. Matching compares the scheme, host, effective port, and path-segment prefix, preventing credentials for `registry.example.com` from being sent to a lookalike host such as `registry.example.com.evil.test`. The longest matching scope wins, so you can configure different credentials for different paths:
```yaml ```yaml
upstream: upstream:
@ -234,8 +236,6 @@ cooldown:
Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. 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. 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. Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata.
@ -246,6 +246,8 @@ Note: Hex cooldown requires disabling registry signature verification since the
By default the proxy fetches metadata fresh from upstream on every request. Enable `cache_metadata` to store metadata responses in the database and storage backend for offline fallback. When upstream is unreachable, the proxy serves the last cached copy. ETag-based revalidation avoids re-downloading unchanged metadata. By default the proxy fetches metadata fresh from upstream on every request. Enable `cache_metadata` to store metadata responses in the database and storage backend for offline fallback. When upstream is unreachable, the proxy serves the last cached copy. ETag-based revalidation avoids re-downloading unchanged metadata.
OCI manifests are always cached because cached image blobs cannot be pulled without their manifests. Digest-addressed manifests are immutable and served directly from cache. Tag-addressed manifests follow `metadata_ttl`, revalidate when stale, and fall back to the last cached response when the registry is unavailable.
```yaml ```yaml
cache_metadata: true cache_metadata: true
``` ```

43
go.mod
View file

@ -5,18 +5,18 @@ go 1.25.6
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/CycloneDX/cyclonedx-go v0.11.0 github.com/CycloneDX/cyclonedx-go v0.11.0
github.com/git-pkgs/archives v0.3.1 github.com/git-pkgs/archives v0.3.0
github.com/git-pkgs/cooldown v0.1.1 github.com/git-pkgs/cooldown v0.1.1
github.com/git-pkgs/enrichment v0.6.4 github.com/git-pkgs/enrichment v0.4.1
github.com/git-pkgs/purl v0.1.15 github.com/git-pkgs/purl v0.1.13
github.com/git-pkgs/registries v0.6.4 github.com/git-pkgs/registries v0.6.2
github.com/git-pkgs/spdx v0.1.4 github.com/git-pkgs/spdx v0.1.4
github.com/git-pkgs/vers v0.3.0 github.com/git-pkgs/vers v0.2.6
github.com/git-pkgs/vulns v0.2.1 github.com/git-pkgs/vulns v0.1.6
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.24.0 github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 github.com/prometheus/client_model v0.6.2
github.com/spdx/tools-golang v0.5.7 github.com/spdx/tools-golang v0.5.7
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
@ -24,7 +24,7 @@ require (
golang.org/x/sync v0.22.0 golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0 modernc.org/sqlite v1.53.0
) )
require ( require (
@ -115,7 +115,7 @@ require (
github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ecosyste-ms/ecosystems-go v0.4.0 // indirect github.com/ecosyste-ms/ecosystems-go v0.2.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect github.com/ettle/strcase v0.2.0 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
@ -217,16 +217,15 @@ require (
github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/oapi-codegen/nullable v1.1.0 // indirect github.com/oapi-codegen/runtime v1.4.1 // indirect
github.com/oapi-codegen/runtime v1.6.0 // indirect
github.com/package-url/packageurl-go v0.1.6 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect
github.com/pandatix/go-cvss v0.6.2 // indirect github.com/pandatix/go-cvss v0.6.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.70.0 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect
@ -266,7 +265,7 @@ require (
github.com/timonwong/loggercheck v0.11.0 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ulikunitz/xz v0.5.16 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect github.com/urfave/cli/v2 v2.3.0 // indirect
@ -290,25 +289,25 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // 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/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.37.0 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.47.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.272.0 // indirect google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.82.1 // indirect google.golang.org/grpc v1.81.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect mvdan.cc/gofumpt v0.9.2 // indirect

112
go.sum
View file

@ -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/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 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
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 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/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= 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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA= github.com/ecosyste-ms/ecosystems-go v0.2.0 h1:Nhpg54C+St8Sd/mf8bNJmQqx35ZgHw33TfFoxaXMQI8=
github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA= github.com/ecosyste-ms/ecosystems-go v0.2.0/go.mod h1:CCdzT1iAZirbEZAbFSnWpK88eKKaIWex7gjtZ0UudXA=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= 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 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
@ -244,30 +244,30 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= 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 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/git-pkgs/archives v0.3.1 h1:GKUuw++0YXAAElxweVHiR4AaSShKKYoVQmyxlF5blG4= github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78=
github.com/git-pkgs/archives v0.3.1/go.mod h1:408oQv3FxLCtePa33zp3sg3njXnwH74vnHZFxkRqoPo= github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU=
github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w=
github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E=
github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU= github.com/git-pkgs/enrichment v0.4.1 h1:A8BKs0XwvpF1sF5qviZy4fkJAe18qB9OgpbRnmwnT34=
github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY= github.com/git-pkgs/enrichment v0.4.1/go.mod h1:stHqZUitV9ZkwACqHzBysLMSe6T4QZn81hxTdSroNhM=
github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M= 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/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 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY=
github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
github.com/git-pkgs/purl v0.1.15 h1:iQ3clh0Cw41rkM0rf24B7ShnN9Z+UtLMAFlNDUs+Qd4= github.com/git-pkgs/purl v0.1.13 h1:at8BU6vnP5oonHFHAPA064BzgRqij+SZcOUDgNT2DC8=
github.com/git-pkgs/purl v0.1.15/go.mod h1:PqCLVBDeZrZgHysR803/AntMELgIr2LFZVNCcwLH2m0= github.com/git-pkgs/purl v0.1.13/go.mod h1:8oCcdcYZA/e1B33e7Ylju6azboTKjdqf3ybcbQj6I/o=
github.com/git-pkgs/registries v0.6.4 h1:Kq/KlStjaQyE83UXT/tKuzCrIzc4keGeBjtroMqgoHA= github.com/git-pkgs/registries v0.6.2 h1:26G5zW6Q7x1CSfNkaEqEjRMJiA4JwfdKOCJ7Qm+u0a8=
github.com/git-pkgs/registries v0.6.4/go.mod h1:YkGHbxHIe2Ha/ROH6zNkS5PJUUoa9g0Ti/s2XhZnrak= github.com/git-pkgs/registries v0.6.2/go.mod h1:GR0Bu6nC3NQe6f7lfDoEVqAnoQkMocf4M98B12a7B3E=
github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs= 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/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto=
github.com/git-pkgs/vers v0.3.0 h1:xM4LLUCRmqzdDfe+/pVQUx4SRyFXRVth6tOsJ14wMKU= github.com/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8=
github.com/git-pkgs/vers v0.3.0/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
github.com/git-pkgs/vulns v0.2.1 h1:tWGhOfPVDZwkM2Y9vRkMpMR+gjtlu2jhERS5JeNBoKQ= github.com/git-pkgs/vulns v0.1.6 h1:8RRSgdlxp4JMU0Zykr63XTOMo5CyZKwt/PwaQxrx9Yg=
github.com/git-pkgs/vulns v0.2.1/go.mod h1:/0gHKHQR5SWttZVEMqgOvCXssKFwAtbac/PfkhBax9o= github.com/git-pkgs/vulns v0.1.6/go.mod h1:TsZC4MjoCkKJslgmbcmRCnytwnFcjESC2N8b0a2xDWc=
github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= 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/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.0/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 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= 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= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@ -422,8 +422,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/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 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -512,8 +512,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/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 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oapi-codegen/runtime v1.4.1/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 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
@ -541,14 +541,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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 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/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= 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 v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
@ -654,8 +654,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/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 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= 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/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
@ -702,8 +702,8 @@ go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDoj
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
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 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/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= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
@ -726,8 +726,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/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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
@ -755,8 +755,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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -807,8 +807,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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -839,8 +839,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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= 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/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@ -857,12 +857,12 @@ google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= 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 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= 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-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= 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-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -882,20 +882,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@ -904,8 +904,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -54,12 +54,10 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -139,38 +137,9 @@ type CooldownConfig struct {
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"` Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
// Packages overrides the cooldown for specific packages (keyed by PURL). // 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"` 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. // StorageConfig configures artifact storage.
type StorageConfig struct { type StorageConfig struct {
// URL is the storage backend URL. // URL is the storage backend URL.
@ -305,23 +274,29 @@ type UpstreamConfig struct {
CargoDownload string `json:"cargo_download" yaml:"cargo_download"` CargoDownload string `json:"cargo_download" yaml:"cargo_download"`
// Auth configures authentication for upstream registries. // Auth configures authentication for upstream registries.
// Keys are URL prefixes that are matched against request URLs. // Keys are absolute URL scopes matched by scheme, host, effective port,
// and path-segment prefix.
// Example: "https://npm.pkg.github.com" matches all requests to that host. // Example: "https://npm.pkg.github.com" matches all requests to that host.
Auth map[string]AuthConfig `json:"auth" yaml:"auth"` Auth map[string]AuthConfig `json:"auth" yaml:"auth"`
} }
// AuthForURL returns the auth config that matches the given URL. // AuthForURL returns the auth config that matches the given URL.
// Matches are based on URL prefix - the longest matching prefix wins. // The longest matching URL scope wins.
func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig { func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
if u.Auth == nil { if u.Auth == nil {
return nil return nil
} }
target, err := parseAuthURL(url)
if err != nil {
return nil
}
var bestMatch *AuthConfig var bestMatch *AuthConfig
var bestLen int var bestLen int
for pattern, auth := range u.Auth { for pattern, auth := range u.Auth {
if strings.HasPrefix(url, pattern) && len(pattern) > bestLen { configured, err := parseAuthURL(pattern)
if err == nil && authURLMatches(configured, target) && len(pattern) > bestLen {
a := auth // copy to avoid loop variable capture a := auth // copy to avoid loop variable capture
bestMatch = &a bestMatch = &a
bestLen = len(pattern) bestLen = len(pattern)
@ -331,6 +306,45 @@ func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
return bestMatch return bestMatch
} }
func parseAuthURL(value string) (*url.URL, error) {
parsed, err := url.Parse(value)
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" || parsed.Opaque != "" {
return nil, fmt.Errorf("invalid authentication URL")
}
return parsed, nil
}
func authURLMatches(configured, target *url.URL) bool {
if !strings.EqualFold(configured.Scheme, target.Scheme) ||
!strings.EqualFold(configured.Hostname(), target.Hostname()) ||
authURLPort(configured) != authURLPort(target) {
return false
}
if configured.RawQuery != "" && configured.RawQuery != target.RawQuery {
return false
}
configuredPath := strings.TrimSuffix(configured.EscapedPath(), "/")
if configuredPath == "" {
return true
}
targetPath := strings.TrimSuffix(target.EscapedPath(), "/")
return targetPath == configuredPath || strings.HasPrefix(targetPath, configuredPath+"/")
}
func authURLPort(value *url.URL) string {
if port := value.Port(); port != "" {
return port
}
if strings.EqualFold(value.Scheme, "https") {
return "443"
}
if strings.EqualFold(value.Scheme, "http") {
return "80"
}
return ""
}
// AuthConfig configures authentication for an upstream registry. // AuthConfig configures authentication for an upstream registry.
type AuthConfig struct { type AuthConfig struct {
// Type is the authentication type: "bearer", "basic", or "header". // Type is the authentication type: "bearer", "basic", or "header".

View file

@ -363,34 +363,6 @@ cooldown:
if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" { 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") 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) { func TestLoadCooldownFromEnv(t *testing.T) {
@ -788,3 +760,45 @@ func TestDatabaseConfigString(t *testing.T) {
} }
} }
} }
func TestUpstreamAuthForURLMatchesURLComponents(t *testing.T) {
registryAuth := AuthConfig{Type: "bearer", Token: "registry-token"}
privateAuth := AuthConfig{Type: "bearer", Token: "private-token"}
config := UpstreamConfig{Auth: map[string]AuthConfig{
"https://registry.example.com": registryAuth,
"https://registry.example.com/private": privateAuth,
}}
tests := []struct {
name string
url string
wantToken string
}{
{name: "registry root", url: "https://registry.example.com/package", wantToken: "registry-token"},
{name: "host is case insensitive", url: "https://REGISTRY.EXAMPLE.COM/package", wantToken: "registry-token"},
{name: "longest path match", url: "https://registry.example.com/private/package", wantToken: "private-token"},
{name: "exact path match", url: "https://registry.example.com/private", wantToken: "private-token"},
{name: "path segment boundary", url: "https://registry.example.com/private-other/package", wantToken: "registry-token"},
{name: "lookalike host rejected", url: "https://registry.example.com.evil.test/package"},
{name: "different scheme rejected", url: "http://registry.example.com/package"},
{name: "different port rejected", url: "https://registry.example.com:8443/package"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
auth := config.AuthForURL(tt.url)
if tt.wantToken == "" {
if auth != nil {
t.Fatalf("AuthForURL() = %+v, want nil", auth)
}
return
}
if auth == nil {
t.Fatal("AuthForURL() = nil, want authentication")
}
if auth.Token != tt.wantToken {
t.Errorf("token = %q, want %q", auth.Token, tt.wantToken)
}
})
}
}

View file

@ -30,8 +30,12 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
StoragePath: "_metadata/npm/lodash/metadata", StoragePath: "_metadata/npm/lodash/metadata",
ETag: sql.NullString{String: `"abc123"`, Valid: true}, ETag: sql.NullString{String: `"abc123"`, Valid: true},
ContentType: sql.NullString{String: "application/json", Valid: true}, ContentType: sql.NullString{String: "application/json", Valid: true},
Size: sql.NullInt64{Int64: 1024, Valid: true}, ContentDigest: sql.NullString{
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true}, String: "sha256:0123456789abcdef",
Valid: true,
},
Size: sql.NullInt64{Int64: 1024, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
} }
err := db.UpsertMetadataCache(entry) err := db.UpsertMetadataCache(entry)
@ -62,6 +66,9 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
if !got.ContentType.Valid || got.ContentType.String != "application/json" { if !got.ContentType.Valid || got.ContentType.String != "application/json" {
t.Errorf("content_type = %v, want %q", got.ContentType, "application/json") t.Errorf("content_type = %v, want %q", got.ContentType, "application/json")
} }
if !got.ContentDigest.Valid || got.ContentDigest.String != "sha256:0123456789abcdef" {
t.Errorf("content_digest = %v, want %q", got.ContentDigest, "sha256:0123456789abcdef")
}
if !got.Size.Valid || got.Size.Int64 != 1024 { if !got.Size.Valid || got.Size.Int64 != 1024 {
t.Errorf("size = %v, want 1024", got.Size) t.Errorf("size = %v, want 1024", got.Size)
} }
@ -178,3 +185,47 @@ func TestMetadataCacheTableCreatedByMigration(t *testing.T) {
t.Error("metadata_cache table should exist after migration") t.Error("metadata_cache table should exist after migration")
} }
} }
func TestMetadataCacheContentDigestMigrationPreservesExistingRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := Create(dbPath)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Exec("ALTER TABLE metadata_cache DROP COLUMN content_digest"); err != nil {
t.Fatalf("dropping content_digest: %v", err)
}
if _, err := db.Exec("DELETE FROM migrations WHERE name = ?", "006_add_metadata_content_digest"); err != nil {
t.Fatalf("resetting digest migration: %v", err)
}
if _, err := db.Exec(`
INSERT INTO metadata_cache (ecosystem, name, storage_path, content_type, size, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, "oci-manifest", "cache-key", "_metadata/oci-manifest/cache-key/metadata", "application/json", 2, time.Now(), time.Now(), time.Now()); err != nil {
t.Fatalf("inserting legacy cache row: %v", err)
}
if err := db.MigrateSchema(); err != nil {
t.Fatalf("MigrateSchema() error = %v", err)
}
hasDigest, err := db.HasColumn("metadata_cache", "content_digest")
if err != nil {
t.Fatalf("HasColumn() error = %v", err)
}
if !hasDigest {
t.Fatal("metadata_cache.content_digest was not added")
}
entry, err := db.GetMetadataCache("oci-manifest", "cache-key")
if err != nil {
t.Fatalf("GetMetadataCache() error = %v", err)
}
if entry == nil || entry.StoragePath != "_metadata/oci-manifest/cache-key/metadata" {
t.Fatalf("existing metadata cache row was not preserved: %#v", entry)
}
if entry.ContentDigest.Valid {
t.Errorf("legacy content digest = %q, want NULL", entry.ContentDigest.String)
}
}

View file

@ -894,7 +894,7 @@ func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, err
var entry MetadataCacheEntry var entry MetadataCacheEntry
query := db.Rebind(` query := db.Rebind(`
SELECT id, ecosystem, name, storage_path, etag, content_type, SELECT id, ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at content_digest, size, last_modified, fetched_at, created_at, updated_at
FROM metadata_cache WHERE ecosystem = ? AND name = ? FROM metadata_cache WHERE ecosystem = ? AND name = ?
`) `)
err := db.Get(&entry, query, ecosystem, name) err := db.Get(&entry, query, ecosystem, name)
@ -914,12 +914,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
if db.dialect == DialectPostgres { if db.dialect == DialectPostgres {
query = ` query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type, INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at) content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT(ecosystem, name) DO UPDATE SET ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = EXCLUDED.storage_path, storage_path = EXCLUDED.storage_path,
etag = EXCLUDED.etag, etag = EXCLUDED.etag,
content_type = EXCLUDED.content_type, content_type = EXCLUDED.content_type,
content_digest = EXCLUDED.content_digest,
size = EXCLUDED.size, size = EXCLUDED.size,
last_modified = EXCLUDED.last_modified, last_modified = EXCLUDED.last_modified,
fetched_at = EXCLUDED.fetched_at, fetched_at = EXCLUDED.fetched_at,
@ -928,12 +929,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
} else { } else {
query = ` query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type, INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at) content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ecosystem, name) DO UPDATE SET ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = excluded.storage_path, storage_path = excluded.storage_path,
etag = excluded.etag, etag = excluded.etag,
content_type = excluded.content_type, content_type = excluded.content_type,
content_digest = excluded.content_digest,
size = excluded.size, size = excluded.size,
last_modified = excluded.last_modified, last_modified = excluded.last_modified,
fetched_at = excluded.fetched_at, fetched_at = excluded.fetched_at,
@ -943,7 +945,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
_, err := db.Exec(query, _, err := db.Exec(query,
entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag, entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag,
entry.ContentType, entry.Size, entry.LastModified, entry.FetchedAt, now, now, entry.ContentType, entry.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
) )
if err != nil { if err != nil {
return fmt.Errorf("upserting metadata cache: %w", err) return fmt.Errorf("upserting metadata cache: %w", err)

View file

@ -102,6 +102,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
storage_path TEXT NOT NULL, storage_path TEXT NOT NULL,
etag TEXT, etag TEXT,
content_type TEXT, content_type TEXT,
content_digest TEXT,
size INTEGER, size INTEGER,
last_modified DATETIME, last_modified DATETIME,
fetched_at DATETIME, fetched_at DATETIME,
@ -202,6 +203,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
storage_path TEXT NOT NULL, storage_path TEXT NOT NULL,
etag TEXT, etag TEXT,
content_type TEXT, content_type TEXT,
content_digest TEXT,
size BIGINT, size BIGINT,
last_modified TIMESTAMP, last_modified TIMESTAMP,
fetched_at TIMESTAMP, fetched_at TIMESTAMP,
@ -359,6 +361,7 @@ var migrations = []migration{
{"003_ensure_artifacts_table", migrateEnsureArtifactsTable}, {"003_ensure_artifacts_table", migrateEnsureArtifactsTable},
{"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable}, {"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
{"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable}, {"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable},
{"006_add_metadata_content_digest", migrateAddMetadataContentDigest},
} }
// isTableNotFound returns true if the error indicates a missing table. // isTableNotFound returns true if the error indicates a missing table.
@ -581,6 +584,20 @@ func migrateEnsureMetadataCacheTable(db *DB) error {
return db.EnsureMetadataCacheTable() return db.EnsureMetadataCacheTable()
} }
func migrateAddMetadataContentDigest(db *DB) error {
hasColumn, err := db.HasColumn("metadata_cache", "content_digest")
if err != nil {
return fmt.Errorf("checking metadata_cache content_digest column: %w", err)
}
if hasColumn {
return nil
}
if _, err := db.Exec("ALTER TABLE metadata_cache ADD COLUMN content_digest TEXT"); err != nil {
return fmt.Errorf("adding metadata_cache content_digest column: %w", err)
}
return nil
}
// EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist. // EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist.
func (db *DB) EnsureMetadataCacheTable() error { func (db *DB) EnsureMetadataCacheTable() error {
has, err := db.HasTable("metadata_cache") has, err := db.HasTable("metadata_cache")
@ -601,6 +618,7 @@ func (db *DB) EnsureMetadataCacheTable() error {
storage_path TEXT NOT NULL, storage_path TEXT NOT NULL,
etag TEXT, etag TEXT,
content_type TEXT, content_type TEXT,
content_digest TEXT,
size BIGINT, size BIGINT,
last_modified TIMESTAMP, last_modified TIMESTAMP,
fetched_at TIMESTAMP, fetched_at TIMESTAMP,
@ -618,6 +636,7 @@ func (db *DB) EnsureMetadataCacheTable() error {
storage_path TEXT NOT NULL, storage_path TEXT NOT NULL,
etag TEXT, etag TEXT,
content_type TEXT, content_type TEXT,
content_digest TEXT,
size INTEGER, size INTEGER,
last_modified DATETIME, last_modified DATETIME,
fetched_at DATETIME, fetched_at DATETIME,

View file

@ -78,17 +78,18 @@ func (a *Artifact) IsCached() bool {
// MetadataCacheEntry represents a cached metadata blob for offline serving. // MetadataCacheEntry represents a cached metadata blob for offline serving.
type MetadataCacheEntry struct { type MetadataCacheEntry struct {
ID int64 `db:"id" json:"id"` ID int64 `db:"id" json:"id"`
Ecosystem string `db:"ecosystem" json:"ecosystem"` Ecosystem string `db:"ecosystem" json:"ecosystem"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
StoragePath string `db:"storage_path" json:"storage_path"` StoragePath string `db:"storage_path" json:"storage_path"`
ETag sql.NullString `db:"etag" json:"etag,omitempty"` ETag sql.NullString `db:"etag" json:"etag,omitempty"`
ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"` ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"`
Size sql.NullInt64 `db:"size" json:"size,omitempty"` ContentDigest sql.NullString `db:"content_digest" json:"content_digest,omitempty"`
LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"` Size sql.NullInt64 `db:"size" json:"size,omitempty"`
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"` LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"` FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// Vulnerability represents a cached vulnerability record. // Vulnerability represents a cached vulnerability record.

View file

@ -6,9 +6,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -29,18 +30,11 @@ type CargoHandler struct {
} }
// NewCargoHandler creates a new cargo protocol handler. // NewCargoHandler creates a new cargo protocol handler.
func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler { func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler {
if strings.TrimSpace(indexURL) == "" {
indexURL = cargoUpstream
}
if strings.TrimSpace(downloadURL) == "" {
downloadURL = cargoDownloadBase
}
return &CargoHandler{ return &CargoHandler{
proxy: proxy, proxy: proxy,
indexURL: strings.TrimSuffix(indexURL, "/"), indexURL: cargoUpstream,
downloadURL: strings.TrimSuffix(downloadURL, "/"), downloadURL: cargoDownloadBase,
proxyURL: strings.TrimSuffix(proxyURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"),
} }
} }
@ -149,7 +143,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr
continue continue
} }
cratePURL := canonicalPackagePURL("cargo", crate.Name) cratePURL := purl.MakePURLString("cargo", crate.Name, "")
if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering cargo version", h.proxy.Logger.Info("cooldown: filtering cargo version",
@ -197,17 +191,10 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("cargo download request", h.proxy.Logger.Info("cargo download request",
"crate", name, "version", version, "filename", filename) "crate", name, "version", version, "filename", filename)
downloadURL := fmt.Sprintf( result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename)
"%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 { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch crate") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch crate", http.StatusBadGateway)
return return
} }

View file

@ -2,7 +2,6 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -11,7 +10,6 @@ import (
"time" "time"
"github.com/git-pkgs/cooldown" "github.com/git-pkgs/cooldown"
"github.com/git-pkgs/registries/fetch"
) )
func cargoTestProxy() *Proxy { func cargoTestProxy() *Proxy {
@ -72,75 +70,6 @@ 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) { func TestCargoIndexProxy(t *testing.T) {
// Create a mock upstream index server // Create a mock upstream index server
indexContent := `{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123"} indexContent := `{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123"}

View file

@ -10,6 +10,8 @@ import (
"path" "path"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -214,7 +216,7 @@ func deepCopyValue(v any) any {
// filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs
// for a single package's version list. // for a single package's version list.
func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any {
packagePURL := canonicalPackagePURL("composer", packageName) packagePURL := purl.MakePURLString("composer", packageName, "")
filtered := versionList[:0] filtered := versionList[:0]
for _, v := range versionList { for _, v := range versionList {
@ -346,7 +348,8 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -84,7 +84,8 @@ func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch file") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch file", http.StatusBadGateway)
return return
} }
@ -121,7 +122,8 @@ func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch file") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch file", http.StatusBadGateway)
return return
} }

View file

@ -6,6 +6,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -72,7 +74,8 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }
@ -215,7 +218,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
continue continue
} }
packagePURL := canonicalPackagePURL("conda", name) packagePURL := purl.MakePURLString("conda", name, "")
if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) { if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
version, _ := entryMap["version"].(string) version, _ := entryMap["version"].(string)

View file

@ -2,7 +2,6 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -12,7 +11,6 @@ import (
const ( const (
dockerHubRegistry = "https://registry-1.docker.io" dockerHubRegistry = "https://registry-1.docker.io"
dockerHubAuth = "https://auth.docker.io"
blobMatchCount = 3 // full match + name + digest blobMatchCount = 3 // full match + name + digest
manifestMatchCount = 3 // full match + name + reference manifestMatchCount = 3 // full match + name + reference
tagsListMatchCount = 2 // full match + name tagsListMatchCount = 2 // full match + name
@ -24,7 +22,6 @@ const (
type ContainerHandler struct { type ContainerHandler struct {
proxy *Proxy proxy *Proxy
registryURL string registryURL string
authURL string
proxyURL string proxyURL string
} }
@ -33,7 +30,6 @@ func NewContainerHandler(proxy *Proxy, proxyURL string) *ContainerHandler {
return &ContainerHandler{ return &ContainerHandler{
proxy: proxy, proxy: proxy,
registryURL: dockerHubRegistry, registryURL: dockerHubRegistry,
authURL: dockerHubAuth,
proxyURL: strings.TrimSuffix(proxyURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"),
} }
} }
@ -90,40 +86,43 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
h.proxy.Logger.Info("container blob request", "name", name, "digest", digest) h.proxy.Logger.Info("container blob request", "name", name, "digest", digest)
// Get auth token for upstream filename := digest
token, err := h.getAuthToken(r.Context(), name, "pull") cached, err := h.proxy.GetCachedArtifact(r.Context(), "oci", name, digest, filename)
if err != nil { if err != nil {
h.proxy.Logger.Error("failed to get auth token", "error", err) h.proxy.Logger.Error("failed to check blob cache", "error", err)
h.containerError(w, http.StatusUnauthorized, "UNAUTHORIZED", "failed to authenticate") h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check blob cache")
return
}
if cached != nil {
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", "application/octet-stream")
if r.Method == http.MethodHead {
serveArtifactHead(w, cached)
return
}
ServeArtifact(w, cached)
return return
} }
// For HEAD requests, just proxy to upstream // For HEAD requests, just proxy to upstream
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
h.proxyBlobHead(w, r, name, digest, token) h.proxyBlobHead(w, r, name, digest)
return return
} }
// Try to get from cache, or fetch from upstream with auth // Try to get from cache, or fetch from the authentication-aware upstream client.
filename := digest result, err := h.proxy.GetOrFetchArtifactFromURL(
headers := http.Header{"Authorization": {"Bearer " + token}}
result, err := h.proxy.GetOrFetchArtifactFromURLWithHeaders(
r.Context(), r.Context(),
"oci", "oci",
name, name,
digest, // use digest as version digest, // use digest as version
filename, filename,
fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest), fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest),
headers,
) )
if err != nil { 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.proxy.Logger.Error("failed to fetch blob", "error", err)
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob") h.containerError(w, http.StatusBadGateway, "BLOB_UNKNOWN", "failed to fetch blob")
return return
} }
@ -132,8 +131,31 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
ServeArtifact(w, result) ServeArtifact(w, result)
} }
// handleManifest proxies manifest requests to upstream. func serveArtifactHead(w http.ResponseWriter, result *CacheResult) {
// Manifests change when tags are updated, so we proxy these directly. if result.RedirectURL != "" {
if result.Hash != "" {
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash))
}
w.Header().Set("Location", result.RedirectURL)
w.WriteHeader(http.StatusFound)
return
}
if result.Reader != nil {
_ = result.Reader.Close()
}
if result.ContentType != "" {
w.Header().Set("Content-Type", result.ContentType)
}
if result.Size >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size))
}
if result.Hash != "" {
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash))
}
w.WriteHeader(http.StatusOK)
}
// handleManifest serves immutable manifests from cache and revalidates mutable tags.
// Path format: {name}/manifests/{reference} // Path format: {name}/manifests/{reference}
func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request, path string) { func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
@ -148,57 +170,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request
} }
h.proxy.Logger.Info("container manifest request", "name", name, "reference", reference) h.proxy.Logger.Info("container manifest request", "name", name, "reference", reference)
h.serveManifest(w, r, name, reference)
// Get auth token
token, err := h.getAuthToken(r.Context(), name, "pull")
if err != nil {
h.proxy.Logger.Error("failed to get auth token", "error", err)
h.containerError(w, http.StatusUnauthorized, "UNAUTHORIZED", "failed to authenticate")
return
}
// Proxy to upstream
upstreamURL := fmt.Sprintf("%s/v2/%s/manifests/%s", h.registryURL, name, reference)
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request")
return
}
req.Header.Set("Authorization", "Bearer "+token)
// Forward Accept header for content negotiation
if accept := r.Header.Get("Accept"); accept != "" {
req.Header.Set("Accept", accept)
} else {
// Default accept headers for manifests
req.Header.Set("Accept", strings.Join([]string{
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v1+prettyjws",
}, ", "))
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.proxy.Logger.Error("failed to fetch manifest", "error", err)
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
return
}
defer func() { _ = resp.Body.Close() }()
// Copy relevant headers
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag"} {
if v := resp.Header.Get(header); v != "" {
w.Header().Set(header, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
} }
// handleTagsList proxies tag list requests to upstream. // handleTagsList proxies tag list requests to upstream.
@ -214,13 +186,6 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request
return return
} }
// Get auth token
token, err := h.getAuthToken(r.Context(), name, "pull")
if err != nil {
h.containerError(w, http.StatusUnauthorized, "UNAUTHORIZED", "failed to authenticate")
return
}
upstreamURL := fmt.Sprintf("%s/v2/%s/tags/list", h.registryURL, name) upstreamURL := fmt.Sprintf("%s/v2/%s/tags/list", h.registryURL, name)
if r.URL.RawQuery != "" { if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery upstreamURL += "?" + r.URL.RawQuery
@ -232,8 +197,6 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request
return return
} }
req.Header.Set("Authorization", "Bearer "+token)
resp, err := h.proxy.HTTPClient.Do(req) resp, err := h.proxy.HTTPClient.Do(req)
if err != nil { if err != nil {
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
@ -246,45 +209,8 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
// getAuthToken gets a bearer token for the specified repository.
// Docker Hub requires auth even for public images.
func (h *ContainerHandler) getAuthToken(_ interface{ Done() <-chan struct{} }, repository, action string) (string, error) {
// For Docker Hub: https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull
authURL := fmt.Sprintf("%s/token?service=registry.docker.io&scope=repository:%s:%s",
h.authURL, repository, action)
req, err := http.NewRequest(http.MethodGet, authURL, nil)
if err != nil {
return "", err
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("auth failed with status %d", resp.StatusCode)
}
var tokenResp struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", err
}
if tokenResp.Token != "" {
return tokenResp.Token, nil
}
return tokenResp.AccessToken, nil
}
// proxyBlobHead handles HEAD requests for blobs. // proxyBlobHead handles HEAD requests for blobs.
func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, name, digest, token string) { func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, name, digest string) {
upstreamURL := fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest) upstreamURL := fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest)
req, err := http.NewRequestWithContext(r.Context(), http.MethodHead, upstreamURL, nil) req, err := http.NewRequestWithContext(r.Context(), http.MethodHead, upstreamURL, nil)
@ -293,8 +219,6 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request,
return return
} }
req.Header.Set("Authorization", "Bearer "+token)
resp, err := h.proxy.HTTPClient.Do(req) resp, err := h.proxy.HTTPClient.Do(req)
if err != nil { if err != nil {
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")

View file

@ -0,0 +1,251 @@
package handler
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/git-pkgs/proxy/internal/database"
)
const (
containerManifestCacheEcosystem = "oci-manifest"
containerStaleWarning = `110 - "Response is Stale"`
)
var manifestDigestReferencePattern = regexp.MustCompile(`^[a-z0-9]+:[a-f0-9]+$`)
type cachedContainerManifest struct {
body []byte
contentType string
contentDigest string
etag string
size int64
fetchedAt time.Time
}
func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, name, reference string) {
accept := containerManifestAccept(r)
cacheKey := h.containerManifestCacheKey(name, reference, accept)
cached, err := h.loadContainerManifest(r.Context(), cacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read cached container manifest", "error", err)
cached = nil
}
immutable := manifestDigestReferencePattern.MatchString(reference)
if cached != nil && (immutable || h.containerManifestFresh(cached)) {
writeContainerManifest(w, r.Method, cached, false)
return
}
upstreamURL := fmt.Sprintf("%s/v2/%s/manifests/%s", h.registryURL, name, reference)
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request")
return
}
req.Header.Set("Accept", accept)
if cached != nil && cached.etag != "" {
req.Header.Set("If-None-Match", cached.etag)
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.serveStaleManifestOrError(w, r, cached, err)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotModified && cached != nil {
cached.fetchedAt = time.Now()
if err := h.storeContainerManifest(r.Context(), cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err)
}
writeContainerManifest(w, r.Method, cached, false)
return
}
if resp.StatusCode != http.StatusOK {
if cached != nil && shouldServeStaleManifest(resp.StatusCode) {
writeContainerManifest(w, r.Method, cached, true)
return
}
copyContainerManifestHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
if r.Method == http.MethodHead {
copyContainerManifestHeaders(w.Header(), resp.Header)
w.WriteHeader(http.StatusOK)
return
}
body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil {
h.serveStaleManifestOrError(w, r, cached, fmt.Errorf("reading manifest: %w", err))
return
}
manifest := &cachedContainerManifest{
body: body,
contentType: resp.Header.Get("Content-Type"),
contentDigest: resp.Header.Get("Docker-Content-Digest"),
etag: resp.Header.Get("ETag"),
size: int64(len(body)),
fetchedAt: time.Now(),
}
if manifest.contentDigest == "" {
manifest.contentDigest = sha256Digest(body)
}
if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest", "error", err)
}
if manifest.contentDigest != reference && manifestDigestReferencePattern.MatchString(manifest.contentDigest) {
digestKey := h.containerManifestCacheKey(name, manifest.contentDigest, accept)
if err := h.storeContainerManifest(r.Context(), digestKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest by digest", "error", err)
}
}
writeContainerManifest(w, r.Method, manifest, false)
}
func (h *ContainerHandler) serveStaleManifestOrError(w http.ResponseWriter, r *http.Request, cached *cachedContainerManifest, err error) {
if cached != nil {
h.proxy.Logger.Warn("upstream manifest fetch failed, serving stale cache", "error", err)
writeContainerManifest(w, r.Method, cached, true)
return
}
h.proxy.Logger.Error("failed to fetch manifest", "error", err)
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
}
func (h *ContainerHandler) containerManifestFresh(manifest *cachedContainerManifest) bool {
return h.proxy.MetadataTTL > 0 && !manifest.fetchedAt.IsZero() && time.Since(manifest.fetchedAt) < h.proxy.MetadataTTL
}
func (h *ContainerHandler) containerManifestCacheKey(name, reference, accept string) string {
identity := strings.Join([]string{h.registryURL, name, reference, accept}, "\x00")
sum := sha256.Sum256([]byte(identity))
return hex.EncodeToString(sum[:])
}
func (h *ContainerHandler) loadContainerManifest(ctx context.Context, cacheKey string) (*cachedContainerManifest, error) {
if h.proxy.DB == nil || h.proxy.Storage == nil {
return nil, nil
}
entry, err := h.proxy.DB.GetMetadataCache(containerManifestCacheEcosystem, cacheKey)
if err != nil || entry == nil {
return nil, err
}
reader, err := h.proxy.Storage.Open(ctx, entry.StoragePath)
if err != nil {
return nil, nil
}
defer func() { _ = reader.Close() }()
body, err := h.proxy.ReadMetadata(reader)
if err != nil {
return nil, err
}
manifest := &cachedContainerManifest{body: body, size: int64(len(body))}
if entry.ContentType.Valid {
manifest.contentType = entry.ContentType.String
}
if entry.ContentDigest.Valid {
manifest.contentDigest = entry.ContentDigest.String
} else {
manifest.contentDigest = sha256Digest(body)
}
if entry.ETag.Valid {
manifest.etag = entry.ETag.String
}
if entry.Size.Valid {
manifest.size = entry.Size.Int64
}
if entry.FetchedAt.Valid {
manifest.fetchedAt = entry.FetchedAt.Time
}
return manifest, nil
}
func (h *ContainerHandler) storeContainerManifest(ctx context.Context, cacheKey string, manifest *cachedContainerManifest) error {
if h.proxy.DB == nil || h.proxy.Storage == nil {
return nil
}
storagePath := metadataStoragePath(containerManifestCacheEcosystem, cacheKey)
size, _, err := h.proxy.Storage.Store(ctx, storagePath, bytes.NewReader(manifest.body))
if err != nil {
return fmt.Errorf("storing manifest: %w", err)
}
manifest.size = size
return h.proxy.DB.UpsertMetadataCache(&database.MetadataCacheEntry{
Ecosystem: containerManifestCacheEcosystem,
Name: cacheKey,
StoragePath: storagePath,
ETag: sql.NullString{String: manifest.etag, Valid: manifest.etag != ""},
ContentType: sql.NullString{String: manifest.contentType, Valid: manifest.contentType != ""},
ContentDigest: sql.NullString{String: manifest.contentDigest, Valid: manifest.contentDigest != ""},
Size: sql.NullInt64{Int64: size, Valid: true},
FetchedAt: sql.NullTime{Time: manifest.fetchedAt, Valid: !manifest.fetchedAt.IsZero()},
})
}
func writeContainerManifest(w http.ResponseWriter, method string, manifest *cachedContainerManifest, stale bool) {
if manifest.contentType != "" {
w.Header().Set("Content-Type", manifest.contentType)
}
w.Header().Set("Content-Length", strconv.FormatInt(manifest.size, 10))
if manifest.contentDigest != "" {
w.Header().Set("Docker-Content-Digest", manifest.contentDigest)
}
if manifest.etag != "" {
w.Header().Set("ETag", manifest.etag)
}
if stale {
w.Header().Set("Warning", containerStaleWarning)
}
w.WriteHeader(http.StatusOK)
if method != http.MethodHead {
_, _ = w.Write(manifest.body)
}
}
func containerManifestAccept(r *http.Request) string {
if accept := r.Header.Get("Accept"); accept != "" {
return accept
}
return strings.Join([]string{
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v1+prettyjws",
}, ", ")
}
func copyContainerManifestHeaders(destination, source http.Header) {
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "WWW-Authenticate"} {
if value := source.Get(header); value != "" {
destination.Set(header, value)
}
}
}
func shouldServeStaleManifest(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
func sha256Digest(body []byte) string {
digest := sha256.Sum256(body)
return "sha256:" + hex.EncodeToString(digest[:])
}

View file

@ -1,16 +1,14 @@
package handler package handler
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"testing" "testing"
"github.com/git-pkgs/proxy/internal/database" upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/registries/fetch" "github.com/git-pkgs/registries/fetch"
) )
@ -135,90 +133,387 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) {
} }
} }
func TestContainerHandler_BlobDownload_CachesWithAuth(t *testing.T) { func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
// Set up a mock auth server that returns a token digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { registryRequests := 0
w.Header().Set("Content-Type", "application/json") tokenRequests := 0
_ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token-123"}) var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
tokenRequests++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "discovered-token",
"expires_in": 3600,
})
case "/v2/library/nginx/blobs/" + digest:
registryRequests++
if r.Header.Get("Authorization") != "Bearer discovered-token" {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+upstream.URL+`/token",service="registry.test",scope="repository:library/nginx:pull"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, "upstream blob")
default:
http.NotFound(w, r)
}
})) }))
defer authServer.Close() defer upstream.Close()
// Set up mock fetcher that captures headers proxy, _, _, _ := setupTestProxy(t)
var capturedHeaders http.Header authTransport := upstreamhttp.NewTransport(http.DefaultTransport, nil)
mf := &mockFetcherWithHeaders{ client := &http.Client{Transport: authTransport}
fetchFn: func(_ context.Context, _ string, headers http.Header) (*fetch.Artifact, error) { artifactFetcher := fetch.NewFetcher(
capturedHeaders = headers fetch.WithHTTPClient(client),
return &fetch.Artifact{ fetch.WithMaxRetries(0),
Body: io.NopCloser(bytes.NewReader([]byte("blob-content"))), )
Size: 12, t.Cleanup(func() { _ = artifactFetcher.Close() })
ContentType: "application/octet-stream", proxy.Fetcher = artifactFetcher
}, nil proxy.HTTPClient = client
},
}
dir := t.TempDir()
db, err := database.Create(dir + "/test.db")
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
store := newMockStorage()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
proxy := &Proxy{
DB: db,
Storage: store,
Fetcher: mf,
Logger: logger,
HTTPClient: &http.Client{},
}
h := &ContainerHandler{ h := &ContainerHandler{
proxy: proxy, proxy: proxy,
registryURL: "https://registry-1.docker.io", registryURL: upstream.URL,
authURL: authServer.URL,
proxyURL: "http://localhost:8080", proxyURL: "http://localhost:8080",
} }
handler := h.Routes() for range 2 {
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil) req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, 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 got := w.Body.String(); got != "upstream blob" {
t.Errorf("body = %q, want %q", got, "upstream blob")
}
}
if tokenRequests != 1 {
t.Errorf("token requests = %d, want 1", tokenRequests)
}
if registryRequests != 2 {
t.Errorf("registry requests = %d, want 2", registryRequests)
}
}
func TestContainerHandler_BlobDownload_CacheHitSkipsAuth(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer upstream.Close()
h := &ContainerHandler{
proxy: proxy,
registryURL: upstream.URL,
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
} }
if got := w.Body.String(); got != "cached blob" {
// Verify auth header was passed to the fetcher t.Errorf("body = %q, want %q", got, "cached blob")
if capturedHeaders == nil {
t.Fatal("expected headers to be passed to fetcher, got nil")
} }
auth := capturedHeaders.Get("Authorization") if upstreamRequests != 0 {
if auth != "Bearer test-token-123" { t.Errorf("upstream requests = %d, want 0", upstreamRequests)
t.Errorf("Authorization = %q, want %q", auth, "Bearer test-token-123")
} }
if fetcher.fetchCalled {
// Verify response headers t.Error("fetcher should not be called on cache hit")
if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" {
t.Errorf("Docker-Content-Digest = %q, want digest", got)
} }
} }
// mockFetcherWithHeaders captures headers passed to FetchWithHeaders. func TestContainerHandler_BlobHead_CacheHitSkipsUpstreamAndAuth(t *testing.T) {
type mockFetcherWithHeaders struct { proxy, db, store, fetcher := setupTestProxy(t)
fetchFn func(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer upstream.Close()
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{
proxy: proxy,
registryURL: upstream.URL,
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodHead, "/library/nginx/blobs/"+digest, 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 got := w.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, digest)
}
if got := w.Header().Get("Content-Length"); got != "11" {
t.Errorf("Content-Length = %q, want %q", got, "11")
}
if w.Body.Len() != 0 {
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
}
if upstreamRequests != 0 {
t.Errorf("upstream requests = %d, want 0", upstreamRequests)
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
} }
func (f *mockFetcherWithHeaders) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) { func TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) {
return f.FetchWithHeaders(ctx, url, nil) proxy, db, store, fetcher := setupTestProxy(t)
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
store.signedURL = "https://storage.example.test/cached-blob?signature=test"
proxy.DirectServe = true
h := &ContainerHandler{
proxy: proxy,
registryURL: "https://registry.example.test",
proxyURL: "http://localhost:8080",
}
req := httptest.NewRequest(http.MethodHead, "/library/nginx/blobs/"+digest, nil)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", w.Code, http.StatusFound)
}
if got := w.Header().Get("Location"); got != store.signedURL {
t.Errorf("Location = %q, want %q", got, store.signedURL)
}
if got := w.Header().Get("ETag"); got != `"abc123"` {
t.Errorf("ETag = %q, want %q", got, `"abc123"`)
}
if w.Body.Len() != 0 {
t.Errorf("HEAD response body length = %d, want 0", w.Body.Len())
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
} }
func (f *mockFetcherWithHeaders) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) { func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
return f.fetchFn(ctx, url, headers) digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Path != "/v2/library/nginx/manifests/"+digest {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("ETag", `"manifest-etag"`)
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, manifest)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
if first.Body.String() != manifest {
t.Fatalf("initial body = %q, want %q", first.Body.String(), manifest)
}
upstreamAvailable = false
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if second.Code != http.StatusOK {
t.Fatalf("cached status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String())
}
if second.Body.String() != manifest {
t.Errorf("cached body = %q, want %q", second.Body.String(), manifest)
}
if got := second.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("cached Docker-Content-Digest = %q, want %q", got, digest)
}
head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil))
if head.Code != http.StatusOK {
t.Fatalf("cached HEAD status = %d, want %d", head.Code, http.StatusOK)
}
wantLength := strconv.Itoa(len(manifest))
if got := head.Header().Get("Content-Length"); got != wantLength {
t.Errorf("cached HEAD Content-Length = %q, want %q", got, wantLength)
}
if head.Body.Len() != 0 {
t.Errorf("cached HEAD body length = %d, want 0", head.Body.Len())
}
if upstreamRequests != 1 {
t.Errorf("upstream requests = %d, want 1", upstreamRequests)
}
} }
func (f *mockFetcherWithHeaders) Head(_ context.Context, _ string) (int64, string, error) { func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) {
return 0, "", nil digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
upstreamAvailable = false
second := httptest.NewRecorder()
h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if second.Code != http.StatusOK {
t.Fatalf("stale status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String())
}
if second.Body.String() != manifest {
t.Errorf("stale body = %q, want %q", second.Body.String(), manifest)
}
if got := second.Header().Get("Warning"); got != `110 - "Response is Stale"` {
t.Errorf("Warning = %q, want stale warning", got)
}
if got := second.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("stale Docker-Content-Digest = %q, want %q", got, digest)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
}
func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) {
digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
upstreamAvailable := true
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
if !upstreamAvailable {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Path != "/v2/library/nginx/manifests/latest" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
_, _ = io.WriteString(w, manifest)
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("tag status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String())
}
upstreamAvailable = false
byDigest := httptest.NewRecorder()
h.Routes().ServeHTTP(byDigest, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil))
if byDigest.Code != http.StatusOK {
t.Fatalf("digest status = %d, want %d; body: %s", byDigest.Code, http.StatusOK, byDigest.Body.String())
}
if byDigest.Body.String() != manifest {
t.Errorf("digest body = %q, want %q", byDigest.Body.String(), manifest)
}
if got := byDigest.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, digest)
}
if upstreamRequests != 1 {
t.Errorf("upstream requests = %d, want 1", upstreamRequests)
}
}
func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
oldDigest := "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
currentDigest := oldDigest
upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequests++
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", currentDigest)
w.Header().Set("ETag", `"`+currentDigest+`"`)
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, `{"schemaVersion":2}`)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.MetadataTTL = 0
h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"}
first := httptest.NewRecorder()
h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil))
if first.Code != http.StatusOK {
t.Fatalf("initial status = %d, want %d", first.Code, http.StatusOK)
}
currentDigest = newDigest
head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/latest", nil))
if head.Code != http.StatusOK {
t.Fatalf("HEAD status = %d, want %d", head.Code, http.StatusOK)
}
if got := head.Header().Get("Docker-Content-Digest"); got != newDigest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, newDigest)
}
if upstreamRequests != 2 {
t.Errorf("upstream requests = %d, want 2", upstreamRequests)
}
} }
func TestContainerHandler_Routes_VersionCheck(t *testing.T) { func TestContainerHandler_Routes_VersionCheck(t *testing.T) {

View file

@ -72,7 +72,8 @@ func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }
@ -106,7 +107,8 @@ func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -81,7 +81,8 @@ func (h *DebianHandler) handlePackageDownload(w http.ResponseWriter, r *http.Req
result, err := h.proxy.GetOrFetchArtifactFromURL( result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "deb", name, version, filename, downloadURL) r.Context(), "deb", name, version, filename, downloadURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get debian package", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -1,39 +0,0 @@
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)
}

View file

@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -58,13 +60,30 @@ func (h *GemHandler) Routes() http.Handler {
// handleDownload serves a gem file, fetching and caching from upstream if needed. // handleDownload serves a gem file, fetching and caching from upstream if needed.
func (h *GemHandler) handleDownload(w http.ResponseWriter, r *http.Request) { func (h *GemHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.handleFilenameDownload(w, r, filenameDownload{ filename := r.PathValue("filename")
ecosystem: "gem", if filename == "" || !strings.HasSuffix(filename, ".gem") {
suffix: ".gem", http.Error(w, "invalid filename", http.StatusBadRequest)
parseErr: "could not parse gem filename", return
fetchErr: "failed to fetch gem", }
parse: h.parseGemFilename,
}) // 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)
} }
// parseGemFilename extracts name and version from a gem filename. // parseGemFilename extracts name and version from a gem filename.
@ -247,7 +266,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err return nil, err
} }
packagePURL := canonicalPackagePURL("gem", name) packagePURL := purl.MakePURLString("gem", name, "")
filtered := make(map[string]bool) filtered := make(map[string]bool)
for _, v := range versions { for _, v := range versions {

View file

@ -48,14 +48,6 @@ func hasDotDotSegment(path string) bool {
const defaultHTTPTimeout = 30 * time.Second 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 contentTypeJSON = "application/json"
const headerAcceptEncoding = "Accept-Encoding" const headerAcceptEncoding = "Accept-Encoding"
@ -104,7 +96,6 @@ type Proxy struct {
// storage at an internal one. // storage at an internal one.
DirectServeBaseURL string DirectServeBaseURL string
HTTPClient *http.Client HTTPClient *http.Client
AuthForURL func(string) (headerName, headerValue string)
} }
// NewProxy creates a new Proxy with the given dependencies. // NewProxy creates a new Proxy with the given dependencies.
@ -136,18 +127,25 @@ type CacheResult struct {
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream. // GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) { func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "") if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
versionPURL := purl.MakePURLString(ecosystem, name, version)
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err return nil, err
} else if cached != nil { } else if cached != nil {
return cached, nil return cached, nil
} }
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
return p.fetchAndCache(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL) return p.fetchAndCache(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL)
} }
// GetCachedArtifact retrieves an artifact from cache without contacting an upstream.
// It returns nil when no usable cache entry exists.
func (p *Proxy) GetCachedArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
return p.checkCache(ctx, pkgPURL, versionPURL, filename)
}
// checkCache looks up an artifact in the cache. Returns nil if not cached. // checkCache looks up an artifact in the cache. Returns nil if not cached.
func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) { func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
pkg, err := p.DB.GetPackageByPURL(pkgPURL) pkg, err := p.DB.GetPackageByPURL(pkgPURL)
@ -252,9 +250,6 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
// Resolve download URL // Resolve download URL
info, err := p.Resolver.Resolve(ctx, ecosystem, name, version) info, err := p.Resolver.Resolve(ctx, ecosystem, name, version)
if err != nil { if err != nil {
if errors.Is(err, fetch.ErrNotFound) {
return nil, ErrUpstreamNotFound
}
return nil, fmt.Errorf("resolving download URL: %w", err) return nil, fmt.Errorf("resolving download URL: %w", err)
} }
@ -274,9 +269,6 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
if err != nil { if err != nil {
metrics.RecordUpstreamFetch(ecosystem, fetchDuration) metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
metrics.RecordUpstreamError(ecosystem, "fetch_failed") metrics.RecordUpstreamError(ecosystem, "fetch_failed")
if errors.Is(err, fetch.ErrNotFound) {
return nil, ErrUpstreamNotFound
}
return nil, fmt.Errorf("fetching from upstream: %w", err) return nil, fmt.Errorf("fetching from upstream: %w", err)
} }
metrics.RecordUpstreamFetch(ecosystem, fetchDuration) metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
@ -406,7 +398,6 @@ func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamUR
req.Header.Set(header, v) req.Header.Set(header, v)
} }
} }
p.applyUpstreamAuth(req)
resp, err := p.HTTPClient.Do(req) resp, err := p.HTTPClient.Do(req)
if err != nil { if err != nil {
@ -433,7 +424,6 @@ func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL st
http.Error(w, "failed to create request", http.StatusInternalServerError) http.Error(w, "failed to create request", http.StatusInternalServerError)
return return
} }
p.applyUpstreamAuth(req)
resp, err := p.HTTPClient.Do(req) resp, err := p.HTTPClient.Do(req)
if err != nil { if err != nil {
@ -460,18 +450,7 @@ func JSONError(w http.ResponseWriter, status int, message string) {
} }
// ErrUpstreamNotFound indicates the upstream returned 404. // ErrUpstreamNotFound indicates the upstream returned 404.
var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound) var ErrUpstreamNotFound = fmt.Errorf("upstream: not found")
// 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. // 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") var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing")
@ -574,7 +553,6 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
return nil, "", "", zeroTime, fmt.Errorf("creating request: %w", err) return nil, "", "", zeroTime, fmt.Errorf("creating request: %w", err)
} }
req.Header.Set("Accept", accept) req.Header.Set("Accept", accept)
p.applyUpstreamAuth(req)
if entry != nil && entry.ETag.Valid { if entry != nil && entry.ETag.Valid {
req.Header.Set("If-None-Match", entry.ETag.String) req.Header.Set("If-None-Match", entry.ETag.String)
@ -764,7 +742,6 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
accept = acceptHeaders[0] accept = acceptHeaders[0]
} }
req.Header.Set("Accept", accept) req.Header.Set("Accept", accept)
p.applyUpstreamAuth(req)
for _, header := range []string{headerAcceptEncoding, "If-Modified-Since", "If-None-Match"} { for _, header := range []string{headerAcceptEncoding, "If-Modified-Since", "If-None-Match"} {
if v := r.Header.Get(header); v != "" { if v := r.Header.Get(header); v != "" {
@ -789,17 +766,6 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
_, _ = io.Copy(w, resp.Body) _, _ = 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. // GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
// This is useful for registries where download URLs are determined from metadata. // 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) { func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
@ -807,18 +773,16 @@ func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name,
} }
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL // GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
// with additional HTTP headers. This is needed for registries that require authentication // with additional request-specific HTTP headers.
// (e.g. Docker Hub requires a Bearer token even for public images).
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) { func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "") if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
versionPURL := purl.MakePURLString(ecosystem, name, version)
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err return nil, err
} else if cached != nil { } else if cached != nil {
return cached, nil return cached, nil
} }
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers) return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers)
} }
@ -828,9 +792,6 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers) artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers)
if err != nil { if err != nil {
if errors.Is(err, fetch.ErrNotFound) {
return nil, ErrUpstreamNotFound
}
return nil, fmt.Errorf("fetching from upstream: %w", err) return nil, fmt.Errorf("fetching from upstream: %w", err)
} }

View file

@ -5,7 +5,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@ -14,9 +13,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage" "github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch" "github.com/git-pkgs/registries/fetch"
) )
@ -152,7 +151,7 @@ func seedPackage(t *testing.T, db *database.DB, store *mockStorage, ecosystem, n
t.Helper() t.Helper()
pkg := &database.Package{ pkg := &database.Package{
PURL: fmt.Sprintf("pkg:%s/%s", ecosystem, name), PURL: purl.MakePURLString(ecosystem, name, ""),
Ecosystem: ecosystem, Ecosystem: ecosystem,
Name: name, Name: name,
} }
@ -160,7 +159,7 @@ func seedPackage(t *testing.T, db *database.DB, store *mockStorage, ecosystem, n
t.Fatalf("failed to upsert package: %v", err) t.Fatalf("failed to upsert package: %v", err)
} }
versionPURL := fmt.Sprintf("pkg:%s/%s@%s", ecosystem, name, version) versionPURL := purl.MakePURLString(ecosystem, name, version)
ver := &database.Version{ ver := &database.Version{
PURL: versionPURL, PURL: versionPURL,
PackagePURL: pkg.PURL, PackagePURL: pkg.PURL,
@ -1011,33 +1010,3 @@ func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
t.Errorf("Warning should be empty for fresh response, got %q", got) 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)
}
})
}
}

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
"google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/encoding/protowire"
) )
@ -53,13 +54,30 @@ func (h *HexHandler) Routes() http.Handler {
// handleDownload serves a package tarball, fetching and caching from upstream if needed. // handleDownload serves a package tarball, fetching and caching from upstream if needed.
func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) { func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.handleFilenameDownload(w, r, filenameDownload{ filename := r.PathValue("filename")
ecosystem: "hex", if filename == "" || !strings.HasSuffix(filename, ".tar") {
suffix: ".tar", http.Error(w, "invalid filename", http.StatusBadRequest)
parseErr: "could not parse tarball filename", return
fetchErr: "failed to fetch package", }
parse: h.parseTarballFilename,
}) // 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)
} }
// parseTarballFilename extracts name and version from a hex tarball filename. // parseTarballFilename extracts name and version from a hex tarball filename.
@ -219,7 +237,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
return nil, err return nil, err
} }
packagePURL := canonicalPackagePURL("hex", name) packagePURL := purl.MakePURLString("hex", name, "")
filtered := make(map[string]bool) filtered := make(map[string]bool)
for _, release := range pkg.Releases { for _, release := range pkg.Releases {

View file

@ -90,7 +90,8 @@ func (h *JuliaHandler) handleRegistry(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path upstreamURL := h.upstreamURL + r.URL.Path
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaRegistryName, hash, hash+".tar.gz", upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaRegistryName, hash, hash+".tar.gz", upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch registry") h.proxy.Logger.Error("failed to get registry", "error", err)
http.Error(w, "failed to fetch registry", http.StatusBadGateway)
return return
} }
@ -118,7 +119,8 @@ func (h *JuliaHandler) handlePackage(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path upstreamURL := h.upstreamURL + r.URL.Path
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", name, hash, hash+".tar.gz", upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", name, hash, hash+".tar.gz", upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get package", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }
@ -139,7 +141,8 @@ func (h *JuliaHandler) handleArtifact(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path upstreamURL := h.upstreamURL + r.URL.Path
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaArtifactName, hash, hash+".tar.gz", upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaArtifactName, hash, hash+".tar.gz", upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch artifact") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
return return
} }

View file

@ -130,7 +130,12 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
} }
} }
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch artifact") if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
return return
} }

View file

@ -1,151 +0,0 @@
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())
}
}

View file

@ -1,83 +0,0 @@
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)
}
}

View file

@ -9,6 +9,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -25,14 +27,10 @@ type NPMHandler struct {
} }
// NewNPMHandler creates a new npm protocol handler. // NewNPMHandler creates a new npm protocol handler.
func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler { func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler {
if strings.TrimSpace(upstreamURL) == "" {
upstreamURL = npmUpstream
}
return &NPMHandler{ return &NPMHandler{
proxy: proxy, proxy: proxy,
upstreamURL: strings.TrimSuffix(upstreamURL, "/"), upstreamURL: npmUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"),
} }
} }
@ -136,7 +134,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma
return return
} }
packagePURL := canonicalPackagePURL("npm", packageName) packagePURL := purl.MakePURLString("npm", packageName, "")
for version := range versions { for version := range versions {
publishedStr, ok := timeMap[version].(string) publishedStr, ok := timeMap[version].(string)
@ -265,20 +263,8 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("npm download request", h.proxy.Logger.Info("npm download request",
"package", packageName, "version", version, "filename", filename) "package", packageName, "version", version, "filename", filename)
downloadURL := fmt.Sprintf( result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename)
"%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 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) h.proxy.Logger.Error("failed to get artifact", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch package") JSONError(w, http.StatusBadGateway, "failed to fetch package")
return return
@ -287,14 +273,6 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
ServeArtifact(w, result) 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. // extractPackageName extracts the package name from the request path.
// Handles both scoped (@scope/name) and unscoped (name) packages. // Handles both scoped (@scope/name) and unscoped (name) packages.
func (h *NPMHandler) extractPackageName(r *http.Request) string { func (h *NPMHandler) extractPackageName(r *http.Request) string {

View file

@ -2,16 +2,13 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
"github.com/git-pkgs/cooldown" "github.com/git-pkgs/cooldown"
"github.com/git-pkgs/registries/fetch"
) )
const testVersion100 = "1.0.0" const testVersion100 = "1.0.0"
@ -49,86 +46,6 @@ 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) { func TestNPMRewriteMetadata(t *testing.T) {
h := &NPMHandler{ h := &NPMHandler{
proxy: testProxy(), proxy: testProxy(),

View file

@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -269,7 +271,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
} }
} }
packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id)) packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) { if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering nuget version", h.proxy.Logger.Info("cooldown: filtering nuget version",
@ -314,7 +316,8 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -67,7 +69,8 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename) result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }
@ -124,7 +127,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
return body, nil return body, nil
} }
packagePURL := canonicalPackagePURL("pub", name) packagePURL := purl.MakePURLString("pub", name, "")
filtered := h.filterAndRewriteVersions(name, packagePURL, versions) filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
metadata["versions"] = filtered metadata["versions"] = filtered

View file

@ -12,6 +12,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/git-pkgs/purl"
) )
const ( const (
@ -129,7 +131,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st
return nil return nil
} }
packagePURL := canonicalPackagePURL("pypi", name) packagePURL := purl.MakePURLString("pypi", name, "")
filtered := make(map[string]bool) filtered := make(map[string]bool)
for version, files := range releases { for version, files := range releases {
@ -260,7 +262,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
packageName, _ := extractPyPIName(metadata) packageName, _ := extractPyPIName(metadata)
packagePURL := "" packagePURL := ""
if packageName != "" { if packageName != "" {
packagePURL = canonicalPackagePURL("pypi", packageName) packagePURL = purl.MakePURLString("pypi", packageName, "")
} }
h.filterAndRewriteReleases(metadata, packageName, packagePURL) h.filterAndRewriteReleases(metadata, packageName, packagePURL)
@ -426,7 +428,8 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -83,7 +83,8 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques
result, err := h.proxy.GetOrFetchArtifactFromURL( result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "rpm", name, version, filename, downloadURL) r.Context(), "rpm", name, version, filename, downloadURL)
if err != nil { if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package") h.proxy.Logger.Error("failed to get rpm package", "error", err)
http.Error(w, "failed to fetch package", http.StatusBadGateway)
return return
} }

View file

@ -0,0 +1,418 @@
// Package httpclient provides authentication-aware HTTP transports for upstream requests.
package httpclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
defaultTokenLifetime = 60 * time.Second
tokenExpirySkew = 5 * time.Second
maxTokenResponseSize = 1 << 20
shortTokenSkewDivisor = 10
)
// AuthFunc returns a configured authentication header for a URL.
type AuthFunc func(url string) (headerName, headerValue string)
// Transport adds configured authentication and follows OCI Bearer challenges.
type Transport struct {
base http.RoundTripper
authForURL AuthFunc
mu sync.Mutex
tokens map[string]cachedToken
challenges map[string]bearerChallenge
}
type cachedToken struct {
value string
expiresAt time.Time
}
type bearerChallenge struct {
realm string
service string
scopes []string
}
type tokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
IssuedAt string `json:"issued_at"`
}
// NewTransport creates an authentication-aware transport around base.
func NewTransport(base http.RoundTripper, authForURL AuthFunc) *Transport {
if base == nil {
base = http.DefaultTransport
}
return &Transport{
base: base,
authForURL: authForURL,
tokens: make(map[string]cachedToken),
challenges: make(map[string]bearerChallenge),
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
outbound := cloneRequest(req)
t.applyAuthentication(outbound, req.Header.Get("Authorization") != "")
resp, err := t.base.RoundTrip(outbound)
if err != nil || resp.StatusCode != http.StatusUnauthorized {
return resp, err
}
if registryProtectionSpace(req.URL) == "" {
return resp, nil
}
challenge, ok := parseBearerChallenge(resp.Header.Values("WWW-Authenticate"))
if !ok || !canReplay(req) {
return resp, nil
}
drainAndClose(resp.Body)
token, err := t.token(req.Context(), challenge)
if err != nil {
return nil, fmt.Errorf("registry authentication: %w", err)
}
t.rememberChallenge(req.URL, challenge)
retry, err := cloneRequestForRetry(req)
if err != nil {
return nil, err
}
t.applyConfiguredAuthentication(retry)
retry.Header.Set("Authorization", "Bearer "+token)
return t.base.RoundTrip(retry)
}
func (t *Transport) applyAuthentication(req *http.Request, hasExplicitAuthorization bool) {
t.applyConfiguredAuthentication(req)
if hasExplicitAuthorization {
return
}
if token := t.cachedTokenForRequest(req.URL); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
func (t *Transport) applyConfiguredAuthentication(req *http.Request) {
if t.authForURL == nil {
return
}
name, value := t.authForURL(req.URL.String())
if name != "" && value != "" && req.Header.Get(name) == "" {
req.Header.Set(name, value)
}
}
func (t *Transport) token(ctx context.Context, challenge bearerChallenge) (string, error) {
key := challenge.key()
if token := t.cachedToken(key); token != "" {
return token, nil
}
token, expiresAt, err := t.fetchToken(ctx, challenge)
if err != nil {
return "", err
}
t.mu.Lock()
t.tokens[key] = cachedToken{value: token, expiresAt: expiresAt}
t.mu.Unlock()
return token, nil
}
func (t *Transport) fetchToken(ctx context.Context, challenge bearerChallenge) (string, time.Time, error) {
tokenURL, err := url.Parse(challenge.realm)
if err != nil || !tokenURL.IsAbs() || (tokenURL.Scheme != "https" && tokenURL.Scheme != "http") {
return "", time.Time{}, fmt.Errorf("invalid token realm %q", challenge.realm)
}
query := tokenURL.Query()
if challenge.service != "" {
query.Set("service", challenge.service)
}
for _, scope := range challenge.scopes {
query.Add("scope", scope)
}
query.Set("client_id", "git-pkgs-proxy")
tokenURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil)
if err != nil {
return "", time.Time{}, err
}
client := &http.Client{Transport: configuredTransport{parent: t}}
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("requesting token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseSize))
return "", time.Time{}, fmt.Errorf("token service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload tokenResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, maxTokenResponseSize)).Decode(&payload); err != nil {
return "", time.Time{}, fmt.Errorf("decoding token response: %w", err)
}
token := payload.Token
if token == "" {
token = payload.AccessToken
}
if token == "" {
return "", time.Time{}, fmt.Errorf("token response did not contain a token")
}
issuedAt := time.Now()
if payload.IssuedAt != "" {
if parsed, parseErr := time.Parse(time.RFC3339, payload.IssuedAt); parseErr == nil {
issuedAt = parsed
}
}
lifetime := time.Duration(payload.ExpiresIn) * time.Second
if lifetime <= 0 {
lifetime = defaultTokenLifetime
}
expiresAt := issuedAt.Add(lifetime).Add(-expirySkew(lifetime))
return token, expiresAt, nil
}
type configuredTransport struct {
parent *Transport
}
func (t configuredTransport) RoundTrip(req *http.Request) (*http.Response, error) {
outbound := cloneRequest(req)
t.parent.applyConfiguredAuthentication(outbound)
return t.parent.base.RoundTrip(outbound)
}
func (t *Transport) cachedTokenForRequest(requestURL *url.URL) string {
space := registryProtectionSpace(requestURL)
if space == "" {
return ""
}
t.mu.Lock()
challenge, ok := t.challenges[space]
t.mu.Unlock()
if !ok {
return ""
}
return t.cachedToken(challenge.key())
}
func (t *Transport) cachedToken(key string) string {
now := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
token, ok := t.tokens[key]
if !ok {
return ""
}
if !now.Before(token.expiresAt) {
delete(t.tokens, key)
return ""
}
return token.value
}
func (t *Transport) rememberChallenge(requestURL *url.URL, challenge bearerChallenge) {
space := registryProtectionSpace(requestURL)
if space == "" {
return
}
t.mu.Lock()
t.challenges[space] = challenge
t.mu.Unlock()
}
func (c bearerChallenge) key() string {
return c.realm + "\x00" + c.service + "\x00" + strings.Join(c.scopes, "\x00")
}
func registryProtectionSpace(u *url.URL) string {
const registryPrefix = "/v2/"
if u == nil || !strings.HasPrefix(u.Path, registryPrefix) {
return ""
}
rest := strings.TrimPrefix(u.Path, registryPrefix)
end := len(rest)
for _, marker := range []string{"/blobs/", "/manifests/", "/tags/", "/referrers/"} {
if index := strings.Index(rest, marker); index >= 0 && index < end {
end = index
}
}
if end == len(rest) || end == 0 {
return ""
}
return u.Scheme + "://" + u.Host + registryPrefix + rest[:end]
}
func parseBearerChallenge(values []string) (bearerChallenge, bool) {
for _, value := range values {
params, ok := bearerParameters(value)
if !ok || params["realm"] == "" {
continue
}
challenge := bearerChallenge{
realm: params["realm"],
service: params["service"],
}
if scope := params["scope"]; scope != "" {
challenge.scopes = append(challenge.scopes, scope)
}
return challenge, true
}
return bearerChallenge{}, false
}
func bearerParameters(value string) (map[string]string, bool) {
start := findAuthScheme(value, "Bearer")
if start < 0 {
return nil, false
}
rest := value[start+len("Bearer"):]
params := make(map[string]string)
for {
rest = strings.TrimLeft(rest, " \t,")
if rest == "" {
break
}
keyEnd := strings.IndexAny(rest, "= \t,")
if keyEnd <= 0 {
break
}
key := strings.ToLower(rest[:keyEnd])
rest = strings.TrimLeft(rest[keyEnd:], " \t")
if rest == "" || rest[0] != '=' {
break
}
rest = strings.TrimLeft(rest[1:], " \t")
parsed, remaining, ok := parseAuthValue(rest)
if !ok {
return nil, false
}
params[key] = parsed
rest = remaining
}
return params, true
}
func findAuthScheme(value, scheme string) int {
inQuote := false
escaped := false
for index := 0; index+len(scheme) <= len(value); index++ {
char := value[index]
if escaped {
escaped = false
continue
}
if char == '\\' && inQuote {
escaped = true
continue
}
if char == '"' {
inQuote = !inQuote
continue
}
if inQuote || !strings.EqualFold(value[index:index+len(scheme)], scheme) {
continue
}
beforeOK := index == 0 || value[index-1] == ',' || value[index-1] == ' ' || value[index-1] == '\t'
after := index + len(scheme)
afterOK := after < len(value) && (value[after] == ' ' || value[after] == '\t')
if beforeOK && afterOK {
return index
}
}
return -1
}
func parseAuthValue(value string) (parsed, remaining string, ok bool) {
if value == "" {
return "", "", false
}
if value[0] != '"' {
end := strings.IndexAny(value, " \t,")
if end < 0 {
return value, "", true
}
return value[:end], value[end:], end > 0
}
var builder strings.Builder
escaped := false
for index := 1; index < len(value); index++ {
char := value[index]
if escaped {
builder.WriteByte(char)
escaped = false
continue
}
if char == '\\' {
escaped = true
continue
}
if char == '"' {
return builder.String(), value[index+1:], true
}
builder.WriteByte(char)
}
return "", "", false
}
func cloneRequest(req *http.Request) *http.Request {
clone := req.Clone(req.Context())
clone.Header = req.Header.Clone()
return clone
}
func canReplay(req *http.Request) bool {
return req.Body == nil || req.GetBody != nil
}
func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
clone := cloneRequest(req)
if req.Body == nil {
return clone, nil
}
body, err := req.GetBody()
if err != nil {
return nil, fmt.Errorf("replaying authenticated request: %w", err)
}
clone.Body = body
return clone, nil
}
func expirySkew(lifetime time.Duration) time.Duration {
if lifetime < tokenExpirySkew*2 {
return lifetime / shortTokenSkewDivisor
}
return tokenExpirySkew
}
func drainAndClose(body io.ReadCloser) {
_, _ = io.Copy(io.Discard, io.LimitReader(body, maxTokenResponseSize))
_ = body.Close()
}

View file

@ -0,0 +1,151 @@
package httpclient
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTransportFollowsBearerChallengeAndCachesToken(t *testing.T) {
var registryRequests int
var tokenRequests int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
tokenRequests++
if got := r.URL.Query().Get("service"); got != "registry.test" {
t.Errorf("service = %q, want %q", got, "registry.test")
}
if got := r.URL.Query().Get("scope"); got != "repository:library/test:pull" {
t.Errorf("scope = %q, want %q", got, "repository:library/test:pull")
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"token":"registry-token","expires_in":3600}`)
case "/v2/library/test/blobs/sha256:first", "/v2/library/test/blobs/sha256:second":
registryRequests++
if r.Header.Get("Authorization") != "Bearer registry-token" {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token",service="registry.test",scope="repository:library/test:pull"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
_, _ = io.WriteString(w, "blob")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := &http.Client{Transport: NewTransport(http.DefaultTransport, nil)}
for _, digest := range []string{"sha256:first", "sha256:second"} {
resp, err := client.Get(server.URL + "/v2/library/test/blobs/" + digest)
if err != nil {
t.Fatalf("GET %s: %v", digest, err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
t.Fatalf("read %s response: %v", digest, readErr)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s status = %d, want %d", digest, resp.StatusCode, http.StatusOK)
}
if string(body) != "blob" {
t.Errorf("GET %s body = %q, want %q", digest, body, "blob")
}
}
if tokenRequests != 1 {
t.Errorf("token requests = %d, want 1", tokenRequests)
}
if registryRequests != 3 {
t.Errorf("registry requests = %d, want 3", registryRequests)
}
}
func TestTransportAddsConfiguredAuthentication(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Registry-Token"); got != "configured-token" {
t.Errorf("X-Registry-Token = %q, want %q", got, "configured-token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
authForURL := func(url string) (string, string) {
if strings.HasPrefix(url, server.URL) {
return "X-Registry-Token", "configured-token"
}
return "", ""
}
client := &http.Client{Transport: NewTransport(http.DefaultTransport, authForURL)}
resp, err := client.Get(server.URL + "/metadata")
if err != nil {
t.Fatalf("GET metadata: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
}
func TestTransportPreservesExplicitAuthentication(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer explicit-token" {
t.Errorf("Authorization = %q, want %q", got, "Bearer explicit-token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
authForURL := func(string) (string, string) {
return "Authorization", "Bearer configured-token"
}
client := &http.Client{Transport: NewTransport(http.DefaultTransport, authForURL)}
req, err := http.NewRequest(http.MethodGet, server.URL+"/artifact", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer explicit-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET artifact: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
}
func TestTransportDoesNotFollowBearerChallengeOutsideOCIRegistry(t *testing.T) {
tokenRequests := 0
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
tokenRequests++
_, _ = io.WriteString(w, `{"token":"unexpected"}`)
return
}
w.Header().Set("WWW-Authenticate", `Bearer realm="`+server.URL+`/token"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
}))
defer server.Close()
client := &http.Client{Transport: NewTransport(http.DefaultTransport, nil)}
resp, err := client.Get(server.URL + "/api/packages")
if err != nil {
t.Fatalf("GET API: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
}
if tokenRequests != 0 {
t.Errorf("token requests = %d, want 0", tokenRequests)
}
}

View file

@ -58,17 +58,19 @@ import (
"strings" "strings"
"time" "time"
"github.com/git-pkgs/cooldown"
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
"github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment" "github.com/git-pkgs/proxy/internal/enrichment"
"github.com/git-pkgs/proxy/internal/handler" "github.com/git-pkgs/proxy/internal/handler"
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/proxy/internal/metrics" "github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/mirror" "github.com/git-pkgs/proxy/internal/mirror"
"github.com/git-pkgs/proxy/internal/storage" "github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl" "github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch" "github.com/git-pkgs/registries/fetch"
"github.com/git-pkgs/registries/safehttp"
"github.com/git-pkgs/spdx" "github.com/git-pkgs/spdx"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@ -84,12 +86,12 @@ const (
// Server is the main proxy server. // Server is the main proxy server.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
db *database.DB db *database.DB
storage storage.Storage storage storage.Storage
logger *slog.Logger logger *slog.Logger
http *http.Server http *http.Server
templates *Templates templates *Templates
cancel context.CancelFunc cancel context.CancelFunc
healthCache *healthCache healthCache *healthCache
} }
@ -156,18 +158,27 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
// Start starts the HTTP server. // Start starts the HTTP server.
func (s *Server) Start() error { func (s *Server) Start() error {
// Create shared components with circuit breaker // Use one authentication-aware transport for metadata and artifacts so
baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL)) // configured credentials and cached OCI challenges apply consistently.
safeClient := safehttp.New(nil, safehttp.Options{})
authTransport := upstreamhttp.NewTransport(safeClient.Transport, upstreamhttp.AuthFunc(s.authForURL))
metadataClient := *safeClient
metadataClient.Timeout = s.cfg.ParseHTTPTimeout()
metadataClient.Transport = authTransport
artifactClient := metadataClient
artifactClient.Timeout = serverWriteTimeout
// Create shared components with circuit breaker.
baseFetcher := fetch.NewFetcher(fetch.WithHTTPClient(&artifactClient))
fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher) fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher)
resolver := fetch.NewResolver() resolver := fetch.NewResolver()
cd := &cooldown.Config{ cd := &cooldown.Config{
Default: s.cfg.Cooldown.Default, Default: s.cfg.Cooldown.Default,
Ecosystems: s.cfg.Cooldown.Ecosystems, Ecosystems: s.cfg.Cooldown.Ecosystems,
Packages: s.cfg.Cooldown.NormalizedPackages(), Packages: s.cfg.Cooldown.Packages,
} }
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout() proxy.HTTPClient = &metadataClient
proxy.AuthForURL = s.authForURL
proxy.Cooldown = cd proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL() proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
@ -197,13 +208,8 @@ func (s *Server) Start() error {
}) })
// Mount protocol handlers // Mount protocol handlers
npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM) npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL)
cargoHandler := handler.NewCargoHandler( cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL)
proxy,
s.cfg.BaseURL,
s.cfg.Upstream.Cargo,
s.cfg.Upstream.CargoDownload,
)
gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL) gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL)
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL) hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)

View file

@ -67,13 +67,8 @@ func newTestServer(t *testing.T) *testServer {
r := chi.NewRouter() r := chi.NewRouter()
// Mount handlers // Mount handlers
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM) npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL)
cargoHandler := handler.NewCargoHandler( cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL)
proxy,
cfg.BaseURL,
cfg.Upstream.Cargo,
cfg.Upstream.CargoDownload,
)
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL) gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)