diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3d87d..9712038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ matrix.go-version }} @@ -35,12 +35,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.25' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c08cfa6..4844dde 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@3d3c42e5aac5ba805825da76410c181273ba90b1 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: persist-credentials: false - name: Log in to the Container registry - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee with: registry: ghcr.io username: ${{ github.actor }} @@ -33,12 +33,12 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 with: images: ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf with: context: . push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d32181..dc15164 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -22,12 +22,12 @@ jobs: - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: false - - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: "~> v2" args: release --clean diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 625f944..d18ba0f 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.25' diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 4df0e18..1c212f7 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/Dockerfile b/Dockerfile index c2e197f..f31cd05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.5-alpine AS builder +FROM golang:1.26.4-alpine AS builder WORKDIR /src @@ -15,7 +15,7 @@ COPY . . # Build the binary RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /proxy ./cmd/proxy -FROM alpine:3.24.1 +FROM alpine:3.24.0 RUN apk add --no-cache ca-certificates diff --git a/README.md b/README.md index 4609b66..3270212 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,7 @@ The proxy can be configured via: -config string Path to configuration file -listen string Address to listen on (default ":8080") -base-url string Public URL of this proxy (default "http://localhost:8080") --storage-url string Storage URL (file:// or s3://) +-storage-url string Storage URL (file://, s3://, gs://, azblob://) -storage-path string Path to artifact storage directory (deprecated, use -storage-url) -database-driver string Database driver: sqlite or postgres (default "sqlite") -database-path string Path to SQLite database file (default "./cache/proxy.db") @@ -504,6 +504,57 @@ storage: Set credentials via standard AWS environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`). +### Google Cloud Storage + +The proxy can store cached artifacts in a GCS bucket using the `gs://` URL scheme. + +```yaml +storage: + url: "gs://my-bucket-name" +``` + +Authentication uses [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials), which means no credentials need to be embedded in the config or environment. Supported sources, in order: + +- **GKE Workload Identity** — bind the Kubernetes service account running the proxy to a Google service account that has `roles/storage.objectAdmin` on the bucket. The proxy will use the workload's token automatically. +- **Attached service account** on GCE, Cloud Run, Cloud Functions, etc. +- **`GOOGLE_APPLICATION_CREDENTIALS`** environment variable pointing at a service account JSON key file. +- **`gcloud auth application-default login`** for local development. + +#### GKE Workload Identity setup + +```bash +# 1. Create a Google service account +gcloud iam service-accounts create git-pkgs-proxy \ + --project=PROJECT_ID + +# 2. Grant it access to the bucket +gsutil iam ch \ + serviceAccount:git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com:objectAdmin \ + gs://my-bucket-name + +# 3. Bind the Kubernetes service account to it +gcloud iam service-accounts add-iam-policy-binding \ + git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com \ + --role=roles/iam.workloadIdentityUser \ + --member="serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]" + +# 4. Annotate the Kubernetes service account +kubectl annotate serviceaccount KSA_NAME \ + --namespace=NAMESPACE \ + iam.gke.io/gcp-service-account=git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com +``` + +#### Direct serve (signed URLs) with Workload Identity + +When `direct_serve: true` is enabled, the proxy issues HTTP 302 redirects to presigned GCS URLs. Because Workload Identity provides no private key, the gcsblob driver falls back to the [IAM Credentials `signBlob` API](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob). For this to work, grant the service account the token-creator role on itself: + +```bash +gcloud iam service-accounts add-iam-policy-binding \ + git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com \ + --role=roles/iam.serviceAccountTokenCreator \ + --member="serviceAccount:git-pkgs-proxy@PROJECT_ID.iam.gserviceaccount.com" +``` + ## CLI Commands ### serve (default) diff --git a/config.example.yaml b/config.example.yaml index 1176f5b..bb080ec 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -9,11 +9,6 @@ listen: ":8080" # so users know what to point their package manager at. base_url: "http://localhost:8080" -# Timeout for individual upstream HTTP requests made by protocol handlers -# (metadata fetches, pass-through file requests). Uses Go duration syntax. -# Set to "0" to disable the timeout. Default: "30s". -# http_timeout: "30s" - # Public URL where the web UI is reached. Defaults to base_url when unset. # Set this separately when the UI is served on a different hostname than the # package endpoints — for example, the UI on a public domain behind auth while @@ -27,9 +22,20 @@ storage: # - file:///path/to/dir - Local filesystem (default) # - s3://bucket-name - Amazon S3 # - s3://bucket?endpoint=http://localhost:9000 - S3-compatible (MinIO) + # - gs://bucket-name - Google Cloud Storage + # - azblob://container-name - Azure Blob Storage # # For S3, configure credentials via environment variables: # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION + # + # For GCS, authentication uses Application Default Credentials. On GKE with + # Workload Identity, bind the Kubernetes service account to a Google service + # account that has roles/storage.objectAdmin on the bucket. No extra config + # is needed in this file. For local development, run: + # gcloud auth application-default login + # If direct_serve is enabled, the service account also needs + # roles/iam.serviceAccountTokenCreator on itself so the IAM Credentials + # signBlob API can sign URLs without a private key. url: "" # Local filesystem path (used when url is empty) @@ -42,7 +48,7 @@ storage: max_size: "" # Redirect cached artifact downloads to presigned storage URLs (HTTP 302) - # instead of streaming through the proxy. Only effective for S3 and Azure. + # instead of streaming through the proxy. Only effective for S3, GCS, and Azure. # Leave disabled if clients reach the proxy through an authenticating gateway, # since presigned URLs bypass it. direct_serve: false @@ -168,8 +174,7 @@ cooldown: # npm: "7d" # cargo: "0" - # Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes - # may use either @scope or the canonical %40scope form. + # Per-package overrides (keyed by PURL) # packages: # "pkg:npm/lodash": "0" # "pkg:npm/@babel/core": "14d" diff --git a/docs/configuration.md b/docs/configuration.md index 8998ac2..f220279 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,8 +234,6 @@ 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. @@ -278,18 +276,6 @@ metadata_max_size: "100MB" # default Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`. -## Upstream HTTP timeout - -Protocol handlers use a shared HTTP client for upstream requests such as metadata fetches and pass-through file downloads. `http_timeout` sets that client's per-request timeout. Raise it if slow upstreams or large metadata responses cause `context deadline exceeded` errors. - -```yaml -http_timeout: "30s" # default -``` - -Or via environment variable: `PROXY_HTTP_TIMEOUT=2m`. - -Set to `"0"` to disable the timeout entirely (requests then rely only on the server's write timeout). - ## Mirror API The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP: diff --git a/go.mod b/go.mod index e6e0a08..44aec66 100644 --- a/go.mod +++ b/go.mod @@ -3,29 +3,30 @@ module github.com/git-pkgs/proxy go 1.25.6 require ( + cloud.google.com/go/compute/metadata v0.9.0 github.com/BurntSushi/toml v1.6.0 github.com/CycloneDX/cyclonedx-go v0.11.0 - github.com/git-pkgs/archives v0.4.0 + github.com/git-pkgs/archives v0.3.0 github.com/git-pkgs/cooldown v0.1.1 - 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/enrichment v0.3.0 + github.com/git-pkgs/purl v0.1.12 + github.com/git-pkgs/registries v0.6.1 github.com/git-pkgs/spdx v0.1.4 - github.com/git-pkgs/vers v0.3.0 - github.com/git-pkgs/vulns v0.2.1 - github.com/go-chi/chi/v5 v5.3.1 + github.com/git-pkgs/vers v0.2.6 + github.com/git-pkgs/vulns v0.1.5 + github.com/go-chi/chi/v5 v5.3.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/prometheus/client_golang v1.24.0 + github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/spdx/tools-golang v0.5.7 github.com/swaggo/swag v1.16.6 gocloud.dev v0.46.0 - golang.org/x/sync v0.22.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.51.0 ) require ( @@ -33,7 +34,6 @@ require ( 4d63.com/gochecknoglobals v0.2.2 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect @@ -112,11 +112,11 @@ require ( github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 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.4.0 // indirect + github.com/ecosyste-ms/ecosystems-go v0.1.1 // 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 @@ -126,7 +126,7 @@ require ( github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.20 // indirect github.com/git-pkgs/packageurl-go v0.3.1 // indirect - github.com/git-pkgs/pom v0.1.5 // indirect + github.com/git-pkgs/pom v0.1.4 // indirect github.com/github/go-spdx/v2 v2.7.0 // indirect github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -218,16 +218,15 @@ 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/nullable v1.1.0 // indirect - github.com/oapi-codegen/runtime v1.6.0 // indirect + github.com/oapi-codegen/runtime v1.2.0 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect github.com/pandatix/go-cvss v0.6.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/common v0.70.0 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.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 @@ -267,7 +266,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.16 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/urfave/cli/v2 v2.3.0 // indirect @@ -291,25 +290,24 @@ 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.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.49.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.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.272.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/grpc v1.81.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.74.1 // indirect + modernc.org/libc v1.72.3 // 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 c239240..ff69d3b 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.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/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/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= @@ -209,16 +209,17 @@ github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEy github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 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.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA= -github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA= +github.com/ecosyste-ms/ecosystems-go v0.1.1 h1:YYiBK9TCCTeE+BtmpN2FssaRFcmF+T0v4LrupIOjehQ= +github.com/ecosyste-ms/ecosystems-go v0.1.1/go.mod h1:VczXs1CO9nL8XbL1NwvgmwIaqzMsAxcsXnTpRtwi9gU= 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,32 +245,30 @@ 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.4.0 h1:KNmmIsLiSH27lUdT27EfUkQXFaLgXV5KezE81iyOIgo= -github.com/git-pkgs/archives v0.4.0/go.mod h1:tfio0OIuPKEBKHs/UCL5XBUvYmKpnvtnba2iDlfSd6g= +github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78= +github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU= github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= -github.com/git-pkgs/enrichment v0.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/enrichment v0.3.0 h1:tsATMAwR/qcSIw4JV37uH2TBgTv415RfJC7w3nzWfD4= +github.com/git-pkgs/enrichment v0.3.0/go.mod h1:MBv5nhHzjwLxeSgx2+7waCcpReUjhCD+9B0bvufpMO0= 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.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/pom v0.1.4 h1:C6st+XSbF75eKuwfdkDZZtYHoTcaWRIEQYar5VtszUo= +github.com/git-pkgs/pom v0.1.4/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= +github.com/git-pkgs/purl v0.1.12 h1:qCskrEU1LWQhCkIVZd992W5++Bsxazvx2Cx1/65qCvU= +github.com/git-pkgs/purl v0.1.12/go.mod h1:ofp4mHsR0cUeVONQaf33n6Wxg2QTEvtUdRfCedI8ouA= +github.com/git-pkgs/registries v0.6.1 h1:xZfVZQmffIfdeJthn5o2EozbVJ6gBeImYwKQnfdKUfU= +github.com/git-pkgs/registries v0.6.1/go.mod h1:a3BP/56VW3O/CFRqiJCtSy+OqRrSH25wF1PWHP76ka0= github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs= github.com/git-pkgs/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto= -github.com/git-pkgs/vers v0.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/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8= +github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= +github.com/git-pkgs/vulns v0.1.5 h1:mtX88/27toFl+B95kaH5QbAdOCQ3YIDGjJrlrrnqQTE= +github.com/git-pkgs/vulns v0.1.5/go.mod h1:bZFikfrR/5gC0ZMwXh7qcEu2gpKfXMBhVsy4kF12Ae0= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= -github.com/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-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-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= @@ -424,8 +423,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.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -512,10 +511,8 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= 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.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= -github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= 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,16 +538,17 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= -github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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_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.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/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/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= @@ -656,8 +654,8 @@ github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVF github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/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.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= -github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +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/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= @@ -704,8 +702,8 @@ go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDoj go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.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.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +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/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= @@ -728,8 +726,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +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/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= @@ -740,8 +738,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= @@ -757,8 +755,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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= @@ -773,8 +771,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -786,8 +784,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -809,8 +807,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/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= @@ -825,8 +823,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -841,8 +839,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.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.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 v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= @@ -859,12 +857,12 @@ google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/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-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/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/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= @@ -884,20 +882,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.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/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 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= @@ -906,8 +904,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 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 ec510d2..d70ff2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,10 +24,23 @@ // storage: // url: "s3://bucket?endpoint=http://localhost:9000" // +// Google Cloud Storage: +// +// storage: +// url: "gs://bucket-name" +// // For S3, configure credentials via AWS environment variables: // // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION // +// For GCS, authentication uses Application Default Credentials. This works +// transparently on GKE with Workload Identity, GCE/Cloud Run with attached +// service accounts, or locally via `gcloud auth application-default login`. +// When the proxy is signing URLs (direct_serve) without a private key, +// gcsblob automatically falls back to the IAM Credentials signBlob API, +// which requires the service account to hold roles/iam.serviceAccountTokenCreator +// on itself. +// // Database Configuration: // // The proxy supports two database backends: @@ -54,12 +67,10 @@ import ( "net/url" "os" "path/filepath" - "sort" "strconv" "strings" "time" - "github.com/git-pkgs/purl" "gopkg.in/yaml.v3" ) @@ -112,12 +123,6 @@ type Config struct { // size return ErrMetadataTooLarge. Default: "100MB". MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"` - // HTTPTimeout is the timeout for individual upstream HTTP requests made - // by protocol handlers (metadata fetches, pass-through file requests). - // Uses Go duration syntax (e.g. "30s", "2m"). Default: "30s". - // Set to "0" to disable the timeout entirely. - HTTPTimeout string `json:"http_timeout" yaml:"http_timeout"` - // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP. // Disabled by default to prevent unauthenticated users from triggering downloads. MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"` @@ -139,38 +144,9 @@ 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. @@ -178,6 +154,8 @@ type StorageConfig struct { // - file:///path/to/dir - Local filesystem (default) // - s3://bucket-name - Amazon S3 // - s3://bucket?endpoint=http://localhost:9000 - S3-compatible (MinIO) + // - gs://bucket-name - Google Cloud Storage (Workload Identity supported) + // - azblob://container-name - Azure Blob Storage // If empty, defaults to file:// with the Path value. URL string `json:"url" yaml:"url"` @@ -194,7 +172,7 @@ type StorageConfig struct { // DirectServe enables redirecting cached artifact downloads to presigned // storage URLs (HTTP 302) instead of streaming bytes through the proxy. - // Only effective for backends that support URL signing (S3, Azure). + // Only effective for backends that support URL signing (S3, GCS, Azure). DirectServe bool `json:"direct_serve" yaml:"direct_serve"` // DirectServeTTL is how long presigned URLs remain valid. @@ -256,21 +234,6 @@ type DatabaseConfig struct { URL string `json:"url" yaml:"url"` } -// String returns a human-readable description of the configured database -// suitable for logging. For postgres the password in the connection URL is -// redacted; if the URL cannot be parsed only the driver name is returned to -// avoid leaking credentials. -func (d DatabaseConfig) String() string { - if d.Driver == "postgres" { - u, err := url.Parse(d.URL) - if err != nil || u.Host == "" { - return "postgres" - } - return u.Redacted() - } - return d.Path -} - // LogConfig configures logging. type LogConfig struct { // Level is the minimum log level: "debug", "info", "warn", "error". @@ -497,9 +460,6 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" { c.MetadataMaxSize = v } - if v := os.Getenv("PROXY_HTTP_TIMEOUT"); v != "" { - c.HTTPTimeout = v - } if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" { c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1" } @@ -607,10 +567,6 @@ func (c *Config) Validate() error { return err } - if err := validateHTTPTimeout(c.HTTPTimeout); err != nil { - return err - } - if err := c.Health.Validate(); err != nil { return err } @@ -680,7 +636,6 @@ func (g *GradleBuildCacheConfig) Validate() error { const ( defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default - defaultHTTPTimeout = 30 * time.Second //nolint:mnd // sensible default defaultMetadataMaxSize = 100 << 20 defaultGradleBuildCacheMaxUploadSize = 100 << 20 defaultGradleBuildCacheSweepInterval = 10 * time.Minute @@ -701,20 +656,6 @@ func (c *Config) ParseMaxSize() int64 { return size } -func validateHTTPTimeout(s string) error { - if s == "" || s == "0" { - return nil - } - d, err := time.ParseDuration(s) - if err != nil { - return fmt.Errorf("invalid http_timeout %q: %w", s, err) - } - if d < 0 { - return fmt.Errorf("invalid http_timeout %q: must be non-negative", s) - } - return nil -} - func validateMetadataMaxSize(s string) error { if s == "" { return nil @@ -742,22 +683,6 @@ func (c *Config) ParseMetadataMaxSize() int64 { return size } -// ParseHTTPTimeout returns the upstream HTTP client timeout. -// Returns 30s if unset, 0 (no timeout) if explicitly set to "0". -func (c *Config) ParseHTTPTimeout() time.Duration { - if c.HTTPTimeout == "" { - return defaultHTTPTimeout - } - if c.HTTPTimeout == "0" { - return 0 - } - d, err := time.ParseDuration(c.HTTPTimeout) - if err != nil || d < 0 { - return defaultHTTPTimeout - } - return d -} - // ParseMetadataTTL returns the metadata TTL duration. // Returns 5 minutes if unset, 0 if explicitly disabled. func (c *Config) ParseMetadataTTL() time.Duration { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index decc18f..2f4fdd2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -363,34 +363,6 @@ 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) { @@ -552,69 +524,6 @@ func TestValidateHealthStorageProbeInterval(t *testing.T) { } } -func TestParseHTTPTimeout(t *testing.T) { - tests := []struct { - name string - timeout string - want time.Duration - }{ - {"empty defaults to 30s", "", 30 * time.Second}, - {"explicit zero disables", "0", 0}, - {"2 minutes", "2m", 2 * time.Minute}, - {"90 seconds", "90s", 90 * time.Second}, - {"invalid defaults to 30s", "not-a-duration", 30 * time.Second}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Default() - cfg.HTTPTimeout = tt.timeout - got := cfg.ParseHTTPTimeout() - if got != tt.want { - t.Errorf("ParseHTTPTimeout() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestValidateHTTPTimeout(t *testing.T) { - cfg := Default() - cfg.HTTPTimeout = "not-a-duration" - if err := cfg.Validate(); err == nil { - t.Error("expected validation error for invalid http_timeout") - } - - cfg.HTTPTimeout = "-5s" - if err := cfg.Validate(); err == nil { - t.Error("expected validation error for negative http_timeout") - } - - cfg.HTTPTimeout = "2m" - if err := cfg.Validate(); err != nil { - t.Errorf("unexpected error for valid http_timeout: %v", err) - } - - cfg.HTTPTimeout = "0" - if err := cfg.Validate(); err != nil { - t.Errorf("unexpected error for zero http_timeout: %v", err) - } - - cfg.HTTPTimeout = "" - if err := cfg.Validate(); err != nil { - t.Errorf("unexpected error for empty http_timeout: %v", err) - } -} - -func TestLoadHTTPTimeoutFromEnv(t *testing.T) { - cfg := Default() - t.Setenv("PROXY_HTTP_TIMEOUT", "90s") - cfg.LoadFromEnv() - - if cfg.HTTPTimeout != "90s" { - t.Errorf("HTTPTimeout = %q, want %q", cfg.HTTPTimeout, "90s") - } -} - func TestLoadMetadataTTLFromEnv(t *testing.T) { cfg := Default() t.Setenv("PROXY_METADATA_TTL", "10m") @@ -767,24 +676,3 @@ func TestValidateDirectServeBaseURL(t *testing.T) { t.Errorf("unexpected error for valid direct_serve_base_url: %v", err) } } - -func TestDatabaseConfigString(t *testing.T) { - tests := []struct { - name string - cfg DatabaseConfig - want string - }{ - {"sqlite", DatabaseConfig{Driver: "sqlite", Path: "./cache/proxy.db"}, "./cache/proxy.db"}, - {"default driver", DatabaseConfig{Path: "/var/lib/proxy.db"}, "/var/lib/proxy.db"}, - {"postgres no password", DatabaseConfig{Driver: "postgres", URL: "postgres://user@localhost:5432/proxy"}, "postgres://user@localhost:5432/proxy"}, - {"postgres redacts password", DatabaseConfig{Driver: "postgres", URL: "postgres://user:secret@localhost:5432/proxy?sslmode=disable"}, "postgres://user:xxxxx@localhost:5432/proxy?sslmode=disable"}, - {"postgres unparseable url", DatabaseConfig{Driver: "postgres", URL: "host=localhost user=foo password=bar"}, "postgres"}, - {"postgres ignores sqlite path", DatabaseConfig{Driver: "postgres", URL: "postgres://localhost/db", Path: "./cache/proxy.db"}, "postgres://localhost/db"}, - } - - for _, tt := range tests { - if got := tt.cfg.String(); got != tt.want { - t.Errorf("%s: String() = %q, want %q", tt.name, got, tt.want) - } - } -} diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index 79fb750..5d7810c 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -6,9 +6,10 @@ import ( "errors" "fmt" "net/http" - "net/url" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -29,18 +30,11 @@ type CargoHandler struct { } // NewCargoHandler creates a new cargo protocol handler. -func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler { - if strings.TrimSpace(indexURL) == "" { - indexURL = cargoUpstream - } - if strings.TrimSpace(downloadURL) == "" { - downloadURL = cargoDownloadBase - } - +func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler { return &CargoHandler{ proxy: proxy, - indexURL: strings.TrimSuffix(indexURL, "/"), - downloadURL: strings.TrimSuffix(downloadURL, "/"), + indexURL: cargoUpstream, + downloadURL: cargoDownloadBase, proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -149,7 +143,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr continue } - cratePURL := canonicalPackagePURL("cargo", crate.Name) + cratePURL := purl.MakePURLString("cargo", crate.Name, "") if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering cargo version", @@ -197,17 +191,10 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("cargo download request", "crate", name, "version", version, "filename", 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, - ) + result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch crate") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch crate", http.StatusBadGateway) return } diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go index 895ff7b..10d3faf 100644 --- a/internal/handler/cargo_test.go +++ b/internal/handler/cargo_test.go @@ -2,7 +2,6 @@ package handler import ( "encoding/json" - "io" "log/slog" "net/http" "net/http/httptest" @@ -11,7 +10,6 @@ import ( "time" "github.com/git-pkgs/cooldown" - "github.com/git-pkgs/registries/fetch" ) func cargoTestProxy() *Proxy { @@ -72,75 +70,6 @@ func TestCargoConfigEndpoint(t *testing.T) { } } -func TestCargoHandlerUsesConfiguredUpstreams(t *testing.T) { - t.Run("index", func(t *testing.T) { - var requestPath, authHeader string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestPath = r.URL.Path - authHeader = r.Header.Get("Authorization") - if authHeader != "Bearer cargo-token" { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.Header().Set("Content-Type", "text/plain") - _, _ = io.WriteString(w, `{"name":"serde","vers":"1.0.0"}`) - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - proxy.AuthForURL = func(string) (string, string) { - return "Authorization", "Bearer cargo-token" - } - h := NewCargoHandler( - proxy, - "http://proxy.test", - upstream.URL+"/index/", - "https://crates.example.test/files/", - ) - - req := httptest.NewRequest(http.MethodGet, "/se/rd/serde", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - if requestPath != "/index/se/rd/serde" { - t.Errorf("upstream path = %q, want %q", requestPath, "/index/se/rd/serde") - } - if authHeader != "Bearer cargo-token" { - t.Errorf("Authorization = %q, want %q", authHeader, "Bearer cargo-token") - } - }) - - t.Run("download", func(t *testing.T) { - proxy, _, _, artifactFetcher := setupTestProxy(t) - artifactFetcher.artifact = &fetch.Artifact{ - Body: io.NopCloser(strings.NewReader("crate")), - ContentType: "application/gzip", - } - h := NewCargoHandler( - proxy, - "http://proxy.test", - "https://index.example.test/root/", - "https://crates.example.test/files/", - ) - - req := httptest.NewRequest(http.MethodGet, "/crates/serde/1.0.0/download", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - want := "https://crates.example.test/files/serde/serde-1.0.0.crate" - if artifactFetcher.fetchedURL != want { - t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) - } - }) -} - func TestCargoIndexProxy(t *testing.T) { // 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 45935b7..065ddf9 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -1,7 +1,6 @@ package handler import ( - "context" "encoding/json" "errors" "fmt" @@ -10,6 +9,8 @@ import ( "path" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -214,7 +215,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 := canonicalPackagePURL("composer", packageName) + packagePURL := purl.MakePURLString("composer", packageName, "") filtered := versionList[:0] for _, v := range versionList { @@ -306,127 +307,50 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) h.proxy.Logger.Info("composer download request", "package", packageName, "version", version, "filename", filename) - // We need to fetch the metadata to get the actual download URL since - // Packagist URLs include a hash. Packagist serves dev versions (e.g. - // "3.x-dev", "dev-master") from a separate "~dev" metadata file, while - // tagged releases live in the regular file. Try the file most likely to - // contain this version first, then fall back to the other so that both - // stable and dev versions resolve correctly. - metaURLs := h.metadataURLsForVersion(vendor, pkg, version) + // We need to fetch the metadata to get the actual download URL + // since Packagist URLs include a hash + metaURL := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) - h.proxy.Logger.Debug("resolving download URL", - "package", packageName, "version", version, - "metadata_urls", metaURLs) - - var downloadURL string - for _, metaURL := range metaURLs { - url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version) - if err != nil { - h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL) - http.Error(w, "failed to fetch metadata", http.StatusBadGateway) - return - } - if url != "" { - downloadURL = url - break - } - } - - if downloadURL == "" { - h.proxy.Logger.Debug("version not found in any metadata source", - "package", packageName, "version", version, - "tried_urls", metaURLs) - http.Error(w, "version not found", http.StatusNotFound) - return - } - - h.proxy.Logger.Debug("resolved download URL", - "package", packageName, "version", version, - "download_url", downloadURL) - - result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, metaURL, nil) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + http.Error(w, "failed to create request", http.StatusInternalServerError) return } - ServeArtifact(w, result) -} - -// isDevVersion reports whether a Composer version string refers to a -// development (unstable, branch) version rather than a tagged release. -// Composer formats these as either "dev-" (e.g. "dev-master") or -// "-dev" (e.g. "3.x-dev"). -func isDevVersion(version string) bool { - return strings.HasPrefix(version, "dev-") || strings.HasSuffix(version, "-dev") -} - -// metadataURLsForVersion returns the upstream metadata URLs to consult for a -// given version, in priority order. Dev versions are served from the "~dev" -// file, tagged releases from the regular file; the other file is included as a -// fallback so an unexpected classification still resolves. -func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []string { - stable := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) - dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", h.repoURL, vendor, pkg) - - if isDevVersion(version) { - return []string{dev, stable} - } - return []string{stable, dev} -} - -// findDownloadURLFromMetadata fetches a metadata document and returns the dist -// URL for the given version, or an empty string if the version is not present. -// An error is returned only on transport failure; a missing document (non-200) -// or a missing version both yield an empty string so the caller can fall back. -func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, error) { - h.proxy.Logger.Debug("fetching upstream metadata for download lookup", - "url", metaURL, "package", packageName, "version", version) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) - if err != nil { - return "", err - } - resp, err := h.proxy.HTTPClient.Do(req) if err != nil { - return "", err + h.proxy.Logger.Error("failed to fetch metadata", "error", err) + http.Error(w, "failed to fetch metadata", http.StatusBadGateway) + return } defer func() { _ = resp.Body.Close() }() - h.proxy.Logger.Debug("upstream metadata response", - "url", metaURL, "status", resp.StatusCode) - if resp.StatusCode != http.StatusOK { - return "", nil + http.Error(w, "package not found", http.StatusNotFound) + return } var metadata map[string]any if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { - return "", err + http.Error(w, "failed to parse metadata", http.StatusInternalServerError) + return } - // Expand minified Composer v2 format so that inherited fields (including - // dist) are present on every version entry. Without this, versions that - // inherit dist from a previous entry will appear to have no download URL. - if metadata["minified"] == "composer/2.0" { - h.proxy.Logger.Debug("expanding minified metadata", "url", metaURL) - if packages, ok := metadata["packages"].(map[string]any); ok { - for pkgName, versions := range packages { - versionList, ok := versions.([]any) - if !ok { - continue - } - packages[pkgName] = expandMinifiedVersions(versionList) - } - } + // Find the download URL for this version + downloadURL := h.findDownloadURL(metadata, packageName, version) + if downloadURL == "" { + http.Error(w, "version not found", http.StatusNotFound) + return } - url := h.findDownloadURL(metadata, packageName, version) - h.proxy.Logger.Debug("download URL lookup result", - "url", metaURL, "package", packageName, "version", version, - "download_url", url) - return url, nil + 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) + return + } + + ServeArtifact(w, result) } // findDownloadURL finds the dist URL for a specific version in metadata. diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go index 9898664..baf13b6 100644 --- a/internal/handler/composer_test.go +++ b/internal/handler/composer_test.go @@ -1,11 +1,8 @@ package handler import ( - "context" "encoding/json" "log/slog" - "net/http" - "net/http/httptest" "strings" "testing" "time" @@ -468,100 +465,6 @@ func TestComposerExpandMinifiedSharedDistReferences(t *testing.T) { } } -// TestComposerDownloadDevVersionUsesDevMetadata is a regression test for the -// bug that made it impossible to install a *-dev dependency from dist. -// -// Packagist serves development versions (e.g. "3.x-dev", "dev-master") from a -// separate "{package}~dev.json" metadata file; the regular "{package}.json" -// file contains only tagged releases. The download handler used to fetch only -// the regular file, so it could never find the dist URL for a dev version and -// returned 404 — causing Composer to silently fall back to a git clone. -// -// This test serves both files from a mock upstream and asserts that: -// - the OLD behavior (regular file only) cannot resolve the dev version, and -// - the FIXED behavior (consulting the ~dev file) does. -func TestComposerDownloadDevVersionUsesDevMetadata(t *testing.T) { - const ( - pkg = "phpmd/phpmd" - vendor = "phpmd" - name = "phpmd" - version = "3.x-dev" - distURL = "https://api.github.com/repos/phpmd/phpmd/zipball/2a9217f60aaf27bf6ddad9188f254d020ab70745" - ) - - // Regular metadata: tagged releases only — no dev versions. - stableBody := `{ - "packages": { - "phpmd/phpmd": [ - {"version": "2.15.0", "dist": {"url": "https://example.com/2.15.0.zip", "type": "zip"}} - ] - } - }` - - // ~dev metadata: where the 3.x-dev version actually lives. - devBody := `{ - "packages": { - "phpmd/phpmd": [ - {"version": "3.x-dev", "dist": {"url": "` + distURL + `", "type": "zip"}} - ] - } - }` - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/p2/phpmd/phpmd.json": - _, _ = w.Write([]byte(stableBody)) - case "/p2/phpmd/phpmd~dev.json": - _, _ = w.Write([]byte(devBody)) - default: - http.NotFound(w, r) - } - })) - defer srv.Close() - - h := &ComposerHandler{ - proxy: testProxy(), - repoURL: srv.URL, - proxyURL: "http://localhost:8080", - } - - ctx := context.Background() - - // OLD behavior: fetching only the regular file fails to resolve the dev - // version, which is what produced the 404 before the fix. - stableURL := srv.URL + "/p2/phpmd/phpmd.json" - got, err := h.findDownloadURLFromMetadata(ctx, stableURL, pkg, version) - if err != nil { - t.Fatalf("unexpected error fetching regular metadata: %v", err) - } - if got != "" { - t.Fatalf("regular metadata unexpectedly contained dev version %q (got %q); "+ - "the test no longer reproduces the original bug", version, got) - } - - // FIXED behavior: the handler consults the ~dev file (it is first in the - // candidate list for dev versions) and resolves the dist URL. - urls := h.metadataURLsForVersion(vendor, name, version) - if len(urls) == 0 || !strings.HasSuffix(urls[0], "/p2/phpmd/phpmd~dev.json") { - t.Fatalf("dev version should consult the ~dev metadata file first, got %v", urls) - } - - var resolved string - for _, u := range urls { - resolved, err = h.findDownloadURLFromMetadata(ctx, u, pkg, version) - if err != nil { - t.Fatalf("unexpected error fetching metadata %q: %v", u, err) - } - if resolved != "" { - break - } - } - - if resolved != distURL { - t.Errorf("dev version dist URL = %q, want %q", resolved, distURL) - } -} - func TestComposerRewriteMetadataCooldown(t *testing.T) { now := time.Now() old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339) diff --git a/internal/handler/conan.go b/internal/handler/conan.go index c0476f9..53f6428 100644 --- a/internal/handler/conan.go +++ b/internal/handler/conan.go @@ -84,7 +84,8 @@ func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch file") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch file", http.StatusBadGateway) return } @@ -121,7 +122,8 @@ func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", packageName, storageVersion, storageFilename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch file") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch file", http.StatusBadGateway) return } diff --git a/internal/handler/conda.go b/internal/handler/conda.go index 224c25f..cfa20c8 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -72,7 +74,8 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } @@ -215,7 +218,7 @@ func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) { continue } - packagePURL := canonicalPackagePURL("conda", name) + packagePURL := purl.MakePURLString("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 0710ed1..8ba5e97 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -2,7 +2,6 @@ package handler import ( "encoding/json" - "errors" "fmt" "io" "net/http" @@ -118,12 +117,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req ) if err != nil { - if errors.Is(err, ErrUpstreamNotFound) { - h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry") - return - } h.proxy.Logger.Error("failed to fetch blob", "error", err) - h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob") + h.containerError(w, http.StatusBadGateway, "BLOB_UNKNOWN", "failed to fetch blob") return } diff --git a/internal/handler/cran.go b/internal/handler/cran.go index 2fc4fab..0ecd2a3 100644 --- a/internal/handler/cran.go +++ b/internal/handler/cran.go @@ -72,7 +72,8 @@ func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Reques result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } @@ -106,7 +107,8 @@ func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Reques result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } diff --git a/internal/handler/debian.go b/internal/handler/debian.go index 9a4aaab..b767f6d 100644 --- a/internal/handler/debian.go +++ b/internal/handler/debian.go @@ -81,7 +81,8 @@ 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.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get debian package", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } diff --git a/internal/handler/filename_download.go b/internal/handler/filename_download.go deleted file mode 100644 index bedec16..0000000 --- a/internal/handler/filename_download.go +++ /dev/null @@ -1,39 +0,0 @@ -package handler - -import ( - "net/http" - "strings" -) - -type filenameDownload struct { - ecosystem string - suffix string - parseErr string - fetchErr string - parse func(string) (name, version string) -} - -func (p *Proxy) handleFilenameDownload(w http.ResponseWriter, r *http.Request, d filenameDownload) { - filename := r.PathValue("filename") - if filename == "" || !strings.HasSuffix(filename, d.suffix) { - http.Error(w, "invalid filename", http.StatusBadRequest) - return - } - - name, version := d.parse(filename) - if name == "" || version == "" { - http.Error(w, d.parseErr, http.StatusBadRequest) - return - } - - p.Logger.Info(d.ecosystem+" download request", - "name", name, "version", version, "filename", filename) - - result, err := p.GetOrFetchArtifact(r.Context(), d.ecosystem, name, version, filename) - if err != nil { - p.serveArtifactError(w, err, d.fetchErr) - return - } - - ServeArtifact(w, result) -} diff --git a/internal/handler/gem.go b/internal/handler/gem.go index 260568a..9ec57e3 100644 --- a/internal/handler/gem.go +++ b/internal/handler/gem.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -58,13 +60,30 @@ 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) { - h.proxy.handleFilenameDownload(w, r, filenameDownload{ - ecosystem: "gem", - suffix: ".gem", - parseErr: "could not parse gem filename", - fetchErr: "failed to fetch gem", - parse: h.parseGemFilename, - }) + 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) } // parseGemFilename extracts name and version from a gem filename. @@ -247,7 +266,7 @@ func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := canonicalPackagePURL("gem", name) + packagePURL := purl.MakePURLString("gem", name, "") filtered := make(map[string]bool) for _, v := range versions { diff --git a/internal/handler/go.go b/internal/handler/go.go index cf40aa1..955a89c 100644 --- a/internal/handler/go.go +++ b/internal/handler/go.go @@ -1,12 +1,9 @@ package handler import ( - "errors" "fmt" "net/http" "strings" - - "github.com/git-pkgs/registries/fetch" ) const ( @@ -103,10 +100,6 @@ func (h *GoHandler) handleDownload(w http.ResponseWriter, r *http.Request, modul result, err := h.proxy.GetOrFetchArtifact(r.Context(), "golang", decodedModule, version, filename) if err != nil { - if errors.Is(err, fetch.ErrNotFound) { - http.Error(w, "not found", http.StatusNotFound) - return - } h.proxy.Logger.Error("failed to get artifact", "error", err) http.Error(w, "failed to fetch module", http.StatusBadGateway) return diff --git a/internal/handler/go_test.go b/internal/handler/go_test.go index ae998c9..da4ea63 100644 --- a/internal/handler/go_test.go +++ b/internal/handler/go_test.go @@ -1,49 +1,9 @@ package handler import ( - "errors" - "net/http" - "net/http/httptest" "testing" - - "github.com/git-pkgs/registries/fetch" ) -func TestGoModuleDownloadUpstreamErrors(t *testing.T) { - tests := []struct { - name string - fetchErr error - wantStatus int - }{ - { - name: "module not found", - fetchErr: fetch.ErrNotFound, - wantStatus: http.StatusNotFound, - }, - { - name: "upstream failure", - fetchErr: errors.New("connection refused"), - wantStatus: http.StatusBadGateway, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = tt.fetchErr - handler := NewGoHandler(proxy, "http://localhost:8080") - - req := httptest.NewRequest(http.MethodGet, "/example.com/mod/@v/v1.0.0.zip", nil) - resp := httptest.NewRecorder() - handler.Routes().ServeHTTP(resp, req) - - if resp.Code != tt.wantStatus { - t.Fatalf("status = %d, want %d", resp.Code, tt.wantStatus) - } - }) - } -} - func TestDecodeGoModule(t *testing.T) { tests := []struct { encoded string diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fc78dbf..d06ca83 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -48,14 +48,6 @@ 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" @@ -104,7 +96,6 @@ 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. @@ -252,9 +243,6 @@ 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) } @@ -274,9 +262,6 @@ 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) @@ -406,7 +391,6 @@ 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 { @@ -433,7 +417,6 @@ 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 { @@ -460,18 +443,7 @@ func JSONError(w http.ResponseWriter, status int, message string) { } // ErrUpstreamNotFound indicates the upstream returned 404. -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) -} +var ErrUpstreamNotFound = fmt.Errorf("upstream: not found") // errStale304 is returned when upstream sends 304 but the cached file is missing. var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing") @@ -574,7 +546,6 @@ 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) @@ -764,7 +735,6 @@ 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 != "" { @@ -789,17 +759,6 @@ 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) { @@ -828,9 +787,6 @@ 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 9a2b329..bbcab72 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -14,7 +14,6 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/storage" "github.com/git-pkgs/registries/fetch" @@ -1011,33 +1010,3 @@ 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 2ff4f0f..0f0c72e 100644 --- a/internal/handler/hex.go +++ b/internal/handler/hex.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/git-pkgs/purl" "google.golang.org/protobuf/encoding/protowire" ) @@ -53,13 +54,30 @@ func (h *HexHandler) Routes() http.Handler { // handleDownload serves a package tarball, fetching and caching from upstream if needed. func (h *HexHandler) handleDownload(w http.ResponseWriter, r *http.Request) { - h.proxy.handleFilenameDownload(w, r, filenameDownload{ - ecosystem: "hex", - suffix: ".tar", - parseErr: "could not parse tarball filename", - fetchErr: "failed to fetch package", - parse: h.parseTarballFilename, - }) + 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) } // parseTarballFilename extracts name and version from a hex tarball filename. @@ -219,7 +237,7 @@ func (h *HexHandler) fetchFilteredVersions(r *http.Request, name string) (map[st return nil, err } - packagePURL := canonicalPackagePURL("hex", name) + packagePURL := purl.MakePURLString("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 0fed8c9..08b1fdf 100644 --- a/internal/handler/julia.go +++ b/internal/handler/julia.go @@ -90,7 +90,8 @@ 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.serveArtifactError(w, err, "failed to fetch registry") + h.proxy.Logger.Error("failed to get registry", "error", err) + http.Error(w, "failed to fetch registry", http.StatusBadGateway) return } @@ -118,7 +119,8 @@ 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.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get package", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } @@ -139,7 +141,8 @@ 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.serveArtifactError(w, err, "failed to fetch artifact") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch artifact", http.StatusBadGateway) return } diff --git a/internal/handler/maven.go b/internal/handler/maven.go index 10e551e..c423645 100644 --- a/internal/handler/maven.go +++ b/internal/handler/maven.go @@ -130,7 +130,12 @@ func (h *MavenHandler) handleDownload(w http.ResponseWriter, r *http.Request, ur } } if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch artifact") + if errors.Is(err, ErrUpstreamNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch artifact", http.StatusBadGateway) return } diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go deleted file mode 100644 index 3c2cecf..0000000 --- a/internal/handler/notfound_ecosystems_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package handler - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/git-pkgs/registries/fetch" -) - -func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) { - tests := []struct { - name string - path string - handler func(p *Proxy) http.Handler - }{ - {"debian", "/pool/main/n/nginx/nginx_1.18.0-6_amd64.deb", - func(p *Proxy) http.Handler { return NewDebianHandler(p, "http://localhost").Routes() }}, - {"rpm", "/releases/39/Everything/x86_64/os/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm", - func(p *Proxy) http.Handler { return NewRPMHandler(p, "http://localhost").Routes() }}, - {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", - func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }}, - {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl", - func(p *Proxy) http.Handler { return NewPyPIHandler(p, "http://localhost").Routes() }}, - {"cran", "/src/contrib/ggplot2_3.4.4.tar.gz", - func(p *Proxy) http.Handler { return NewCRANHandler(p, "http://localhost").Routes() }}, - {"conda", "/conda-forge/linux-64/numpy-1.26.0-py311_0.tar.bz2", - func(p *Proxy) http.Handler { return NewCondaHandler(p, "http://localhost").Routes() }}, - {"conan", "/v1/files/zlib/1.3.1/_/_/0/recipe/conan_sources.tgz", - func(p *Proxy) http.Handler { return NewConanHandler(p, "http://localhost").Routes() }}, - {"gem", "/gems/rails-7.1.0.gem", - func(p *Proxy) http.Handler { return NewGemHandler(p, "http://localhost").Routes() }}, - {"hex", "/tarballs/phoenix-1.7.10.tar", - func(p *Proxy) http.Handler { return NewHexHandler(p, "http://localhost").Routes() }}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = fetch.ErrNotFound - - srv := httptest.NewServer(tt.handler(proxy)) - defer srv.Close() - - resp, err := http.Get(srv.URL + tt.path) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode) - } - }) - } -} - -func TestJuliaPackageUpstreamNotFoundReturns404(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = fetch.ErrNotFound - - dead := httptest.NewServer(http.NotFoundHandler()) - defer dead.Close() - - h := NewJuliaHandler(proxy, "http://localhost") - h.upstreamURL = dead.URL - - srv := httptest.NewServer(h.Routes()) - defer srv.Close() - - resp, err := http.Get(srv.URL + - "/package/7876af07-990d-54b4-ab0e-23690620f79a/0123456789abcdef0123456789abcdef01234567") - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("want 404 for missing upstream package, got %d", resp.StatusCode) - } -} - -func TestComposerDownloadUpstreamNotFoundReturns404(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = fetch.ErrNotFound - - meta := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/p2/monolog/monolog.json" { - _, _ = w.Write([]byte(`{ - "packages": { - "monolog/monolog": [ - {"version": "2.9.1", "dist": {"url": "https://example.com/monolog-2.9.1.zip", "type": "zip"}} - ] - } - }`)) - return - } - http.NotFound(w, r) - })) - defer meta.Close() - - h := &ComposerHandler{proxy: proxy, repoURL: meta.URL, proxyURL: "http://localhost"} - srv := httptest.NewServer(h.Routes()) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/files/monolog/monolog/2.9.1/monolog-2.9.1.zip") - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("want 404 for missing upstream dist, got %d", resp.StatusCode) - } -} - -func TestContainerBlobUpstreamNotFoundReturns404(t *testing.T) { - authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"token": "test-token-123"}`)) - })) - defer authServer.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.Fetcher = &mockFetcherWithHeaders{ - fetchFn: func(_ context.Context, _ string, _ http.Header) (*fetch.Artifact, error) { - return nil, fetch.ErrNotFound - }, - } - - h := &ContainerHandler{ - proxy: proxy, - registryURL: "https://registry-1.docker.io", - authURL: authServer.URL, - proxyURL: "http://localhost:8080", - } - - req := httptest.NewRequest(http.MethodGet, - "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("want 404 for missing upstream blob, got %d; body: %s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "BLOB_UNKNOWN") { - t.Errorf("want BLOB_UNKNOWN error code in body, got: %s", w.Body.String()) - } -} diff --git a/internal/handler/notfound_test.go b/internal/handler/notfound_test.go deleted file mode 100644 index 9ea38ac..0000000 --- a/internal/handler/notfound_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package handler - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/git-pkgs/registries/fetch" -) - -func TestErrUpstreamNotFoundWrapsFetchErrNotFound(t *testing.T) { - if !errors.Is(ErrUpstreamNotFound, fetch.ErrNotFound) { - t.Fatal("ErrUpstreamNotFound does not wrap fetch.ErrNotFound") - } -} - -func TestGetOrFetchArtifactFromURL_NotFound(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = fetch.ErrNotFound - - _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), - "maven", "org.example:missing", "1.0", "missing-1.0.jar", - "http://upstream.test/org/example/missing/1.0/missing-1.0.jar") - - if !errors.Is(err, ErrUpstreamNotFound) { - t.Fatalf("want ErrUpstreamNotFound, got %v", err) - } -} - -func TestMavenHandler_UpstreamNotFoundReturns404(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErr = fetch.ErrNotFound - - h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test") - srv := httptest.NewServer(h.Routes()) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/org/example/missing/1.0/missing-1.0.jar") - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNotFound { - t.Errorf("want 404 for missing upstream artifact, got %d", resp.StatusCode) - } -} - -func TestMavenHandler_PluginPortalFallback(t *testing.T) { - proxy, _, _, fetcher := setupTestProxy(t) - fetcher.fetchErrByURL = map[string]error{ - "http://upstream.test/org/example/plugin/1.0/plugin-1.0.jar": fetch.ErrNotFound, - } - fetcher.artifact = &fetch.Artifact{ - Body: io.NopCloser(strings.NewReader("portal artifact")), - ContentType: "application/java-archive", - } - - h := NewMavenHandler(proxy, "http://localhost", "http://upstream.test", "http://portal.test") - srv := httptest.NewServer(h.Routes()) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/org/example/plugin/1.0/plugin-1.0.jar") - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - t.Fatalf("want 200 via plugin portal fallback, got %d", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "portal artifact" { - t.Errorf("want portal artifact body, got %q", body) - } - if fetcher.fetchedURL != "http://portal.test/org/example/plugin/1.0/plugin-1.0.jar" { - t.Errorf("fallback did not hit plugin portal, last URL: %s", fetcher.fetchedURL) - } -} diff --git a/internal/handler/npm.go b/internal/handler/npm.go index ee9b59c..0585eda 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -25,14 +27,10 @@ type NPMHandler struct { } // NewNPMHandler creates a new npm protocol handler. -func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler { - if strings.TrimSpace(upstreamURL) == "" { - upstreamURL = npmUpstream - } - +func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler { return &NPMHandler{ proxy: proxy, - upstreamURL: strings.TrimSuffix(upstreamURL, "/"), + upstreamURL: npmUpstream, proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -136,7 +134,7 @@ func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions ma return } - packagePURL := canonicalPackagePURL("npm", packageName) + packagePURL := purl.MakePURLString("npm", packageName, "") for version := range versions { publishedStr, ok := timeMap[version].(string) @@ -265,20 +263,8 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("npm download request", "package", packageName, "version", version, "filename", 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, - ) + result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename) 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 @@ -287,14 +273,6 @@ 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 e0257dd..bc1edde 100644 --- a/internal/handler/npm_test.go +++ b/internal/handler/npm_test.go @@ -2,16 +2,13 @@ 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" @@ -49,86 +46,6 @@ func TestNPMExtractVersionFromFilename(t *testing.T) { } } -func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) { - t.Run("metadata", func(t *testing.T) { - var requestPath, authHeader string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestPath = r.URL.Path - authHeader = r.Header.Get("Authorization") - if authHeader != "Bearer npm-token" { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"versions":{}}`) - })) - defer upstream.Close() - - proxy, _, _, _ := setupTestProxy(t) - proxy.HTTPClient = upstream.Client() - proxy.AuthForURL = func(string) (string, string) { - return "Authorization", "Bearer npm-token" - } - h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/") - - req := httptest.NewRequest(http.MethodGet, "/testpkg", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - if requestPath != "/root/testpkg" { - t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg") - } - if authHeader != "Bearer npm-token" { - t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token") - } - }) - - t.Run("download", func(t *testing.T) { - proxy, _, _, artifactFetcher := setupTestProxy(t) - artifactFetcher.artifact = &fetch.Artifact{ - Body: io.NopCloser(strings.NewReader("package")), - ContentType: "application/gzip", - } - h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") - - req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz" - if artifactFetcher.fetchedURL != want { - t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) - } - }) - - t.Run("scoped download", func(t *testing.T) { - proxy, _, _, artifactFetcher := setupTestProxy(t) - artifactFetcher.artifact = &fetch.Artifact{ - Body: io.NopCloser(strings.NewReader("package")), - ContentType: "application/gzip", - } - h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") - - req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil) - w := httptest.NewRecorder() - h.Routes().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz" - if artifactFetcher.fetchedURL != want { - t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) - } - }) -} - func TestNPMRewriteMetadata(t *testing.T) { h := &NPMHandler{ proxy: testProxy(), diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 4785e40..40b8b5f 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -269,7 +271,7 @@ func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) { } } - packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id)) + packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "") if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering nuget version", @@ -314,7 +316,8 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } diff --git a/internal/handler/pub.go b/internal/handler/pub.go index e5ca199..60bbbad 100644 --- a/internal/handler/pub.go +++ b/internal/handler/pub.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -67,7 +69,8 @@ func (h *PubHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifact(r.Context(), "pub", name, version, filename) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } @@ -124,7 +127,7 @@ func (h *PubHandler) rewriteMetadata(name string, body []byte) ([]byte, error) { return body, nil } - packagePURL := canonicalPackagePURL("pub", name) + packagePURL := purl.MakePURLString("pub", name, "") filtered := h.filterAndRewriteVersions(name, packagePURL, versions) metadata["versions"] = filtered diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index f5a0232..3021d2b 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -12,6 +12,8 @@ import ( "regexp" "strings" "time" + + "github.com/git-pkgs/purl" ) const ( @@ -129,7 +131,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[st return nil } - packagePURL := canonicalPackagePURL("pypi", name) + packagePURL := purl.MakePURLString("pypi", name, "") filtered := make(map[string]bool) for version, files := range releases { @@ -260,7 +262,7 @@ func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) { packageName, _ := extractPyPIName(metadata) packagePURL := "" if packageName != "" { - packagePURL = canonicalPackagePURL("pypi", packageName) + packagePURL = purl.MakePURLString("pypi", packageName, "") } h.filterAndRewriteReleases(metadata, packageName, packagePURL) @@ -426,7 +428,8 @@ func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) { result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL) if err != nil { - h.proxy.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go index a5752ec..6440d0f 100644 --- a/internal/handler/rpm.go +++ b/internal/handler/rpm.go @@ -83,7 +83,8 @@ 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.serveArtifactError(w, err, "failed to fetch package") + h.proxy.Logger.Error("failed to get rpm package", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } diff --git a/internal/server/browse.go b/internal/server/browse.go index 56aa1c5..504f5f1 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -1,7 +1,6 @@ package server import ( - "bufio" "encoding/json" "fmt" "io" @@ -11,22 +10,29 @@ 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" - browseSniffSize = 512 -) +const contentTypePlainText = "text/plain; charset=utf-8" // maxBrowseArchiveSize caps how much data openArchive will buffer for // prefix detection. Artifacts larger than this are rejected to prevent // memory exhaustion from a single request. const maxBrowseArchiveSize = 512 << 20 // 512 MB +// archiveFilename returns a filename suitable for archive format detection. +// Some ecosystems (e.g. composer) store artifacts with bare hash filenames +// that have no extension. This adds .zip when the original has no extension +// and the content is likely a zip archive. +func archiveFilename(filename string) string { + if path.Ext(filename) == "" { + return filename + ".zip" + } + return filename +} + // detectSingleRootDir returns the single top-level directory name if all files // in the archive live under one common directory (e.g. GitHub zipballs use // "repo-hash/"). Returns "" if there's no single root or the archive is flat. @@ -60,6 +66,8 @@ 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 { @@ -70,17 +78,17 @@ func openArchive(filename string, content io.Reader, ecosystem string) (archives } if ecosystem == "npm" { - return archives.OpenBytesWithPrefix(filename, data, "package/") + return archives.OpenBytesWithPrefix(fname, data, "package/") } - probe, err := archives.OpenBytes(filename, data) + probe, err := archives.OpenBytes(fname, data) if err != nil { return nil, err } prefix := detectSingleRootDir(probe) _ = probe.Close() - return archives.OpenBytesWithPrefix(filename, data, prefix) + return archives.OpenBytesWithPrefix(fname, data, prefix) } // BrowseListResponse contains the file listing for a directory in an archives. @@ -353,14 +361,7 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n } defer func() { _ = fileReader.Close() }() - 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 - } + contentType := detectContentType(filePath) w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Security-Policy", "sandbox") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -369,103 +370,85 @@ 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, content) + _, _ = io.Copy(w, fileReader) } -func detectContentTypeFromPath(filename string) (string, bool) { +// detectContentType returns an appropriate content type based on file extension. +func detectContentType(filename string) string { ext := strings.ToLower(path.Ext(filename)) switch ext { // Text formats case ".txt", ".md", ".markdown": - return contentTypePlainText, true + return contentTypePlainText case ".html", ".htm", ".xhtml": - return contentTypePlainText, true + return contentTypePlainText case ".css": - return "text/css; charset=utf-8", true + return "text/css; charset=utf-8" case ".js", ".mjs": - return "application/javascript; charset=utf-8", true + return "application/javascript; charset=utf-8" case ".json": - return "application/json; charset=utf-8", true + return "application/json; charset=utf-8" case ".xml": - return "application/xml; charset=utf-8", true + return "application/xml; charset=utf-8" case ".yaml", ".yml": - return "text/yaml; charset=utf-8", true + return "text/yaml; charset=utf-8" case ".toml": - return "text/toml; charset=utf-8", true + return "text/toml; charset=utf-8" // Programming languages case ".go": - return "text/x-go; charset=utf-8", true + return "text/x-go; charset=utf-8" case ".rs": - return "text/x-rust; charset=utf-8", true + return "text/x-rust; charset=utf-8" case ".py": - return "text/x-python; charset=utf-8", true + return "text/x-python; charset=utf-8" case ".rb": - return "text/x-ruby; charset=utf-8", true + return "text/x-ruby; charset=utf-8" case ".java": - return "text/x-java; charset=utf-8", true + return "text/x-java; charset=utf-8" case ".c", ".h": - return "text/x-c; charset=utf-8", true + return "text/x-c; charset=utf-8" case ".cpp", ".cc", ".cxx", ".hpp": - return "text/x-c++; charset=utf-8", true + return "text/x-c++; charset=utf-8" case ".ts": - return "text/typescript; charset=utf-8", true + return "text/typescript; charset=utf-8" case ".tsx": - return "text/tsx; charset=utf-8", true + return "text/tsx; charset=utf-8" case ".jsx": - return "text/jsx; charset=utf-8", true + return "text/jsx; charset=utf-8" case ".php": - return "text/x-php; charset=utf-8", true + return "text/x-php; charset=utf-8" // Config files case ".conf", ".config", ".ini": - return contentTypePlainText, true + return contentTypePlainText case ".sh", ".bash": - return "text/x-shellscript; charset=utf-8", true + return "text/x-shellscript; charset=utf-8" case ".dockerfile": - return "text/x-dockerfile; charset=utf-8", true + return "text/x-dockerfile; charset=utf-8" // Images case ".png": - return "image/png", true + return "image/png" case ".jpg", ".jpeg": - return "image/jpeg", true + return "image/jpeg" case ".gif": - return "image/gif", true + return "image/gif" case ".svg": - return contentTypePlainText, true + return contentTypePlainText case ".ico": - return "image/x-icon", true + return "image/x-icon" // Archives case ".zip", ".tar", ".gz", ".bz2", ".xz": - return "application/octet-stream", true + return "application/octet-stream" default: + // Try to detect if it looks like text if isLikelyText(filename) { - return contentTypePlainText, true + return contentTypePlainText } - 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 840bc75..03f3f02 100644 --- a/internal/server/browse_bench_test.go +++ b/internal/server/browse_bench_test.go @@ -55,35 +55,3 @@ 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 5240a92..f1fb993 100644 --- a/internal/server/browse_test.go +++ b/internal/server/browse_test.go @@ -137,44 +137,29 @@ func TestHandleBrowseFile(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - 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) + // 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()) - } - 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) - } - }) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + 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) } // 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 { @@ -182,52 +167,36 @@ func TestHandleBrowseFile(t *testing.T) { } } -func TestBrowseContentTypePolicy(t *testing.T) { +func TestDetectContentType(t *testing.T) { tests := []struct { - name string filename string - prefix []byte expectedCT string }{ - {"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}, + {"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"}, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, knownPath := detectContentTypeFromPath(tt.filename) - if !knownPath { - got = detectContentTypeFromPrefix(tt.prefix) - } + t.Run(tt.filename, func(t *testing.T) { + got := detectContentType(tt.filename) if got != tt.expectedCT { - t.Errorf("content type for %q with prefix %q = %q, want %q", tt.filename, tt.prefix, got, tt.expectedCT) + t.Errorf("detectContentType(%q) = %q, want %q", tt.filename, got, tt.expectedCT) } }) } @@ -286,10 +255,6 @@ 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 { @@ -644,19 +609,25 @@ func TestHandleComparePage(t *testing.T) { } } -func TestOpenArchiveDetectsExtensionlessTarGz(t *testing.T) { - reader, err := openArchive("artifact", bytes.NewReader(createTestArchive(t)), "npm") - if err != nil { - t.Fatalf("openArchive failed: %v", err) +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"}, } - defer func() { _ = reader.Close() }() - files, err := reader.List() - if err != nil { - t.Fatalf("List failed: %v", err) - } - if len(files) == 0 { - t.Fatal("expected files in extensionless archive") + 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) + } + }) } } diff --git a/internal/server/server.go b/internal/server/server.go index 13c5997..cb99648 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -163,11 +163,9 @@ func (s *Server) Start() error { cd := &cooldown.Config{ Default: s.cfg.Cooldown.Default, Ecosystems: s.cfg.Cooldown.Ecosystems, - Packages: s.cfg.Cooldown.NormalizedPackages(), + Packages: s.cfg.Cooldown.Packages, } proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) - proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout() - proxy.AuthForURL = s.authForURL proxy.Cooldown = cd proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() @@ -197,13 +195,8 @@ func (s *Server) Start() error { }) // Mount protocol handlers - 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, - ) + npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL) + cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL) gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL) hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL) @@ -309,7 +302,7 @@ func (s *Server) Start() error { "base_url", s.cfg.BaseURL, "ui_url", s.cfg.UIBaseURL, "storage", s.storage.URL(), - "database", s.cfg.Database.String()) + "database", s.cfg.Database.Path) go s.updateCacheStatsMetrics() go s.startEvictionLoop(bgCtx) @@ -934,7 +927,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { TotalSize: size, TotalSizeHuman: formatSize(size), StorageURL: s.storage.URL(), - DatabasePath: s.cfg.Database.String(), + DatabasePath: s.cfg.Database.Path, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2d27147..f1de62d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -67,13 +67,8 @@ func newTestServer(t *testing.T) *testServer { r := chi.NewRouter() // Mount handlers - npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM) - cargoHandler := handler.NewCargoHandler( - proxy, - cfg.BaseURL, - cfg.Upstream.Cargo, - cfg.Upstream.CargoDownload, - ) + npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL) + cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL) gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL) diff --git a/internal/server/templates/pages/packages_list.html b/internal/server/templates/pages/packages_list.html index 95897db..5ef42c7 100644 --- a/internal/server/templates/pages/packages_list.html +++ b/internal/server/templates/pages/packages_list.html @@ -100,7 +100,7 @@ document.getElementById('ecosystem-filter').addEventListener('change', function( const params = new URLSearchParams(); if (ecosystem) params.set('ecosystem', ecosystem); if (sortBy) params.set('sort', sortBy); - window.location.href = '/ui/packages?' + params.toString(); + window.location.href = '/packages?' + params.toString(); }); document.getElementById('sort-by').addEventListener('change', function(e) { @@ -109,7 +109,7 @@ document.getElementById('sort-by').addEventListener('change', function(e) { const params = new URLSearchParams(); if (ecosystem) params.set('ecosystem', ecosystem); if (sortBy) params.set('sort', sortBy); - window.location.href = '/ui/packages?' + params.toString(); + window.location.href = '/packages?' + params.toString(); }); {{end}} diff --git a/internal/storage/blob.go b/internal/storage/blob.go index 67e91d0..34f6f08 100644 --- a/internal/storage/blob.go +++ b/internal/storage/blob.go @@ -25,8 +25,9 @@ const osWindows = "windows" // Blob implements Storage using gocloud.dev/blob. // Supports local filesystem (file://) and S3 (s3://) URLs. type Blob struct { - bucket *blob.Bucket - url string + bucket *blob.Bucket + backend Storage + url string } // OpenBucket opens a blob bucket from a URL. @@ -35,9 +36,20 @@ type Blob struct { // - file:///path/to/dir - Local filesystem storage // - s3://bucket-name - Amazon S3 (uses AWS_* environment variables) // - s3://bucket-name?region=us-east-1&endpoint=http://localhost:9000 - S3-compatible (MinIO, etc.) +// - gs://bucket-name - Google Cloud Storage (uses Application Default Credentials; +// supports Workload Identity on GKE/GCE without any extra configuration) +// - azblob://container-name - Azure Blob Storage // // For local filesystem, the directory is created if it doesn't exist. func OpenBucket(ctx context.Context, urlStr string) (*Blob, error) { + if strings.HasPrefix(urlStr, "gs://") { + backend, err := OpenGCS(ctx, urlStr) + if err != nil { + return nil, err + } + return &Blob{backend: backend, url: urlStr}, nil + } + // Handle file:// URLs specially to create the directory if strings.HasPrefix(urlStr, "file://") { path := strings.TrimPrefix(urlStr, "file://") @@ -90,6 +102,10 @@ func OpenBucket(ctx context.Context, urlStr string) (*Blob, error) { } func (b *Blob) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) { + if b.backend != nil { + return b.backend.Store(ctx, path, r) + } + // Compute hash while writing h := sha256.New() tee := io.TeeReader(r, h) @@ -115,6 +131,10 @@ func (b *Blob) Store(ctx context.Context, path string, r io.Reader) (int64, stri } func (b *Blob) Open(ctx context.Context, path string) (io.ReadCloser, error) { + if b.backend != nil { + return b.backend.Open(ctx, path) + } + r, err := b.bucket.NewReader(ctx, path, nil) if err != nil { if isNotExist(err) { @@ -126,6 +146,10 @@ func (b *Blob) Open(ctx context.Context, path string) (io.ReadCloser, error) { } func (b *Blob) Exists(ctx context.Context, path string) (bool, error) { + if b.backend != nil { + return b.backend.Exists(ctx, path) + } + exists, err := b.bucket.Exists(ctx, path) if err != nil { return false, fmt.Errorf("checking existence: %w", err) @@ -134,6 +158,10 @@ func (b *Blob) Exists(ctx context.Context, path string) (bool, error) { } func (b *Blob) Delete(ctx context.Context, path string) error { + if b.backend != nil { + return b.backend.Delete(ctx, path) + } + err := b.bucket.Delete(ctx, path) if err != nil && !isNotExist(err) { return fmt.Errorf("deleting object: %w", err) @@ -142,6 +170,10 @@ func (b *Blob) Delete(ctx context.Context, path string) error { } func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) { + if b.backend != nil { + return b.backend.SignedURL(ctx, path, expiry) + } + url, err := b.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{ Method: http.MethodGet, Expiry: expiry, @@ -156,6 +188,10 @@ func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) } func (b *Blob) Size(ctx context.Context, path string) (int64, error) { + if b.backend != nil { + return b.backend.Size(ctx, path) + } + attrs, err := b.bucket.Attributes(ctx, path) if err != nil { if isNotExist(err) { @@ -167,6 +203,10 @@ func (b *Blob) Size(ctx context.Context, path string) (int64, error) { } func (b *Blob) UsedSpace(ctx context.Context) (int64, error) { + if b.backend != nil { + return b.backend.UsedSpace(ctx) + } + var total int64 iter := b.bucket.List(nil) @@ -186,6 +226,16 @@ func (b *Blob) UsedSpace(ctx context.Context) (int64, error) { // ListPrefix returns object metadata for keys under a prefix. func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { + if b.backend != nil { + lister, ok := b.backend.(interface { + ListPrefix(context.Context, string) ([]ObjectInfo, error) + }) + if !ok { + return nil, ErrNotFound + } + return lister.ListPrefix(ctx, prefix) + } + iter := b.bucket.List(&blob.ListOptions{Prefix: prefix}) objects := make([]ObjectInfo, 0) @@ -214,10 +264,18 @@ func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, err } func (b *Blob) Close() error { + if b.backend != nil { + return b.backend.Close() + } + return b.bucket.Close() } func (b *Blob) URL() string { + if b.backend != nil { + return b.backend.URL() + } + return b.url } diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index d509e9d..1e5a24f 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -34,31 +34,11 @@ func NewFilesystem(root string) (*Filesystem, error) { } func (fs *Filesystem) fullPath(path string) (string, error) { - localPath, err := cleanStoragePath(path) - if err != nil { - return "", err + full := filepath.Clean(filepath.Join(fs.root, filepath.FromSlash(path))) + if full != fs.root && !strings.HasPrefix(full, fs.root+string(filepath.Separator)) { + return "", fmt.Errorf("%w: path escapes storage root", ErrNotFound) } - return filepath.Join(fs.root, localPath), nil -} - -func (fs *Filesystem) prefixPath(prefix string) (string, error) { - if prefix == "" { - return fs.root, nil - } - return fs.fullPath(prefix) -} - -func cleanStoragePath(path string) (string, error) { - if path == "." || strings.Contains(path, `\`) || !filepath.IsLocal(path) { - return "", fmt.Errorf("%w: invalid storage path", ErrNotFound) - } - - localPath, err := filepath.Localize(path) - if err != nil || localPath == "." || !filepath.IsLocal(localPath) { - return "", fmt.Errorf("%w: invalid storage path", ErrNotFound) - } - - return localPath, nil + return full, nil } func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) { @@ -210,7 +190,7 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) { // ListPrefix returns object metadata for paths under a prefix. func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { - searchRoot, err := fs.prefixPath(prefix) + searchRoot, err := fs.fullPath(prefix) if err != nil { return nil, err } diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index de0e418..332dfbf 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -243,70 +243,19 @@ func TestFilesystemLargeFile(t *testing.T) { assertLargeFileRoundTrip(t, createTestFilesystem(t)) } -func TestFilesystemRejectsInvalidPaths(t *testing.T) { +func TestFilesystemRejectsTraversal(t *testing.T) { tmp := t.TempDir() fs, err := NewFilesystem(tmp) if err != nil { t.Fatal(err) } - for _, p := range []string{ - "", - ".", - "../etc/passwd", - "../../etc/passwd", - "a/../../etc/passwd", - "/etc/passwd", - "test//file.txt", - "test/./file.txt", - "test/../file.txt", - `test\..\file.txt`, - } { - name := p - if name == "" { - name = "empty" + for _, p := range []string{"../etc/passwd", "../../etc/passwd", "a/../../etc/passwd"} { + if _, err := fs.Open(context.Background(), p); err == nil { + t.Errorf("Open(%q) should reject traversal", p) + } + if _, _, err := fs.Store(context.Background(), p, strings.NewReader("x")); err == nil { + t.Errorf("Store(%q) should reject traversal", p) } - - t.Run(name, func(t *testing.T) { - ctx := context.Background() - - if _, err := fs.FullPath(p); !errors.Is(err, ErrNotFound) { - t.Errorf("FullPath(%q) = %v, want ErrNotFound", p, err) - } - if _, err := fs.Open(ctx, p); err == nil { - t.Errorf("Open(%q) should reject invalid path", p) - } - if _, _, err := fs.Store(ctx, p, strings.NewReader("x")); err == nil { - t.Errorf("Store(%q) should reject invalid path", p) - } - if _, err := fs.Exists(ctx, p); err == nil { - t.Errorf("Exists(%q) should reject invalid path", p) - } - if err := fs.Delete(ctx, p); err == nil { - t.Errorf("Delete(%q) should reject invalid path", p) - } - if _, err := fs.Size(ctx, p); err == nil { - t.Errorf("Size(%q) should reject invalid path", p) - } - if _, err := fs.ListPrefix(ctx, p); p != "" && err == nil { - t.Errorf("ListPrefix(%q) should reject invalid path", p) - } - }) - } -} - -func TestFilesystemListPrefixAllowsEmptyPrefix(t *testing.T) { - fs := createTestFilesystem(t) - ctx := context.Background() - - _, _, _ = fs.Store(ctx, "a.txt", strings.NewReader("aaaa")) - _, _, _ = fs.Store(ctx, "c/d.txt", strings.NewReader("ccccc")) - - objects, err := fs.ListPrefix(ctx, "") - if err != nil { - t.Fatalf("ListPrefix empty prefix failed: %v", err) - } - if len(objects) != 2 { - t.Fatalf("ListPrefix empty prefix returned %d objects, want 2", len(objects)) } } diff --git a/internal/storage/gcs.go b/internal/storage/gcs.go new file mode 100644 index 0000000..e97e772 --- /dev/null +++ b/internal/storage/gcs.go @@ -0,0 +1,455 @@ +package storage + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "cloud.google.com/go/compute/metadata" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const ( + gcsScope = "https://www.googleapis.com/auth/cloud-platform" + gcsDefaultHost = "https://storage.googleapis.com" +) + +// GCS implements Storage using the Cloud Storage JSON API. +type GCS struct { + bucket string + url string + client *http.Client + apiBase string + uploadBase string + + accessID string + privateKey []byte + signBytes func(context.Context, []byte) ([]byte, error) +} + +// OpenGCS opens a Google Cloud Storage bucket from a gs:// URL. +func OpenGCS(ctx context.Context, urlStr string) (*GCS, error) { + u, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("parsing GCS URL: %w", err) + } + if u.Scheme != "gs" || u.Host == "" { + return nil, fmt.Errorf("invalid GCS URL %q", urlStr) + } + if u.Path != "" && u.Path != "/" { + return nil, fmt.Errorf("GCS URL must name a bucket, got path %q", u.Path) + } + + host := strings.TrimRight(gcsDefaultHost, "/") + client := http.DefaultClient + var accessID string + var privateKey []byte + + if emulator := os.Getenv("STORAGE_EMULATOR_HOST"); emulator != "" { + host = strings.TrimRight(emulator, "/") + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "http://" + host + } + } else { + var credsJSON []byte + var err error + client, credsJSON, err = gcsHTTPClient(ctx) + if err != nil { + return nil, err + } + accessID, privateKey = readGCSCredentials(credsJSON) + if accessID == "" && metadata.OnGCE() { + accessID, _ = metadata.Email("") + } + } + + g := &GCS{ + bucket: u.Host, + url: urlStr, + client: client, + apiBase: host + "/storage/v1", + uploadBase: host + "/upload/storage/v1", + accessID: accessID, + privateKey: privateKey, + } + if len(privateKey) == 0 && accessID != "" { + g.signBytes = g.signBlob + } + return g, nil +} + +func gcsHTTPClient(ctx context.Context) (*http.Client, []byte, error) { + creds, err := google.FindDefaultCredentials(ctx, gcsScope) + if err != nil { + return nil, nil, fmt.Errorf("loading GCS default credentials: %w", err) + } + return oauth2.NewClient(ctx, creds.TokenSource), creds.JSON, nil +} + +func readGCSCredentials(credFileAsJSON []byte) (string, []byte) { + var serviceAccount struct { + ClientEmail string `json:"client_email"` + PrivateKey string `json:"private_key"` + } + if err := json.Unmarshal(credFileAsJSON, &serviceAccount); err == nil && serviceAccount.ClientEmail != "" { + return serviceAccount.ClientEmail, []byte(serviceAccount.PrivateKey) + } + + var impersonated struct { + ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` + } + if err := json.Unmarshal(credFileAsJSON, &impersonated); err == nil { + if email := serviceAccountFromImpersonationURL(impersonated.ServiceAccountImpersonationURL); email != "" { + return email, nil + } + } + + return "", nil +} + +func serviceAccountFromImpersonationURL(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + const marker = "/serviceAccounts/" + idx := strings.Index(u.Path, marker) + if idx == -1 { + return "" + } + email := strings.TrimSuffix(u.Path[idx+len(marker):], ":generateAccessToken") + email, _ = url.PathUnescape(email) + return email +} + +func (g *GCS) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) { + h := sha256.New() + body := &countingReader{r: io.TeeReader(r, h)} + + endpoint := g.uploadBase + "/b/" + url.PathEscape(g.bucket) + "/o" + reqURL, _ := url.Parse(endpoint) + q := reqURL.Query() + q.Set("uploadType", "media") + q.Set("name", path) + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL.String(), body) + if err != nil { + return 0, "", err + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := g.client.Do(req) + if err != nil { + return 0, "", fmt.Errorf("uploading GCS object: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := g.checkResponse(resp, http.StatusOK); err != nil { + return 0, "", fmt.Errorf("uploading GCS object: %w", err) + } + + hash := hex.EncodeToString(h.Sum(nil)) + return body.n, hash, nil +} + +func (g *GCS) Open(ctx context.Context, path string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.objectURL(path)+"?alt=media", nil) + if err != nil { + return nil, err + } + resp, err := g.client.Do(req) + if err != nil { + return nil, fmt.Errorf("opening GCS object: %w", err) + } + if resp.StatusCode == http.StatusNotFound { + _ = resp.Body.Close() + return nil, ErrNotFound + } + if err := g.checkResponse(resp, http.StatusOK); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("opening GCS object: %w", err) + } + return resp.Body, nil +} + +func (g *GCS) Exists(ctx context.Context, path string) (bool, error) { + _, err := g.attrs(ctx, path) + if errors.Is(err, ErrNotFound) { + return false, nil + } + return err == nil, err +} + +func (g *GCS) Delete(ctx context.Context, path string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, g.objectURL(path), nil) + if err != nil { + return err + } + resp, err := g.client.Do(req) + if err != nil { + return fmt.Errorf("deleting GCS object: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + return nil + } + if err := g.checkResponse(resp, http.StatusNoContent); err != nil { + return fmt.Errorf("deleting GCS object: %w", err) + } + return nil +} + +func (g *GCS) Size(ctx context.Context, path string) (int64, error) { + obj, err := g.attrs(ctx, path) + if err != nil { + return 0, err + } + return obj.size(), nil +} + +func (g *GCS) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) { + if g.accessID == "" { + return "", ErrSignedURLUnsupported + } + + expires := time.Now().Add(expiry) + u := &url.URL{Path: fmt.Sprintf("/%s/%s", g.bucket, path)} + stringToSign := fmt.Sprintf("GET\n\n\n%d\n%s", expires.Unix(), u.String()) + + signed, err := g.sign(ctx, []byte(stringToSign)) + if err != nil { + return "", fmt.Errorf("signing GCS URL: %w", err) + } + + u.Scheme = "https" + u.Host = "storage.googleapis.com" + q := u.Query() + q.Set("GoogleAccessId", g.accessID) + q.Set("Expires", strconv.FormatInt(expires.Unix(), 10)) + q.Set("Signature", base64.StdEncoding.EncodeToString(signed)) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (g *GCS) UsedSpace(ctx context.Context) (int64, error) { + objects, err := g.ListPrefix(ctx, "") + if err != nil { + return 0, err + } + var total int64 + for _, obj := range objects { + total += obj.Size + } + return total, nil +} + +func (g *GCS) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { + var objects []ObjectInfo + pageToken := "" + + for { + reqURL, _ := url.Parse(g.apiBase + "/b/" + url.PathEscape(g.bucket) + "/o") + q := reqURL.Query() + q.Set("prefix", prefix) + if pageToken != "" { + q.Set("pageToken", pageToken) + } + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, err + } + resp, err := g.client.Do(req) + if err != nil { + return nil, fmt.Errorf("listing GCS objects: %w", err) + } + if err := g.checkResponse(resp, http.StatusOK); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("listing GCS objects: %w", err) + } + + var page gcsListResponse + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("decoding GCS list response: %w", err) + } + _ = resp.Body.Close() + + for _, item := range page.Items { + objects = append(objects, ObjectInfo{ + Path: item.Name, + Size: item.size(), + ModTime: item.updated(), + }) + } + if page.NextPageToken == "" { + return objects, nil + } + pageToken = page.NextPageToken + } +} + +func (g *GCS) Close() error { + return nil +} + +func (g *GCS) URL() string { + return g.url +} + +func (g *GCS) attrs(ctx context.Context, path string) (*gcsObject, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.objectURL(path), nil) + if err != nil { + return nil, err + } + resp, err := g.client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting GCS object attributes: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + return nil, ErrNotFound + } + if err := g.checkResponse(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("getting GCS object attributes: %w", err) + } + + var obj gcsObject + if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil { + return nil, fmt.Errorf("decoding GCS attributes: %w", err) + } + return &obj, nil +} + +func (g *GCS) objectURL(path string) string { + return g.apiBase + "/b/" + url.PathEscape(g.bucket) + "/o/" + url.PathEscape(path) +} + +func (g *GCS) checkResponse(resp *http.Response, want int) error { + if resp.StatusCode == want { + return nil + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) +} + +func (g *GCS) sign(ctx context.Context, b []byte) ([]byte, error) { + if len(g.privateKey) > 0 { + key, err := parseGCSPrivateKey(g.privateKey) + if err != nil { + return nil, err + } + sum := sha256.Sum256(b) + return rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) + } + if g.signBytes != nil { + return g.signBytes(ctx, b) + } + return nil, ErrSignedURLUnsupported +} + +func (g *GCS) signBlob(ctx context.Context, payload []byte) ([]byte, error) { + reqBody, err := json.Marshal(map[string]string{ + "payload": base64.StdEncoding.EncodeToString(payload), + }) + if err != nil { + return nil, err + } + + endpoint := "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" + + url.PathEscape(g.accessID) + ":signBlob" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := g.client.Do(req) + if err != nil { + return nil, fmt.Errorf("calling IAM signBlob: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := g.checkResponse(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("calling IAM signBlob: %w", err) + } + + var out struct { + SignedBlob string `json:"signedBlob"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decoding IAM signBlob response: %w", err) + } + return base64.StdEncoding.DecodeString(out.SignedBlob) +} + +func parseGCSPrivateKey(key []byte) (*rsa.PrivateKey, error) { + if block, _ := pem.Decode(key); block != nil { + key = block.Bytes + } + parsedKey, err := x509.ParsePKCS8PrivateKey(key) + if err != nil { + parsedKey, err = x509.ParsePKCS1PrivateKey(key) + if err != nil { + return nil, err + } + } + parsed, ok := parsedKey.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return parsed, nil +} + +type gcsObject struct { + Name string `json:"name"` + Size string `json:"size"` + Updated string `json:"updated"` +} + +type countingReader struct { + r io.Reader + n int64 +} + +func (r *countingReader) Read(p []byte) (int, error) { + n, err := r.r.Read(p) + r.n += int64(n) + return n, err +} + +func (o gcsObject) size() int64 { + n, _ := strconv.ParseInt(o.Size, 10, 64) + return n +} + +func (o gcsObject) updated() time.Time { + t, _ := time.Parse(time.RFC3339Nano, o.Updated) + return t +} + +type gcsListResponse struct { + NextPageToken string `json:"nextPageToken"` + Items []gcsObject `json:"items"` +} diff --git a/internal/storage/gcs_test.go b/internal/storage/gcs_test.go new file mode 100644 index 0000000..44dff00 --- /dev/null +++ b/internal/storage/gcs_test.go @@ -0,0 +1,145 @@ +package storage + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +func TestGCSRoundTripWithEmulator(t *testing.T) { + objects := map[string]string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/upload/storage/v1/b/test-bucket/o": + name := r.URL.Query().Get("name") + data, _ := io.ReadAll(r.Body) + objects[name] = string(data) + writeJSON(w, gcsObject{Name: name, Size: strconv.Itoa(len(data)), Updated: time.Now().UTC().Format(time.RFC3339Nano)}) + case r.Method == http.MethodGet && r.URL.Path == "/storage/v1/b/test-bucket/o": + prefix := r.URL.Query().Get("prefix") + page := gcsListResponse{} + for name, data := range objects { + if strings.HasPrefix(name, prefix) { + page.Items = append(page.Items, gcsObject{Name: name, Size: strconv.Itoa(len(data)), Updated: time.Now().UTC().Format(time.RFC3339Nano)}) + } + } + sort.Slice(page.Items, func(i, j int) bool { return page.Items[i].Name < page.Items[j].Name }) + writeJSON(w, page) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/storage/v1/b/test-bucket/o/"): + name := objectNameFromPath(r.URL.Path) + data, ok := objects[name] + if !ok { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("alt") == "media" { + _, _ = io.WriteString(w, data) + return + } + writeJSON(w, gcsObject{Name: name, Size: strconv.Itoa(len(data)), Updated: time.Now().UTC().Format(time.RFC3339Nano)}) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/storage/v1/b/test-bucket/o/"): + delete(objects, objectNameFromPath(r.URL.Path)) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + t.Setenv("STORAGE_EMULATOR_HOST", server.URL) + + ctx := context.Background() + store, err := OpenGCS(ctx, "gs://test-bucket") + if err != nil { + t.Fatalf("OpenGCS failed: %v", err) + } + + size, hash, err := store.Store(ctx, "npm/pkg/file.tgz", strings.NewReader("content")) + if err != nil { + t.Fatalf("Store failed: %v", err) + } + if size != int64(len("content")) || hash == "" { + t.Fatalf("Store returned size=%d hash=%q", size, hash) + } + + exists, err := store.Exists(ctx, "npm/pkg/file.tgz") + if err != nil || !exists { + t.Fatalf("Exists = %v, %v; want true, nil", exists, err) + } + + r, err := store.Open(ctx, "npm/pkg/file.tgz") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + data, _ := io.ReadAll(r) + _ = r.Close() + if string(data) != "content" { + t.Fatalf("Open content = %q, want content", data) + } + + list, err := store.ListPrefix(ctx, "npm/") + if err != nil { + t.Fatalf("ListPrefix failed: %v", err) + } + if len(list) != 1 || list[0].Path != "npm/pkg/file.tgz" { + t.Fatalf("ListPrefix = %#v", list) + } + + if err := store.Delete(ctx, "npm/pkg/file.tgz"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + exists, err = store.Exists(ctx, "npm/pkg/file.tgz") + if err != nil || exists { + t.Fatalf("Exists after delete = %v, %v; want false, nil", exists, err) + } +} + +func TestGCSSignedURLUsesSigner(t *testing.T) { + store := &GCS{ + bucket: "test-bucket", + accessID: "service@example.com", + signBytes: func(_ context.Context, b []byte) ([]byte, error) { + if !strings.Contains(string(b), "/test-bucket/npm/pkg/file.tgz") { + t.Fatalf("string to sign = %q", b) + } + return []byte("signed"), nil + }, + } + + got, err := store.SignedURL(context.Background(), "npm/pkg/file.tgz", time.Minute) + if err != nil { + t.Fatalf("SignedURL failed: %v", err) + } + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parsing signed URL: %v", err) + } + if u.Scheme != "https" || u.Host != "storage.googleapis.com" || u.Path != "/test-bucket/npm/pkg/file.tgz" { + t.Fatalf("signed URL location = %s", got) + } + if u.Query().Get("GoogleAccessId") != "service@example.com" { + t.Fatalf("GoogleAccessId = %q", u.Query().Get("GoogleAccessId")) + } + if u.Query().Get("Signature") != base64.StdEncoding.EncodeToString([]byte("signed")) { + t.Fatalf("Signature = %q", u.Query().Get("Signature")) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func objectNameFromPath(p string) string { + escaped := strings.TrimPrefix(p, "/storage/v1/b/test-bucket/o/") + name, _ := url.PathUnescape(escaped) + return name +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e11db53..4bf8f43 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -5,6 +5,9 @@ // - file:///path/to/dir - Local filesystem storage // - s3://bucket-name - Amazon S3 // - s3://bucket?endpoint=http://localhost:9000 - S3-compatible (MinIO) +// - gs://bucket-name - Google Cloud Storage (supports GKE Workload Identity +// via Application Default Credentials) +// - azblob://container-name - Azure Blob Storage // // Use OpenBucket to create a storage backend from a URL. package storage