mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-01 01:58:09 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a9a82176d |
||
|
|
31ecca8cf1 |
||
|
|
1057ee926e |
||
|
|
b56a1fed65 |
||
|
|
a5d456790d |
||
|
|
90422697b8 |
||
|
|
44041d07a9 |
||
|
|
cf3741162f |
||
|
|
532e4925fe |
||
|
|
6b767d0128 |
||
|
|
4737777e80 |
||
|
|
238c628b31 |
||
|
|
ced9c1198b |
||
|
|
13ae3970e9 |
||
|
|
7071d4fb0e |
||
|
|
5ae5eab031 |
||
|
|
c3f1577017 |
||
|
|
d9b7a30294 |
||
|
|
60c72be65e |
||
|
|
0a3b9e58f2 |
||
|
|
cdbd1a9369 | ||
|
|
785c989f12 |
||
|
|
41ef27907e |
||
|
|
95b4e34ebe |
||
|
|
61b91fbebb |
||
|
|
4e0575a3ab |
||
|
|
a79ec25bc9 |
||
|
|
df997e5825 |
||
|
|
96e113213d |
||
|
|
ec3cc35795 |
||
|
|
cdf075695f |
||
|
|
be15a0f826 |
46 changed files with 988 additions and 1667 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -17,12 +17,12 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
|
@ -20,12 +20,12 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
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@c99871dec2022cc055c062a10cc1a1310835ceb4
|
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: false
|
cache: false
|
||||||
|
|
|
||||||
4
.github/workflows/swagger.yml
vendored
4
.github/workflows/swagger.yml
vendored
|
|
@ -12,12 +12,12 @@ jobs:
|
||||||
swagger:
|
swagger:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/zizmor.yml
vendored
4
.github/workflows/zizmor.yml
vendored
|
|
@ -21,9 +21,9 @@ jobs:
|
||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM golang:1.26.4-alpine AS builder
|
FROM golang:1.26.5-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,7 @@ 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 absolute URL scopes. Scheme, host, effective port, and path
|
# Keys are URL prefixes matched against request URLs.
|
||||||
# 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:
|
||||||
|
|
@ -169,7 +168,8 @@ cooldown:
|
||||||
# npm: "7d"
|
# npm: "7d"
|
||||||
# cargo: "0"
|
# cargo: "0"
|
||||||
|
|
||||||
# Per-package overrides (keyed by PURL)
|
# Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes
|
||||||
|
# may use either @scope or the canonical %40scope form.
|
||||||
# packages:
|
# packages:
|
||||||
# "pkg:npm/lodash": "0"
|
# "pkg:npm/lodash": "0"
|
||||||
# "pkg:npm/@babel/core": "14d"
|
# "pkg:npm/@babel/core": "14d"
|
||||||
|
|
|
||||||
|
|
@ -240,8 +240,6 @@ 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
|
||||||
|
|
@ -353,7 +351,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,7 @@ upstream:
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
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.
|
Configure authentication for private upstream registries. Auth is matched by URL prefix, 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
|
||||||
|
|
||||||
|
|
@ -174,7 +172,7 @@ upstream:
|
||||||
|
|
||||||
### URL Matching
|
### URL Matching
|
||||||
|
|
||||||
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:
|
Auth configs are matched by URL prefix. The longest matching prefix wins, so you can configure different credentials for different paths:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
upstream:
|
upstream:
|
||||||
|
|
@ -236,6 +234,8 @@ 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,8 +246,6 @@ 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
43
go.mod
|
|
@ -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.0
|
github.com/git-pkgs/archives v0.3.1
|
||||||
github.com/git-pkgs/cooldown v0.1.1
|
github.com/git-pkgs/cooldown v0.1.1
|
||||||
github.com/git-pkgs/enrichment v0.4.1
|
github.com/git-pkgs/enrichment v0.6.4
|
||||||
github.com/git-pkgs/purl v0.1.13
|
github.com/git-pkgs/purl v0.1.15
|
||||||
github.com/git-pkgs/registries v0.6.2
|
github.com/git-pkgs/registries v0.6.4
|
||||||
github.com/git-pkgs/spdx v0.1.4
|
github.com/git-pkgs/spdx v0.1.4
|
||||||
github.com/git-pkgs/vers v0.2.6
|
github.com/git-pkgs/vers v0.3.0
|
||||||
github.com/git-pkgs/vulns v0.1.6
|
github.com/git-pkgs/vulns v0.2.1
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
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.23.2
|
github.com/prometheus/client_golang v1.24.0
|
||||||
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.53.0
|
modernc.org/sqlite v1.55.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.2.0 // indirect
|
github.com/ecosyste-ms/ecosystems-go v0.4.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,15 +217,16 @@ 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/runtime v1.4.1 // indirect
|
github.com/oapi-codegen/nullable v1.1.0 // indirect
|
||||||
|
github.com/oapi-codegen/runtime v1.6.0 // indirect
|
||||||
github.com/package-url/packageurl-go v0.1.6 // indirect
|
github.com/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.67.5 // indirect
|
github.com/prometheus/common v0.70.0 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.21.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
|
||||||
|
|
@ -265,7 +266,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.15 // indirect
|
github.com/ulikunitz/xz v0.5.16 // 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
|
||||||
|
|
@ -289,25 +290,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.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // 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.36.0 // indirect
|
golang.org/x/mod v0.37.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.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.47.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-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
google.golang.org/grpc v1.82.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.73.4 // indirect
|
modernc.org/libc v1.74.1 // 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
112
go.sum
|
|
@ -66,8 +66,8 @@ github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3w
|
||||||
github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8=
|
github.com/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.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
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/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 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.2.0 h1:Nhpg54C+St8Sd/mf8bNJmQqx35ZgHw33TfFoxaXMQI8=
|
github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA=
|
||||||
github.com/ecosyste-ms/ecosystems-go v0.2.0/go.mod h1:CCdzT1iAZirbEZAbFSnWpK88eKKaIWex7gjtZ0UudXA=
|
github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA=
|
||||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
github.com/envoyproxy/go-control-plane 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.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78=
|
github.com/git-pkgs/archives v0.3.1 h1:GKUuw++0YXAAElxweVHiR4AaSShKKYoVQmyxlF5blG4=
|
||||||
github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU=
|
github.com/git-pkgs/archives v0.3.1/go.mod h1:408oQv3FxLCtePa33zp3sg3njXnwH74vnHZFxkRqoPo=
|
||||||
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.4.1 h1:A8BKs0XwvpF1sF5qviZy4fkJAe18qB9OgpbRnmwnT34=
|
github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU=
|
||||||
github.com/git-pkgs/enrichment v0.4.1/go.mod h1:stHqZUitV9ZkwACqHzBysLMSe6T4QZn81hxTdSroNhM=
|
github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY=
|
||||||
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.13 h1:at8BU6vnP5oonHFHAPA064BzgRqij+SZcOUDgNT2DC8=
|
github.com/git-pkgs/purl v0.1.15 h1:iQ3clh0Cw41rkM0rf24B7ShnN9Z+UtLMAFlNDUs+Qd4=
|
||||||
github.com/git-pkgs/purl v0.1.13/go.mod h1:8oCcdcYZA/e1B33e7Ylju6azboTKjdqf3ybcbQj6I/o=
|
github.com/git-pkgs/purl v0.1.15/go.mod h1:PqCLVBDeZrZgHysR803/AntMELgIr2LFZVNCcwLH2m0=
|
||||||
github.com/git-pkgs/registries v0.6.2 h1:26G5zW6Q7x1CSfNkaEqEjRMJiA4JwfdKOCJ7Qm+u0a8=
|
github.com/git-pkgs/registries v0.6.4 h1:Kq/KlStjaQyE83UXT/tKuzCrIzc4keGeBjtroMqgoHA=
|
||||||
github.com/git-pkgs/registries v0.6.2/go.mod h1:GR0Bu6nC3NQe6f7lfDoEVqAnoQkMocf4M98B12a7B3E=
|
github.com/git-pkgs/registries v0.6.4/go.mod h1:YkGHbxHIe2Ha/ROH6zNkS5PJUUoa9g0Ti/s2XhZnrak=
|
||||||
github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs=
|
github.com/git-pkgs/spdx v0.1.4 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.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8=
|
github.com/git-pkgs/vers v0.3.0 h1:xM4LLUCRmqzdDfe+/pVQUx4SRyFXRVth6tOsJ14wMKU=
|
||||||
github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
|
github.com/git-pkgs/vers v0.3.0/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
|
||||||
github.com/git-pkgs/vulns v0.1.6 h1:8RRSgdlxp4JMU0Zykr63XTOMo5CyZKwt/PwaQxrx9Yg=
|
github.com/git-pkgs/vulns v0.2.1 h1:tWGhOfPVDZwkM2Y9vRkMpMR+gjtlu2jhERS5JeNBoKQ=
|
||||||
github.com/git-pkgs/vulns v0.1.6/go.mod h1:TsZC4MjoCkKJslgmbcmRCnytwnFcjESC2N8b0a2xDWc=
|
github.com/git-pkgs/vulns v0.2.1/go.mod h1:/0gHKHQR5SWttZVEMqgOvCXssKFwAtbac/PfkhBax9o=
|
||||||
github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ=
|
github.com/github/go-spdx/v2 v2.7.0 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.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
|
github.com/go-critic/go-critic v0.14.3 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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.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.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
|
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
|
||||||
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
github.com/onsi/ginkgo/v2 v2.28.1 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
|
github.com/quasilyte/go-ruleguard v0.4.5 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.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
|
||||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
github.com/ultraware/funlen v0.2.0 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.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
golang.org/x/tools/go/expect v0.1.1-deprecated 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-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
|
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/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 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.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
||||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 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=
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,12 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -137,9 +139,38 @@ 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.
|
||||||
|
|
@ -274,29 +305,23 @@ 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 absolute URL scopes matched by scheme, host, effective port,
|
// Keys are URL prefixes that are matched against request URLs.
|
||||||
// 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.
|
||||||
// The longest matching URL scope wins.
|
// Matches are based on URL prefix - the longest matching prefix 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 {
|
||||||
configured, err := parseAuthURL(pattern)
|
if strings.HasPrefix(url, pattern) && len(pattern) > bestLen {
|
||||||
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)
|
||||||
|
|
@ -306,45 +331,6 @@ 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".
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,34 @@ 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) {
|
||||||
|
|
@ -760,45 +788,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,8 @@ 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},
|
||||||
ContentDigest: sql.NullString{
|
Size: sql.NullInt64{Int64: 1024, Valid: true},
|
||||||
String: "sha256:0123456789abcdef",
|
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
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)
|
||||||
|
|
@ -66,9 +62,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -185,47 +178,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
content_digest, size, last_modified, fetched_at, created_at, updated_at
|
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,13 +914,12 @@ 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,
|
||||||
content_digest, size, last_modified, fetched_at, created_at, updated_at)
|
size, last_modified, fetched_at, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
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,
|
||||||
|
|
@ -929,13 +928,12 @@ 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,
|
||||||
content_digest, size, last_modified, fetched_at, created_at, updated_at)
|
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,
|
||||||
|
|
@ -945,7 +943,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.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
|
entry.ContentType, 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)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@ 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,
|
||||||
|
|
@ -203,7 +202,6 @@ 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,
|
||||||
|
|
@ -361,7 +359,6 @@ 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.
|
||||||
|
|
@ -584,20 +581,6 @@ 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")
|
||||||
|
|
@ -618,7 +601,6 @@ 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,
|
||||||
|
|
@ -636,7 +618,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -78,18 +78,17 @@ 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"`
|
||||||
ContentDigest sql.NullString `db:"content_digest" json:"content_digest,omitempty"`
|
Size sql.NullInt64 `db:"size" json:"size,omitempty"`
|
||||||
Size sql.NullInt64 `db:"size" json:"size,omitempty"`
|
LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
|
||||||
LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
|
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
|
||||||
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vulnerability represents a cached vulnerability record.
|
// Vulnerability represents a cached vulnerability record.
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -30,11 +29,18 @@ type CargoHandler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCargoHandler creates a new cargo protocol handler.
|
// NewCargoHandler creates a new cargo protocol handler.
|
||||||
func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler {
|
func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler {
|
||||||
|
if strings.TrimSpace(indexURL) == "" {
|
||||||
|
indexURL = cargoUpstream
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(downloadURL) == "" {
|
||||||
|
downloadURL = cargoDownloadBase
|
||||||
|
}
|
||||||
|
|
||||||
return &CargoHandler{
|
return &CargoHandler{
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
indexURL: cargoUpstream,
|
indexURL: strings.TrimSuffix(indexURL, "/"),
|
||||||
downloadURL: cargoDownloadBase,
|
downloadURL: strings.TrimSuffix(downloadURL, "/"),
|
||||||
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +149,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
cratePURL := purl.MakePURLString("cargo", crate.Name, "")
|
cratePURL := canonicalPackagePURL("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",
|
||||||
|
|
@ -191,10 +197,17 @@ 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)
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename)
|
downloadURL := fmt.Sprintf(
|
||||||
|
"%s/%s/%s",
|
||||||
|
h.downloadURL,
|
||||||
|
url.PathEscape(name),
|
||||||
|
url.PathEscape(filename),
|
||||||
|
)
|
||||||
|
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
||||||
|
r.Context(), "cargo", name, version, filename, downloadURL,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch crate")
|
||||||
http.Error(w, "failed to fetch crate", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -10,6 +11,7 @@ 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 {
|
||||||
|
|
@ -70,6 +72,75 @@ func TestCargoConfigEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCargoHandlerUsesConfiguredUpstreams(t *testing.T) {
|
||||||
|
t.Run("index", func(t *testing.T) {
|
||||||
|
var requestPath, authHeader string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
if authHeader != "Bearer cargo-token" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = io.WriteString(w, `{"name":"serde","vers":"1.0.0"}`)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
proxy, _, _, _ := setupTestProxy(t)
|
||||||
|
proxy.HTTPClient = upstream.Client()
|
||||||
|
proxy.AuthForURL = func(string) (string, string) {
|
||||||
|
return "Authorization", "Bearer cargo-token"
|
||||||
|
}
|
||||||
|
h := NewCargoHandler(
|
||||||
|
proxy,
|
||||||
|
"http://proxy.test",
|
||||||
|
upstream.URL+"/index/",
|
||||||
|
"https://crates.example.test/files/",
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/se/rd/serde", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
if requestPath != "/index/se/rd/serde" {
|
||||||
|
t.Errorf("upstream path = %q, want %q", requestPath, "/index/se/rd/serde")
|
||||||
|
}
|
||||||
|
if authHeader != "Bearer cargo-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", authHeader, "Bearer cargo-token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("download", func(t *testing.T) {
|
||||||
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
||||||
|
artifactFetcher.artifact = &fetch.Artifact{
|
||||||
|
Body: io.NopCloser(strings.NewReader("crate")),
|
||||||
|
ContentType: "application/gzip",
|
||||||
|
}
|
||||||
|
h := NewCargoHandler(
|
||||||
|
proxy,
|
||||||
|
"http://proxy.test",
|
||||||
|
"https://index.example.test/root/",
|
||||||
|
"https://crates.example.test/files/",
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/crates/serde/1.0.0/download", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
want := "https://crates.example.test/files/serde/serde-1.0.0.crate"
|
||||||
|
if artifactFetcher.fetchedURL != want {
|
||||||
|
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCargoIndexProxy(t *testing.T) {
|
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"}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -216,7 +214,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 := purl.MakePURLString("composer", packageName, "")
|
packagePURL := canonicalPackagePURL("composer", packageName)
|
||||||
|
|
||||||
filtered := versionList[:0]
|
filtered := versionList[:0]
|
||||||
for _, v := range versionList {
|
for _, v := range versionList {
|
||||||
|
|
@ -348,8 +346,7 @@ 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.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,7 @@ func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch file")
|
||||||
http.Error(w, "failed to fetch file", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,8 +121,7 @@ func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch file")
|
||||||
http.Error(w, "failed to fetch file", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -74,8 +72,7 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,7 +215,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("conda", name, "")
|
packagePURL := canonicalPackagePURL("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)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -11,6 +12,7 @@ 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
|
||||||
|
|
@ -22,6 +24,7 @@ const (
|
||||||
type ContainerHandler struct {
|
type ContainerHandler struct {
|
||||||
proxy *Proxy
|
proxy *Proxy
|
||||||
registryURL string
|
registryURL string
|
||||||
|
authURL string
|
||||||
proxyURL string
|
proxyURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +33,7 @@ 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, "/"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,43 +90,40 @@ 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)
|
||||||
|
|
||||||
filename := digest
|
// Get auth token for upstream
|
||||||
cached, err := h.proxy.GetCachedArtifact(r.Context(), "oci", name, digest, filename)
|
token, err := h.getAuthToken(r.Context(), name, "pull")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to check blob cache", "error", err)
|
h.proxy.Logger.Error("failed to get auth token", "error", err)
|
||||||
h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check blob cache")
|
h.containerError(w, http.StatusUnauthorized, "UNAUTHORIZED", "failed to authenticate")
|
||||||
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)
|
h.proxyBlobHead(w, r, name, digest, token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get from cache, or fetch from the authentication-aware upstream client.
|
// Try to get from cache, or fetch from upstream with auth
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
filename := digest
|
||||||
|
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, "BLOB_UNKNOWN", "failed to fetch blob")
|
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,31 +132,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
|
||||||
ServeArtifact(w, result)
|
ServeArtifact(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveArtifactHead(w http.ResponseWriter, result *CacheResult) {
|
// handleManifest proxies manifest requests to upstream.
|
||||||
if result.RedirectURL != "" {
|
// Manifests change when tags are updated, so we proxy these directly.
|
||||||
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 {
|
||||||
|
|
@ -170,7 +148,57 @@ 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.
|
||||||
|
|
@ -186,6 +214,13 @@ 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
|
||||||
|
|
@ -197,6 +232,8 @@ 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")
|
||||||
|
|
@ -209,8 +246,45 @@ 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 string) {
|
func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, name, digest, token 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)
|
||||||
|
|
@ -219,6 +293,8 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
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[:])
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
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"
|
||||||
|
|
||||||
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
|
"github.com/git-pkgs/proxy/internal/database"
|
||||||
"github.com/git-pkgs/registries/fetch"
|
"github.com/git-pkgs/registries/fetch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -133,387 +135,90 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
|
func TestContainerHandler_BlobDownload_CachesWithAuth(t *testing.T) {
|
||||||
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
|
// Set up a mock auth server that returns a token
|
||||||
registryRequests := 0
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenRequests := 0
|
w.Header().Set("Content-Type", "application/json")
|
||||||
var upstream *httptest.Server
|
_ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token-123"})
|
||||||
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 upstream.Close()
|
defer authServer.Close()
|
||||||
|
|
||||||
proxy, _, _, _ := setupTestProxy(t)
|
// Set up mock fetcher that captures headers
|
||||||
authTransport := upstreamhttp.NewTransport(http.DefaultTransport, nil)
|
var capturedHeaders http.Header
|
||||||
client := &http.Client{Transport: authTransport}
|
mf := &mockFetcherWithHeaders{
|
||||||
artifactFetcher := fetch.NewFetcher(
|
fetchFn: func(_ context.Context, _ string, headers http.Header) (*fetch.Artifact, error) {
|
||||||
fetch.WithHTTPClient(client),
|
capturedHeaders = headers
|
||||||
fetch.WithMaxRetries(0),
|
return &fetch.Artifact{
|
||||||
)
|
Body: io.NopCloser(bytes.NewReader([]byte("blob-content"))),
|
||||||
t.Cleanup(func() { _ = artifactFetcher.Close() })
|
Size: 12,
|
||||||
proxy.Fetcher = artifactFetcher
|
ContentType: "application/octet-stream",
|
||||||
proxy.HTTPClient = client
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := database.Create(dir + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
store := newMockStorage()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
proxy := &Proxy{
|
||||||
|
DB: db,
|
||||||
|
Storage: store,
|
||||||
|
Fetcher: mf,
|
||||||
|
Logger: logger,
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
}
|
||||||
|
|
||||||
h := &ContainerHandler{
|
h := &ContainerHandler{
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
registryURL: upstream.URL,
|
registryURL: "https://registry-1.docker.io",
|
||||||
|
authURL: authServer.URL,
|
||||||
proxyURL: "http://localhost:8080",
|
proxyURL: "http://localhost:8080",
|
||||||
}
|
}
|
||||||
|
|
||||||
for range 2 {
|
handler := h.Routes()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil)
|
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", 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()
|
||||||
h.Routes().ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
t.Errorf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
}
|
}
|
||||||
if got := w.Body.String(); got != "cached blob" {
|
|
||||||
t.Errorf("body = %q, want %q", got, "cached blob")
|
// Verify auth header was passed to the fetcher
|
||||||
|
if capturedHeaders == nil {
|
||||||
|
t.Fatal("expected headers to be passed to fetcher, got nil")
|
||||||
}
|
}
|
||||||
if upstreamRequests != 0 {
|
auth := capturedHeaders.Get("Authorization")
|
||||||
t.Errorf("upstream requests = %d, want 0", upstreamRequests)
|
if auth != "Bearer test-token-123" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", auth, "Bearer test-token-123")
|
||||||
}
|
}
|
||||||
if fetcher.fetchCalled {
|
|
||||||
t.Error("fetcher should not be called on cache hit")
|
// Verify response headers
|
||||||
|
if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" {
|
||||||
|
t.Errorf("Docker-Content-Digest = %q, want digest", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContainerHandler_BlobHead_CacheHitSkipsUpstreamAndAuth(t *testing.T) {
|
// mockFetcherWithHeaders captures headers passed to FetchWithHeaders.
|
||||||
proxy, db, store, fetcher := setupTestProxy(t)
|
type mockFetcherWithHeaders struct {
|
||||||
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
|
fetchFn func(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error)
|
||||||
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 TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) {
|
func (f *mockFetcherWithHeaders) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
|
||||||
proxy, db, store, fetcher := setupTestProxy(t)
|
return f.FetchWithHeaders(ctx, url, nil)
|
||||||
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 TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
|
func (f *mockFetcherWithHeaders) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
|
||||||
digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
return f.fetchFn(ctx, url, headers)
|
||||||
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 TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) {
|
func (f *mockFetcherWithHeaders) Head(_ context.Context, _ string) (int64, string, error) {
|
||||||
digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
return 0, "", nil
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,7 @@ func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Reques
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,8 +106,7 @@ func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Reques
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,7 @@ 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.Logger.Error("failed to get debian package", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
39
internal/handler/filename_download.go
Normal file
39
internal/handler/filename_download.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type filenameDownload struct {
|
||||||
|
ecosystem string
|
||||||
|
suffix string
|
||||||
|
parseErr string
|
||||||
|
fetchErr string
|
||||||
|
parse func(string) (name, version string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) handleFilenameDownload(w http.ResponseWriter, r *http.Request, d filenameDownload) {
|
||||||
|
filename := r.PathValue("filename")
|
||||||
|
if filename == "" || !strings.HasSuffix(filename, d.suffix) {
|
||||||
|
http.Error(w, "invalid filename", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name, version := d.parse(filename)
|
||||||
|
if name == "" || version == "" {
|
||||||
|
http.Error(w, d.parseErr, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Logger.Info(d.ecosystem+" download request",
|
||||||
|
"name", name, "version", version, "filename", filename)
|
||||||
|
|
||||||
|
result, err := p.GetOrFetchArtifact(r.Context(), d.ecosystem, name, version, filename)
|
||||||
|
if err != nil {
|
||||||
|
p.serveArtifactError(w, err, d.fetchErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ServeArtifact(w, result)
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -60,30 +58,13 @@ 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) {
|
||||||
filename := r.PathValue("filename")
|
h.proxy.handleFilenameDownload(w, r, filenameDownload{
|
||||||
if filename == "" || !strings.HasSuffix(filename, ".gem") {
|
ecosystem: "gem",
|
||||||
http.Error(w, "invalid filename", http.StatusBadRequest)
|
suffix: ".gem",
|
||||||
return
|
parseErr: "could not parse gem filename",
|
||||||
}
|
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.
|
||||||
|
|
@ -266,7 +247,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("gem", name, "")
|
packagePURL := canonicalPackagePURL("gem", name)
|
||||||
filtered := make(map[string]bool)
|
filtered := make(map[string]bool)
|
||||||
|
|
||||||
for _, v := range versions {
|
for _, v := range versions {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,14 @@ 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"
|
||||||
|
|
@ -96,6 +104,7 @@ 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.
|
||||||
|
|
@ -127,25 +136,18 @@ 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) {
|
||||||
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
|
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||||
|
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)
|
||||||
|
|
@ -250,6 +252,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,6 +274,9 @@ 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)
|
||||||
|
|
@ -398,6 +406,7 @@ 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 {
|
||||||
|
|
@ -424,6 +433,7 @@ 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 {
|
||||||
|
|
@ -450,7 +460,18 @@ 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: not found")
|
var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound)
|
||||||
|
|
||||||
|
// serveArtifactError writes response for a failed fetch:
|
||||||
|
// 404 when upstream reports artifact missing, 502 otherwise.
|
||||||
|
func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg string) {
|
||||||
|
if errors.Is(err, ErrUpstreamNotFound) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Logger.Error("failed to get artifact", "error", err)
|
||||||
|
http.Error(w, clientMsg, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
|
||||||
// errStale304 is returned when upstream sends 304 but the cached file is missing.
|
// 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")
|
||||||
|
|
@ -553,6 +574,7 @@ 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)
|
||||||
|
|
@ -742,6 +764,7 @@ 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 != "" {
|
||||||
|
|
@ -766,6 +789,17 @@ 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) {
|
||||||
|
|
@ -773,16 +807,18 @@ 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 request-specific HTTP headers.
|
// with additional HTTP headers. This is needed for registries that require authentication
|
||||||
|
// (e.g. Docker Hub requires a Bearer token even for public images).
|
||||||
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
|
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
|
||||||
if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
|
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -792,6 +828,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -13,9 +14,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -151,7 +152,7 @@ func seedPackage(t *testing.T, db *database.DB, store *mockStorage, ecosystem, n
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
pkg := &database.Package{
|
pkg := &database.Package{
|
||||||
PURL: purl.MakePURLString(ecosystem, name, ""),
|
PURL: fmt.Sprintf("pkg:%s/%s", ecosystem, name),
|
||||||
Ecosystem: ecosystem,
|
Ecosystem: ecosystem,
|
||||||
Name: name,
|
Name: name,
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +160,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 := purl.MakePURLString(ecosystem, name, version)
|
versionPURL := fmt.Sprintf("pkg:%s/%s@%s", ecosystem, name, version)
|
||||||
ver := &database.Version{
|
ver := &database.Version{
|
||||||
PURL: versionPURL,
|
PURL: versionPURL,
|
||||||
PackagePURL: pkg.PURL,
|
PackagePURL: pkg.PURL,
|
||||||
|
|
@ -1010,3 +1011,33 @@ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -54,30 +53,13 @@ func (h *HexHandler) Routes() http.Handler {
|
||||||
|
|
||||||
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
|
// 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) {
|
||||||
filename := r.PathValue("filename")
|
h.proxy.handleFilenameDownload(w, r, filenameDownload{
|
||||||
if filename == "" || !strings.HasSuffix(filename, ".tar") {
|
ecosystem: "hex",
|
||||||
http.Error(w, "invalid filename", http.StatusBadRequest)
|
suffix: ".tar",
|
||||||
return
|
parseErr: "could not parse tarball filename",
|
||||||
}
|
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.
|
||||||
|
|
@ -237,7 +219,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("hex", name, "")
|
packagePURL := canonicalPackagePURL("hex", name)
|
||||||
filtered := make(map[string]bool)
|
filtered := make(map[string]bool)
|
||||||
|
|
||||||
for _, release := range pkg.Releases {
|
for _, release := range pkg.Releases {
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,7 @@ 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.Logger.Error("failed to get registry", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch registry")
|
||||||
http.Error(w, "failed to fetch registry", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,8 +118,7 @@ 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.Logger.Error("failed to get package", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,8 +139,7 @@ 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.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
|
||||||
http.Error(w, "failed to fetch artifact", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,7 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrUpstreamNotFound) {
|
h.proxy.serveArtifactError(w, err, "failed to fetch artifact")
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
151
internal/handler/notfound_ecosystems_test.go
Normal file
151
internal/handler/notfound_ecosystems_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/git-pkgs/registries/fetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
handler func(p *Proxy) http.Handler
|
||||||
|
}{
|
||||||
|
{"debian", "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb",
|
||||||
|
func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm",
|
||||||
|
func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg",
|
||||||
|
func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl",
|
||||||
|
func(p *Proxy) http.Handler { return NewPyPIHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"cran", "/src/contrib/ggplot2_3.4.4.tar.gz",
|
||||||
|
func(p *Proxy) http.Handler { return NewCRANHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"conda", "/conda-forge/linux-64/numpy-1.26.0-py311_0.tar.bz2",
|
||||||
|
func(p *Proxy) http.Handler { return NewCondaHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"conan", "/v1/files/zlib/1.3.1/_/_/0/recipe/conan_sources.tgz",
|
||||||
|
func(p *Proxy) http.Handler { return NewConanHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"gem", "/gems/rails-7.1.0.gem",
|
||||||
|
func(p *Proxy) http.Handler { return NewGemHandler(p, "http://localhost").Routes() }},
|
||||||
|
{"hex", "/tarballs/phoenix-1.7.10.tar",
|
||||||
|
func(p *Proxy) http.Handler { return NewHexHandler(p, "http://localhost").Routes() }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErr = fetch.ErrNotFound
|
||||||
|
|
||||||
|
srv := httptest.NewServer(tt.handler(proxy))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJuliaPackageUpstreamNotFoundReturns404(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErr = fetch.ErrNotFound
|
||||||
|
|
||||||
|
dead := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer dead.Close()
|
||||||
|
|
||||||
|
h := NewJuliaHandler(proxy, "http://localhost")
|
||||||
|
h.upstreamURL = dead.URL
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h.Routes())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL +
|
||||||
|
"/package/7876af07-990d-54b4-ab0e-23690620f79a/0123456789abcdef0123456789abcdef01234567")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("want 404 for missing upstream package, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposerDownloadUpstreamNotFoundReturns404(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErr = fetch.ErrNotFound
|
||||||
|
|
||||||
|
meta := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/p2/monolog/monolog.json" {
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"packages": {
|
||||||
|
"monolog/monolog": [
|
||||||
|
{"version": "2.9.1", "dist": {"url": "https://example.com/monolog-2.9.1.zip", "type": "zip"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer meta.Close()
|
||||||
|
|
||||||
|
h := &ComposerHandler{proxy: proxy, repoURL: meta.URL, proxyURL: "http://localhost"}
|
||||||
|
srv := httptest.NewServer(h.Routes())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/files/monolog/monolog/2.9.1/monolog-2.9.1.zip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("want 404 for missing upstream dist, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerBlobUpstreamNotFoundReturns404(t *testing.T) {
|
||||||
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"token": "test-token-123"}`))
|
||||||
|
}))
|
||||||
|
defer authServer.Close()
|
||||||
|
|
||||||
|
proxy, _, _, _ := setupTestProxy(t)
|
||||||
|
proxy.Fetcher = &mockFetcherWithHeaders{
|
||||||
|
fetchFn: func(_ context.Context, _ string, _ http.Header) (*fetch.Artifact, error) {
|
||||||
|
return nil, fetch.ErrNotFound
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &ContainerHandler{
|
||||||
|
proxy: proxy,
|
||||||
|
registryURL: "https://registry-1.docker.io",
|
||||||
|
authURL: authServer.URL,
|
||||||
|
proxyURL: "http://localhost:8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("want 404 for missing upstream blob, got %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "BLOB_UNKNOWN") {
|
||||||
|
t.Errorf("want BLOB_UNKNOWN error code in body, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/handler/notfound_test.go
Normal file
83
internal/handler/notfound_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/git-pkgs/registries/fetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrUpstreamNotFoundWrapsFetchErrNotFound(t *testing.T) {
|
||||||
|
if !errors.Is(ErrUpstreamNotFound, fetch.ErrNotFound) {
|
||||||
|
t.Fatal("ErrUpstreamNotFound does not wrap fetch.ErrNotFound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrFetchArtifactFromURL_NotFound(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErr = fetch.ErrNotFound
|
||||||
|
|
||||||
|
_, err := proxy.GetOrFetchArtifactFromURL(context.Background(),
|
||||||
|
"maven", "org.example:missing", "1.0", "missing-1.0.jar",
|
||||||
|
"http://upstream.test/org/example/missing/1.0/missing-1.0.jar")
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrUpstreamNotFound) {
|
||||||
|
t.Fatalf("want ErrUpstreamNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMavenHandler_UpstreamNotFoundReturns404(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErr = fetch.ErrNotFound
|
||||||
|
|
||||||
|
h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
|
||||||
|
srv := httptest.NewServer(h.Routes())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/org/example/missing/1.0/missing-1.0.jar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMavenHandler_PluginPortalFallback(t *testing.T) {
|
||||||
|
proxy, _, _, fetcher := setupTestProxy(t)
|
||||||
|
fetcher.fetchErrByURL = map[string]error{
|
||||||
|
"http://upstream.test/org/example/plugin/1.0/plugin-1.0.jar": fetch.ErrNotFound,
|
||||||
|
}
|
||||||
|
fetcher.artifact = &fetch.Artifact{
|
||||||
|
Body: io.NopCloser(strings.NewReader("portal artifact")),
|
||||||
|
ContentType: "application/java-archive",
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test")
|
||||||
|
srv := httptest.NewServer(h.Routes())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/org/example/plugin/1.0/plugin-1.0.jar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 via plugin portal fallback, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if string(body) != "portal artifact" {
|
||||||
|
t.Errorf("want portal artifact body, got %q", body)
|
||||||
|
}
|
||||||
|
if fetcher.fetchedURL != "http://portal.test/org/example/plugin/1.0/plugin-1.0.jar" {
|
||||||
|
t.Errorf("fallback did not hit plugin portal, last URL: %s", fetcher.fetchedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,6 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -27,10 +25,14 @@ type NPMHandler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNPMHandler creates a new npm protocol handler.
|
// NewNPMHandler creates a new npm protocol handler.
|
||||||
func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler {
|
func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler {
|
||||||
|
if strings.TrimSpace(upstreamURL) == "" {
|
||||||
|
upstreamURL = npmUpstream
|
||||||
|
}
|
||||||
|
|
||||||
return &NPMHandler{
|
return &NPMHandler{
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
upstreamURL: npmUpstream,
|
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
|
||||||
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +136,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("npm", packageName, "")
|
packagePURL := canonicalPackagePURL("npm", packageName)
|
||||||
|
|
||||||
for version := range versions {
|
for version := range versions {
|
||||||
publishedStr, ok := timeMap[version].(string)
|
publishedStr, ok := timeMap[version].(string)
|
||||||
|
|
@ -263,8 +265,20 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
h.proxy.Logger.Info("npm download request",
|
h.proxy.Logger.Info("npm download request",
|
||||||
"package", packageName, "version", version, "filename", filename)
|
"package", packageName, "version", version, "filename", filename)
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename)
|
downloadURL := fmt.Sprintf(
|
||||||
|
"%s/%s/-/%s",
|
||||||
|
h.upstreamURL,
|
||||||
|
escapeNPMDownloadPackage(packageName),
|
||||||
|
url.PathEscape(filename),
|
||||||
|
)
|
||||||
|
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
||||||
|
r.Context(), "npm", packageName, version, filename, downloadURL,
|
||||||
|
)
|
||||||
if err != nil {
|
if 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
|
||||||
|
|
@ -273,6 +287,14 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ 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"
|
||||||
|
|
@ -46,6 +49,86 @@ func TestNPMExtractVersionFromFilename(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) {
|
||||||
|
t.Run("metadata", func(t *testing.T) {
|
||||||
|
var requestPath, authHeader string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
if authHeader != "Bearer npm-token" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"versions":{}}`)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
proxy, _, _, _ := setupTestProxy(t)
|
||||||
|
proxy.HTTPClient = upstream.Client()
|
||||||
|
proxy.AuthForURL = func(string) (string, string) {
|
||||||
|
return "Authorization", "Bearer npm-token"
|
||||||
|
}
|
||||||
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
if requestPath != "/root/testpkg" {
|
||||||
|
t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg")
|
||||||
|
}
|
||||||
|
if authHeader != "Bearer npm-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("download", func(t *testing.T) {
|
||||||
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
||||||
|
artifactFetcher.artifact = &fetch.Artifact{
|
||||||
|
Body: io.NopCloser(strings.NewReader("package")),
|
||||||
|
ContentType: "application/gzip",
|
||||||
|
}
|
||||||
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz"
|
||||||
|
if artifactFetcher.fetchedURL != want {
|
||||||
|
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scoped download", func(t *testing.T) {
|
||||||
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
||||||
|
artifactFetcher.artifact = &fetch.Artifact{
|
||||||
|
Body: io.NopCloser(strings.NewReader("package")),
|
||||||
|
ContentType: "application/gzip",
|
||||||
|
}
|
||||||
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz"
|
||||||
|
if artifactFetcher.fetchedURL != want {
|
||||||
|
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestNPMRewriteMetadata(t *testing.T) {
|
func TestNPMRewriteMetadata(t *testing.T) {
|
||||||
h := &NPMHandler{
|
h := &NPMHandler{
|
||||||
proxy: testProxy(),
|
proxy: testProxy(),
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -271,7 +269,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
|
packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id))
|
||||||
|
|
||||||
if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
|
if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
|
||||||
h.proxy.Logger.Info("cooldown: filtering nuget version",
|
h.proxy.Logger.Info("cooldown: filtering nuget version",
|
||||||
|
|
@ -316,8 +314,7 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -69,8 +67,7 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename)
|
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +124,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) {
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("pub", name, "")
|
packagePURL := canonicalPackagePURL("pub", name)
|
||||||
filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
|
filtered := h.filterAndRewriteVersions(name, packagePURL, versions)
|
||||||
metadata["versions"] = filtered
|
metadata["versions"] = filtered
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/git-pkgs/purl"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -131,7 +129,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
packagePURL := purl.MakePURLString("pypi", name, "")
|
packagePURL := canonicalPackagePURL("pypi", name)
|
||||||
filtered := make(map[string]bool)
|
filtered := make(map[string]bool)
|
||||||
|
|
||||||
for version, files := range releases {
|
for version, files := range releases {
|
||||||
|
|
@ -262,7 +260,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
|
||||||
packageName, _ := extractPyPIName(metadata)
|
packageName, _ := extractPyPIName(metadata)
|
||||||
packagePURL := ""
|
packagePURL := ""
|
||||||
if packageName != "" {
|
if packageName != "" {
|
||||||
packagePURL = purl.MakePURLString("pypi", packageName, "")
|
packagePURL = canonicalPackagePURL("pypi", packageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.filterAndRewriteReleases(metadata, packageName, packagePURL)
|
h.filterAndRewriteReleases(metadata, packageName, packagePURL)
|
||||||
|
|
@ -428,8 +426,7 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,7 @@ 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.Logger.Error("failed to get rpm package", "error", err)
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
||||||
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,418 +0,0 @@
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,19 +58,17 @@ 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"
|
||||||
|
|
@ -86,12 +84,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
|
||||||
}
|
}
|
||||||
|
|
@ -158,27 +156,18 @@ 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 {
|
||||||
// Use one authentication-aware transport for metadata and artifacts so
|
// Create shared components with circuit breaker
|
||||||
// configured credentials and cached OCI challenges apply consistently.
|
baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL))
|
||||||
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.Packages,
|
Packages: s.cfg.Cooldown.NormalizedPackages(),
|
||||||
}
|
}
|
||||||
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
|
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
|
||||||
proxy.HTTPClient = &metadataClient
|
proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout()
|
||||||
|
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()
|
||||||
|
|
@ -208,8 +197,13 @@ func (s *Server) Start() error {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mount protocol handlers
|
// Mount protocol handlers
|
||||||
npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL)
|
npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM)
|
||||||
cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL)
|
cargoHandler := handler.NewCargoHandler(
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,13 @@ func newTestServer(t *testing.T) *testServer {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Mount handlers
|
// Mount handlers
|
||||||
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL)
|
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM)
|
||||||
cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL)
|
cargoHandler := handler.NewCargoHandler(
|
||||||
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue