Compare commits

..

1 commit

Author SHA1 Message Date
0351cd1cf3 WIP 2026-04-19 07:27:30 -04:00
18 changed files with 44 additions and 1000 deletions

View file

@ -27,7 +27,7 @@ jobs:
go-version-file: go.mod
cache: false
- uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
version: "~> v2"
args: release --clean

View file

@ -26,4 +26,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

View file

@ -36,10 +36,11 @@ checksum:
signs:
- cmd: cosign
signature: "${artifact}.cosign.bundle"
certificate: "${artifact}.pem"
args:
- sign-blob
- "--bundle=${signature}"
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum

View file

@ -29,23 +29,6 @@ storage:
# Empty or "0" means unlimited
max_size: ""
# Redirect cached artifact downloads to presigned storage URLs (HTTP 302)
# instead of streaming through the proxy. Only effective for S3 and Azure.
# Leave disabled if clients reach the proxy through an authenticating gateway,
# since presigned URLs bypass it.
direct_serve: false
# How long presigned URLs remain valid (e.g. "5m", "1h"). Default: "15m".
direct_serve_ttl: "15m"
# Public base URL to substitute into presigned URLs. Set this when the
# proxy reaches storage at an internal address (127.0.0.1, a Docker
# service name) but clients must use a public hostname. Only scheme and
# host are used; the signed path and query are preserved. For S3/MinIO
# the reverse proxy at this address must forward requests with the
# internal Host header or the SigV4 signature will not validate.
# direct_serve_base_url: "https://minio.example.com"
# Database configuration
database:
# Database driver: "sqlite" (default) or "postgres"

22
go.mod
View file

@ -7,7 +7,7 @@ require (
github.com/git-pkgs/archives v0.2.2
github.com/git-pkgs/enrichment v0.2.2
github.com/git-pkgs/purl v0.1.10
github.com/git-pkgs/registries v0.4.1
github.com/git-pkgs/registries v0.4.0
github.com/git-pkgs/spdx v0.1.2
github.com/git-pkgs/vers v0.2.4
github.com/git-pkgs/vulns v0.1.4
@ -22,14 +22,12 @@ require (
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.49.1
modernc.org/sqlite v1.48.2
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
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
@ -42,13 +40,6 @@ require (
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
@ -147,7 +138,6 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
@ -160,10 +150,8 @@ require (
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
@ -186,7 +174,6 @@ require (
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
@ -222,7 +209,6 @@ require (
github.com/pandatix/go-cvss v0.6.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
@ -291,12 +277,10 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.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.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
@ -307,7 +291,7 @@ require (
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.72.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect

47
go.sum
View file

@ -43,26 +43,6 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc=
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
@ -258,8 +238,8 @@ github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6
github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
github.com/git-pkgs/purl v0.1.10 h1:NMjeF10nzFn3tdQlz6rbmHB+i+YkyrFQxho3e33ePTQ=
github.com/git-pkgs/purl v0.1.10/go.mod h1:C5Vp/kyZ/wGckCLexx4wPVfUxEiToRkdsOPh5Z7ig/I=
github.com/git-pkgs/registries v0.4.1 h1:4qlKVNhC/6x6Bt87t3wrGJtF3EFrUpHQt9/zKsa5IvU=
github.com/git-pkgs/registries v0.4.1/go.mod h1:49UCPFWQmwNV7rBEr9TrTDWKR7vYxFcxp3VfdkeFbdE=
github.com/git-pkgs/registries v0.4.0 h1:GO7fQ8/jot0ulSQHBdxLSNSX/p8eB3gEXWO+98fmoEo=
github.com/git-pkgs/registries v0.4.0/go.mod h1:49UCPFWQmwNV7rBEr9TrTDWKR7vYxFcxp3VfdkeFbdE=
github.com/git-pkgs/spdx v0.1.2 h1:wHSK+CqFsO5N7yDTPvxDmer5LgNEa7vAsiZhi5Aci0A=
github.com/git-pkgs/spdx v0.1.2/go.mod h1:V98MgZapNgYw54/pdGR82d7RU93qzJoybahbpZqTfw8=
github.com/git-pkgs/vers v0.2.4 h1:Zr3jR/Xf1i/6cvBaJKPxhCwjzqz7uvYHE0Fhid/GPBk=
@ -325,8 +305,6 @@ github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5W
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
@ -418,8 +396,6 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
@ -533,8 +509,6 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterbourgon/g2s v0.0.0-20170223122336-d4e7ad98afea h1:sKwxy1H95npauwu8vtF95vG/syrL0p8fSZo/XlDg5gk=
github.com/peterbourgon/g2s v0.0.0-20170223122336-d4e7ad98afea/go.mod h1:1VcHEd3ro4QMoHfiNl/j7Jkln9+KQuorp0PItHMJYNg=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
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=
@ -797,7 +771,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -878,10 +851,10 @@ 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.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
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=
@ -890,8 +863,8 @@ 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.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
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=
@ -900,8 +873,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/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.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
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=

View file

@ -51,7 +51,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
@ -139,21 +138,6 @@ type StorageConfig struct {
// When exceeded, least recently used artifacts are evicted.
// Empty or "0" means unlimited.
MaxSize string `json:"max_size" yaml:"max_size"`
// 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).
DirectServe bool `json:"direct_serve" yaml:"direct_serve"`
// DirectServeTTL is how long presigned URLs remain valid.
// Uses Go duration syntax (e.g. "5m", "1h"). Default: "15m".
DirectServeTTL string `json:"direct_serve_ttl" yaml:"direct_serve_ttl"`
// DirectServeBaseURL overrides the scheme and host of presigned URLs
// before returning them to clients. Useful when the proxy reaches
// storage at an internal address (e.g. 127.0.0.1 or a Docker hostname)
// but clients must use a public one.
DirectServeBaseURL string `json:"direct_serve_base_url" yaml:"direct_serve_base_url"`
}
// DatabaseConfig configures the cache database.
@ -332,15 +316,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_STORAGE_MAX_SIZE"); v != "" {
c.Storage.MaxSize = v
}
if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE"); v != "" {
c.Storage.DirectServe = envBool(v)
}
if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE_TTL"); v != "" {
c.Storage.DirectServeTTL = v
}
if v := os.Getenv("PROXY_STORAGE_DIRECT_SERVE_BASE_URL"); v != "" {
c.Storage.DirectServeBaseURL = v
}
if v := os.Getenv("PROXY_DATABASE_DRIVER"); v != "" {
c.Database.Driver = v
}
@ -360,10 +335,10 @@ func (c *Config) LoadFromEnv() {
c.Cooldown.Default = v
}
if v := os.Getenv("PROXY_CACHE_METADATA"); v != "" {
c.CacheMetadata = envBool(v)
c.CacheMetadata = v == "true" || v == "1"
}
if v := os.Getenv("PROXY_MIRROR_API"); v != "" {
c.MirrorAPI = envBool(v)
c.MirrorAPI = v == "true" || v == "1"
}
if v := os.Getenv("PROXY_METADATA_TTL"); v != "" {
c.MetadataTTL = v
@ -425,21 +400,6 @@ func (c *Config) Validate() error {
}
}
// Validate direct serve TTL if specified
if c.Storage.DirectServeTTL != "" {
if _, err := time.ParseDuration(c.Storage.DirectServeTTL); err != nil {
return fmt.Errorf("invalid storage.direct_serve_ttl %q: %w", c.Storage.DirectServeTTL, err)
}
}
// Validate direct serve base URL if specified
if c.Storage.DirectServeBaseURL != "" {
u, err := url.Parse(c.Storage.DirectServeBaseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("invalid storage.direct_serve_base_url %q: must be an absolute URL", c.Storage.DirectServeBaseURL)
}
}
// Validate metadata TTL if specified
if c.MetadataTTL != "" && c.MetadataTTL != "0" {
if _, err := time.ParseDuration(c.MetadataTTL); err != nil {
@ -457,23 +417,7 @@ func (c *Config) Validate() error {
return nil
}
const (
defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
)
// ParseMaxSize returns the maximum cache size in bytes.
// Returns 0 if unset or explicitly disabled (meaning unlimited).
func (c *Config) ParseMaxSize() int64 {
if c.Storage.MaxSize == "" || c.Storage.MaxSize == "0" {
return 0
}
size, err := ParseSize(c.Storage.MaxSize)
if err != nil {
return 0
}
return size
}
const defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
// ParseMetadataTTL returns the metadata TTL duration.
// Returns 5 minutes if unset, 0 if explicitly disabled.
@ -491,19 +435,6 @@ func (c *Config) ParseMetadataTTL() time.Duration {
return d
}
// ParseDirectServeTTL returns the presigned URL expiry duration.
// Returns 15 minutes if unset.
func (c *Config) ParseDirectServeTTL() time.Duration {
if c.Storage.DirectServeTTL == "" {
return defaultDirectServeTTL
}
d, err := time.ParseDuration(c.Storage.DirectServeTTL)
if err != nil {
return defaultDirectServeTTL
}
return d
}
// ParseSize parses a human-readable size string (e.g., "10GB", "500MB").
// Returns the size in bytes.
func ParseSize(s string) (int64, error) {
@ -584,7 +515,3 @@ func (a *AuthConfig) Header() (name, value string) {
func expandEnv(s string) string {
return os.Expand(s, os.Getenv)
}
func envBool(v string) bool {
return v == "true" || v == "1"
}

View file

@ -303,31 +303,6 @@ func TestLoadFileNotFound(t *testing.T) {
}
}
func TestParseMaxSize(t *testing.T) {
tests := []struct {
name string
maxSize string
want int64
}{
{"empty means unlimited", "", 0},
{"zero means unlimited", "0", 0},
{"10GB", "10GB", 10 * 1024 * 1024 * 1024},
{"500MB", "500MB", 500 * 1024 * 1024},
{"invalid returns 0", "invalid", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
cfg.Storage.MaxSize = tt.maxSize
got := cfg.ParseMaxSize()
if got != tt.want {
t.Errorf("ParseMaxSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestParseMetadataTTL(t *testing.T) {
tests := []struct {
name string
@ -380,77 +355,3 @@ func TestLoadMetadataTTLFromEnv(t *testing.T) {
t.Errorf("MetadataTTL = %q, want %q", cfg.MetadataTTL, "10m")
}
}
func TestParseDirectServeTTL(t *testing.T) {
tests := []struct {
name string
ttl string
want time.Duration
}{
{"empty defaults to 15m", "", 15 * time.Minute},
{"5 minutes", "5m", 5 * time.Minute},
{"1 hour", "1h", 1 * time.Hour},
{"invalid defaults to 15m", "not-a-duration", 15 * time.Minute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
cfg.Storage.DirectServeTTL = tt.ttl
got := cfg.ParseDirectServeTTL()
if got != tt.want {
t.Errorf("ParseDirectServeTTL() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidateDirectServeTTL(t *testing.T) {
cfg := Default()
cfg.Storage.DirectServeTTL = "invalid"
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for invalid storage.direct_serve_ttl")
}
cfg.Storage.DirectServeTTL = "5m"
if err := cfg.Validate(); err != nil {
t.Errorf("unexpected error for valid storage.direct_serve_ttl: %v", err)
}
}
func TestLoadDirectServeFromEnv(t *testing.T) {
cfg := Default()
t.Setenv("PROXY_STORAGE_DIRECT_SERVE", "true")
t.Setenv("PROXY_STORAGE_DIRECT_SERVE_TTL", "30m")
t.Setenv("PROXY_STORAGE_DIRECT_SERVE_BASE_URL", "https://cdn.example.com")
cfg.LoadFromEnv()
if !cfg.Storage.DirectServe {
t.Error("Storage.DirectServe should be true")
}
if cfg.Storage.DirectServeTTL != "30m" {
t.Errorf("Storage.DirectServeTTL = %q, want %q", cfg.Storage.DirectServeTTL, "30m")
}
if cfg.Storage.DirectServeBaseURL != "https://cdn.example.com" {
t.Errorf("Storage.DirectServeBaseURL = %q, want %q", cfg.Storage.DirectServeBaseURL, "https://cdn.example.com")
}
}
func TestValidateDirectServeBaseURL(t *testing.T) {
cfg := Default()
cfg.Storage.DirectServeBaseURL = "not a url"
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for relative direct_serve_base_url")
}
cfg.Storage.DirectServeBaseURL = "://bad"
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for unparseable direct_serve_base_url")
}
cfg.Storage.DirectServeBaseURL = "https://cdn.example.com"
if err := cfg.Validate(); err != nil {
t.Errorf("unexpected error for valid direct_serve_base_url: %v", err)
}
}

View file

@ -10,7 +10,6 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -62,21 +61,15 @@ func ReadMetadata(r io.Reader) ([]byte, error) {
// Proxy provides shared functionality for protocol handlers.
type Proxy struct {
DB *database.DB
Storage storage.Storage
Fetcher fetch.FetcherInterface
Resolver *fetch.Resolver
Logger *slog.Logger
Cooldown *cooldown.Config
CacheMetadata bool
MetadataTTL time.Duration
DirectServe bool
DirectServeTTL time.Duration
// DirectServeBaseURL, if set, replaces the scheme and host of presigned
// URLs so clients receive a public address even when the proxy reaches
// storage at an internal one.
DirectServeBaseURL string
HTTPClient *http.Client
DB *database.DB
Storage storage.Storage
Fetcher fetch.FetcherInterface
Resolver *fetch.Resolver
Logger *slog.Logger
Cooldown *cooldown.Config
CacheMetadata bool
MetadataTTL time.Duration
HTTPClient *http.Client
}
// NewProxy creates a new Proxy with the given dependencies.
@ -99,7 +92,6 @@ func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInter
// CacheResult contains information about a cached or fetched artifact.
type CacheResult struct {
Reader io.ReadCloser
RedirectURL string
Size int64
ContentType string
Hash string
@ -146,26 +138,6 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
return nil, nil
}
result := &CacheResult{
Size: artifact.Size.Int64,
ContentType: artifact.ContentType.String,
Hash: artifact.ContentHash.String,
Cached: true,
}
if p.DirectServe {
signed, err := p.Storage.SignedURL(ctx, artifact.StoragePath.String, p.DirectServeTTL)
if err == nil {
result.RedirectURL = rewriteSignedURLHost(signed, p.DirectServeBaseURL)
p.recordCacheHit(pkgPURL, versionPURL, filename)
return result, nil
}
if !errors.Is(err, storage.ErrSignedURLUnsupported) {
p.Logger.Warn("failed to sign storage URL, falling back to streaming",
"path", artifact.StoragePath.String, "error", err)
}
}
start := time.Now()
reader, err := p.Storage.Open(ctx, artifact.StoragePath.String)
metrics.RecordStorageOperation("read", time.Since(start))
@ -176,36 +148,20 @@ func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename s
return nil, nil
}
result.Reader = reader
p.recordCacheHit(pkgPURL, versionPURL, filename)
return result, nil
}
// rewriteSignedURLHost replaces the scheme and host of a signed URL with those
// from baseURL, preserving the path and query (which carry the signature).
// Returns signed unchanged if baseURL is empty or either URL fails to parse.
func rewriteSignedURLHost(signed, baseURL string) string {
if baseURL == "" {
return signed
}
s, err := url.Parse(signed)
if err != nil {
return signed
}
b, err := url.Parse(baseURL)
if err != nil || b.Scheme == "" || b.Host == "" {
return signed
}
s.Scheme = b.Scheme
s.Host = b.Host
return s.String()
}
func (p *Proxy) recordCacheHit(pkgPURL, versionPURL, filename string) {
_ = p.DB.RecordArtifactHit(versionPURL, filename)
if parsed, err := purl.Parse(pkgPURL); err == nil {
metrics.RecordCacheHit(purl.PURLTypeToEcosystem(parsed.Type))
// Extract ecosystem from pkgPURL for metrics
if p, err := purl.Parse(pkgPURL); err == nil {
metrics.RecordCacheHit(purl.PURLTypeToEcosystem(p.Type))
}
return &CacheResult{
Reader: reader,
Size: artifact.Size.Int64,
ContentType: artifact.ContentType.String,
Hash: artifact.ContentHash.String,
Cached: true,
}, nil
}
func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL string) (*CacheResult, error) {
@ -320,15 +276,6 @@ func (p *Proxy) updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, u
// ServeArtifact writes a CacheResult to an HTTP response.
func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
if result.RedirectURL != "" {
if result.Hash != "" {
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash))
}
w.Header().Set("Location", result.RedirectURL)
w.WriteHeader(http.StatusFound)
return
}
defer func() { _ = result.Reader.Close() }()
if result.ContentType != "" {

View file

@ -21,11 +21,9 @@ import (
// mockStorage implements storage.Storage for testing.
type mockStorage struct {
files map[string][]byte
storeErr error
openErr error
signedURL string
signErr error
files map[string][]byte
storeErr error
openErr error
}
func newMockStorage() *mockStorage {
@ -81,16 +79,6 @@ func (s *mockStorage) UsedSpace(_ context.Context) (int64, error) {
return total, nil
}
func (s *mockStorage) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
if s.signErr != nil {
return "", s.signErr
}
if s.signedURL == "" {
return "", storage.ErrSignedURLUnsupported
}
return s.signedURL, nil
}
func (s *mockStorage) URL() string { return "mem://" }
func (s *mockStorage) Close() error { return nil }
@ -323,213 +311,6 @@ func TestGetOrFetchArtifactFromURL_CacheMiss_StorageMissing(t *testing.T) {
}
}
func TestGetOrFetchArtifact_DirectServe_Redirect(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
proxy.DirectServeTTL = 15 * time.Minute
store.signedURL = "https://bucket.s3.amazonaws.com/npm/lodash?X-Amz-Signature=abc"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Cached {
t.Error("expected result to be cached")
}
if result.RedirectURL != store.signedURL {
t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, store.signedURL)
}
if result.Reader != nil {
t.Error("Reader should be nil when redirecting")
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
// Hit count should still be recorded on the redirect path.
art, _ := db.GetArtifact("pkg:npm/lodash@4.17.21", "lodash-4.17.21.tgz")
if art == nil || art.HitCount != 1 {
t.Errorf("artifact hit count not recorded: %+v", art)
}
}
func TestGetOrFetchArtifact_DirectServe_BaseURLRewrite(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
proxy.DirectServeBaseURL = "https://cdn.example.com"
store.signedURL = "http://127.0.0.1:9000/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "https://cdn.example.com/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
if result.RedirectURL != want {
t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, want)
}
}
func TestRewriteSignedURLHost(t *testing.T) {
tests := []struct {
name string
signed string
baseURL string
want string
}{
{
"empty base url is no-op",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"",
"http://127.0.0.1:9000/bucket/key?sig=abc",
},
{
"replaces scheme and host",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"https://cdn.example.com",
"https://cdn.example.com/bucket/key?sig=abc",
},
{
"preserves path and query",
"http://minio:9000/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
"https://files.example.com",
"https://files.example.com/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
},
{
"ignores base url path",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"https://cdn.example.com/ignored",
"https://cdn.example.com/bucket/key?sig=abc",
},
{
"invalid base url is no-op",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"://bad",
"http://127.0.0.1:9000/bucket/key?sig=abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rewriteSignedURLHost(tt.signed, tt.baseURL)
if got != tt.want {
t.Errorf("rewriteSignedURLHost(%q, %q) = %q, want %q", tt.signed, tt.baseURL, got, tt.want)
}
})
}
}
func TestGetOrFetchArtifact_DirectServe_FallbackOnUnsupported(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
// store.signedURL is empty so SignedURL returns ErrSignedURLUnsupported.
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty, got %q", result.RedirectURL)
}
if result.Reader == nil {
t.Fatal("Reader should be set when signing is unsupported")
}
body, _ := io.ReadAll(result.Reader)
if string(body) != "cached content" {
t.Errorf("got body %q, want %q", body, "cached content")
}
}
func TestGetOrFetchArtifact_DirectServe_FallbackOnError(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
store.signErr = errors.New("signing failed")
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty on signing error, got %q", result.RedirectURL)
}
if result.Reader == nil {
t.Fatal("Reader should be set when signing fails")
}
}
func TestGetOrFetchArtifact_DirectServe_DisabledIgnoresSigning(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = false
store.signedURL = "https://bucket.example/should-not-be-used"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty when DirectServe is off, got %q", result.RedirectURL)
}
}
func TestServeArtifact_Redirect(t *testing.T) {
w := httptest.NewRecorder()
ServeArtifact(w, &CacheResult{
RedirectURL: "https://bucket.s3.amazonaws.com/file?sig=abc",
Hash: "abc123",
Cached: true,
})
if w.Code != http.StatusFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusFound)
}
if loc := w.Header().Get("Location"); loc != "https://bucket.s3.amazonaws.com/file?sig=abc" {
t.Errorf("Location = %q", loc)
}
if etag := w.Header().Get("ETag"); etag != `"abc123"` {
t.Errorf("ETag = %q, want %q", etag, `"abc123"`)
}
if cl := w.Header().Get("Content-Length"); cl != "" {
t.Errorf("Content-Length should not be set on redirect, got %q", cl)
}
}
func TestServeArtifact_Stream(t *testing.T) {
w := httptest.NewRecorder()
ServeArtifact(w, &CacheResult{
Reader: io.NopCloser(strings.NewReader("payload")),
Size: 7,
ContentType: "application/octet-stream",
Hash: "abc123",
})
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Body.String() != "payload" {
t.Errorf("body = %q, want %q", w.Body.String(), "payload")
}
if ct := w.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q", ct)
}
}
func TestGetOrFetchArtifactFromURL_CacheHit(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "pypi", "requests", "2.28.0", "requests-2.28.0.tar.gz", "pypi content")

View file

@ -1,105 +0,0 @@
package server
import (
"context"
"log/slog"
"time"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage"
)
const (
evictionInterval = 1 * time.Minute
evictionBatch = 50
)
func (s *Server) startEvictionLoop(ctx context.Context) {
maxSize := s.cfg.ParseMaxSize()
if maxSize <= 0 {
return
}
s.logger.Info("cache eviction enabled", "max_size", s.cfg.Storage.MaxSize)
ticker := time.NewTicker(evictionInterval)
defer ticker.Stop()
s.runEviction(ctx, maxSize)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runEviction(ctx, maxSize)
}
}
}
func (s *Server) runEviction(ctx context.Context, maxSize int64) {
evictLRU(ctx, s.db, s.storage, s.logger, maxSize)
}
func evictLRU(ctx context.Context, db *database.DB, store storage.Storage, logger *slog.Logger, maxSize int64) {
totalSize, err := db.GetTotalCacheSize()
if err != nil {
logger.Warn("eviction: failed to get cache size", "error", err)
return
}
if totalSize <= maxSize {
return
}
logger.Info("eviction: cache size exceeds limit, evicting",
"current_size", totalSize, "max_size", maxSize)
evicted := 0
freedBytes := int64(0)
for totalSize-freedBytes > maxSize {
artifacts, err := db.GetLeastRecentlyUsedArtifacts(evictionBatch)
if err != nil {
logger.Warn("eviction: failed to get LRU artifacts", "error", err)
return
}
if len(artifacts) == 0 {
break
}
for _, art := range artifacts {
if totalSize-freedBytes <= maxSize {
break
}
if !art.StoragePath.Valid {
continue
}
if err := store.Delete(ctx, art.StoragePath.String); err != nil {
logger.Warn("eviction: failed to delete from storage",
"path", art.StoragePath.String, "error", err)
continue
}
if err := db.ClearArtifactCache(art.VersionPURL, art.Filename); err != nil {
logger.Warn("eviction: failed to clear artifact record",
"version_purl", art.VersionPURL, "filename", art.Filename, "error", err)
continue
}
size := int64(0)
if art.Size.Valid {
size = art.Size.Int64
}
freedBytes += size
evicted++
}
}
if evicted > 0 {
logger.Info("eviction: completed",
"evicted", evicted, "freed_bytes", freedBytes)
}
}

View file

@ -1,290 +0,0 @@
package server
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage"
)
func setupEvictionTest(t *testing.T) (*database.DB, *storage.Filesystem) {
t.Helper()
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test.db")
storagePath := filepath.Join(tempDir, "artifacts")
db, err := database.Create(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
store, err := storage.NewFilesystem(storagePath)
if err != nil {
_ = db.Close()
t.Fatalf("failed to create storage: %v", err)
}
t.Cleanup(func() {
_ = db.Close()
})
return db, store
}
func seedArtifact(t *testing.T, ctx context.Context, db *database.DB, store storage.Storage, name string, dataSize int, accessedAt time.Time) {
t.Helper()
pkgPURL := "pkg:npm/" + name
versionPURL := pkgPURL + "@1.0.0"
filename := name + "-1.0.0.tgz"
if err := db.UpsertPackage(&database.Package{
PURL: pkgPURL,
Ecosystem: "npm",
Name: name,
}); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
if err := db.UpsertVersion(&database.Version{
PURL: versionPURL,
PackagePURL: pkgPURL,
}); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
storagePath := storage.ArtifactPath("npm", "", name, "1.0.0", filename)
data := strings.NewReader(strings.Repeat("x", dataSize))
size, hash, err := store.Store(ctx, storagePath, data)
if err != nil {
t.Fatalf("failed to store artifact: %v", err)
}
if err := db.UpsertArtifact(&database.Artifact{
VersionPURL: versionPURL,
Filename: filename,
UpstreamURL: "https://example.com/" + filename,
StoragePath: sql.NullString{String: storagePath, Valid: true},
ContentHash: sql.NullString{String: hash, Valid: true},
Size: sql.NullInt64{Int64: size, Valid: true},
ContentType: sql.NullString{String: "application/gzip", Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
LastAccessedAt: sql.NullTime{Time: accessedAt, Valid: true},
}); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
}
func TestEvictLRU_NoEvictionWhenUnderLimit(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
seedArtifact(t, ctx, db, store, "pkg-a", 100, time.Now())
evictLRU(ctx, db, store, logger, 1024)
count, err := db.GetCachedArtifactCount()
if err != nil {
t.Fatalf("failed to get count: %v", err)
}
if count != 1 {
t.Errorf("expected 1 cached artifact, got %d", count)
}
}
func TestEvictLRU_EvictsOldestFirst(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
now := time.Now()
seedArtifact(t, ctx, db, store, "old-pkg", 500, now.Add(-3*time.Hour))
seedArtifact(t, ctx, db, store, "mid-pkg", 500, now.Add(-1*time.Hour))
seedArtifact(t, ctx, db, store, "new-pkg", 500, now)
// Total is 1500 bytes, limit to 1100 so only the oldest gets evicted
evictLRU(ctx, db, store, logger, 1100)
// old-pkg should be evicted
art, err := db.GetArtifact("pkg:npm/old-pkg@1.0.0", "old-pkg-1.0.0.tgz")
if err != nil {
t.Fatalf("failed to get artifact: %v", err)
}
if art.StoragePath.Valid {
t.Error("expected old-pkg to be evicted (storage_path should be NULL)")
}
// mid-pkg and new-pkg should remain
art, err = db.GetArtifact("pkg:npm/mid-pkg@1.0.0", "mid-pkg-1.0.0.tgz")
if err != nil {
t.Fatalf("failed to get artifact: %v", err)
}
if !art.StoragePath.Valid {
t.Error("expected mid-pkg to remain cached")
}
art, err = db.GetArtifact("pkg:npm/new-pkg@1.0.0", "new-pkg-1.0.0.tgz")
if err != nil {
t.Fatalf("failed to get artifact: %v", err)
}
if !art.StoragePath.Valid {
t.Error("expected new-pkg to remain cached")
}
// Storage file should be removed for old-pkg
storagePath := storage.ArtifactPath("npm", "", "old-pkg", "1.0.0", "old-pkg-1.0.0.tgz")
exists, _ := store.Exists(ctx, storagePath)
if exists {
t.Error("expected old-pkg file to be deleted from storage")
}
}
func TestEvictLRU_EvictsMultipleToGetUnderLimit(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
now := time.Now()
seedArtifact(t, ctx, db, store, "pkg-1", 400, now.Add(-4*time.Hour))
seedArtifact(t, ctx, db, store, "pkg-2", 400, now.Add(-3*time.Hour))
seedArtifact(t, ctx, db, store, "pkg-3", 400, now.Add(-2*time.Hour))
seedArtifact(t, ctx, db, store, "pkg-4", 400, now)
// Total is 1600 bytes, limit to 900 so pkg-1 and pkg-2 get evicted
evictLRU(ctx, db, store, logger, 900)
count, err := db.GetCachedArtifactCount()
if err != nil {
t.Fatalf("failed to get count: %v", err)
}
if count != 2 {
t.Errorf("expected 2 cached artifacts remaining, got %d", count)
}
// Verify the right ones remain
for _, name := range []string{"pkg-3", "pkg-4"} {
art, err := db.GetArtifact("pkg:npm/"+name+"@1.0.0", name+"-1.0.0.tgz")
if err != nil {
t.Fatalf("failed to get artifact %s: %v", name, err)
}
if !art.StoragePath.Valid {
t.Errorf("expected %s to remain cached", name)
}
}
}
func TestEvictLRU_NothingToEvictWhenEmpty(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// Should not panic or error with no artifacts
evictLRU(ctx, db, store, logger, 1024)
count, err := db.GetCachedArtifactCount()
if err != nil {
t.Fatalf("failed to get count: %v", err)
}
if count != 0 {
t.Errorf("expected 0 cached artifacts, got %d", count)
}
}
func TestEvictLRU_StorageFileDeleted(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
seedArtifact(t, ctx, db, store, "delete-me", 1000, time.Now().Add(-1*time.Hour))
storagePath := storage.ArtifactPath("npm", "", "delete-me", "1.0.0", "delete-me-1.0.0.tgz")
exists, _ := store.Exists(ctx, storagePath)
if !exists {
t.Fatal("expected artifact file to exist before eviction")
}
evictLRU(ctx, db, store, logger, 500)
exists, _ = store.Exists(ctx, storagePath)
if exists {
t.Error("expected artifact file to be deleted after eviction")
}
art, err := db.GetArtifact("pkg:npm/delete-me@1.0.0", "delete-me-1.0.0.tgz")
if err != nil {
t.Fatalf("failed to get artifact: %v", err)
}
if art.StoragePath.Valid {
t.Error("expected storage_path to be NULL after eviction")
}
if art.Size.Valid {
t.Error("expected size to be NULL after eviction")
}
}
func TestStartEvictionLoop_UnlimitedSkips(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test.db")
storagePath := filepath.Join(tempDir, "artifacts")
db, err := database.Create(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
store, err := storage.NewFilesystem(storagePath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
cfg := defaultTestConfig(storagePath, dbPath)
s := &Server{
cfg: cfg,
db: db,
storage: store,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Should return immediately since max_size is empty (unlimited)
done := make(chan struct{})
go func() {
s.startEvictionLoop(ctx)
close(done)
}()
select {
case <-done:
// Good, returned immediately
case <-time.After(1 * time.Second):
t.Error("startEvictionLoop should return immediately when max_size is unlimited")
cancel()
}
}
func defaultTestConfig(storagePath, dbPath string) *config.Config {
return &config.Config{
Listen: ":8080",
BaseURL: "http://localhost:8080",
Storage: config.StorageConfig{Path: storagePath, MaxSize: ""},
Database: config.DatabaseConfig{
Driver: "sqlite",
Path: dbPath,
},
Log: config.LogConfig{Level: "info", Format: "text"},
}
}

View file

@ -148,9 +148,6 @@ func (s *Server) Start() error {
proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
proxy.DirectServe = s.cfg.Storage.DirectServe
proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL()
proxy.DirectServeBaseURL = s.cfg.Storage.DirectServeBaseURL
// Create router with Chi
r := chi.NewRouter()
@ -272,7 +269,6 @@ func (s *Server) Start() error {
"storage", s.storage.URL(),
"database", s.cfg.Database.Path)
go s.updateCacheStatsMetrics()
go s.startEvictionLoop(bgCtx)
return s.http.ListenAndServe()
}

View file

@ -6,15 +6,12 @@ import (
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"gocloud.dev/blob"
_ "gocloud.dev/blob/azureblob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/s3blob"
"gocloud.dev/gcerrors"
@ -141,20 +138,6 @@ func (b *Blob) Delete(ctx context.Context, path string) error {
return nil
}
func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
url, err := b.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{
Method: http.MethodGet,
Expiry: expiry,
})
if err != nil {
if gcerrors.Code(err) == gcerrors.Unimplemented {
return "", ErrSignedURLUnsupported
}
return "", fmt.Errorf("signing URL: %w", err)
}
return url, nil
}
func (b *Blob) Size(ctx context.Context, path string) (int64, error) {
attrs, err := b.bucket.Attributes(ctx, path)
if err != nil {

View file

@ -10,7 +10,6 @@ import (
"runtime"
"strings"
"testing"
"time"
)
func TestOpenBucket(t *testing.T) {
@ -189,18 +188,6 @@ func TestBlobLargeFile(t *testing.T) {
assertLargeFileRoundTrip(t, createTestBlob(t))
}
func TestBlobSignedURLUnsupported(t *testing.T) {
b := createTestBlob(t)
ctx := context.Background()
// fileblob has no URL signer configured, so this must surface as
// ErrSignedURLUnsupported rather than a generic error.
_, err := b.SignedURL(ctx, "test/file.txt", time.Minute)
if !errors.Is(err, ErrSignedURLUnsupported) {
t.Errorf("SignedURL on fileblob = %v, want ErrSignedURLUnsupported", err)
}
}
func TestBlobOverwrite(t *testing.T) {
b := createTestBlob(t)
ctx := context.Background()

View file

@ -8,7 +8,6 @@ import (
"io"
"os"
"path/filepath"
"time"
)
// Filesystem implements Storage using the local filesystem.
@ -130,10 +129,6 @@ func (fs *Filesystem) Delete(ctx context.Context, path string) error {
return nil
}
func (fs *Filesystem) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", ErrSignedURLUnsupported
}
func (fs *Filesystem) Size(ctx context.Context, path string) (int64, error) {
fullPath := fs.fullPath(path)

View file

@ -10,7 +10,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestNewFilesystem(t *testing.T) {
@ -237,15 +236,6 @@ func TestFilesystemLargeFile(t *testing.T) {
assertLargeFileRoundTrip(t, createTestFilesystem(t))
}
func TestFilesystemSignedURLUnsupported(t *testing.T) {
fs := createTestFilesystem(t)
_, err := fs.SignedURL(context.Background(), "test/file.txt", time.Minute)
if !errors.Is(err, ErrSignedURLUnsupported) {
t.Errorf("SignedURL = %v, want ErrSignedURLUnsupported", err)
}
}
func createTestFilesystem(t *testing.T) *Filesystem {
t.Helper()
dir := t.TempDir()

View file

@ -15,17 +15,12 @@ import (
"encoding/hex"
"errors"
"io"
"time"
)
const dirPermissions = 0755
var (
ErrNotFound = errors.New("artifact not found")
// ErrSignedURLUnsupported is returned by SignedURL when the backend
// cannot generate presigned URLs (e.g. local filesystem).
ErrSignedURLUnsupported = errors.New("signed URLs not supported by storage backend")
)
// Storage defines the interface for artifact storage backends.
@ -50,10 +45,6 @@ type Storage interface {
// Returns ErrNotFound if the path does not exist.
Size(ctx context.Context, path string) (int64, error)
// SignedURL returns a presigned URL granting time-limited GET access to path.
// Returns ErrSignedURLUnsupported if the backend cannot generate presigned URLs.
SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error)
// UsedSpace returns the total bytes used by all stored content.
UsedSpace(ctx context.Context) (int64, error)