2026-01-20 21:52:44 +00:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-08-13 07:08:23 +01:00
|
|
|
"strings"
|
2026-01-20 21:52:44 +00:00
|
|
|
"testing"
|
2026-04-06 19:30:59 +01:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const (
|
|
|
|
|
testDriverPostgres = "postgres"
|
|
|
|
|
testInvalid = "invalid"
|
|
|
|
|
testLevelDebug = "debug"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
func upstreamConfigValues(upstream UpstreamConfig) map[string]string {
|
|
|
|
|
return map[string]string{
|
|
|
|
|
"npm": upstream.NPM,
|
|
|
|
|
"cargo": upstream.Cargo,
|
|
|
|
|
"cargo_download": upstream.CargoDownload,
|
|
|
|
|
"gem": upstream.Gem,
|
|
|
|
|
"go": upstream.Go,
|
|
|
|
|
"hex": upstream.Hex,
|
|
|
|
|
"hex_api": upstream.HexAPI,
|
|
|
|
|
"pub": upstream.Pub,
|
|
|
|
|
"pypi": upstream.PyPI,
|
|
|
|
|
"pypi_download": upstream.PyPIDownload,
|
|
|
|
|
"maven": upstream.Maven,
|
|
|
|
|
"gradle_plugin_portal": upstream.GradlePluginPortal,
|
|
|
|
|
"nuget": upstream.NuGet,
|
|
|
|
|
"nuget_search": upstream.NuGetSearch,
|
|
|
|
|
"composer": upstream.Composer,
|
|
|
|
|
"composer_repository": upstream.ComposerRepository,
|
|
|
|
|
"conan": upstream.Conan,
|
|
|
|
|
"conda": upstream.Conda,
|
|
|
|
|
"cran": upstream.CRAN,
|
|
|
|
|
"julia": upstream.Julia,
|
2026-09-02 13:06:34 +01:00
|
|
|
"swift": upstream.Swift,
|
2026-08-29 11:19:54 +01:00
|
|
|
"oci_default": upstream.OCIDefault,
|
|
|
|
|
"debian": upstream.Debian,
|
|
|
|
|
"rpm": upstream.RPM,
|
2026-09-03 16:59:12 +01:00
|
|
|
"homebrew_api": upstream.HomebrewAPI,
|
|
|
|
|
"homebrew_artifact": upstream.HomebrewArtifact,
|
2026-08-29 11:19:54 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func defaultUpstreamValues() map[string]string {
|
|
|
|
|
return map[string]string{
|
|
|
|
|
"npm": "https://registry.npmjs.org",
|
|
|
|
|
"cargo": "https://index.crates.io",
|
|
|
|
|
"cargo_download": "https://static.crates.io/crates",
|
|
|
|
|
"gem": "https://rubygems.org",
|
|
|
|
|
"go": "https://proxy.golang.org",
|
|
|
|
|
"hex": "https://repo.hex.pm",
|
|
|
|
|
"hex_api": "https://hex.pm",
|
|
|
|
|
"pub": "https://pub.dev",
|
|
|
|
|
"pypi": "https://pypi.org",
|
|
|
|
|
"pypi_download": "https://files.pythonhosted.org",
|
|
|
|
|
"maven": "https://repo1.maven.org/maven2",
|
|
|
|
|
"gradle_plugin_portal": "https://plugins.gradle.org/m2",
|
|
|
|
|
"nuget": "https://api.nuget.org",
|
|
|
|
|
"nuget_search": "https://azuresearch-usnc.nuget.org",
|
|
|
|
|
"composer": "https://packagist.org",
|
|
|
|
|
"composer_repository": "https://repo.packagist.org",
|
|
|
|
|
"conan": "https://center.conan.io",
|
|
|
|
|
"conda": "https://conda.anaconda.org",
|
|
|
|
|
"cran": "https://cloud.r-project.org",
|
|
|
|
|
"julia": "https://pkg.julialang.org",
|
2026-09-02 13:06:34 +01:00
|
|
|
"swift": "https://tuist.dev/api/registry/swift",
|
2026-08-29 11:19:54 +01:00
|
|
|
"oci_default": "https://registry-1.docker.io",
|
|
|
|
|
"debian": "http://deb.debian.org/debian",
|
|
|
|
|
"rpm": "https://dl.fedoraproject.org/pub/fedora/linux",
|
2026-09-03 16:59:12 +01:00
|
|
|
"homebrew_api": "https://formulae.brew.sh/api",
|
|
|
|
|
"homebrew_artifact": "https://ghcr.io",
|
2026-08-29 11:19:54 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func upstreamEnvironmentVariables() map[string]string {
|
|
|
|
|
return map[string]string{
|
|
|
|
|
"npm": "PROXY_UPSTREAM_NPM",
|
|
|
|
|
"cargo": "PROXY_UPSTREAM_CARGO",
|
|
|
|
|
"cargo_download": "PROXY_UPSTREAM_CARGO_DOWNLOAD",
|
|
|
|
|
"gem": "PROXY_UPSTREAM_GEM",
|
|
|
|
|
"go": "PROXY_UPSTREAM_GO",
|
|
|
|
|
"hex": "PROXY_UPSTREAM_HEX",
|
|
|
|
|
"hex_api": "PROXY_UPSTREAM_HEX_API",
|
|
|
|
|
"pub": "PROXY_UPSTREAM_PUB",
|
|
|
|
|
"pypi": "PROXY_UPSTREAM_PYPI",
|
|
|
|
|
"pypi_download": "PROXY_UPSTREAM_PYPI_DOWNLOAD",
|
|
|
|
|
"maven": "PROXY_UPSTREAM_MAVEN",
|
|
|
|
|
"gradle_plugin_portal": "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL",
|
|
|
|
|
"nuget": "PROXY_UPSTREAM_NUGET",
|
|
|
|
|
"nuget_search": "PROXY_UPSTREAM_NUGET_SEARCH",
|
|
|
|
|
"composer": "PROXY_UPSTREAM_COMPOSER",
|
|
|
|
|
"composer_repository": "PROXY_UPSTREAM_COMPOSER_REPOSITORY",
|
|
|
|
|
"conan": "PROXY_UPSTREAM_CONAN",
|
|
|
|
|
"conda": "PROXY_UPSTREAM_CONDA",
|
|
|
|
|
"cran": "PROXY_UPSTREAM_CRAN",
|
|
|
|
|
"julia": "PROXY_UPSTREAM_JULIA",
|
2026-09-02 13:06:34 +01:00
|
|
|
"swift": "PROXY_UPSTREAM_SWIFT",
|
2026-08-29 11:19:54 +01:00
|
|
|
"oci_default": "PROXY_UPSTREAM_OCI_DEFAULT",
|
|
|
|
|
"debian": "PROXY_UPSTREAM_DEBIAN",
|
|
|
|
|
"rpm": "PROXY_UPSTREAM_RPM",
|
2026-09-03 16:59:12 +01:00
|
|
|
"homebrew_api": "PROXY_UPSTREAM_HOMEBREW_API",
|
|
|
|
|
"homebrew_artifact": "PROXY_UPSTREAM_HOMEBREW_ARTIFACT",
|
2026-08-29 11:19:54 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertUpstreamValues(t *testing.T, cfg *Config, want map[string]string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
got := upstreamConfigValues(cfg.Upstream)
|
|
|
|
|
for name, wantValue := range want {
|
|
|
|
|
if gotValue := got[name]; gotValue != wantValue {
|
|
|
|
|
t.Errorf("Upstream %s = %q, want %q", name, gotValue, wantValue)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func TestDefault(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
|
|
|
|
|
if cfg.Listen != ":8080" {
|
|
|
|
|
t.Errorf("Listen = %q, want %q", cfg.Listen, ":8080")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Storage.Path == "" {
|
|
|
|
|
t.Error("Storage.Path should not be empty")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Database.Path == "" {
|
|
|
|
|
t.Error("Database.Path should not be empty")
|
|
|
|
|
}
|
2026-08-16 18:07:39 +01:00
|
|
|
if cfg.AccessLog.Path != "" {
|
|
|
|
|
t.Errorf("AccessLog.Path = %q, want disabled by default", cfg.AccessLog.Path)
|
|
|
|
|
}
|
2026-05-04 12:15:16 +02:00
|
|
|
if cfg.Gradle.BuildCache.MaxUploadSize != "100MB" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "100MB")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Gradle.BuildCache.MaxAge != "168h" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "168h")
|
|
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
if len(cfg.Upstream.AllowPrivateHosts) != 0 {
|
|
|
|
|
t.Errorf("Upstream.AllowPrivateHosts = %v, want empty", cfg.Upstream.AllowPrivateHosts)
|
2026-05-22 18:05:20 +02:00
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
if cfg.Upstream.AllowLoopback {
|
|
|
|
|
t.Error("Upstream.AllowLoopback = true, want false")
|
2026-08-05 18:48:00 +02:00
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
assertUpstreamValues(t, cfg, defaultUpstreamValues())
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidate(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
modify func(*Config)
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "valid default",
|
|
|
|
|
modify: func(c *Config) {},
|
|
|
|
|
wantErr: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty listen",
|
|
|
|
|
modify: func(c *Config) { c.Listen = "" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty base_url",
|
|
|
|
|
modify: func(c *Config) { c.BaseURL = "" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-01-29 16:13:16 +00:00
|
|
|
name: "empty storage path and url",
|
|
|
|
|
modify: func(c *Config) { c.Storage.Path = ""; c.Storage.URL = "" },
|
2026-01-20 21:52:44 +00:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-01-29 16:13:16 +00:00
|
|
|
{
|
|
|
|
|
name: "storage url set",
|
|
|
|
|
modify: func(c *Config) { c.Storage.Path = ""; c.Storage.URL = "s3://bucket" },
|
|
|
|
|
wantErr: false,
|
|
|
|
|
},
|
2026-01-20 21:52:44 +00:00
|
|
|
{
|
2026-01-29 16:06:56 +00:00
|
|
|
name: "empty database path for sqlite",
|
2026-01-20 21:52:44 +00:00
|
|
|
modify: func(c *Config) { c.Database.Path = "" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-01-29 16:06:56 +00:00
|
|
|
{
|
|
|
|
|
name: "invalid database driver",
|
|
|
|
|
modify: func(c *Config) { c.Database.Driver = "mysql" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "postgres without url",
|
2026-03-18 10:59:29 +00:00
|
|
|
modify: func(c *Config) { c.Database.Driver = testDriverPostgres; c.Database.URL = "" },
|
2026-01-29 16:06:56 +00:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "postgres with url",
|
2026-03-18 10:59:29 +00:00
|
|
|
modify: func(c *Config) { c.Database.Driver = testDriverPostgres; c.Database.URL = "postgres://localhost/test" },
|
2026-01-29 16:06:56 +00:00
|
|
|
wantErr: false,
|
|
|
|
|
},
|
2026-01-20 21:52:44 +00:00
|
|
|
{
|
|
|
|
|
name: "invalid log level",
|
2026-03-18 10:59:29 +00:00
|
|
|
modify: func(c *Config) { c.Log.Level = testInvalid },
|
2026-01-20 21:52:44 +00:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid log format",
|
2026-03-18 10:59:29 +00:00
|
|
|
modify: func(c *Config) { c.Log.Format = testInvalid },
|
2026-01-20 21:52:44 +00:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid max size",
|
2026-03-18 10:59:29 +00:00
|
|
|
modify: func(c *Config) { c.Storage.MaxSize = testInvalid },
|
2026-01-20 21:52:44 +00:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "valid max size",
|
|
|
|
|
modify: func(c *Config) { c.Storage.MaxSize = "10GB" },
|
|
|
|
|
wantErr: false,
|
|
|
|
|
},
|
2026-05-04 12:15:16 +02:00
|
|
|
{
|
|
|
|
|
name: "invalid gradle upload size",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.MaxUploadSize = testInvalid },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "zero gradle upload size",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.MaxUploadSize = "0" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid gradle max age",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.MaxAge = testInvalid },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "valid gradle max age",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.MaxAge = "24h" },
|
|
|
|
|
wantErr: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid gradle max size",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.MaxSize = testInvalid },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid gradle sweep interval",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.SweepInterval = "0" },
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "valid gradle sweep interval",
|
|
|
|
|
modify: func(c *Config) { c.Gradle.BuildCache.SweepInterval = "30m" },
|
|
|
|
|
wantErr: false,
|
|
|
|
|
},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
tt.modify(cfg)
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseSize(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
input string
|
|
|
|
|
want int64
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{"", 0, false},
|
|
|
|
|
{"0", 0, false},
|
|
|
|
|
{"100", 100, false},
|
|
|
|
|
{"1KB", 1024, false},
|
|
|
|
|
{"1K", 1024, false},
|
|
|
|
|
{"1MB", 1024 * 1024, false},
|
|
|
|
|
{"1M", 1024 * 1024, false},
|
|
|
|
|
{"1GB", 1024 * 1024 * 1024, false},
|
|
|
|
|
{"1G", 1024 * 1024 * 1024, false},
|
|
|
|
|
{"10GB", 10 * 1024 * 1024 * 1024, false},
|
|
|
|
|
{"1.5GB", int64(1.5 * 1024 * 1024 * 1024), false},
|
|
|
|
|
{"1TB", 1024 * 1024 * 1024 * 1024, false},
|
|
|
|
|
{"invalid", 0, true},
|
|
|
|
|
{"10XB", 0, true},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
got, err := ParseSize(tt.input)
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("ParseSize(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("ParseSize(%q) = %d, want %d", tt.input, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadYAML(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "config.yaml")
|
|
|
|
|
|
|
|
|
|
content := `
|
|
|
|
|
listen: ":3000"
|
|
|
|
|
base_url: "https://example.com"
|
|
|
|
|
storage:
|
|
|
|
|
path: "/data/cache"
|
|
|
|
|
max_size: "5GB"
|
|
|
|
|
database:
|
|
|
|
|
path: "/data/proxy.db"
|
|
|
|
|
log:
|
|
|
|
|
level: "debug"
|
|
|
|
|
format: "json"
|
2026-08-16 18:07:39 +01:00
|
|
|
access_log:
|
|
|
|
|
path: "/var/log/proxy/access.jsonl"
|
2026-09-03 16:59:12 +01:00
|
|
|
upstream:
|
|
|
|
|
homebrew_api: "https://homebrew-api.example.com"
|
|
|
|
|
homebrew_artifact: "https://homebrew-artifact.example.com"
|
2026-01-20 21:52:44 +00:00
|
|
|
`
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
|
|
|
t.Fatalf("writing config file: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cfg.Listen != ":3000" {
|
|
|
|
|
t.Errorf("Listen = %q, want %q", cfg.Listen, ":3000")
|
|
|
|
|
}
|
|
|
|
|
if cfg.BaseURL != "https://example.com" {
|
|
|
|
|
t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://example.com")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Storage.Path != "/data/cache" {
|
|
|
|
|
t.Errorf("Storage.Path = %q, want %q", cfg.Storage.Path, "/data/cache")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Storage.MaxSize != "5GB" {
|
|
|
|
|
t.Errorf("Storage.MaxSize = %q, want %q", cfg.Storage.MaxSize, "5GB")
|
|
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
if cfg.Log.Level != testLevelDebug {
|
|
|
|
|
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
if cfg.Log.Format != "json" {
|
|
|
|
|
t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json")
|
|
|
|
|
}
|
2026-08-16 18:07:39 +01:00
|
|
|
if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" {
|
|
|
|
|
t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/var/log/proxy/access.jsonl")
|
|
|
|
|
}
|
2026-09-03 16:59:12 +01:00
|
|
|
if cfg.Upstream.HomebrewAPI != "https://homebrew-api.example.com" {
|
|
|
|
|
t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://homebrew-api.example.com")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Upstream.HomebrewArtifact != "https://homebrew-artifact.example.com" {
|
|
|
|
|
t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://homebrew-artifact.example.com")
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
func TestLoadYAMLUpstreams(t *testing.T) {
|
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
content := `
|
|
|
|
|
upstream:
|
|
|
|
|
allow_private_hosts:
|
|
|
|
|
- "registry.internal"
|
|
|
|
|
- "10.0.0.12"
|
|
|
|
|
allow_loopback: true
|
|
|
|
|
npm: "https://upstream.example.com/npm"
|
|
|
|
|
cargo: "https://upstream.example.com/cargo"
|
|
|
|
|
cargo_download: "https://upstream.example.com/cargo_download"
|
|
|
|
|
gem: "https://upstream.example.com/gem"
|
|
|
|
|
go: "https://upstream.example.com/go"
|
|
|
|
|
hex: "https://upstream.example.com/hex"
|
|
|
|
|
hex_api: "https://upstream.example.com/hex_api"
|
|
|
|
|
pub: "https://upstream.example.com/pub"
|
|
|
|
|
pypi: "https://upstream.example.com/pypi"
|
|
|
|
|
pypi_download: "https://upstream.example.com/pypi_download"
|
|
|
|
|
maven: "https://upstream.example.com/maven"
|
|
|
|
|
gradle_plugin_portal: "https://upstream.example.com/gradle_plugin_portal"
|
|
|
|
|
nuget: "https://upstream.example.com/nuget"
|
|
|
|
|
nuget_search: "https://upstream.example.com/nuget_search"
|
|
|
|
|
composer: "https://upstream.example.com/composer"
|
|
|
|
|
composer_repository: "https://upstream.example.com/composer_repository"
|
|
|
|
|
conan: "https://upstream.example.com/conan"
|
|
|
|
|
conda: "https://upstream.example.com/conda"
|
|
|
|
|
cran: "https://upstream.example.com/cran"
|
|
|
|
|
julia: "https://upstream.example.com/julia"
|
2026-09-02 13:06:34 +01:00
|
|
|
swift: "https://upstream.example.com/swift"
|
2026-08-29 11:19:54 +01:00
|
|
|
oci_default: "https://upstream.example.com/oci_default"
|
|
|
|
|
debian: "https://upstream.example.com/debian"
|
|
|
|
|
rpm: "https://upstream.example.com/rpm"
|
2026-09-03 16:59:12 +01:00
|
|
|
homebrew_api: "https://upstream.example.com/homebrew_api"
|
|
|
|
|
homebrew_artifact: "https://upstream.example.com/homebrew_artifact"
|
2026-08-29 11:19:54 +01:00
|
|
|
`
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
|
|
|
t.Fatalf("writing config file: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
want := make(map[string]string)
|
|
|
|
|
for name := range defaultUpstreamValues() {
|
|
|
|
|
want[name] = "https://upstream.example.com/" + name
|
|
|
|
|
}
|
|
|
|
|
assertUpstreamValues(t, cfg, want)
|
|
|
|
|
if got := strings.Join(cfg.Upstream.AllowPrivateHosts, ","); got != "registry.internal,10.0.0.12" {
|
|
|
|
|
t.Errorf("Upstream.AllowPrivateHosts = %q, want %q", got, "registry.internal,10.0.0.12")
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Upstream.AllowLoopback {
|
|
|
|
|
t.Error("Upstream.AllowLoopback = false, want true")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func TestLoadJSON(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
content := `{
|
|
|
|
|
"listen": ":4000",
|
2026-08-29 11:19:54 +01:00
|
|
|
"base_url": "https://json.example.com",
|
|
|
|
|
"upstream": {
|
|
|
|
|
"gem": "https://json.example.com/gem",
|
|
|
|
|
"rpm": "https://json.example.com/rpm"
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}`
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
|
|
|
t.Fatalf("writing config file: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cfg.Listen != ":4000" {
|
|
|
|
|
t.Errorf("Listen = %q, want %q", cfg.Listen, ":4000")
|
|
|
|
|
}
|
|
|
|
|
if cfg.BaseURL != "https://json.example.com" {
|
|
|
|
|
t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://json.example.com")
|
|
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
if cfg.Upstream.Gem != "https://json.example.com/gem" {
|
|
|
|
|
t.Errorf("Upstream.Gem = %q, want %q", cfg.Upstream.Gem, "https://json.example.com/gem")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Upstream.RPM != "https://json.example.com/rpm" {
|
|
|
|
|
t.Errorf("Upstream.RPM = %q, want %q", cfg.Upstream.RPM, "https://json.example.com/rpm")
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
|
|
|
|
|
t.Setenv("PROXY_LISTEN", ":9000")
|
|
|
|
|
t.Setenv("PROXY_BASE_URL", "https://env.example.com")
|
2026-06-07 16:12:43 +01:00
|
|
|
t.Setenv("PROXY_UI_URL", "https://ui.env.example.com/ui")
|
2026-01-20 21:52:44 +00:00
|
|
|
t.Setenv("PROXY_STORAGE_PATH", "/env/cache")
|
2026-03-18 10:59:29 +00:00
|
|
|
t.Setenv("PROXY_LOG_LEVEL", testLevelDebug)
|
2026-08-16 18:07:39 +01:00
|
|
|
t.Setenv("PROXY_ACCESS_LOG_PATH", "/tmp/proxy-access.jsonl")
|
2026-10-01 14:32:21 +02:00
|
|
|
t.Setenv("PROXY_TRUST_FORWARDED_FOR", "true")
|
|
|
|
|
t.Setenv("PROXY_UI_REQUEST_SOURCES", "true")
|
2026-10-12 09:41:42 +02:00
|
|
|
t.Setenv("PROXY_UI_FAILURE_DETAILS", "true")
|
2026-08-29 11:19:54 +01:00
|
|
|
t.Setenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS", "registry.internal, 10.0.0.12")
|
|
|
|
|
t.Setenv("PROXY_UPSTREAM_ALLOW_LOOPBACK", "true")
|
2026-05-04 12:15:16 +02:00
|
|
|
t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true")
|
|
|
|
|
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
|
|
|
|
|
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h")
|
|
|
|
|
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE", "10GB")
|
|
|
|
|
t.Setenv("PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL", "15m")
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.Listen != ":9000" {
|
|
|
|
|
t.Errorf("Listen = %q, want %q", cfg.Listen, ":9000")
|
|
|
|
|
}
|
|
|
|
|
if cfg.BaseURL != "https://env.example.com" {
|
|
|
|
|
t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://env.example.com")
|
|
|
|
|
}
|
2026-06-07 16:12:43 +01:00
|
|
|
if cfg.UIBaseURL != "https://ui.env.example.com/ui" {
|
|
|
|
|
t.Errorf("UIBaseURL = %q, want %q", cfg.UIBaseURL, "https://ui.env.example.com/ui")
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
if cfg.Storage.Path != "/env/cache" {
|
|
|
|
|
t.Errorf("Storage.Path = %q, want %q", cfg.Storage.Path, "/env/cache")
|
|
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
if cfg.Log.Level != testLevelDebug {
|
|
|
|
|
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-08-16 18:07:39 +01:00
|
|
|
if cfg.AccessLog.Path != "/tmp/proxy-access.jsonl" {
|
|
|
|
|
t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/tmp/proxy-access.jsonl")
|
|
|
|
|
}
|
2026-10-01 14:32:21 +02:00
|
|
|
// Containers configure the proxy through the environment, so a setting
|
|
|
|
|
// reachable only from YAML is unreachable in the deployment that most needs
|
|
|
|
|
// it -- which is the one sitting behind an ingress.
|
|
|
|
|
if !cfg.TrustForwardedFor {
|
|
|
|
|
t.Error("TrustForwardedFor = false, want true from the environment")
|
|
|
|
|
}
|
|
|
|
|
if !cfg.UIRequestSources {
|
|
|
|
|
t.Error("UIRequestSources = false, want true from the environment")
|
|
|
|
|
}
|
2026-10-12 09:41:42 +02:00
|
|
|
if !cfg.UIFailureDetails {
|
|
|
|
|
t.Error("UIFailureDetails = false, want true from the environment")
|
|
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
if got := strings.Join(cfg.Upstream.AllowPrivateHosts, ","); got != "registry.internal,10.0.0.12" {
|
|
|
|
|
t.Errorf("Upstream.AllowPrivateHosts = %q, want %q", got, "registry.internal,10.0.0.12")
|
2026-05-22 18:05:20 +02:00
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
if !cfg.Upstream.AllowLoopback {
|
|
|
|
|
t.Error("Upstream.AllowLoopback = false, want true")
|
2026-05-22 18:05:20 +02:00
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
|
|
|
|
|
t.Setenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS", " , ")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
if got := strings.Join(cfg.Upstream.AllowPrivateHosts, ","); got != "registry.internal,10.0.0.12" {
|
|
|
|
|
t.Errorf("Upstream.AllowPrivateHosts after empty env = %q, want unchanged", got)
|
2026-08-05 18:48:00 +02:00
|
|
|
}
|
2026-08-29 11:19:54 +01:00
|
|
|
|
2026-05-04 12:15:16 +02:00
|
|
|
if !cfg.Gradle.BuildCache.ReadOnly {
|
|
|
|
|
t.Error("Gradle.BuildCache.ReadOnly = false, want true")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Gradle.BuildCache.MaxUploadSize != "32MB" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "32MB")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Gradle.BuildCache.MaxAge != "12h" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "12h")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Gradle.BuildCache.MaxSize != "10GB" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.MaxSize = %q, want %q", cfg.Gradle.BuildCache.MaxSize, "10GB")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Gradle.BuildCache.SweepInterval != "15m" {
|
|
|
|
|
t.Errorf("Gradle.BuildCache.SweepInterval = %q, want %q", cfg.Gradle.BuildCache.SweepInterval, "15m")
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
func TestLoadFromEnvUpstreams(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
want := make(map[string]string)
|
|
|
|
|
for name, envName := range upstreamEnvironmentVariables() {
|
|
|
|
|
value := "https://env.example.com/" + name
|
|
|
|
|
t.Setenv(envName, value)
|
|
|
|
|
want[name] = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
assertUpstreamValues(t, cfg, want)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
func TestLoadCooldownConfig(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "config.yaml")
|
|
|
|
|
|
|
|
|
|
content := `
|
|
|
|
|
listen: ":8080"
|
|
|
|
|
base_url: "http://localhost:8080"
|
|
|
|
|
storage:
|
|
|
|
|
path: "/data/cache"
|
|
|
|
|
database:
|
|
|
|
|
path: "/data/proxy.db"
|
|
|
|
|
cooldown:
|
|
|
|
|
default: "3d"
|
|
|
|
|
ecosystems:
|
|
|
|
|
npm: "7d"
|
|
|
|
|
cargo: "0"
|
|
|
|
|
packages:
|
|
|
|
|
"pkg:npm/lodash": "0"
|
|
|
|
|
"pkg:npm/@babel/core": "14d"
|
2026-10-04 20:48:41 +01:00
|
|
|
package_patterns:
|
|
|
|
|
"pkg:npm/@example/*": "0"
|
2026-03-04 19:00:31 +00:00
|
|
|
`
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
|
|
|
t.Fatalf("writing config file: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cfg.Cooldown.Default != "3d" {
|
|
|
|
|
t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "3d")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Cooldown.Ecosystems["npm"] != "7d" {
|
|
|
|
|
t.Errorf("Cooldown.Ecosystems[npm] = %q, want %q", cfg.Cooldown.Ecosystems["npm"], "7d")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Cooldown.Ecosystems["cargo"] != "0" {
|
|
|
|
|
t.Errorf("Cooldown.Ecosystems[cargo] = %q, want %q", cfg.Cooldown.Ecosystems["cargo"], "0")
|
|
|
|
|
}
|
|
|
|
|
if cfg.Cooldown.Packages["pkg:npm/lodash"] != "0" {
|
|
|
|
|
t.Errorf("Cooldown.Packages[lodash] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/lodash"], "0")
|
|
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
}
|
2026-07-13 17:11:04 -07:00
|
|
|
if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" {
|
|
|
|
|
t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d")
|
|
|
|
|
}
|
2026-10-04 20:48:41 +01:00
|
|
|
if cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"] != "0" {
|
|
|
|
|
t.Errorf("Cooldown.PackagePatterns[example] = %q, want %q", cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"], "0")
|
|
|
|
|
}
|
2026-07-13 17:11:04 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
2026-03-04 19:00:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadCooldownFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_COOLDOWN_DEFAULT", "5d")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.Cooldown.Default != "5d" {
|
|
|
|
|
t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "5d")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func TestLoadFileNotFound(t *testing.T) {
|
|
|
|
|
_, err := Load("/nonexistent/config.yaml")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Error("expected error for nonexistent file")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 19:30:59 +01:00
|
|
|
|
2026-04-30 18:09:01 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
func TestParseMetadataTTL(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
ttl string
|
|
|
|
|
want time.Duration
|
|
|
|
|
}{
|
|
|
|
|
{"empty defaults to 5m", "", 5 * time.Minute},
|
|
|
|
|
{"explicit zero", "0", 0},
|
|
|
|
|
{"10 minutes", "10m", 10 * time.Minute},
|
|
|
|
|
{"1 hour", "1h", 1 * time.Hour},
|
|
|
|
|
{"invalid defaults to 5m", "not-a-duration", 5 * time.Minute},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataTTL = tt.ttl
|
|
|
|
|
got := cfg.ParseMetadataTTL()
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("ParseMetadataTTL() = %v, want %v", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 07:59:00 +01:00
|
|
|
func TestParseMetadataMaxSize(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
size string
|
|
|
|
|
want int64
|
|
|
|
|
}{
|
|
|
|
|
{"unset uses default", "", defaultMetadataMaxSize},
|
|
|
|
|
{"explicit value", "250MB", 250 << 20},
|
|
|
|
|
{"bytes", "1024", 1024},
|
|
|
|
|
{"invalid uses default", "lots", defaultMetadataMaxSize},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataMaxSize = tt.size
|
|
|
|
|
got := cfg.ParseMetadataMaxSize()
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("ParseMetadataMaxSize() = %d, want %d", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateMetadataMaxSize(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataMaxSize = "not-a-size"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for invalid metadata_max_size")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.MetadataMaxSize = "0"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for zero metadata_max_size")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.MetadataMaxSize = "250MB"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for valid metadata_max_size: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.MetadataMaxSize = ""
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for unset metadata_max_size: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
func TestValidateMetadataTTL(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataTTL = "invalid"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for invalid metadata_ttl")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.MetadataTTL = "5m"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for valid metadata_ttl: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.MetadataTTL = "0"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for zero metadata_ttl: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
func TestValidateHealthStorageProbeInterval(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Health.StorageProbeInterval = "not-a-duration"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for invalid health.storage_probe_interval")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Health.StorageProbeInterval = "30s"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for valid health.storage_probe_interval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Health.StorageProbeInterval = "0"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for zero health.storage_probe_interval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Health.StorageProbeInterval = ""
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for empty health.storage_probe_interval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Health.StorageProbeInterval = "-5s"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for negative health.storage_probe_interval")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 05:06:44 -04:00
|
|
|
func TestScanningConfigValidate(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
cfg ScanningConfig
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "disabled skips validation entirely",
|
|
|
|
|
cfg: ScanningConfig{Enabled: false, Timeout: "not-a-duration"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "enabled without signing key fails",
|
|
|
|
|
cfg: ScanningConfig{Enabled: true},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "enabled with signing key and no scanners fails",
|
|
|
|
|
cfg: ScanningConfig{Enabled: true, SigningKey: "s3cret"},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid fetch_base_url fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
FetchBaseURL: "not-a-url",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid timeout fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Timeout: "not-a-duration",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "zero timeout fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Timeout: "0",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty timeout defaults and is valid",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Timeout: "",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "scanner missing name fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Scanners: []ScannerConfig{{URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "scanner with invalid url fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "not-a-url"}},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "scanner with invalid mode fails",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Scanners: []ScannerConfig{
|
|
|
|
|
{Name: "clamav", URL: "http://scanner.invalid/scan", Mode: "quarantine"},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "scanner with default mode is valid",
|
|
|
|
|
cfg: ScanningConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
SigningKey: "s3cret",
|
|
|
|
|
Scanners: []ScannerConfig{{Name: "clamav", URL: "http://scanner.invalid/scan"}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
err := tt.cfg.Validate()
|
|
|
|
|
if tt.wantErr && err == nil {
|
|
|
|
|
t.Error("Validate() error = nil, want error")
|
|
|
|
|
}
|
|
|
|
|
if !tt.wantErr && err != nil {
|
|
|
|
|
t.Errorf("Validate() unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:48:36 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
func TestParseMetadataRewriteCacheSize(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
size string
|
|
|
|
|
want int64
|
|
|
|
|
}{
|
|
|
|
|
{"empty defaults to 256MB", "", 256 << 20},
|
|
|
|
|
{"explicit zero disables", "0", 0},
|
|
|
|
|
{"1GB", "1GB", 1 << 30},
|
|
|
|
|
{"invalid defaults to 256MB", "lots", 256 << 20},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataRewriteCacheSize = tt.size
|
|
|
|
|
if got := cfg.ParseMetadataRewriteCacheSize(); got != tt.want {
|
|
|
|
|
t.Errorf("ParseMetadataRewriteCacheSize() = %d, want %d", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateMetadataRewriteCacheSize(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.MetadataRewriteCacheSize = "lots"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for invalid metadata_rewrite_cache_size")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ok := range []string{"512MB", "0", ""} {
|
|
|
|
|
cfg.MetadataRewriteCacheSize = ok
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for metadata_rewrite_cache_size %q: %v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadMetadataRewriteCacheSizeFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_METADATA_REWRITE_CACHE_SIZE", "1GB")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.MetadataRewriteCacheSize != "1GB" {
|
|
|
|
|
t.Errorf("MetadataRewriteCacheSize = %q, want %q", cfg.MetadataRewriteCacheSize, "1GB")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:48:36 +01:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:09:04 -07:00
|
|
|
func TestParseHitFlushInterval(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
interval string
|
|
|
|
|
want time.Duration
|
|
|
|
|
}{
|
|
|
|
|
{"empty defaults to 1s", "", time.Second},
|
|
|
|
|
{"explicit zero disables", "0", 0},
|
|
|
|
|
{"10 seconds", "10s", 10 * time.Second},
|
|
|
|
|
{"invalid defaults to 1s", "not-a-duration", time.Second},
|
|
|
|
|
{"negative defaults to 1s", "-5s", time.Second},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Database.HitFlushInterval = tt.interval
|
|
|
|
|
got := cfg.ParseHitFlushInterval()
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("ParseHitFlushInterval() = %v, want %v", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateHitFlushInterval(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Database.HitFlushInterval = "not-a-duration"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for invalid hit_flush_interval")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Database.HitFlushInterval = "-5s"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for negative hit_flush_interval")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ok := range []string{"10s", "0", ""} {
|
|
|
|
|
cfg.Database.HitFlushInterval = ok
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for hit_flush_interval %q: %v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadHitFlushIntervalFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_DATABASE_HIT_FLUSH_INTERVAL", "10s")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.Database.HitFlushInterval != "10s" {
|
|
|
|
|
t.Errorf("HitFlushInterval = %q, want %q", cfg.Database.HitFlushInterval, "10s")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
func TestLoadMetadataTTLFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_METADATA_TTL", "10m")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.MetadataTTL != "10m" {
|
|
|
|
|
t.Errorf("MetadataTTL = %q, want %q", cfg.MetadataTTL, "10m")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-27 12:04:38 +01:00
|
|
|
|
2026-05-04 12:15:16 +02:00
|
|
|
func TestParseGradleBuildCacheConfig(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxUploadSize(); got != 100*1024*1024 {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxUploadSize() = %d, want %d", got, 100*1024*1024)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxAge(); got != 168*time.Hour {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxAge() = %v, want %v", got, 168*time.Hour)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxSize(); got != 0 {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxSize() = %d, want 0", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheSweepInterval(); got != 10*time.Minute {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheSweepInterval() = %v, want %v", got, 10*time.Minute)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Gradle.BuildCache.MaxUploadSize = "64MB"
|
|
|
|
|
cfg.Gradle.BuildCache.MaxAge = "48h"
|
|
|
|
|
cfg.Gradle.BuildCache.MaxSize = "2GB"
|
|
|
|
|
cfg.Gradle.BuildCache.SweepInterval = "20m"
|
|
|
|
|
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxUploadSize(); got != 64*1024*1024 {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxUploadSize() = %d, want %d", got, 64*1024*1024)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxAge(); got != 48*time.Hour {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxAge() = %v, want %v", got, 48*time.Hour)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheMaxSize(); got != 2*1024*1024*1024 {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheMaxSize() = %d, want %d", got, 2*1024*1024*1024)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.ParseGradleBuildCacheSweepInterval(); got != 20*time.Minute {
|
|
|
|
|
t.Errorf("ParseGradleBuildCacheSweepInterval() = %v, want %v", got, 20*time.Minute)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
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")
|
2026-04-27 12:14:37 +01:00
|
|
|
t.Setenv("PROXY_STORAGE_DIRECT_SERVE_BASE_URL", "https://cdn.example.com")
|
2026-04-27 12:04:38 +01:00
|
|
|
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")
|
|
|
|
|
}
|
2026-04-27 12:14:37 +01:00
|
|
|
if cfg.Storage.DirectServeBaseURL != "https://cdn.example.com" {
|
|
|
|
|
t.Errorf("Storage.DirectServeBaseURL = %q, want %q", cfg.Storage.DirectServeBaseURL, "https://cdn.example.com")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 16:12:43 +01:00
|
|
|
func TestValidateUIBaseURLDefaultsToBaseURL(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.BaseURL = "https://proxy.example.com"
|
|
|
|
|
cfg.UIBaseURL = ""
|
|
|
|
|
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.UIBaseURL != "https://proxy.example.com" {
|
|
|
|
|
t.Errorf("UIBaseURL = %q, want it to default to BaseURL %q", cfg.UIBaseURL, "https://proxy.example.com")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateUIBaseURL(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
|
|
|
|
|
cfg.UIBaseURL = "not a url"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for relative ui_base_url")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg = Default()
|
|
|
|
|
cfg.UIBaseURL = "://bad"
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Error("expected validation error for unparseable ui_base_url")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg = Default()
|
|
|
|
|
cfg.UIBaseURL = "https://ui.example.com/ui"
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for valid ui_base_url: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:14:37 +01:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-27 12:04:38 +01:00
|
|
|
}
|
2026-06-26 07:42:30 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-13 07:08:23 +01:00
|
|
|
|
|
|
|
|
func TestUpstreamAuthForURLMatchesURLComponents(t *testing.T) {
|
|
|
|
|
registryAuth := AuthConfig{Type: "bearer", Token: "registry-token"}
|
|
|
|
|
privateAuth := AuthConfig{Type: "bearer", Token: "private-token"}
|
|
|
|
|
config := UpstreamConfig{Auth: map[string]AuthConfig{
|
|
|
|
|
"https://registry.example.com": registryAuth,
|
|
|
|
|
"https://registry.example.com/private": privateAuth,
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
url string
|
|
|
|
|
wantToken string
|
|
|
|
|
}{
|
|
|
|
|
{name: "registry root", url: "https://registry.example.com/package", wantToken: "registry-token"},
|
|
|
|
|
{name: "host is case insensitive", url: "https://REGISTRY.EXAMPLE.COM/package", wantToken: "registry-token"},
|
|
|
|
|
{name: "longest path match", url: "https://registry.example.com/private/package", wantToken: "private-token"},
|
|
|
|
|
{name: "exact path match", url: "https://registry.example.com/private", wantToken: "private-token"},
|
|
|
|
|
{name: "path segment boundary", url: "https://registry.example.com/private-other/package", wantToken: "registry-token"},
|
|
|
|
|
{name: "lookalike host rejected", url: "https://registry.example.com.evil.test/package"},
|
|
|
|
|
{name: "different scheme rejected", url: "http://registry.example.com/package"},
|
|
|
|
|
{name: "different port rejected", url: "https://registry.example.com:8443/package"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
auth := config.AuthForURL(tt.url)
|
|
|
|
|
if tt.wantToken == "" {
|
|
|
|
|
if auth != nil {
|
|
|
|
|
t.Fatalf("AuthForURL() = %+v, want nil", auth)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if auth == nil {
|
|
|
|
|
t.Fatal("AuthForURL() = nil, want authentication")
|
|
|
|
|
}
|
|
|
|
|
if auth.Token != tt.wantToken {
|
|
|
|
|
t.Errorf("token = %q, want %q", auth.Token, tt.wantToken)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateUpstreamAuthURLs(t *testing.T) {
|
|
|
|
|
t.Run("valid absolute URL", func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Upstream.Auth = map[string]AuthConfig{
|
|
|
|
|
"https://registry.example.com/private": {Type: "bearer", Token: "token"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Fatalf("Validate() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("invalid URL", func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Upstream.Auth = map[string]AuthConfig{
|
|
|
|
|
"registry.example.com": {Type: "bearer", Token: "token"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("Validate() error = nil, want invalid upstream.auth URL error")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "upstream.auth") || !strings.Contains(err.Error(), "registry.example.com") {
|
|
|
|
|
t.Errorf("Validate() error = %q, want field and URL", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-08-16 22:42:55 +05:30
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
func TestValidatePackageRoutes(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
modify func(*Config)
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "valid Composer and npm routes",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.ComposerRoutes = map[string]string{"acme/*": "https://composer.example.com/packages"}
|
|
|
|
|
cfg.Upstream.NPMRoutes = map[string]string{"@acme/*": "https://npm.example.com/registry"}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Composer route URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.ComposerRoutes = map[string]string{"acme/*": "composer.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "npm route URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.NPMRoutes = map[string]string{"@acme/*": "npm.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Composer route pattern is malformed",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.ComposerRoutes = map[string]string{"acme/[": "https://composer.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "npm route pattern is empty",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.NPMRoutes = map[string]string{"": "https://npm.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
tt.modify(cfg)
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 22:42:55 +05:30
|
|
|
func TestValidateNamedUpstreams(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
modify func(*Config)
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
2026-10-11 23:41:16 +02:00
|
|
|
name: "valid Helm, OCI, APK, Arch, and generic upstreams",
|
2026-08-16 22:42:55 +05:30
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Helm = map[string]string{"bitnami": "https://charts.bitnami.com/bitnami"}
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://ghcr.io"}
|
Add Alpine APK repository proxy support (#293)
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-02 13:36:00 +02:00
|
|
|
cfg.Upstream.APK = map[string]string{"alpine": "https://dl-cdn.alpinelinux.org/alpine"}
|
2026-10-11 23:41:16 +02:00
|
|
|
cfg.Upstream.Arch = map[string]string{
|
|
|
|
|
"archlinux": "https://geo.mirror.pkgbuild.com",
|
|
|
|
|
"archlinuxarm": "https://de3.mirror.archlinuxarm.org",
|
|
|
|
|
}
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
cfg.Upstream.Generic = map[string]string{
|
|
|
|
|
"github": "https://github.com",
|
|
|
|
|
"github-api": "https://api.github.com",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "generic upstream name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"github/releases": "https://github.com"}
|
2026-08-16 22:42:55 +05:30
|
|
|
},
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-10-12 09:12:16 +01:00
|
|
|
{
|
|
|
|
|
name: "generic artifact patterns",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"apache": "https://archive.apache.org"}
|
|
|
|
|
cfg.Upstream.GenericArtifacts = map[string][]string{
|
|
|
|
|
"apache": {`dist/(?P<name>maven)/maven-3/(?P<version>[^/]+)/binaries/(?P<file>[^/]+)`},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "generic artifact patterns for an unknown upstream",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.GenericArtifacts = map[string][]string{
|
|
|
|
|
"apache": {`(?P<version>[^/]+)/(?P<file>[^/]+)`},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "generic artifact pattern does not compile",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"apache": "https://archive.apache.org"}
|
|
|
|
|
cfg.Upstream.GenericArtifacts = map[string][]string{"apache": {`(?P<version>[^/]+/(?P<file>[^/]+)`}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "generic artifact pattern without a file group",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"apache": "https://archive.apache.org"}
|
|
|
|
|
cfg.Upstream.GenericArtifacts = map[string][]string{"apache": {`(?P<version>[^/]+)/[^/]+`}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
{
|
|
|
|
|
name: "generic upstream URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"github": "github.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
2026-08-16 22:42:55 +05:30
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Helm upstream name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Helm = map[string]string{"team/charts": "https://charts.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
Route containerd ns mirror requests to configured registries (#405)
* Route containerd ns mirror requests to configured registries
containerd's hosts.toml mirrors append ?ns=<registry-host> to every
request. The container handler ignored it, so a single _default mirror
entry could not serve more than one registry.
ns is a closed-world lookup key and is never dialed. Docker Hub aliases
and the host of upstream.oci_default select the default route, hosts of
upstream.oci entries select their named upstream. Unknown hosts return
NAME_UNKNOWN so containerd falls back to its next host. Registry URLs
with a path are not indexed, because ns names the registry at the root
of a host. Host collisions log a warning and resolve deterministically.
With ns the path is the verbatim upstream repository, the reserved
upstream/ prefix is rejected, and repository prefix routes such as
Homebrew's do not apply. Cache names match the unprefixed and
upstream/{name}/ routes, so all routes share cached blobs, manifests and
tag lists. ns is no longer forwarded on tag-list requests, and the
pagination Link is rewritten per request instead of at store time so
clients on different routes get links for their own route.
Refs #303
* Document containerd ns mirroring
Refs #303
* Version the tag-list cache identity for the raw-link format
Tag-list rows now store the upstream Link verbatim and rewrite it per
request. Rows written by earlier versions hold a Link already rewritten
for one route. Under the unchanged key an older binary running next to
this one (rolling update on shared Postgres, or a rollback) would serve
the raw Link unrewritten, sending clients to the wrong route for the
next page. A format marker in the key keeps the two apart at the cost of
one cache miss per tag list after the upgrade.
Adversarial review finding F1: tag-list cache rows hold raw upstream
Links under unchanged keys, so an older binary serves them unrewritten.
* Qualify the README claim about ns mirror coverage
Registries whose URL has a path and hosts shared by two entries are not
reachable through ns; point to the configuration guide for both rules.
Adversarial review finding F3: README says ns covers every upstream.oci
registry.
* Redact registry URLs in ns index warnings
Configured registry URLs may carry userinfo credentials, which the new
startup warnings wrote to the log verbatim. Log them with the password
masked, and say precisely what a path-prefixed default registry means:
its host is not indexed, while the Docker Hub aliases still select it.
Adversarial review finding F2: startup warnings print registry URLs
unredacted and the default-registry message is misleading.
* Keep non-default ports apart in the ns index
Dropping 80 and 443 regardless of scheme turned https://host:80 and
https://host into the same key, so a request for one of them could land
on the other registry. Only the default port of the URL's scheme is
optional now. A configured host is indexed with and without that port,
because image references spell it either way; any other port has to
match exactly, and the ns value is used as containerd sends it, apart
from case and IPv6 brackets.
Since a URL now yields two keys that can belong to different routes,
the collision warning lists the owner per key.
The handler test runs two registries on one host that differ only in
the port. Tests can't bind 80 or 443, so a dialer maps those addresses
to the fake servers.
* Allow upstream/{name}/ together with ns when they agree
containerd sends ns on every request to a mirror host, override_path or
not. Per-registry mirrors pointing at /v2/upstream/{name} therefore
arrive as upstream/{name}/...?ns=<registry>, and refusing every prefixed
name under ns broke them, including the only way to mirror an upstream
whose URL has a path.
Such requests are now handled like the prefix route without ns, as long
as ns names that upstream's own host under the same port rules as the
index. Any other ns on a prefixed name is still NAME_UNKNOWN, so ns
can't be used to create cache entries under another registry's name.
* README: containerd config_path and how to check it
A _default/hosts.toml does nothing while CRI has no hosts directory
configured, so add the config.toml snippet for containerd 2.x and 1.x.
To check the setup, look at containerd config dump and pull with
crictl, then find the request in the proxy log; ctr --hosts-dir reads
the directory on its own and would succeed even without config_path.
Also mention that existing per-registry override_path entries keep
working and that k3s generates the directory itself.
* Let the prefix route take ns for aliases and mirrored registries
With upstream.oci.hub pointing at registry-1.docker.io and a docker.io
hosts.toml mirror on /v2/upstream/hub, containerd sends ns=docker.io,
and the prefix check only accepted the upstream's own host. The same
happens whenever the upstream is a mirror of the registry the nodes
pull from, say an Artifactory remote for ghcr.io: ns carries ghcr.io,
the upstream host is something else, and the pull got a 404.
The prefix already decides where the content comes from and which
cache entries are used, so ns can't redirect anything there. The check
now only refuses an ns that contradicts the prefix, meaning a host the
proxy knows that belongs to another route. The Docker Hub aliases count
as one host, and a host the proxy doesn't know at all is accepted,
since a client can only arrive at the prefix through its own mirror
entry.
* Take docker.io on every prefix route
A Docker Hub mirror configured as a named upstream (mirror.gcr.io, an
Artifactory remote) with a docker.io hosts.toml pointing at
/v2/upstream/hub got a 404 again: docker.io is always in the index for
the default route, so the check saw a known host of another route. That
setup worked before this branch. Docker Hub repository names have
exactly two path components, so docker.io/upstream/... can never be a
real image, and accepting the aliases on every prefix route shadows
nothing.
While here: the collision warning claimed ns requests go to the other
route, which is only true without the prefix, and the README said pulls
for unknown registries always get a 404, which isn't the case for paths
under upstream/{name}/.
* Only take a foreign ns on a prefix route when it is configured
A containerd _default mirror sends the same request for a pull of
unconfigured.example/upstream/mirror/owner/app as a ghcr.io hosts.toml
with override_path does for a mirror upstream. Accepting any host the
proxy didn't know therefore let such a pull be answered with owner/app
from the mirror upstream, a different image, instead of a 404 that lets
containerd go to the registry the image actually names.
The prefix route now accepts ns only for the upstream's own host, for
Docker Hub, and for hosts listed in the new upstream.oci_mirrors setting.
That setting is how an Artifactory remote of ghcr.io keeps working:
upstream:
oci_mirrors:
ghcr: ["ghcr.io"]
The listed hosts also select the upstream for unprefixed ns requests,
which makes a mirror with a path in its URL reachable through the
_default entry as well. Hosts from configured URLs still win over
mirror entries; the proxy warns about such overlaps at startup.
* Fix the docs sentence on which ns a prefix route takes
configuration.md still said a prefix request with ns is only refused
when ns names another route's host. Since the last commit it is the
other way round: only the upstream's own host, its oci_mirrors entries
and Docker Hub get through.
* Note what a host accepted on a prefix route gives up
Listing ghcr.io for an upstream (or the upstream's own host) still has
the ambiguity from the wildcard case: ghcr.io/upstream/ghcr/owner/app
lands on that upstream. That is the price of an explicit entry and
fine in practice, but it should be written down.
* Test that oci_mirrors entries stay with their own upstream
Two gaps in the coverage of the last change: nothing failed if a host
listed for one upstream was also accepted on another upstream's prefix,
and nothing checked that the server actually hands upstream.oci_mirrors
to the container handler. The handler test now has a second upstream
that must refuse ghcr.io, and a small server test pulls through a
mirror entry on both the prefix and the unprefixed route.
* Log when a prefix route refuses an ns
Setups with a ghcr.io hosts.toml pointing at a mirror upstream worked
before this branch and now need an oci_mirrors entry. Without one,
containerd gets a 404, quietly pulls from ghcr.io directly, and nobody
notices the proxy is being skipped. The refusal is now logged as a
warning that names the upstream and the ns and points at
upstream.oci_mirrors.
* Drop a check in validateOCIMirrors that could never fire
user@ghcr.io parses with ghcr.io as its host, so the parsed.Host != host
comparison already rejects it and the extra parsed.User test was dead.
A test case for a host with credentials keeps that path covered.
* Reject an oci_mirrors host that is listed for two upstreams
With ghcr.io under both art and nexus, both prefix routes took ns=ghcr.io
while unprefixed requests went to whichever name sorted first, with
nothing but a startup warning. Config validation now refuses the second
entry, comparing hosts without case and without the default :443.
* Log each refused ns once per upstream and refuse overlong values
The refusal warning fired on every request, and a node with a missing
oci_mirrors entry asks for the manifest and each layer, so the same line
piled up. Each upstream/ns pair is now logged once, for up to 1024
pairs; after that every refusal is logged again.
ns comes straight from the client and can be as long as the request
line allows, so remembering it as is would let anyone pin a lot of
memory with a handful of requests. No registry host is longer than 259
characters (a 253-character name plus a port), so anything longer is
refused right away, before it is logged or remembered.
* Compare oci_mirrors hosts the way the handler does
The duplicate check lowercased the host and trimmed :443, while the
handler splits host and port, brackets IPv6 literals and drops an empty
port. So ghcr.io and ghcr.io:, or [::1] and [::1]:, passed as different
hosts in two upstreams and then collided in the handler. The check now
builds the same key the handler uses.
* Rewrap the oci_mirrors paragraphs in the configuration docs
Two sentences had been dropped into the middle of wrapped lines, and the
prefix-route paragraph named Docker Hub twice in a row. Same content,
shorter lines, one mention.
* Stop logging ns refusals once the set is full
Past 1024 different upstream/ns pairs every refusal was logged again,
so anyone could fill the set with made-up hosts and then flood the log
at warning level. When the set is full the proxy now says so once and
keeps quiet after that. A real misconfiguration shows up long before
that, with the first pull.
* Test the limit on remembered ns refusals
Nothing covered what happens once 1024 pairs are remembered. The new
test fills the set, then sends a known pair and two new ones: the proxy
must log the limit notice exactly once, log none of the refusals and
keep the set at its size.
2026-10-10 08:35:50 +02:00
|
|
|
{
|
|
|
|
|
name: "OCI mirrors for a configured upstream",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://artifactory.example/api/docker/ghcr-remote"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"ghcr": {"ghcr.io", "registry.example:5000"}}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirrors for an unknown upstream",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://ghcr.io"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"quay": {"quay.io"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror given as URL",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://mirror.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"ghcr": {"https://ghcr.io"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror with a path",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://mirror.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"ghcr": {"ghcr.io/owner"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror host with credentials",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://mirror.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"ghcr": {"user@ghcr.io"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror host listed for two upstreams",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"art": "https://art.example", "nexus": "https://nexus.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"art": {"ghcr.io"}, "nexus": {"GHCR.io:443"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror host listed for two upstreams with an empty port",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"art": "https://art.example", "nexus": "https://nexus.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"art": {"ghcr.io"}, "nexus": {"ghcr.io:"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror IPv6 host listed for two upstreams",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"art": "https://art.example", "nexus": "https://nexus.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"art": {"[::1]"}, "nexus": {"[::1]:"}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror hosts that differ only in a non-default port",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"art": "https://art.example", "nexus": "https://nexus.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"art": {"registry.example"}, "nexus": {"registry.example:5000"}}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OCI mirror host is empty",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"ghcr": "https://mirror.example"}
|
|
|
|
|
cfg.Upstream.OCIMirrors = map[string][]string{"ghcr": {""}}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-08-16 22:42:55 +05:30
|
|
|
{
|
|
|
|
|
name: "OCI upstream URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.OCI = map[string]string{"private": "registry.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
Add Alpine APK repository proxy support (#293)
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-02 13:36:00 +02:00
|
|
|
{
|
|
|
|
|
name: "APK upstream name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.APK = map[string]string{"alpine/edge": "https://dl-cdn.alpinelinux.org/alpine"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "APK upstream URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.APK = map[string]string{"private": "apk.example.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-10-11 23:41:16 +02:00
|
|
|
{
|
|
|
|
|
name: "Arch upstream name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Arch = map[string]string{"arch/linux": "https://geo.mirror.pkgbuild.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Arch upstream URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.Arch = map[string]string{"archlinux": "geo.mirror.pkgbuild.com"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
feat(debian): serve additional APT archives at /debian/{name}/
A Debian release is served by more than one archive. Security updates live on
a different host than the main archive, so a single upstream.debian URL cannot
serve a complete suite set and -security suites are unreachable.
Add upstream.debian_repositories, a name-to-URL map served at /debian/{name}/,
modelled on upstream.apk. The field is additive: upstream.debian keeps serving
/debian/pool/... and /debian/dists/... with unchanged cache identities, so
existing deployments and their warm caches are unaffected, and the scalar
field and PROXY_UPSTREAM_DEBIAN are untouched.
Named repositories scope both caches by name, since the same filename can hold
different bytes in different archives. Metadata keys are hashed over name,
upstream URL, and path, as APKHandler does. Names are validated through
validateNamedUpstreams, and "pool" and "dists" are refused because they would
shadow the main archive's own paths.
An unconfigured first path segment stays a main-archive path rather than
returning 404 as the APK handler does: the main archive is unnamed and serves
paths of its own at the root.
2026-09-14 15:41:13 -07:00
|
|
|
{
|
|
|
|
|
name: "valid Debian repository",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{
|
|
|
|
|
"security": "https://security.debian.org/debian-security",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Debian repository name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{
|
|
|
|
|
"debian/security": "https://security.debian.org/debian-security",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Debian repository name is a relative path element",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{
|
|
|
|
|
".": "https://security.debian.org/debian-security",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Debian repository URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{"security": "security.debian.org"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Debian repository shadows the pool prefix",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{"pool": "https://security.debian.org"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Debian repository shadows the dists prefix",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.DebianRepositories = map[string]string{"dists": "https://security.debian.org"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
tt.modify(cfg)
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
|
2026-10-10 09:57:43 +02:00
|
|
|
func TestValidateGoSumDBs(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
modify func(*Config)
|
|
|
|
|
wantErr bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "valid Go checksum database",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.GoSumDBs = map[string]string{"sum.golang.org": "https://sum.golang.org"}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Go checksum database name contains path separator",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.GoSumDBs = map[string]string{"sum.golang.org/x": "https://sum.golang.org"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Go checksum database URL is not absolute",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.Upstream.GoSumDBs = map[string]string{"sum.golang.org": "sum.golang.org"}
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
Add upstream.go_sumdbs_offline for networks where only the proxy has egress
On a build network where clients cannot reach sum.golang.org at all, the
proxy should keep answering for the database during an outage, since a 404
on supported would only send the go command somewhere it cannot go. With
the switch set, a cached tree of any age is enough; without it, the tree
has to be fresh, as before.
That does not make every cached module verify. The go command checks a
lookup against tiles of the tree that came with it, and sum.golang.org
attaches a different tree to almost every lookup, so it can ask for a tile
nobody fetched through the proxy before. configuration.md says so.
While the database is known to be unreachable, offline mode also serves
cached lookups and tiles straight away. Otherwise each of them would sit
through the upstream timeout before the fetch falls back to the cache,
and a build would take minutes for as long as the outage lasts. A lookup
or tile that falls back to the cache renews what the proxy knows about
the database, unless a probe has reached it since the request started.
The go command asks supported only once per process, so a long build would
otherwise be back to waiting a minute into the outage. recordTimeout is
now recordFailure, since lookups use it too.
The option needs cache_metadata, since without it there is no cached tree
to fall back on, and the config refuses the combination.
2026-10-10 17:55:04 +02:00
|
|
|
{
|
|
|
|
|
name: "Go checksum database offline mode requires metadata caching",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.CacheMetadata = false
|
|
|
|
|
cfg.Upstream.GoSumDBsOffline = true
|
|
|
|
|
},
|
|
|
|
|
wantErr: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Go checksum database offline mode with metadata caching",
|
|
|
|
|
modify: func(cfg *Config) {
|
|
|
|
|
cfg.CacheMetadata = true
|
|
|
|
|
cfg.Upstream.GoSumDBsOffline = true
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-10-10 09:57:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
tt.modify(cfg)
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
func TestValidateCacheArtifactsDisabled(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
modify func(*Config)
|
|
|
|
|
wantErr string
|
|
|
|
|
}{
|
|
|
|
|
{"alone", func(*Config) {}, ""},
|
|
|
|
|
{"with scanning", func(c *Config) { c.Scanning.Enabled = true }, "scanning.enabled"},
|
|
|
|
|
{"with direct_serve", func(c *Config) { c.Storage.DirectServe = true }, "storage.direct_serve"},
|
|
|
|
|
{"with mirror_api", func(c *Config) { c.MirrorAPI = true }, "mirror_api"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.Storage.CacheArtifacts = false
|
|
|
|
|
tt.modify(cfg)
|
|
|
|
|
err := cfg.Validate()
|
|
|
|
|
if tt.wantErr == "" {
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
|
|
|
t.Fatalf("Validate() = %v, want an error mentioning %q", err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCacheArtifactsDefaultsToTrue(t *testing.T) {
|
|
|
|
|
if !Default().Storage.CacheArtifacts {
|
|
|
|
|
t.Fatal("Default().Storage.CacheArtifacts = false, want true")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
if err := os.WriteFile(path, []byte("storage:\n url: \"file:///tmp/cache\"\n"), 0o600); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
cfg, err := Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Storage.CacheArtifacts {
|
|
|
|
|
t.Error("a config file without storage.cache_artifacts disabled artifact caching")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadCacheArtifactsFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_STORAGE_CACHE_ARTIFACTS", "false")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.Storage.CacheArtifacts {
|
|
|
|
|
t.Error("Storage.CacheArtifacts should be false")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-14 08:39:12 +02:00
|
|
|
|
|
|
|
|
func TestParseArtifactMaxSize(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
size string
|
|
|
|
|
want int64
|
|
|
|
|
}{
|
|
|
|
|
{"unset is unlimited", "", 0},
|
|
|
|
|
{"zero is unlimited", "0", 0},
|
|
|
|
|
{"explicit value", "2GB", 2 << 30},
|
|
|
|
|
{"bytes", "1024", 1024},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.ArtifactMaxSize = tt.size
|
|
|
|
|
if got := cfg.ParseArtifactMaxSize(); got != tt.want {
|
|
|
|
|
t.Errorf("ParseArtifactMaxSize() = %d, want %d", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestValidateArtifactMaxSize(t *testing.T) {
|
|
|
|
|
for _, invalid := range []string{"not-a-size", "-1"} {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.ArtifactMaxSize = invalid
|
|
|
|
|
if err := cfg.Validate(); err == nil {
|
|
|
|
|
t.Errorf("expected validation error for artifact_max_size %q", invalid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, valid := range []string{"", "0", "2GB"} {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
cfg.ArtifactMaxSize = valid
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
t.Errorf("unexpected error for artifact_max_size %q: %v", valid, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadArtifactMaxSizeFromEnv(t *testing.T) {
|
|
|
|
|
cfg := Default()
|
|
|
|
|
t.Setenv("PROXY_ARTIFACT_MAX_SIZE", "2GB")
|
|
|
|
|
cfg.LoadFromEnv()
|
|
|
|
|
|
|
|
|
|
if cfg.ArtifactMaxSize != "2GB" {
|
|
|
|
|
t.Errorf("ArtifactMaxSize = %q, want %q", cfg.ArtifactMaxSize, "2GB")
|
|
|
|
|
}
|
|
|
|
|
}
|