diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 700f28e..bb3d87d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go-version }} @@ -35,12 +35,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.25' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f459b6..c08cfa6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,12 +20,12 @@ jobs: contents: read steps: - name: Check out the repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - name: Log in to the Container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d4d0b5..5d32181 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -22,7 +22,7 @@ jobs: - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: false diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index f947c7e..625f944 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -12,12 +12,12 @@ jobs: swagger: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.25' diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index cce6e4f..4df0e18 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,9 +21,9 @@ jobs: security-events: write steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 diff --git a/Dockerfile b/Dockerfile index 7b2795c..c2e197f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder WORKDIR /src diff --git a/config.example.yaml b/config.example.yaml index 2124e48..1176f5b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -96,8 +96,7 @@ upstream: cargo_download: "https://static.crates.io/crates" # Authentication for upstream registries - # Keys are absolute URL scopes. Scheme, host, effective port, and path - # segment boundaries must match; the longest matching scope wins. + # Keys are URL prefixes matched against request URLs. # Values can reference environment variables using ${VAR_NAME} syntax. # # Supported auth types: @@ -169,7 +168,8 @@ cooldown: # npm: "7d" # cargo: "0" - # Per-package overrides (keyed by PURL) + # Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes + # may use either @scope or the canonical %40scope form. # packages: # "pkg:npm/lodash": "0" # "pkg:npm/@babel/core": "14d" diff --git a/docs/architecture.md b/docs/architecture.md index 6d9bfda..f04d548 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -240,8 +240,6 @@ Fetches artifacts from upstream registries. - Exponential backoff retry on 429 (rate limit) and 5xx errors - Returns streaming reader (doesn't load into memory) - 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:** - Determines download URL for a package/version @@ -353,7 +351,6 @@ Eviction can be implemented as: - Fresh data - new versions visible immediately - Metadata is small, upstream fetch is fast - Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table -- 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?** - Memory efficient - don't load large files into RAM diff --git a/docs/configuration.md b/docs/configuration.md index c73dc2c..8998ac2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,9 +123,7 @@ upstream: ## 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. - -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. +Configure authentication for private upstream registries. Auth is matched by URL prefix, and credentials can reference environment variables using `${VAR_NAME}` syntax. ### Bearer Token @@ -174,7 +172,7 @@ upstream: ### 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 upstream: @@ -236,6 +234,8 @@ cooldown: Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. +Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins. + Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages. Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata. @@ -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. -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 cache_metadata: true ``` diff --git a/go.mod b/go.mod index b063dba..e6e0a08 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,19 @@ go 1.25.6 require ( github.com/BurntSushi/toml v1.6.0 github.com/CycloneDX/cyclonedx-go v0.11.0 - github.com/git-pkgs/archives v0.3.0 + github.com/git-pkgs/archives v0.4.0 github.com/git-pkgs/cooldown v0.1.1 - github.com/git-pkgs/enrichment v0.4.1 - github.com/git-pkgs/purl v0.1.13 - github.com/git-pkgs/registries v0.6.2 + github.com/git-pkgs/enrichment v0.6.4 + github.com/git-pkgs/magic v0.1.0 + github.com/git-pkgs/purl v0.1.15 + github.com/git-pkgs/registries v0.6.4 github.com/git-pkgs/spdx v0.1.4 - github.com/git-pkgs/vers v0.2.6 - github.com/git-pkgs/vulns v0.1.6 - github.com/go-chi/chi/v5 v5.3.0 + github.com/git-pkgs/vers v0.3.0 + github.com/git-pkgs/vulns v0.2.1 + github.com/go-chi/chi/v5 v5.3.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_golang v1.24.0 github.com/prometheus/client_model v0.6.2 github.com/spdx/tools-golang v0.5.7 github.com/swaggo/swag v1.16.6 @@ -24,7 +25,7 @@ require ( golang.org/x/sync v0.22.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.53.0 + modernc.org/sqlite v1.55.0 ) require ( @@ -115,7 +116,7 @@ require ( github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ecosyste-ms/ecosystems-go v0.2.0 // indirect + github.com/ecosyste-ms/ecosystems-go v0.4.0 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/fatih/color v1.18.0 // indirect @@ -217,15 +218,16 @@ require ( github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.23.0 // indirect - github.com/oapi-codegen/runtime v1.4.1 // indirect + github.com/oapi-codegen/nullable v1.1.0 // indirect + github.com/oapi-codegen/runtime v1.6.0 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect github.com/pandatix/go-cvss v0.6.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect @@ -265,7 +267,7 @@ require ( github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect - github.com/ulikunitz/xz v0.5.15 // indirect + github.com/ulikunitz/xz v0.5.16 // indirect github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/urfave/cli/v2 v2.3.0 // indirect @@ -289,25 +291,25 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.36.0 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.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 google.golang.org/api v0.272.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.7.0 // indirect - modernc.org/libc v1.73.4 // indirect + modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect diff --git a/go.sum b/go.sum index b1d35f6..c239240 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3w github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8= github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= @@ -217,8 +217,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ecosyste-ms/ecosystems-go v0.2.0 h1:Nhpg54C+St8Sd/mf8bNJmQqx35ZgHw33TfFoxaXMQI8= -github.com/ecosyste-ms/ecosystems-go v0.2.0/go.mod h1:CCdzT1iAZirbEZAbFSnWpK88eKKaIWex7gjtZ0UudXA= +github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA= +github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= @@ -244,30 +244,32 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78= -github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU= +github.com/git-pkgs/archives v0.4.0 h1:KNmmIsLiSH27lUdT27EfUkQXFaLgXV5KezE81iyOIgo= +github.com/git-pkgs/archives v0.4.0/go.mod h1:tfio0OIuPKEBKHs/UCL5XBUvYmKpnvtnba2iDlfSd6g= github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= -github.com/git-pkgs/enrichment v0.4.1 h1:A8BKs0XwvpF1sF5qviZy4fkJAe18qB9OgpbRnmwnT34= -github.com/git-pkgs/enrichment v0.4.1/go.mod h1:stHqZUitV9ZkwACqHzBysLMSe6T4QZn81hxTdSroNhM= +github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU= +github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY= +github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY= +github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M= github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4= github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY= github.com/git-pkgs/pom v0.1.5/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= -github.com/git-pkgs/purl v0.1.13 h1:at8BU6vnP5oonHFHAPA064BzgRqij+SZcOUDgNT2DC8= -github.com/git-pkgs/purl v0.1.13/go.mod h1:8oCcdcYZA/e1B33e7Ylju6azboTKjdqf3ybcbQj6I/o= -github.com/git-pkgs/registries v0.6.2 h1:26G5zW6Q7x1CSfNkaEqEjRMJiA4JwfdKOCJ7Qm+u0a8= -github.com/git-pkgs/registries v0.6.2/go.mod h1:GR0Bu6nC3NQe6f7lfDoEVqAnoQkMocf4M98B12a7B3E= +github.com/git-pkgs/purl v0.1.15 h1:iQ3clh0Cw41rkM0rf24B7ShnN9Z+UtLMAFlNDUs+Qd4= +github.com/git-pkgs/purl v0.1.15/go.mod h1:PqCLVBDeZrZgHysR803/AntMELgIr2LFZVNCcwLH2m0= +github.com/git-pkgs/registries v0.6.4 h1:Kq/KlStjaQyE83UXT/tKuzCrIzc4keGeBjtroMqgoHA= +github.com/git-pkgs/registries v0.6.4/go.mod h1:YkGHbxHIe2Ha/ROH6zNkS5PJUUoa9g0Ti/s2XhZnrak= github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs= github.com/git-pkgs/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto= -github.com/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8= -github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= -github.com/git-pkgs/vulns v0.1.6 h1:8RRSgdlxp4JMU0Zykr63XTOMo5CyZKwt/PwaQxrx9Yg= -github.com/git-pkgs/vulns v0.1.6/go.mod h1:TsZC4MjoCkKJslgmbcmRCnytwnFcjESC2N8b0a2xDWc= +github.com/git-pkgs/vers v0.3.0 h1:xM4LLUCRmqzdDfe+/pVQUx4SRyFXRVth6tOsJ14wMKU= +github.com/git-pkgs/vers v0.3.0/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= +github.com/git-pkgs/vulns v0.2.1 h1:tWGhOfPVDZwkM2Y9vRkMpMR+gjtlu2jhERS5JeNBoKQ= +github.com/git-pkgs/vulns v0.2.1/go.mod h1:/0gHKHQR5SWttZVEMqgOvCXssKFwAtbac/PfkhBax9o= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -422,8 +424,8 @@ github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3 github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -512,8 +514,8 @@ github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= -github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -541,14 +543,14 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= @@ -654,8 +656,8 @@ github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVF github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= @@ -702,8 +704,8 @@ go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDoj go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= @@ -726,8 +728,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= @@ -755,8 +757,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -807,8 +809,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -839,8 +841,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -857,12 +859,12 @@ google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -882,20 +884,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= -modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= -modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= -modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= -modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -904,8 +906,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= -modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/config/config.go b/internal/config/config.go index a54d50c..ec510d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,10 +54,12 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" "time" + "github.com/git-pkgs/purl" "gopkg.in/yaml.v3" ) @@ -137,9 +139,38 @@ type CooldownConfig struct { Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"` // Packages overrides the cooldown for specific packages (keyed by PURL). + // Valid PURL keys are normalized to canonical form before use. Packages map[string]string `json:"packages" yaml:"packages"` } +// NormalizedPackages returns a copy of the package overrides with valid PURL +// keys in canonical form. An explicitly canonical key wins over an equivalent +// noncanonical key, and invalid keys are preserved unchanged. +func (c *CooldownConfig) NormalizedPackages() map[string]string { + if c == nil || c.Packages == nil { + return nil + } + + keys := make([]string, 0, len(c.Packages)) + for key := range c.Packages { + keys = append(keys, key) + } + sort.Strings(keys) + + normalized := make(map[string]string, len(c.Packages)) + for _, key := range keys { + canonical := key + if parsed, err := purl.Parse(key); err == nil { + canonical = parsed.String() + } + if _, exists := normalized[canonical]; exists && key != canonical { + continue + } + normalized[canonical] = c.Packages[key] + } + return normalized +} + // StorageConfig configures artifact storage. type StorageConfig struct { // URL is the storage backend URL. @@ -274,29 +305,23 @@ type UpstreamConfig struct { CargoDownload string `json:"cargo_download" yaml:"cargo_download"` // Auth configures authentication for upstream registries. - // Keys are absolute URL scopes matched by scheme, host, effective port, - // and path-segment prefix. + // Keys are URL prefixes that are matched against request URLs. // Example: "https://npm.pkg.github.com" matches all requests to that host. Auth map[string]AuthConfig `json:"auth" yaml:"auth"` } // 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 { if u.Auth == nil { return nil } - target, err := parseAuthURL(url) - if err != nil { - return nil - } var bestMatch *AuthConfig var bestLen int for pattern, auth := range u.Auth { - configured, err := parseAuthURL(pattern) - if err == nil && authURLMatches(configured, target) && len(pattern) > bestLen { + if strings.HasPrefix(url, pattern) && len(pattern) > bestLen { a := auth // copy to avoid loop variable capture bestMatch = &a bestLen = len(pattern) @@ -306,45 +331,6 @@ func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig { 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. type AuthConfig struct { // Type is the authentication type: "bearer", "basic", or "header". diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 63520a2..decc18f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -363,6 +363,34 @@ cooldown: if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" { t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d") } + if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" { + t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d") + } +} + +func TestCooldownConfigNormalizedPackages(t *testing.T) { + rawScoped := "pkg:npm/@typescript/typescript-darwin-arm64" + canonicalScoped := "pkg:npm/%40typescript/typescript-darwin-arm64" + cfg := CooldownConfig{Packages: map[string]string{ + rawScoped: "2d", + canonicalScoped: "3d", + "not-a-purl": "4d", + }} + + got := cfg.NormalizedPackages() + + if got[canonicalScoped] != "3d" { + t.Errorf("canonical scoped package duration = %q, want %q", got[canonicalScoped], "3d") + } + if _, exists := got[rawScoped]; exists { + t.Errorf("raw scoped package key %q was not canonicalized", rawScoped) + } + if got["not-a-purl"] != "4d" { + t.Errorf("invalid PURL duration = %q, want preserved value %q", got["not-a-purl"], "4d") + } + if cfg.Packages[rawScoped] != "2d" { + t.Error("NormalizedPackages mutated the source map") + } } func TestLoadCooldownFromEnv(t *testing.T) { @@ -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) - } - }) - } -} diff --git a/internal/database/metadata_cache_test.go b/internal/database/metadata_cache_test.go index 09dcba3..5701816 100644 --- a/internal/database/metadata_cache_test.go +++ b/internal/database/metadata_cache_test.go @@ -30,12 +30,8 @@ func TestUpsertAndGetMetadataCache(t *testing.T) { StoragePath: "_metadata/npm/lodash/metadata", ETag: sql.NullString{String: `"abc123"`, Valid: true}, ContentType: sql.NullString{String: "application/json", Valid: true}, - ContentDigest: sql.NullString{ - String: "sha256:0123456789abcdef", - Valid: true, - }, - Size: sql.NullInt64{Int64: 1024, Valid: true}, - FetchedAt: sql.NullTime{Time: time.Now(), Valid: true}, + Size: sql.NullInt64{Int64: 1024, Valid: true}, + FetchedAt: sql.NullTime{Time: time.Now(), Valid: true}, } err := db.UpsertMetadataCache(entry) @@ -66,9 +62,6 @@ func TestUpsertAndGetMetadataCache(t *testing.T) { if !got.ContentType.Valid || got.ContentType.String != "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 { 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") } } - -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) - } -} diff --git a/internal/database/queries.go b/internal/database/queries.go index f8b76fe..5d95596 100644 --- a/internal/database/queries.go +++ b/internal/database/queries.go @@ -894,7 +894,7 @@ func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, err var entry MetadataCacheEntry query := db.Rebind(` 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 = ? `) err := db.Get(&entry, query, ecosystem, name) @@ -914,13 +914,12 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { if db.dialect == DialectPostgres { query = ` INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type, - content_digest, size, last_modified, fetched_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + size, last_modified, fetched_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(ecosystem, name) DO UPDATE SET storage_path = EXCLUDED.storage_path, etag = EXCLUDED.etag, content_type = EXCLUDED.content_type, - content_digest = EXCLUDED.content_digest, size = EXCLUDED.size, last_modified = EXCLUDED.last_modified, fetched_at = EXCLUDED.fetched_at, @@ -929,13 +928,12 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { } else { query = ` INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type, - content_digest, size, last_modified, fetched_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + size, last_modified, fetched_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ecosystem, name) DO UPDATE SET storage_path = excluded.storage_path, etag = excluded.etag, content_type = excluded.content_type, - content_digest = excluded.content_digest, size = excluded.size, last_modified = excluded.last_modified, fetched_at = excluded.fetched_at, @@ -945,7 +943,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { _, err := db.Exec(query, 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 { return fmt.Errorf("upserting metadata cache: %w", err) diff --git a/internal/database/schema.go b/internal/database/schema.go index c73877d..c8d8d1e 100644 --- a/internal/database/schema.go +++ b/internal/database/schema.go @@ -102,7 +102,6 @@ CREATE TABLE IF NOT EXISTS metadata_cache ( storage_path TEXT NOT NULL, etag TEXT, content_type TEXT, - content_digest TEXT, size INTEGER, last_modified DATETIME, fetched_at DATETIME, @@ -203,7 +202,6 @@ CREATE TABLE IF NOT EXISTS metadata_cache ( storage_path TEXT NOT NULL, etag TEXT, content_type TEXT, - content_digest TEXT, size BIGINT, last_modified TIMESTAMP, fetched_at TIMESTAMP, @@ -361,7 +359,6 @@ var migrations = []migration{ {"003_ensure_artifacts_table", migrateEnsureArtifactsTable}, {"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable}, {"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable}, - {"006_add_metadata_content_digest", migrateAddMetadataContentDigest}, } // isTableNotFound returns true if the error indicates a missing table. @@ -584,20 +581,6 @@ func migrateEnsureMetadataCacheTable(db *DB) error { 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. func (db *DB) EnsureMetadataCacheTable() error { has, err := db.HasTable("metadata_cache") @@ -618,7 +601,6 @@ func (db *DB) EnsureMetadataCacheTable() error { storage_path TEXT NOT NULL, etag TEXT, content_type TEXT, - content_digest TEXT, size BIGINT, last_modified TIMESTAMP, fetched_at TIMESTAMP, @@ -636,7 +618,6 @@ func (db *DB) EnsureMetadataCacheTable() error { storage_path TEXT NOT NULL, etag TEXT, content_type TEXT, - content_digest TEXT, size INTEGER, last_modified DATETIME, fetched_at DATETIME, diff --git a/internal/database/types.go b/internal/database/types.go index 7128f12..47dc47e 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -78,18 +78,17 @@ func (a *Artifact) IsCached() bool { // MetadataCacheEntry represents a cached metadata blob for offline serving. type MetadataCacheEntry struct { - ID int64 `db:"id" json:"id"` - Ecosystem string `db:"ecosystem" json:"ecosystem"` - Name string `db:"name" json:"name"` - StoragePath string `db:"storage_path" json:"storage_path"` - ETag sql.NullString `db:"etag" json:"etag,omitempty"` - ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"` - ContentDigest sql.NullString `db:"content_digest" json:"content_digest,omitempty"` - Size sql.NullInt64 `db:"size" json:"size,omitempty"` - LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"` - FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + Ecosystem string `db:"ecosystem" json:"ecosystem"` + Name string `db:"name" json:"name"` + StoragePath string `db:"storage_path" json:"storage_path"` + ETag sql.NullString `db:"etag" json:"etag,omitempty"` + ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"` + Size sql.NullInt64 `db:"size" json:"size,omitempty"` + LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"` + FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // Vulnerability represents a cached vulnerability record. diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index 5d7810c..79fb750 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -6,10 +6,9 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -30,11 +29,18 @@ type CargoHandler struct { } // NewCargoHandler creates a new cargo protocol handler. -func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler { +func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler { + if strings.TrimSpace(indexURL) == "" { + indexURL = cargoUpstream + } + if strings.TrimSpace(downloadURL) == "" { + downloadURL = cargoDownloadBase + } + return &CargoHandler{ proxy: proxy, - indexURL: cargoUpstream, - downloadURL: cargoDownloadBase, + indexURL: strings.TrimSuffix(indexURL, "/"), + downloadURL: strings.TrimSuffix(downloadURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -143,7 +149,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr continue } - cratePURL := purl.MakePURLString("cargo", crate.Name, "") + cratePURL := canonicalPackagePURL("cargo", crate.Name) if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering cargo version", @@ -191,10 +197,17 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("cargo download request", "crate", name, "version", version, "filename", filename) - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename) + downloadURL := fmt.Sprintf( + "%s/%s/%s", + h.downloadURL, + url.PathEscape(name), + url.PathEscape(filename), + ) + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), "cargo", name, version, filename, downloadURL, + ) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch crate", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch crate") return } diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go index 10d3faf..895ff7b 100644 --- a/internal/handler/cargo_test.go +++ b/internal/handler/cargo_test.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "io" "log/slog" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "time" "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/registries/fetch" ) func cargoTestProxy() *Proxy { @@ -70,6 +72,75 @@ func TestCargoConfigEndpoint(t *testing.T) { } } +func TestCargoHandlerUsesConfiguredUpstreams(t *testing.T) { + t.Run("index", func(t *testing.T) { + var requestPath, authHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + authHeader = r.Header.Get("Authorization") + if authHeader != "Bearer cargo-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, `{"name":"serde","vers":"1.0.0"}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + proxy.AuthForURL = func(string) (string, string) { + return "Authorization", "Bearer cargo-token" + } + h := NewCargoHandler( + proxy, + "http://proxy.test", + upstream.URL+"/index/", + "https://crates.example.test/files/", + ) + + req := httptest.NewRequest(http.MethodGet, "/se/rd/serde", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if requestPath != "/index/se/rd/serde" { + t.Errorf("upstream path = %q, want %q", requestPath, "/index/se/rd/serde") + } + if authHeader != "Bearer cargo-token" { + t.Errorf("Authorization = %q, want %q", authHeader, "Bearer cargo-token") + } + }) + + t.Run("download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("crate")), + ContentType: "application/gzip", + } + h := NewCargoHandler( + proxy, + "http://proxy.test", + "https://index.example.test/root/", + "https://crates.example.test/files/", + ) + + req := httptest.NewRequest(http.MethodGet, "/crates/serde/1.0.0/download", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://crates.example.test/files/serde/serde-1.0.0.crate" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) +} + func TestCargoIndexProxy(t *testing.T) { // Create a mock upstream index server indexContent := `{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123"} diff --git a/internal/handler/composer.go b/internal/handler/composer.go index bc3bc1d..45935b7 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -10,8 +10,6 @@ import ( "path" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -216,7 +214,7 @@ func deepCopyValue(v any) any { // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // for a single package's version list. func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { - packagePURL := purl.MakePURLString("composer", packageName, "") + packagePURL := canonicalPackagePURL("composer", packageName) filtered := versionList[:0] for _, v := range versionList { @@ -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) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/handler/conan.go b/internal/handler/conan.go index 53f6428..c0476f9 100644 --- a/internal/handler/conan.go +++ b/internal/handler/conan.go @@ -84,8 +84,7 @@ func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch file", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch file") return } @@ -122,8 +121,7 @@ func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch file", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch file") return } diff --git a/internal/handler/conda.go b/internal/handler/conda.go index cfa20c8..224c25f 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -6,8 +6,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -74,8 +72,7 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } @@ -218,7 +215,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) { continue } - packagePURL := purl.MakePURLString("conda", name, "") + packagePURL := canonicalPackagePURL("conda", name) if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) { version, _ := entryMap["version"].(string) diff --git a/internal/handler/container.go b/internal/handler/container.go index 3ef2fa1..0710ed1 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( const ( dockerHubRegistry = "https://registry-1.docker.io" + dockerHubAuth = "https://auth.docker.io" blobMatchCount = 3 // full match + name + digest manifestMatchCount = 3 // full match + name + reference tagsListMatchCount = 2 // full match + name @@ -22,6 +24,7 @@ const ( type ContainerHandler struct { proxy *Proxy registryURL string + authURL string proxyURL string } @@ -30,6 +33,7 @@ func NewContainerHandler(proxy *Proxy, proxyURL string) *ContainerHandler { return &ContainerHandler{ proxy: proxy, registryURL: dockerHubRegistry, + authURL: dockerHubAuth, 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) - filename := digest - cached, err := h.proxy.GetCachedArtifact(r.Context(), "oci", name, digest, filename) + // Get auth token for upstream + token, err := h.getAuthToken(r.Context(), name, "pull") if err != nil { - h.proxy.Logger.Error("failed to check blob cache", "error", err) - h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check blob cache") - return - } - if cached != nil { - w.Header().Set("Docker-Content-Digest", digest) - w.Header().Set("Content-Type", "application/octet-stream") - if r.Method == http.MethodHead { - serveArtifactHead(w, cached) - return - } - ServeArtifact(w, cached) + h.proxy.Logger.Error("failed to get auth token", "error", err) + h.containerError(w, http.StatusUnauthorized, "UNAUTHORIZED", "failed to authenticate") return } // For HEAD requests, just proxy to upstream if r.Method == http.MethodHead { - h.proxyBlobHead(w, r, name, digest) + h.proxyBlobHead(w, r, name, digest, token) return } - // Try to get from cache, or fetch from the authentication-aware upstream client. - result, err := h.proxy.GetOrFetchArtifactFromURL( + // Try to get from cache, or fetch from upstream with auth + filename := digest + headers := http.Header{"Authorization": {"Bearer " + token}} + result, err := h.proxy.GetOrFetchArtifactFromURLWithHeaders( r.Context(), "oci", name, digest, // use digest as version filename, fmt.Sprintf("%s/v2/%s/blobs/%s", h.registryURL, name, digest), + headers, ) if err != nil { + if errors.Is(err, ErrUpstreamNotFound) { + h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry") + return + } h.proxy.Logger.Error("failed to fetch blob", "error", err) - h.containerError(w, http.StatusBadGateway, "BLOB_UNKNOWN", "failed to fetch blob") + h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob") return } @@ -131,31 +132,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req ServeArtifact(w, result) } -func serveArtifactHead(w http.ResponseWriter, result *CacheResult) { - if result.RedirectURL != "" { - if result.Hash != "" { - w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash)) - } - w.Header().Set("Location", result.RedirectURL) - w.WriteHeader(http.StatusFound) - return - } - 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. +// handleManifest proxies manifest requests to upstream. +// Manifests change when tags are updated, so we proxy these directly. // Path format: {name}/manifests/{reference} func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request, path string) { 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.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. @@ -186,6 +214,13 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request 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) if r.URL.RawQuery != "" { upstreamURL += "?" + r.URL.RawQuery @@ -197,6 +232,8 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request return } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := h.proxy.HTTPClient.Do(req) if err != nil { 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) } +// 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. -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) 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 } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := h.proxy.HTTPClient.Do(req) if err != nil { h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream") diff --git a/internal/handler/container_manifest.go b/internal/handler/container_manifest.go deleted file mode 100644 index 245ced4..0000000 --- a/internal/handler/container_manifest.go +++ /dev/null @@ -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[:]) -} diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index 404b12a..853059e 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -1,14 +1,16 @@ package handler import ( + "bytes" + "context" "encoding/json" "io" + "log/slog" "net/http" "net/http/httptest" - "strconv" "testing" - upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient" + "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/registries/fetch" ) @@ -133,387 +135,90 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) { } } -func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" - registryRequests := 0 - tokenRequests := 0 - var upstream *httptest.Server - upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/token": - tokenRequests++ - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "token": "discovered-token", - "expires_in": 3600, - }) - case "/v2/library/nginx/blobs/" + digest: - registryRequests++ - if r.Header.Get("Authorization") != "Bearer discovered-token" { - w.Header().Set("WWW-Authenticate", `Bearer realm="`+upstream.URL+`/token",service="registry.test",scope="repository:library/nginx:pull"`) - http.Error(w, "authentication required", http.StatusUnauthorized) - return - } - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = io.WriteString(w, "upstream blob") - default: - http.NotFound(w, r) - } +func TestContainerHandler_BlobDownload_CachesWithAuth(t *testing.T) { + // Set up a mock auth server that returns a token + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token-123"}) })) - defer upstream.Close() + defer authServer.Close() - proxy, _, _, _ := setupTestProxy(t) - authTransport := upstreamhttp.NewTransport(http.DefaultTransport, nil) - client := &http.Client{Transport: authTransport} - artifactFetcher := fetch.NewFetcher( - fetch.WithHTTPClient(client), - fetch.WithMaxRetries(0), - ) - t.Cleanup(func() { _ = artifactFetcher.Close() }) - proxy.Fetcher = artifactFetcher - proxy.HTTPClient = client + // Set up mock fetcher that captures headers + var capturedHeaders http.Header + mf := &mockFetcherWithHeaders{ + fetchFn: func(_ context.Context, _ string, headers http.Header) (*fetch.Artifact, error) { + capturedHeaders = headers + return &fetch.Artifact{ + Body: io.NopCloser(bytes.NewReader([]byte("blob-content"))), + Size: 12, + ContentType: "application/octet-stream", + }, nil + }, + } + + dir := t.TempDir() + db, err := database.Create(dir + "/test.db") + if err != nil { + t.Fatalf("failed to create test database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + store := newMockStorage() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + proxy := &Proxy{ + DB: db, + Storage: store, + Fetcher: mf, + Logger: logger, + HTTPClient: &http.Client{}, + } h := &ContainerHandler{ proxy: proxy, - registryURL: upstream.URL, + registryURL: "https://registry-1.docker.io", + authURL: authServer.URL, proxyURL: "http://localhost:8080", } - for range 2 { - req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - if got := w.Body.String(); got != "upstream blob" { - t.Errorf("body = %q, want %q", got, "upstream blob") - } - } - - if tokenRequests != 1 { - t.Errorf("token requests = %d, want 1", tokenRequests) - } - if registryRequests != 2 { - t.Errorf("registry requests = %d, want 2", registryRequests) - } -} - -func TestContainerHandler_BlobDownload_CacheHitSkipsAuth(t *testing.T) { - proxy, db, store, fetcher := setupTestProxy(t) - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" - seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob") - - upstreamRequests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - upstreamRequests++ - http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) - })) - defer upstream.Close() - - h := &ContainerHandler{ - proxy: proxy, - registryURL: upstream.URL, - proxyURL: "http://localhost:8080", - } - - req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/"+digest, nil) + handler := h.Routes() + req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil) w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) + handler.ServeHTTP(w, req) 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 { - t.Errorf("upstream requests = %d, want 0", upstreamRequests) + auth := capturedHeaders.Get("Authorization") + 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) { - 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() - 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") - } +// mockFetcherWithHeaders captures headers passed to FetchWithHeaders. +type mockFetcherWithHeaders struct { + fetchFn func(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) } -func TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) { - proxy, db, store, fetcher := setupTestProxy(t) - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" - seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob") - store.signedURL = "https://storage.example.test/cached-blob?signature=test" - proxy.DirectServe = true - - h := &ContainerHandler{ - proxy: proxy, - registryURL: "https://registry.example.test", - proxyURL: "http://localhost:8080", - } - - req := httptest.NewRequest(http.MethodHead, "/library/nginx/blobs/"+digest, nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("status = %d, want %d", w.Code, http.StatusFound) - } - if got := w.Header().Get("Location"); got != store.signedURL { - t.Errorf("Location = %q, want %q", got, store.signedURL) - } - if got := w.Header().Get("ETag"); got != `"abc123"` { - t.Errorf("ETag = %q, want %q", got, `"abc123"`) - } - if w.Body.Len() != 0 { - t.Errorf("HEAD response body length = %d, want 0", w.Body.Len()) - } - if fetcher.fetchCalled { - t.Error("fetcher should not be called on cache hit") - } +func (f *mockFetcherWithHeaders) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) { + return f.FetchWithHeaders(ctx, url, nil) } -func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) { - digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` - upstreamAvailable := true - upstreamRequests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamRequests++ - if !upstreamAvailable { - http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) - return - } - if r.URL.Path != "/v2/library/nginx/manifests/"+digest { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", digest) - w.Header().Set("ETag", `"manifest-etag"`) - if r.Method != http.MethodHead { - _, _ = io.WriteString(w, manifest) - } - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"} - - first := httptest.NewRecorder() - h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil)) - if first.Code != http.StatusOK { - t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String()) - } - if first.Body.String() != manifest { - t.Fatalf("initial body = %q, want %q", first.Body.String(), manifest) - } - - upstreamAvailable = false - second := httptest.NewRecorder() - h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil)) - if second.Code != http.StatusOK { - t.Fatalf("cached status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String()) - } - if second.Body.String() != manifest { - t.Errorf("cached body = %q, want %q", second.Body.String(), manifest) - } - if got := second.Header().Get("Docker-Content-Digest"); got != digest { - t.Errorf("cached Docker-Content-Digest = %q, want %q", got, digest) - } - - head := httptest.NewRecorder() - h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil)) - if head.Code != http.StatusOK { - t.Fatalf("cached HEAD status = %d, want %d", head.Code, http.StatusOK) - } - wantLength := strconv.Itoa(len(manifest)) - if got := head.Header().Get("Content-Length"); got != wantLength { - t.Errorf("cached HEAD Content-Length = %q, want %q", got, wantLength) - } - if head.Body.Len() != 0 { - t.Errorf("cached HEAD body length = %d, want 0", head.Body.Len()) - } - if upstreamRequests != 1 { - t.Errorf("upstream requests = %d, want 1", upstreamRequests) - } +func (f *mockFetcherWithHeaders) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) { + return f.fetchFn(ctx, url, headers) } -func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) { - digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` - upstreamAvailable := true - upstreamRequests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - upstreamRequests++ - if !upstreamAvailable { - http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) - return - } - w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json") - w.Header().Set("Docker-Content-Digest", digest) - _, _ = io.WriteString(w, manifest) - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - proxy.MetadataTTL = 0 - h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"} - - first := httptest.NewRecorder() - h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)) - if first.Code != http.StatusOK { - t.Fatalf("initial status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String()) - } - - upstreamAvailable = false - second := httptest.NewRecorder() - h.Routes().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)) - if second.Code != http.StatusOK { - t.Fatalf("stale status = %d, want %d; body: %s", second.Code, http.StatusOK, second.Body.String()) - } - if second.Body.String() != manifest { - t.Errorf("stale body = %q, want %q", second.Body.String(), manifest) - } - if got := second.Header().Get("Warning"); got != `110 - "Response is Stale"` { - t.Errorf("Warning = %q, want stale warning", got) - } - if got := second.Header().Get("Docker-Content-Digest"); got != digest { - t.Errorf("stale Docker-Content-Digest = %q, want %q", got, digest) - } - if upstreamRequests != 2 { - t.Errorf("upstream requests = %d, want 2", upstreamRequests) - } -} - -func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) { - digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` - upstreamAvailable := true - upstreamRequests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamRequests++ - if !upstreamAvailable { - http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) - return - } - if r.URL.Path != "/v2/library/nginx/manifests/latest" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", digest) - _, _ = io.WriteString(w, manifest) - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"} - - first := httptest.NewRecorder() - h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)) - if first.Code != http.StatusOK { - t.Fatalf("tag status = %d, want %d; body: %s", first.Code, http.StatusOK, first.Body.String()) - } - - upstreamAvailable = false - byDigest := httptest.NewRecorder() - h.Routes().ServeHTTP(byDigest, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil)) - if byDigest.Code != http.StatusOK { - t.Fatalf("digest status = %d, want %d; body: %s", byDigest.Code, http.StatusOK, byDigest.Body.String()) - } - if byDigest.Body.String() != manifest { - t.Errorf("digest body = %q, want %q", byDigest.Body.String(), manifest) - } - if got := byDigest.Header().Get("Docker-Content-Digest"); got != digest { - t.Errorf("Docker-Content-Digest = %q, want %q", got, digest) - } - if upstreamRequests != 1 { - t.Errorf("upstream requests = %d, want 1", upstreamRequests) - } -} - -func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) { - oldDigest := "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - currentDigest := oldDigest - upstreamRequests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamRequests++ - w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", currentDigest) - w.Header().Set("ETag", `"`+currentDigest+`"`) - if r.Method != http.MethodHead { - _, _ = io.WriteString(w, `{"schemaVersion":2}`) - } - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - proxy.MetadataTTL = 0 - h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://localhost:8080"} - - first := httptest.NewRecorder() - h.Routes().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/latest", nil)) - if first.Code != http.StatusOK { - t.Fatalf("initial status = %d, want %d", first.Code, http.StatusOK) - } - - currentDigest = newDigest - head := httptest.NewRecorder() - h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/latest", nil)) - if head.Code != http.StatusOK { - t.Fatalf("HEAD status = %d, want %d", head.Code, http.StatusOK) - } - if got := head.Header().Get("Docker-Content-Digest"); got != newDigest { - t.Errorf("Docker-Content-Digest = %q, want %q", got, newDigest) - } - if upstreamRequests != 2 { - t.Errorf("upstream requests = %d, want 2", upstreamRequests) - } +func (f *mockFetcherWithHeaders) Head(_ context.Context, _ string) (int64, string, error) { + return 0, "", nil } func TestContainerHandler_Routes_VersionCheck(t *testing.T) { diff --git a/internal/handler/cran.go b/internal/handler/cran.go index 0ecd2a3..2fc4fab 100644 --- a/internal/handler/cran.go +++ b/internal/handler/cran.go @@ -72,8 +72,7 @@ func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Reques result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } @@ -107,8 +106,7 @@ func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Reques result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/handler/debian.go b/internal/handler/debian.go index b767f6d..9a4aaab 100644 --- a/internal/handler/debian.go +++ b/internal/handler/debian.go @@ -81,8 +81,7 @@ func (h *DebianHandler) handlePackageDownload(w http.ResponseWriter, r *http.Req result, err := h.proxy.GetOrFetchArtifactFromURL( r.Context(), "deb", name, version, filename, downloadURL) if err != nil { - h.proxy.Logger.Error("failed to get debian package", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/handler/filename_download.go b/internal/handler/filename_download.go new file mode 100644 index 0000000..bedec16 --- /dev/null +++ b/internal/handler/filename_download.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + "strings" +) + +type filenameDownload struct { + ecosystem string + suffix string + parseErr string + fetchErr string + parse func(string) (name, version string) +} + +func (p *Proxy) handleFilenameDownload(w http.ResponseWriter, r *http.Request, d filenameDownload) { + filename := r.PathValue("filename") + if filename == "" || !strings.HasSuffix(filename, d.suffix) { + http.Error(w, "invalid filename", http.StatusBadRequest) + return + } + + name, version := d.parse(filename) + if name == "" || version == "" { + http.Error(w, d.parseErr, http.StatusBadRequest) + return + } + + p.Logger.Info(d.ecosystem+" download request", + "name", name, "version", version, "filename", filename) + + result, err := p.GetOrFetchArtifact(r.Context(), d.ecosystem, name, version, filename) + if err != nil { + p.serveArtifactError(w, err, d.fetchErr) + return + } + + ServeArtifact(w, result) +} diff --git a/internal/handler/gem.go b/internal/handler/gem.go index 9ec57e3..260568a 100644 --- a/internal/handler/gem.go +++ b/internal/handler/gem.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -60,30 +58,13 @@ func (h *GemHandler) Routes() http.Handler { // handleDownload serves a gem file, fetching and caching from upstream if needed. func (h *GemHandler) handleDownload(w http.ResponseWriter, r *http.Request) { - filename := r.PathValue("filename") - if filename == "" || !strings.HasSuffix(filename, ".gem") { - http.Error(w, "invalid filename", http.StatusBadRequest) - return - } - - // Extract name and version from filename (e.g., "rails-7.1.0.gem") - name, version := h.parseGemFilename(filename) - if name == "" || version == "" { - http.Error(w, "could not parse gem filename", http.StatusBadRequest) - return - } - - h.proxy.Logger.Info("gem download request", - "name", name, "version", version, "filename", filename) - - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "gem", name, version, filename) - if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch gem", http.StatusBadGateway) - return - } - - ServeArtifact(w, result) + h.proxy.handleFilenameDownload(w, r, filenameDownload{ + ecosystem: "gem", + suffix: ".gem", + parseErr: "could not parse gem filename", + fetchErr: "failed to fetch gem", + parse: h.parseGemFilename, + }) } // parseGemFilename extracts name and version from a gem filename. @@ -266,7 +247,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := purl.MakePURLString("gem", name, "") + packagePURL := canonicalPackagePURL("gem", name) filtered := make(map[string]bool) for _, v := range versions { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 91adc93..fc78dbf 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -48,6 +48,14 @@ func hasDotDotSegment(path string) bool { const defaultHTTPTimeout = 30 * time.Second +// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown +// lookups match keys produced by config.CooldownConfig.NormalizedPackages. +func canonicalPackagePURL(ecosystem, name string) string { + p := purl.MakePURL(ecosystem, name, "") + _ = p.Normalize() + return p.String() +} + const contentTypeJSON = "application/json" const headerAcceptEncoding = "Accept-Encoding" @@ -96,6 +104,7 @@ type Proxy struct { // storage at an internal one. DirectServeBaseURL string HTTPClient *http.Client + AuthForURL func(string) (headerName, headerValue string) } // NewProxy creates a new Proxy with the given dependencies. @@ -127,25 +136,18 @@ type CacheResult struct { // GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream. func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) { - if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil { + pkgPURL := purl.MakePURLString(ecosystem, name, "") + versionPURL := purl.MakePURLString(ecosystem, name, version) + + if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil { return nil, err } else if 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) } -// 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. func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) { pkg, err := p.DB.GetPackageByPURL(pkgPURL) @@ -250,6 +252,9 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil // Resolve download URL info, err := p.Resolver.Resolve(ctx, ecosystem, name, version) if err != nil { + if errors.Is(err, fetch.ErrNotFound) { + return nil, ErrUpstreamNotFound + } return nil, fmt.Errorf("resolving download URL: %w", err) } @@ -269,6 +274,9 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil if err != nil { metrics.RecordUpstreamFetch(ecosystem, fetchDuration) metrics.RecordUpstreamError(ecosystem, "fetch_failed") + if errors.Is(err, fetch.ErrNotFound) { + return nil, ErrUpstreamNotFound + } return nil, fmt.Errorf("fetching from upstream: %w", err) } metrics.RecordUpstreamFetch(ecosystem, fetchDuration) @@ -398,6 +406,7 @@ func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamUR req.Header.Set(header, v) } } + p.applyUpstreamAuth(req) resp, err := p.HTTPClient.Do(req) if err != nil { @@ -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) return } + p.applyUpstreamAuth(req) resp, err := p.HTTPClient.Do(req) if err != nil { @@ -450,7 +460,18 @@ func JSONError(w http.ResponseWriter, status int, message string) { } // ErrUpstreamNotFound indicates the upstream returned 404. -var ErrUpstreamNotFound = fmt.Errorf("upstream: not found") +var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound) + +// serveArtifactError writes response for a failed fetch: +// 404 when upstream reports artifact missing, 502 otherwise. +func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg string) { + if errors.Is(err, ErrUpstreamNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + p.Logger.Error("failed to get artifact", "error", err) + http.Error(w, clientMsg, http.StatusBadGateway) +} // errStale304 is returned when upstream sends 304 but the cached file is missing. var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing") @@ -553,6 +574,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e return nil, "", "", zeroTime, fmt.Errorf("creating request: %w", err) } req.Header.Set("Accept", accept) + p.applyUpstreamAuth(req) if entry != nil && entry.ETag.Valid { req.Header.Set("If-None-Match", entry.ETag.String) @@ -742,6 +764,7 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst accept = acceptHeaders[0] } req.Header.Set("Accept", accept) + p.applyUpstreamAuth(req) for _, header := range []string{headerAcceptEncoding, "If-Modified-Since", "If-None-Match"} { if v := r.Header.Get(header); v != "" { @@ -766,6 +789,17 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst _, _ = io.Copy(w, resp.Body) } +func (p *Proxy) applyUpstreamAuth(req *http.Request) { + if p.AuthForURL == nil { + return + } + + headerName, headerValue := p.AuthForURL(req.URL.String()) + if headerName != "" && headerValue != "" { + req.Header.Set(headerName, headerValue) + } +} + // GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL. // This is useful for registries where download URLs are determined from metadata. func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) { @@ -773,16 +807,18 @@ func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, } // GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL -// with additional request-specific HTTP headers. +// 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) { - 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 } else if 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) } @@ -792,6 +828,9 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers) if err != nil { + if errors.Is(err, fetch.ErrNotFound) { + return nil, ErrUpstreamNotFound + } return nil, fmt.Errorf("fetching from upstream: %w", err) } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index bb2a837..9a2b329 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "errors" + "fmt" "io" "log/slog" "net/http" @@ -13,9 +14,9 @@ import ( "testing" "time" + "github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/storage" - "github.com/git-pkgs/purl" "github.com/git-pkgs/registries/fetch" ) @@ -151,7 +152,7 @@ func seedPackage(t *testing.T, db *database.DB, store *mockStorage, ecosystem, n t.Helper() pkg := &database.Package{ - PURL: purl.MakePURLString(ecosystem, name, ""), + PURL: fmt.Sprintf("pkg:%s/%s", ecosystem, name), Ecosystem: ecosystem, 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) } - versionPURL := purl.MakePURLString(ecosystem, name, version) + versionPURL := fmt.Sprintf("pkg:%s/%s@%s", ecosystem, name, version) ver := &database.Version{ PURL: versionPURL, 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) } } + +// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key +// agrees with config.CooldownConfig.NormalizedPackages for the same package, +// so a configured override is actually found regardless of how the user wrote it. +func TestCanonicalPackagePURLMatchesConfig(t *testing.T) { + tests := []struct { + ecosystem string + requestName string + configKey string + }{ + {"npm", "@babel/core", "pkg:npm/@babel/core"}, + {"npm", "@babel/core", "pkg:npm/%40babel/core"}, + {"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"}, + {"pypi", "Django", "pkg:pypi/Django"}, + {"pypi", "django", "pkg:pypi/Django"}, + {"composer", "symfony/console", "pkg:composer/Symfony/Console"}, + {"cargo", "serde", "pkg:cargo/serde"}, + } + for _, tt := range tests { + t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) { + cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}} + normalized := cfg.NormalizedPackages() + + lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName) + if _, ok := normalized[lookup]; !ok { + t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized) + } + }) + } +} diff --git a/internal/handler/hex.go b/internal/handler/hex.go index 0f0c72e..2ff4f0f 100644 --- a/internal/handler/hex.go +++ b/internal/handler/hex.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/git-pkgs/purl" "google.golang.org/protobuf/encoding/protowire" ) @@ -54,30 +53,13 @@ func (h *HexHandler) Routes() http.Handler { // handleDownload serves a package tarball, fetching and caching from upstream if needed. func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) { - filename := r.PathValue("filename") - if filename == "" || !strings.HasSuffix(filename, ".tar") { - http.Error(w, "invalid filename", http.StatusBadRequest) - return - } - - // Extract name and version from filename (e.g., "phoenix-1.7.10.tar") - name, version := h.parseTarballFilename(filename) - if name == "" || version == "" { - http.Error(w, "could not parse tarball filename", http.StatusBadRequest) - return - } - - h.proxy.Logger.Info("hex download request", - "name", name, "version", version, "filename", filename) - - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "hex", name, version, filename) - if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) - return - } - - ServeArtifact(w, result) + h.proxy.handleFilenameDownload(w, r, filenameDownload{ + ecosystem: "hex", + suffix: ".tar", + parseErr: "could not parse tarball filename", + fetchErr: "failed to fetch package", + parse: h.parseTarballFilename, + }) } // parseTarballFilename extracts name and version from a hex tarball filename. @@ -237,7 +219,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := purl.MakePURLString("hex", name, "") + packagePURL := canonicalPackagePURL("hex", name) filtered := make(map[string]bool) for _, release := range pkg.Releases { diff --git a/internal/handler/julia.go b/internal/handler/julia.go index 08b1fdf..0fed8c9 100644 --- a/internal/handler/julia.go +++ b/internal/handler/julia.go @@ -90,8 +90,7 @@ func (h *JuliaHandler) handleRegistry(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaRegistryName, hash, hash+".tar.gz", upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get registry", "error", err) - http.Error(w, "failed to fetch registry", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch registry") return } @@ -119,8 +118,7 @@ func (h *JuliaHandler) handlePackage(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", name, hash, hash+".tar.gz", upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get package", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } @@ -141,8 +139,7 @@ func (h *JuliaHandler) handleArtifact(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "julia", juliaArtifactName, hash, hash+".tar.gz", upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch artifact", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch artifact") return } diff --git a/internal/handler/maven.go b/internal/handler/maven.go index c423645..10e551e 100644 --- a/internal/handler/maven.go +++ b/internal/handler/maven.go @@ -130,12 +130,7 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur } } if err != nil { - if errors.Is(err, ErrUpstreamNotFound) { - http.Error(w, "not found", http.StatusNotFound) - return - } - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch artifact", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch artifact") return } diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go new file mode 100644 index 0000000..3c2cecf --- /dev/null +++ b/internal/handler/notfound_ecosystems_test.go @@ -0,0 +1,151 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/git-pkgs/registries/fetch" +) + +func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) { + tests := []struct { + name string + path string + handler func(p *Proxy) http.Handler + }{ + {"debian", "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb", + func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost").Routes() }}, + {"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm", + func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }}, + {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", + func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }}, + {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl", + func(p *Proxy) http.Handler { return NewPyPIHandler(p, "http://localhost").Routes() }}, + {"cran", "/src/contrib/ggplot2_3.4.4.tar.gz", + func(p *Proxy) http.Handler { return NewCRANHandler(p, "http://localhost").Routes() }}, + {"conda", "/conda-forge/linux-64/numpy-1.26.0-py311_0.tar.bz2", + func(p *Proxy) http.Handler { return NewCondaHandler(p, "http://localhost").Routes() }}, + {"conan", "/v1/files/zlib/1.3.1/_/_/0/recipe/conan_sources.tgz", + func(p *Proxy) http.Handler { return NewConanHandler(p, "http://localhost").Routes() }}, + {"gem", "/gems/rails-7.1.0.gem", + func(p *Proxy) http.Handler { return NewGemHandler(p, "http://localhost").Routes() }}, + {"hex", "/tarballs/phoenix-1.7.10.tar", + func(p *Proxy) http.Handler { return NewHexHandler(p, "http://localhost").Routes() }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErr = fetch.ErrNotFound + + srv := httptest.NewServer(tt.handler(proxy)) + defer srv.Close() + + resp, err := http.Get(srv.URL + tt.path) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode) + } + }) + } +} + +func TestJuliaPackageUpstreamNotFoundReturns404(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErr = fetch.ErrNotFound + + dead := httptest.NewServer(http.NotFoundHandler()) + defer dead.Close() + + h := NewJuliaHandler(proxy, "http://localhost") + h.upstreamURL = dead.URL + + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + + "/package/7876af07-990d-54b4-ab0e-23690620f79a/0123456789abcdef0123456789abcdef01234567") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("want 404 for missing upstream package, got %d", resp.StatusCode) + } +} + +func TestComposerDownloadUpstreamNotFoundReturns404(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErr = fetch.ErrNotFound + + meta := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/p2/monolog/monolog.json" { + _, _ = w.Write([]byte(`{ + "packages": { + "monolog/monolog": [ + {"version": "2.9.1", "dist": {"url": "https://example.com/monolog-2.9.1.zip", "type": "zip"}} + ] + } + }`)) + return + } + http.NotFound(w, r) + })) + defer meta.Close() + + h := &ComposerHandler{proxy: proxy, repoURL: meta.URL, proxyURL: "http://localhost"} + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/files/monolog/monolog/2.9.1/monolog-2.9.1.zip") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("want 404 for missing upstream dist, got %d", resp.StatusCode) + } +} + +func TestContainerBlobUpstreamNotFoundReturns404(t *testing.T) { + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token": "test-token-123"}`)) + })) + defer authServer.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.Fetcher = &mockFetcherWithHeaders{ + fetchFn: func(_ context.Context, _ string, _ http.Header) (*fetch.Artifact, error) { + return nil, fetch.ErrNotFound + }, + } + + h := &ContainerHandler{ + proxy: proxy, + registryURL: "https://registry-1.docker.io", + authURL: authServer.URL, + proxyURL: "http://localhost:8080", + } + + req := httptest.NewRequest(http.MethodGet, + "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("want 404 for missing upstream blob, got %d; body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "BLOB_UNKNOWN") { + t.Errorf("want BLOB_UNKNOWN error code in body, got: %s", w.Body.String()) + } +} diff --git a/internal/handler/notfound_test.go b/internal/handler/notfound_test.go new file mode 100644 index 0000000..9ea38ac --- /dev/null +++ b/internal/handler/notfound_test.go @@ -0,0 +1,83 @@ +package handler + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/git-pkgs/registries/fetch" +) + +func TestErrUpstreamNotFoundWrapsFetchErrNotFound(t *testing.T) { + if !errors.Is(ErrUpstreamNotFound, fetch.ErrNotFound) { + t.Fatal("ErrUpstreamNotFound does not wrap fetch.ErrNotFound") + } +} + +func TestGetOrFetchArtifactFromURL_NotFound(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErr = fetch.ErrNotFound + + _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), + "maven", "org.example:missing", "1.0", "missing-1.0.jar", + "http://upstream.test/org/example/missing/1.0/missing-1.0.jar") + + if !errors.Is(err, ErrUpstreamNotFound) { + t.Fatalf("want ErrUpstreamNotFound, got %v", err) + } +} + +func TestMavenHandler_UpstreamNotFoundReturns404(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErr = fetch.ErrNotFound + + h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test") + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/org/example/missing/1.0/missing-1.0.jar") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode) + } +} + +func TestMavenHandler_PluginPortalFallback(t *testing.T) { + proxy, _, _, fetcher := setupTestProxy(t) + fetcher.fetchErrByURL = map[string]error{ + "http://upstream.test/org/example/plugin/1.0/plugin-1.0.jar": fetch.ErrNotFound, + } + fetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("portal artifact")), + ContentType: "application/java-archive", + } + + h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test") + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/org/example/plugin/1.0/plugin-1.0.jar") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("want 200 via plugin portal fallback, got %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "portal artifact" { + t.Errorf("want portal artifact body, got %q", body) + } + if fetcher.fetchedURL != "http://portal.test/org/example/plugin/1.0/plugin-1.0.jar" { + t.Errorf("fallback did not hit plugin portal, last URL: %s", fetcher.fetchedURL) + } +} diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 0585eda..ee9b59c 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -9,8 +9,6 @@ import ( "sort" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -27,10 +25,14 @@ type NPMHandler struct { } // NewNPMHandler creates a new npm protocol handler. -func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler { +func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler { + if strings.TrimSpace(upstreamURL) == "" { + upstreamURL = npmUpstream + } + return &NPMHandler{ proxy: proxy, - upstreamURL: npmUpstream, + upstreamURL: strings.TrimSuffix(upstreamURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -134,7 +136,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma return } - packagePURL := purl.MakePURLString("npm", packageName, "") + packagePURL := canonicalPackagePURL("npm", packageName) for version := range versions { publishedStr, ok := timeMap[version].(string) @@ -263,8 +265,20 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("npm download request", "package", packageName, "version", version, "filename", filename) - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename) + downloadURL := fmt.Sprintf( + "%s/%s/-/%s", + h.upstreamURL, + escapeNPMDownloadPackage(packageName), + url.PathEscape(filename), + ) + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), "npm", packageName, version, filename, downloadURL, + ) if err != nil { + if errors.Is(err, ErrUpstreamNotFound) { + JSONError(w, http.StatusNotFound, "package not found") + return + } h.proxy.Logger.Error("failed to get artifact", "error", err) JSONError(w, http.StatusBadGateway, "failed to fetch package") return @@ -273,6 +287,14 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) { ServeArtifact(w, result) } +func escapeNPMDownloadPackage(packageName string) string { + scope, name, scoped := strings.Cut(packageName, "/") + if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") { + return url.PathEscape(scope) + "/" + url.PathEscape(name) + } + return url.PathEscape(packageName) +} + // extractPackageName extracts the package name from the request path. // Handles both scoped (@scope/name) and unscoped (name) packages. func (h *NPMHandler) extractPackageName(r *http.Request) string { diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go index bc1edde..e0257dd 100644 --- a/internal/handler/npm_test.go +++ b/internal/handler/npm_test.go @@ -2,13 +2,16 @@ package handler import ( "encoding/json" + "io" "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/registries/fetch" ) const testVersion100 = "1.0.0" @@ -46,6 +49,86 @@ func TestNPMExtractVersionFromFilename(t *testing.T) { } } +func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) { + t.Run("metadata", func(t *testing.T) { + var requestPath, authHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + authHeader = r.Header.Get("Authorization") + if authHeader != "Bearer npm-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"versions":{}}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + proxy.AuthForURL = func(string) (string, string) { + return "Authorization", "Bearer npm-token" + } + h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/") + + req := httptest.NewRequest(http.MethodGet, "/testpkg", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if requestPath != "/root/testpkg" { + t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg") + } + if authHeader != "Bearer npm-token" { + t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token") + } + }) + + t.Run("download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("package")), + ContentType: "application/gzip", + } + h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") + + req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) + + t.Run("scoped download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("package")), + ContentType: "application/gzip", + } + h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") + + req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) +} + func TestNPMRewriteMetadata(t *testing.T) { h := &NPMHandler{ proxy: testProxy(), diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 40b8b5f..4785e40 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -271,7 +269,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) { } } - packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "") + packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id)) if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering nuget version", @@ -316,8 +314,7 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/handler/pub.go b/internal/handler/pub.go index 60bbbad..e5ca199 100644 --- a/internal/handler/pub.go +++ b/internal/handler/pub.go @@ -7,8 +7,6 @@ import ( "net/http" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -69,8 +67,7 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } @@ -127,7 +124,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) { return body, nil } - packagePURL := purl.MakePURLString("pub", name, "") + packagePURL := canonicalPackagePURL("pub", name) filtered := h.filterAndRewriteVersions(name, packagePURL, versions) metadata["versions"] = filtered diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index 3021d2b..f5a0232 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -12,8 +12,6 @@ import ( "regexp" "strings" "time" - - "github.com/git-pkgs/purl" ) const ( @@ -131,7 +129,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st return nil } - packagePURL := purl.MakePURLString("pypi", name, "") + packagePURL := canonicalPackagePURL("pypi", name) filtered := make(map[string]bool) for version, files := range releases { @@ -262,7 +260,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) { packageName, _ := extractPyPIName(metadata) packagePURL := "" if packageName != "" { - packagePURL = purl.MakePURLString("pypi", packageName, "") + packagePURL = canonicalPackagePURL("pypi", packageName) } h.filterAndRewriteReleases(metadata, packageName, packagePURL) @@ -428,8 +426,7 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL) if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go index 6440d0f..a5752ec 100644 --- a/internal/handler/rpm.go +++ b/internal/handler/rpm.go @@ -83,8 +83,7 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques result, err := h.proxy.GetOrFetchArtifactFromURL( r.Context(), "rpm", name, version, filename, downloadURL) if err != nil { - h.proxy.Logger.Error("failed to get rpm package", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) + h.proxy.serveArtifactError(w, err, "failed to fetch package") return } diff --git a/internal/httpclient/transport.go b/internal/httpclient/transport.go deleted file mode 100644 index 1cdb457..0000000 --- a/internal/httpclient/transport.go +++ /dev/null @@ -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() -} diff --git a/internal/httpclient/transport_test.go b/internal/httpclient/transport_test.go deleted file mode 100644 index 027a378..0000000 --- a/internal/httpclient/transport_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/server/browse.go b/internal/server/browse.go index 504f5f1..56aa1c5 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -1,6 +1,7 @@ package server import ( + "bufio" "encoding/json" "fmt" "io" @@ -10,29 +11,22 @@ import ( "github.com/git-pkgs/archives" "github.com/git-pkgs/archives/diff" + "github.com/git-pkgs/magic" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/purl" "github.com/go-chi/chi/v5" ) -const contentTypePlainText = "text/plain; charset=utf-8" +const ( + contentTypePlainText = "text/plain; charset=utf-8" + browseSniffSize = 512 +) // maxBrowseArchiveSize caps how much data openArchive will buffer for // prefix detection. Artifacts larger than this are rejected to prevent // memory exhaustion from a single request. const maxBrowseArchiveSize = 512 << 20 // 512 MB -// archiveFilename returns a filename suitable for archive format detection. -// Some ecosystems (e.g. composer) store artifacts with bare hash filenames -// that have no extension. This adds .zip when the original has no extension -// and the content is likely a zip archive. -func archiveFilename(filename string) string { - if path.Ext(filename) == "" { - return filename + ".zip" - } - return filename -} - // detectSingleRootDir returns the single top-level directory name if all files // in the archive live under one common directory (e.g. GitHub zipballs use // "repo-hash/"). Returns "" if there's no single root or the archive is flat. @@ -66,8 +60,6 @@ func detectSingleRootDir(reader archives.Reader) string { // and stripping a single top-level directory prefix (like GitHub zipballs). // For npm, the hardcoded "package/" prefix takes precedence. func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations - fname := archiveFilename(filename) - limited := io.LimitReader(content, maxBrowseArchiveSize+1) data, err := io.ReadAll(limited) if err != nil { @@ -78,17 +70,17 @@ func openArchive(filename string, content io.Reader, ecosystem string) (archives } if ecosystem == "npm" { - return archives.OpenBytesWithPrefix(fname, data, "package/") + return archives.OpenBytesWithPrefix(filename, data, "package/") } - probe, err := archives.OpenBytes(fname, data) + probe, err := archives.OpenBytes(filename, data) if err != nil { return nil, err } prefix := detectSingleRootDir(probe) _ = probe.Close() - return archives.OpenBytesWithPrefix(fname, data, prefix) + return archives.OpenBytesWithPrefix(filename, data, prefix) } // BrowseListResponse contains the file listing for a directory in an archives. @@ -361,7 +353,14 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n } defer func() { _ = fileReader.Close() }() - contentType := detectContentType(filePath) + contentType, knownPath := detectContentTypeFromPath(filePath) + var content io.Reader = fileReader + if !knownPath { + bufferedFile := bufio.NewReaderSize(fileReader, browseSniffSize) + prefix, _ := bufferedFile.Peek(browseSniffSize) + contentType = detectContentTypeFromPrefix(prefix) + content = bufferedFile + } w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Security-Policy", "sandbox") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -370,85 +369,103 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) // Stream the file - _, _ = io.Copy(w, fileReader) + _, _ = io.Copy(w, content) } -// detectContentType returns an appropriate content type based on file extension. -func detectContentType(filename string) string { +func detectContentTypeFromPath(filename string) (string, bool) { ext := strings.ToLower(path.Ext(filename)) switch ext { // Text formats case ".txt", ".md", ".markdown": - return contentTypePlainText + return contentTypePlainText, true case ".html", ".htm", ".xhtml": - return contentTypePlainText + return contentTypePlainText, true case ".css": - return "text/css; charset=utf-8" + return "text/css; charset=utf-8", true case ".js", ".mjs": - return "application/javascript; charset=utf-8" + return "application/javascript; charset=utf-8", true case ".json": - return "application/json; charset=utf-8" + return "application/json; charset=utf-8", true case ".xml": - return "application/xml; charset=utf-8" + return "application/xml; charset=utf-8", true case ".yaml", ".yml": - return "text/yaml; charset=utf-8" + return "text/yaml; charset=utf-8", true case ".toml": - return "text/toml; charset=utf-8" + return "text/toml; charset=utf-8", true // Programming languages case ".go": - return "text/x-go; charset=utf-8" + return "text/x-go; charset=utf-8", true case ".rs": - return "text/x-rust; charset=utf-8" + return "text/x-rust; charset=utf-8", true case ".py": - return "text/x-python; charset=utf-8" + return "text/x-python; charset=utf-8", true case ".rb": - return "text/x-ruby; charset=utf-8" + return "text/x-ruby; charset=utf-8", true case ".java": - return "text/x-java; charset=utf-8" + return "text/x-java; charset=utf-8", true case ".c", ".h": - return "text/x-c; charset=utf-8" + return "text/x-c; charset=utf-8", true case ".cpp", ".cc", ".cxx", ".hpp": - return "text/x-c++; charset=utf-8" + return "text/x-c++; charset=utf-8", true case ".ts": - return "text/typescript; charset=utf-8" + return "text/typescript; charset=utf-8", true case ".tsx": - return "text/tsx; charset=utf-8" + return "text/tsx; charset=utf-8", true case ".jsx": - return "text/jsx; charset=utf-8" + return "text/jsx; charset=utf-8", true case ".php": - return "text/x-php; charset=utf-8" + return "text/x-php; charset=utf-8", true // Config files case ".conf", ".config", ".ini": - return contentTypePlainText + return contentTypePlainText, true case ".sh", ".bash": - return "text/x-shellscript; charset=utf-8" + return "text/x-shellscript; charset=utf-8", true case ".dockerfile": - return "text/x-dockerfile; charset=utf-8" + return "text/x-dockerfile; charset=utf-8", true // Images case ".png": - return "image/png" + return "image/png", true case ".jpg", ".jpeg": - return "image/jpeg" + return "image/jpeg", true case ".gif": - return "image/gif" + return "image/gif", true case ".svg": - return contentTypePlainText + return contentTypePlainText, true case ".ico": - return "image/x-icon" + return "image/x-icon", true // Archives case ".zip", ".tar", ".gz", ".bz2", ".xz": - return "application/octet-stream" + return "application/octet-stream", true default: - // Try to detect if it looks like text if isLikelyText(filename) { - return contentTypePlainText + return contentTypePlainText, true } + return "", false + } +} + +func detectContentTypeFromPrefix(prefix []byte) string { + result := magic.DetectPrefix(prefix) + if result.Kind == magic.KindText { + return contentTypePlainText + } + + switch result.Format { + case "png": + return "image/png" + case "jpeg": + return "image/jpeg" + case "gif": + return "image/gif" + case "pdf": + return "application/pdf" + default: return "application/octet-stream" } } diff --git a/internal/server/browse_bench_test.go b/internal/server/browse_bench_test.go index 03f3f02..840bc75 100644 --- a/internal/server/browse_bench_test.go +++ b/internal/server/browse_bench_test.go @@ -55,3 +55,35 @@ func BenchmarkOpenArchive(b *testing.B) { }) } } + +func BenchmarkDetectContentType(b *testing.B) { + cases := []struct { + name string + filename string + prefix []byte + knownPath bool + }{ + {"known-path", "README.md", nil, true}, + {"text-prefix", "artifact", bytes.Repeat([]byte("a"), browseSniffSize), false}, + {"png-prefix", "artifact", append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, browseSniffSize-8)...), false}, + } + + for _, tc := range cases { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + var contentType string + if tc.knownPath { + for b.Loop() { + contentType, _ = detectContentTypeFromPath(tc.filename) + } + } else { + for b.Loop() { + contentType = detectContentTypeFromPrefix(tc.prefix) + } + } + if contentType == "" { + b.Fatal("empty content type") + } + }) + } +} diff --git a/internal/server/browse_test.go b/internal/server/browse_test.go index f1fb993..5240a92 100644 --- a/internal/server/browse_test.go +++ b/internal/server/browse_test.go @@ -137,29 +137,44 @@ func TestHandleBrowseFile(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - // Test fetching a file - req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/README.md", nil) - w := httptest.NewRecorder() - ts.handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + files := []struct { + path string + content string + contentType string + }{ + {"README.md", "# Test Package\n", contentTypePlainText}, + {"notes.data", "short text\n", contentTypePlainText}, + {"logo", "\x89PNG\r\n\x1a\nimage data", "image/png"}, + {"page", "", contentTypePlainText}, + {"misleading.txt", "\x89PNG\r\n\x1a\nimage data", contentTypePlainText}, } + for _, file := range files { + t.Run(file.path, func(t *testing.T) { + req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/"+file.path, nil) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) - body := w.Body.String() - if body != "# Test Package\n" { - t.Errorf("unexpected file content: %q", body) - } - - // Check content type - contentType := w.Header().Get("Content-Type") - if contentType != contentTypePlainText { - t.Errorf("expected text/plain content type, got %q", contentType) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + if w.Body.String() != file.content { + t.Errorf("unexpected file content: %q", w.Body.String()) + } + if got := w.Header().Get("Content-Type"); got != file.contentType { + t.Errorf("Content-Type = %q, want %q", got, file.contentType) + } + if got := w.Header().Get("Content-Security-Policy"); got != "sandbox" { + t.Errorf("Content-Security-Policy = %q, want sandbox", got) + } + if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", got) + } + }) } // Test fetching non-existent file - req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) - w = httptest.NewRecorder() + req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) + w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) if w.Code != http.StatusNotFound { @@ -167,36 +182,52 @@ func TestHandleBrowseFile(t *testing.T) { } } -func TestDetectContentType(t *testing.T) { +func TestBrowseContentTypePolicy(t *testing.T) { tests := []struct { + name string filename string + prefix []byte expectedCT string }{ - {"file.txt", contentTypePlainText}, - {"file.md", contentTypePlainText}, - {"file.json", "application/json; charset=utf-8"}, - {"file.js", "application/javascript; charset=utf-8"}, - {"file.go", "text/x-go; charset=utf-8"}, - {"file.py", "text/x-python; charset=utf-8"}, - {"file.rs", "text/x-rust; charset=utf-8"}, - {"file.html", contentTypePlainText}, - {"file.htm", contentTypePlainText}, - {"file.xhtml", contentTypePlainText}, - {"file.svg", contentTypePlainText}, - {"file.png", "image/png"}, - {"file.jpg", "image/jpeg"}, - {"README", contentTypePlainText}, - {"LICENSE", contentTypePlainText}, - {"Makefile", contentTypePlainText}, - {".gitignore", contentTypePlainText}, - {"file.bin", "application/octet-stream"}, + {"text extension", "file.txt", nil, contentTypePlainText}, + {"markdown extension", "file.md", nil, contentTypePlainText}, + {"JSON extension", "file.json", nil, "application/json; charset=utf-8"}, + {"JavaScript extension", "file.js", nil, "application/javascript; charset=utf-8"}, + {"Go extension", "file.go", nil, "text/x-go; charset=utf-8"}, + {"Python extension", "file.py", nil, "text/x-python; charset=utf-8"}, + {"Rust extension", "file.rs", nil, "text/x-rust; charset=utf-8"}, + {"HTML extension", "file.html", nil, contentTypePlainText}, + {"HTM extension", "file.htm", nil, contentTypePlainText}, + {"XHTML extension", "file.xhtml", nil, contentTypePlainText}, + {"SVG extension", "file.svg", nil, contentTypePlainText}, + {"PNG extension", "file.png", nil, "image/png"}, + {"JPEG extension", "file.jpg", nil, "image/jpeg"}, + {"README", "README", nil, contentTypePlainText}, + {"LICENSE", "LICENSE", nil, contentTypePlainText}, + {"Makefile", "Makefile", nil, contentTypePlainText}, + {"gitignore", ".gitignore", nil, contentTypePlainText}, + {"unknown empty", "file.bin", nil, "application/octet-stream"}, + {"extensionless PNG", "asset", []byte("\x89PNG\r\n\x1a\n"), "image/png"}, + {"extensionless JPEG", "asset", []byte("\xff\xd8\xff"), "image/jpeg"}, + {"extensionless GIF", "asset", []byte("GIF89a"), "image/gif"}, + {"extensionless PDF", "asset", []byte("%PDF-1.7"), "application/pdf"}, + {"extensionless text", "asset", []byte("plain text\n"), contentTypePlainText}, + {"extensionless HTML", "asset", []byte(""), contentTypePlainText}, + {"extensionless XML", "asset", []byte(""), contentTypePlainText}, + {"extensionless SVG", "asset", []byte(""), contentTypePlainText}, + {"extensionless ZIP", "asset", []byte("PK\x03\x04"), "application/octet-stream"}, + {"extensionless binary", "asset", []byte{0, 1, 2}, "application/octet-stream"}, + {"known path wins", "file.txt", []byte("\x89PNG\r\n\x1a\n"), contentTypePlainText}, } for _, tt := range tests { - t.Run(tt.filename, func(t *testing.T) { - got := detectContentType(tt.filename) + t.Run(tt.name, func(t *testing.T) { + got, knownPath := detectContentTypeFromPath(tt.filename) + if !knownPath { + got = detectContentTypeFromPrefix(tt.prefix) + } if got != tt.expectedCT { - t.Errorf("detectContentType(%q) = %q, want %q", tt.filename, got, tt.expectedCT) + t.Errorf("content type for %q with prefix %q = %q, want %q", tt.filename, tt.prefix, got, tt.expectedCT) } }) } @@ -255,6 +286,10 @@ func createTestArchive(t *testing.T) []byte { "package/lib/index.js": "module.exports = {};", "package/lib/helper.js": "module.exports.help = () => {};", "package/test/index.test.js": "// tests", + "package/notes.data": "short text\n", + "package/logo": "\x89PNG\r\n\x1a\nimage data", + "package/page": "", + "package/misleading.txt": "\x89PNG\r\n\x1a\nimage data", } for path, content := range files { @@ -609,25 +644,19 @@ func TestHandleComparePage(t *testing.T) { } } -func TestArchiveFilename(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"package.tar.gz", "package.tar.gz"}, - {"d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856.zip"}, - {"file.zip", "file.zip"}, - {"archive.tgz", "archive.tgz"}, - {"noext", "noext.zip"}, +func TestOpenArchiveDetectsExtensionlessTarGz(t *testing.T) { + reader, err := openArchive("artifact", bytes.NewReader(createTestArchive(t)), "npm") + if err != nil { + t.Fatalf("openArchive failed: %v", err) } + defer func() { _ = reader.Close() }() - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := archiveFilename(tt.input) - if got != tt.want { - t.Errorf("archiveFilename(%q) = %q, want %q", tt.input, got, tt.want) - } - }) + files, err := reader.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(files) == 0 { + t.Fatal("expected files in extensionless archive") } } diff --git a/internal/server/server.go b/internal/server/server.go index b763278..13c5997 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -58,19 +58,17 @@ import ( "strings" "time" - "github.com/git-pkgs/cooldown" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/enrichment" "github.com/git-pkgs/proxy/internal/handler" - upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient" "github.com/git-pkgs/proxy/internal/metrics" "github.com/git-pkgs/proxy/internal/mirror" "github.com/git-pkgs/proxy/internal/storage" "github.com/git-pkgs/purl" "github.com/git-pkgs/registries/fetch" - "github.com/git-pkgs/registries/safehttp" "github.com/git-pkgs/spdx" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -86,12 +84,12 @@ const ( // Server is the main proxy server. type Server struct { - cfg *config.Config - db *database.DB - storage storage.Storage - logger *slog.Logger - http *http.Server - templates *Templates + cfg *config.Config + db *database.DB + storage storage.Storage + logger *slog.Logger + http *http.Server + templates *Templates cancel context.CancelFunc healthCache *healthCache } @@ -158,27 +156,18 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { // Start starts the HTTP server. func (s *Server) Start() error { - // Use one authentication-aware transport for metadata and artifacts so - // configured credentials and cached OCI challenges apply consistently. - safeClient := safehttp.New(nil, safehttp.Options{}) - authTransport := upstreamhttp.NewTransport(safeClient.Transport, upstreamhttp.AuthFunc(s.authForURL)) - metadataClient := *safeClient - metadataClient.Timeout = s.cfg.ParseHTTPTimeout() - metadataClient.Transport = authTransport - artifactClient := metadataClient - artifactClient.Timeout = serverWriteTimeout - - // Create shared components with circuit breaker. - baseFetcher := fetch.NewFetcher(fetch.WithHTTPClient(&artifactClient)) + // Create shared components with circuit breaker + baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL)) fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher) resolver := fetch.NewResolver() cd := &cooldown.Config{ Default: s.cfg.Cooldown.Default, Ecosystems: s.cfg.Cooldown.Ecosystems, - Packages: s.cfg.Cooldown.Packages, + Packages: s.cfg.Cooldown.NormalizedPackages(), } 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.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() @@ -208,8 +197,13 @@ func (s *Server) Start() error { }) // Mount protocol handlers - npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL) - cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL) + npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM) + cargoHandler := handler.NewCargoHandler( + proxy, + s.cfg.BaseURL, + s.cfg.Upstream.Cargo, + s.cfg.Upstream.CargoDownload, + ) gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL) hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f1de62d..2d27147 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -67,8 +67,13 @@ func newTestServer(t *testing.T) *testServer { r := chi.NewRouter() // Mount handlers - npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL) - cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL) + npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM) + cargoHandler := handler.NewCargoHandler( + proxy, + cfg.BaseURL, + cfg.Upstream.Cargo, + cfg.Upstream.CargoDownload, + ) gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)