diff --git a/README.md b/README.md index 411f25c..22beaaf 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Resolution order: package override, then ecosystem override, then global default | pub.dev | Dart | Yes | ✓ | | PyPI | Python | Yes | ✓ | | Maven | Java | | ✓ | +| Gradle Build Cache | Java/Kotlin | | ✓ | | NuGet | .NET | Yes | ✓ | | Composer | PHP | Yes | ✓ | | Conan | C/C++ | | ✓ | @@ -208,6 +209,22 @@ Add to your `~/.m2/settings.xml`: ``` +### Gradle HTTP Build Cache + +Configure in `settings.gradle(.kts)`: + +```kotlin +buildCache { + local { + enabled = false + } + remote { + url = uri("http://localhost:8080/gradle/") + push = true + } +} +``` + ### NuGet Configure in `nuget.config`: diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 0268e9e..946d12a 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -72,6 +72,11 @@ // PROXY_DATABASE_URL - PostgreSQL connection URL // PROXY_LOG_LEVEL - Log level // PROXY_LOG_FORMAT - Log format +// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads +// PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE - Max Gradle PUT request body size +// PROXY_GRADLE_BUILD_CACHE_MAX_AGE - Gradle cache max age eviction +// PROXY_GRADLE_BUILD_CACHE_MAX_SIZE - Gradle cache max total size +// PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL - Gradle cache eviction sweep interval // // Example: // @@ -193,6 +198,11 @@ func runServe() { fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n") fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n") fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL Gradle cache eviction sweep interval\n") } _ = fs.Parse(os.Args[1:]) diff --git a/config.example.yaml b/config.example.yaml index 8f37450..4505849 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -108,6 +108,26 @@ upstream: # header_name: "X-Auth-Token" # header_value: "${MAVEN_TOKEN}" +# Gradle HttpBuildCache configuration +gradle: + build_cache: + # Set to true to disable PUT uploads (read-only cache mode) + read_only: false + + # Maximum accepted Gradle cache upload body size + # Required and must be > 0 + max_upload_size: "100MB" + + # Evict entries older than this age (set to "0" to disable age-based eviction) + max_age: "168h" + + # Cap total Gradle cache size; oldest entries are deleted first + # ("0" disables size-based eviction) + # max_size: "20GB" + + # How often eviction runs when max_age or max_size is set + sweep_interval: "10m" + # Version cooldown configuration # Hides package versions published too recently, giving the community time # to spot malicious releases before they're pulled into projects. diff --git a/docs/configuration.md b/docs/configuration.md index be196de..ac85d54 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -184,6 +184,30 @@ upstream: token: "${PRIVATE_TOKEN}" ``` +## Gradle Build Cache + +The `/gradle` endpoint supports optional safeguards for upload control and cache retention. + +```yaml +gradle: + build_cache: + read_only: false + max_upload_size: "100MB" + max_age: "168h" + max_size: "20GB" + sweep_interval: "10m" +``` + +| Config | Environment | Description | +|--------|-------------|-------------| +| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Disable PUT uploads and keep GET/HEAD read-only | +| `gradle.build_cache.max_upload_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE` | Maximum accepted PUT body size (must be > 0) | +| `gradle.build_cache.max_age` | `PROXY_GRADLE_BUILD_CACHE_MAX_AGE` | Delete entries older than this duration (default `168h`, set `0` to disable) | +| `gradle.build_cache.max_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_SIZE` | Total size cap for `_gradle/http-build-cache`, deleting oldest first (`0` disables) | +| `gradle.build_cache.sweep_interval` | `PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL` | Frequency for background eviction sweeps | + +`max_age` and `max_size` are independent and can be combined. When both are set, age-based eviction runs first, then size-based eviction trims remaining entries oldest-first. + ## Cooldown The cooldown feature hides package versions published too recently, giving the community time to spot malicious releases before they reach your projects. When a version is within its cooldown period, it's stripped from metadata responses so package managers won't install it. diff --git a/internal/config/config.go b/internal/config/config.go index c69e462..b728f68 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,6 +99,9 @@ type Config struct { // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP. // Disabled by default to prevent unauthenticated users from triggering downloads. MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"` + + // Gradle configures Gradle HttpBuildCache behavior. + Gradle GradleConfig `json:"gradle" yaml:"gradle"` } // CooldownConfig configures version cooldown periods. @@ -151,6 +154,34 @@ type StorageConfig struct { DirectServeBaseURL string `json:"direct_serve_base_url" yaml:"direct_serve_base_url"` } +// GradleConfig configures Gradle-specific features. +type GradleConfig struct { + // BuildCache configures the /gradle HttpBuildCache endpoint. + BuildCache GradleBuildCacheConfig `json:"build_cache" yaml:"build_cache"` +} + +// GradleBuildCacheConfig configures Gradle HttpBuildCache safeguards. +type GradleBuildCacheConfig struct { + // ReadOnly disables PUT uploads and keeps cache reads (GET/HEAD) enabled. + ReadOnly bool `json:"read_only" yaml:"read_only"` + + // MaxUploadSize caps a single PUT body size (e.g., "100MB"). Must be > 0. + // Default: "100MB". + MaxUploadSize string `json:"max_upload_size" yaml:"max_upload_size"` + + // MaxAge evicts entries older than this duration (e.g., "24h", "7d"). + // Empty or "0" disables age-based eviction. + MaxAge string `json:"max_age" yaml:"max_age"` + + // MaxSize evicts oldest entries until total Gradle cache size is <= MaxSize. + // Empty or "0" disables size-based eviction. + MaxSize string `json:"max_size" yaml:"max_size"` + + // SweepInterval controls periodic eviction frequency. + // Default: "10m". + SweepInterval string `json:"sweep_interval" yaml:"sweep_interval"` +} + // DatabaseConfig configures the cache database. type DatabaseConfig struct { // Driver is the database driver: "sqlite" or "postgres". @@ -260,6 +291,15 @@ func Default() *Config { Cargo: "https://index.crates.io", CargoDownload: "https://static.crates.io/crates", }, + Gradle: GradleConfig{ + BuildCache: GradleBuildCacheConfig{ + ReadOnly: false, + MaxUploadSize: "100MB", + MaxAge: "168h", + MaxSize: "", + SweepInterval: "10m", + }, + }, } } @@ -355,6 +395,21 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_METADATA_TTL"); v != "" { c.MetadataTTL = v } + if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" { + c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1" + } + if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE"); v != "" { + c.Gradle.BuildCache.MaxUploadSize = v + } + if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE"); v != "" { + c.Gradle.BuildCache.MaxAge = v + } + if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE"); v != "" { + c.Gradle.BuildCache.MaxSize = v + } + if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL"); v != "" { + c.Gradle.BuildCache.SweepInterval = v + } } // Validate checks the configuration for errors. @@ -426,9 +481,48 @@ func (c *Config) Validate() error { } } + // Validate Gradle build cache upload size (always required and must be > 0). + if c.Gradle.BuildCache.MaxUploadSize == "" { + c.Gradle.BuildCache.MaxUploadSize = "100MB" + } + uploadSize, err := ParseSize(c.Gradle.BuildCache.MaxUploadSize) + if err != nil { + return fmt.Errorf("invalid gradle.build_cache.max_upload_size: %w", err) + } + if uploadSize <= 0 { + return fmt.Errorf("invalid gradle.build_cache.max_upload_size %q: must be > 0", c.Gradle.BuildCache.MaxUploadSize) + } + + // Validate Gradle max age if specified. + if c.Gradle.BuildCache.MaxAge != "" && c.Gradle.BuildCache.MaxAge != "0" { + if _, err := time.ParseDuration(c.Gradle.BuildCache.MaxAge); err != nil { + return fmt.Errorf("invalid gradle.build_cache.max_age %q: %w", c.Gradle.BuildCache.MaxAge, err) + } + } + + // Validate Gradle max size if specified. + if c.Gradle.BuildCache.MaxSize != "" { + if _, err := ParseSize(c.Gradle.BuildCache.MaxSize); err != nil { + return fmt.Errorf("invalid gradle.build_cache.max_size: %w", err) + } + } + + // Validate Gradle sweep interval if specified. + if c.Gradle.BuildCache.SweepInterval != "" { + d, err := time.ParseDuration(c.Gradle.BuildCache.SweepInterval) + if err != nil { + return fmt.Errorf("invalid gradle.build_cache.sweep_interval %q: %w", c.Gradle.BuildCache.SweepInterval, err) + } + if d <= 0 { + return fmt.Errorf("invalid gradle.build_cache.sweep_interval %q: must be > 0", c.Gradle.BuildCache.SweepInterval) + } + } + return nil } +const defaultGradleBuildCacheMaxUploadSize = 100 << 20 +const defaultGradleBuildCacheSweepInterval = 10 * time.Minute const ( defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default @@ -463,6 +557,58 @@ func (c *Config) ParseMetadataTTL() time.Duration { return d } +// ParseGradleBuildCacheMaxUploadSize returns the max accepted PUT body size. +// Defaults to 100MB if unset or invalid. +func (c *Config) ParseGradleBuildCacheMaxUploadSize() int64 { + if c.Gradle.BuildCache.MaxUploadSize == "" { + return defaultGradleBuildCacheMaxUploadSize + } + size, err := ParseSize(c.Gradle.BuildCache.MaxUploadSize) + if err != nil || size <= 0 { + return defaultGradleBuildCacheMaxUploadSize + } + return size +} + +// ParseGradleBuildCacheMaxAge returns age-based eviction threshold. +// Returns 0 when disabled or invalid. +func (c *Config) ParseGradleBuildCacheMaxAge() time.Duration { + if c.Gradle.BuildCache.MaxAge == "" || c.Gradle.BuildCache.MaxAge == "0" { + return 0 + } + d, err := time.ParseDuration(c.Gradle.BuildCache.MaxAge) + if err != nil || d <= 0 { + return 0 + } + return d +} + +// ParseGradleBuildCacheMaxSize returns total-size cap in bytes. +// Returns 0 when disabled or invalid. +func (c *Config) ParseGradleBuildCacheMaxSize() int64 { + if c.Gradle.BuildCache.MaxSize == "" || c.Gradle.BuildCache.MaxSize == "0" { + return 0 + } + size, err := ParseSize(c.Gradle.BuildCache.MaxSize) + if err != nil || size <= 0 { + return 0 + } + return size +} + +// ParseGradleBuildCacheSweepInterval returns eviction sweep cadence. +// Defaults to 10m if unset or invalid. +func (c *Config) ParseGradleBuildCacheSweepInterval() time.Duration { + if c.Gradle.BuildCache.SweepInterval == "" { + return defaultGradleBuildCacheSweepInterval + } + d, err := time.ParseDuration(c.Gradle.BuildCache.SweepInterval) + if err != nil || d <= 0 { + return defaultGradleBuildCacheSweepInterval + } + return d +} + // ParseDirectServeTTL returns the presigned URL expiry duration. // Returns 15 minutes if unset. func (c *Config) ParseDirectServeTTL() time.Duration { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index de10191..26a0fc6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,6 +25,12 @@ func TestDefault(t *testing.T) { if cfg.Database.Path == "" { t.Error("Database.Path should not be empty") } + 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") + } } func TestValidate(t *testing.T) { @@ -98,6 +104,41 @@ func TestValidate(t *testing.T) { modify: func(c *Config) { c.Storage.MaxSize = "10GB" }, wantErr: false, }, + { + 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, + }, } for _, tt := range tests { @@ -223,6 +264,11 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_BASE_URL", "https://env.example.com") t.Setenv("PROXY_STORAGE_PATH", "/env/cache") t.Setenv("PROXY_LOG_LEVEL", testLevelDebug) + 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") cfg.LoadFromEnv() @@ -238,6 +284,21 @@ func TestLoadFromEnv(t *testing.T) { if cfg.Log.Level != testLevelDebug { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug) } + 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") + } } func TestLoadCooldownConfig(t *testing.T) { @@ -381,6 +442,41 @@ func TestLoadMetadataTTLFromEnv(t *testing.T) { } } +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) + } +} + func TestParseDirectServeTTL(t *testing.T) { tests := []struct { name string diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go new file mode 100644 index 0000000..41c9f76 --- /dev/null +++ b/internal/handler/gradle.go @@ -0,0 +1,158 @@ +package handler + +import ( + "errors" + "io" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/git-pkgs/proxy/internal/storage" +) + +const ( + gradleBuildCacheContentType = "application/vnd.gradle.build-cache-artifact.v2" + gradleBuildCacheStorageRoot = "_gradle/http-build-cache" + defaultGradleMaxUploadSize = 100 << 20 +) + +var gradleBuildCacheKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// GradleBuildCacheHandler handles Gradle HttpBuildCache GET/HEAD/PUT requests. +// +// This handler accepts /{key} when mounted under a base URL. +type GradleBuildCacheHandler struct { + proxy *Proxy +} + +// NewGradleBuildCacheHandler creates a Gradle HttpBuildCache handler. +func NewGradleBuildCacheHandler(proxy *Proxy) *GradleBuildCacheHandler { + return &GradleBuildCacheHandler{proxy: proxy} +} + +// Routes returns the HTTP handler for Gradle HttpBuildCache requests. +func (h *GradleBuildCacheHandler) Routes() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodPut: + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + key, statusCode := h.parseCacheKey(r.URL.Path) + if statusCode != http.StatusOK { + if statusCode == http.StatusNotFound { + http.NotFound(w, r) + return + } + http.Error(w, "invalid cache key", statusCode) + return + } + + if r.Method == http.MethodPut { + if h.proxy.GradleReadOnly { + http.Error(w, "gradle build cache is read-only", http.StatusMethodNotAllowed) + return + } + h.handlePut(w, r, key) + return + } + + h.handleGetOrHead(w, r, key) + }) +} + +func (h *GradleBuildCacheHandler) parseCacheKey(urlPath string) (string, int) { + keyPath := strings.TrimPrefix(urlPath, "/") + if keyPath == "" { + return "", http.StatusNotFound + } + + if containsPathTraversal(keyPath) { + return "", http.StatusBadRequest + } + + if keyPath == "" || strings.Contains(keyPath, "/") { + return "", http.StatusNotFound + } + + if !gradleBuildCacheKeyPattern.MatchString(keyPath) { + return "", http.StatusBadRequest + } + + return keyPath, http.StatusOK +} + +func (h *GradleBuildCacheHandler) cacheStoragePath(key string) string { + return gradleBuildCacheStorageRoot + "/" + key +} + +func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http.Request, key string) { + storagePath := h.cacheStoragePath(key) + w.Header().Set("Content-Type", gradleBuildCacheContentType) + + if r.Method == http.MethodHead { + exists, err := h.proxy.Storage.Exists(r.Context(), storagePath) + if err != nil { + h.proxy.Logger.Error("failed to check gradle build cache entry", "key", key, "error", err) + http.Error(w, "failed to read cache entry", http.StatusInternalServerError) + return + } + if !exists { + http.NotFound(w, r) + return + } + + if size, err := h.proxy.Storage.Size(r.Context(), storagePath); err == nil && size >= 0 { + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + } + + w.WriteHeader(http.StatusOK) + return + } + + reader, err := h.proxy.Storage.Open(r.Context(), storagePath) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + http.NotFound(w, r) + return + } + h.proxy.Logger.Error("failed to open gradle build cache entry", "key", key, "error", err) + http.Error(w, "failed to read cache entry", http.StatusInternalServerError) + return + } + defer func() { _ = reader.Close() }() + + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, reader) +} + +func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Request, key string) { + storagePath := h.cacheStoragePath(key) + maxUploadSize := h.proxy.GradleMaxUploadSize + if maxUploadSize <= 0 { + maxUploadSize = defaultGradleMaxUploadSize + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + _, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "cache entry too large", http.StatusRequestEntityTooLarge) + return + } + + h.proxy.Logger.Error("failed to store gradle build cache entry", "key", key, "error", err) + http.Error(w, "failed to write cache entry", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Length", "0") + w.Header().Set("ETag", `"`+hash+`"`) + + w.WriteHeader(http.StatusCreated) +} diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go new file mode 100644 index 0000000..f002a51 --- /dev/null +++ b/internal/handler/gradle_test.go @@ -0,0 +1,229 @@ +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + key := "a1b2c3d4e5f6" + payload := "cache entry content" + + putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload)) + if err != nil { + t.Fatalf("failed to create PUT request: %v", err) + } + putResp, err := http.DefaultClient.Do(putReq) + if err != nil { + t.Fatalf("PUT request failed: %v", err) + } + _ = putResp.Body.Close() + + if putResp.StatusCode != http.StatusCreated { + t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated) + } + + getResp, err := http.Get(srv.URL + "/" + key) + if err != nil { + t.Fatalf("GET request failed: %v", err) + } + defer func() { _ = getResp.Body.Close() }() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("GET status = %d, want %d", getResp.StatusCode, http.StatusOK) + } + if getResp.Header.Get("Content-Type") != gradleBuildCacheContentType { + t.Fatalf("GET Content-Type = %q, want %q", getResp.Header.Get("Content-Type"), gradleBuildCacheContentType) + } + + body, _ := io.ReadAll(getResp.Body) + if string(body) != payload { + t.Fatalf("GET body = %q, want %q", body, payload) + } + + headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil) + if err != nil { + t.Fatalf("failed to create HEAD request: %v", err) + } + headResp, err := http.DefaultClient.Do(headReq) + if err != nil { + t.Fatalf("HEAD request failed: %v", err) + } + defer func() { _ = headResp.Body.Close() }() + + if headResp.StatusCode != http.StatusOK { + t.Fatalf("HEAD status = %d, want %d", headResp.StatusCode, http.StatusOK) + } + body, _ = io.ReadAll(headResp.Body) + if len(body) != 0 { + t.Fatalf("HEAD body length = %d, want 0", len(body)) + } +} + +func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + key := "rootpathkey" + putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader("root")) + if err != nil { + t.Fatalf("failed to create PUT request: %v", err) + } + putResp, err := http.DefaultClient.Do(putReq) + if err != nil { + t.Fatalf("PUT request failed: %v", err) + } + _ = putResp.Body.Close() + + if putResp.StatusCode != http.StatusCreated { + t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated) + } + + getResp, err := http.Get(srv.URL + "/" + key) + if err != nil { + t.Fatalf("GET request failed: %v", err) + } + defer func() { _ = getResp.Body.Close() }() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("GET status = %d, want %d", getResp.StatusCode, http.StatusOK) + } +} + +func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/missing-key") + if err != nil { + t.Fatalf("GET request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound) + } +} + +func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + + req := httptest.NewRequest(http.MethodPost, "/key", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + + req := httptest.NewRequest(http.MethodGet, "/../secret", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestGradleBuildCacheHandler_CachePrefixRejected(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + + req := httptest.NewRequest(http.MethodGet, "/cache/key", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + key := "overwrite-key" + + for i, payload := range []string{"first", "second"} { + req, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload)) + if err != nil { + t.Fatalf("failed to create PUT request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT request failed: %v", err) + } + _ = resp.Body.Close() + + want := http.StatusCreated + if resp.StatusCode != want { + t.Fatalf("PUT #%d status = %d, want %d", i+1, resp.StatusCode, want) + } + } +} + +func TestGradleBuildCacheHandler_PutReadOnly(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + proxy.GradleReadOnly = true + + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPut, srv.URL+"/readonly-key", strings.NewReader("payload")) + if err != nil { + t.Fatalf("failed to create PUT request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed) + } +} + +func TestGradleBuildCacheHandler_PutTooLarge(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + proxy.GradleMaxUploadSize = 4 + + h := NewGradleBuildCacheHandler(proxy) + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPut, srv.URL+"/oversized-key", strings.NewReader("12345")) + if err != nil { + t.Fatalf("failed to create PUT request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("PUT status = %d, want %d", resp.StatusCode, http.StatusRequestEntityTooLarge) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6bcf506..d40a1dd 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -74,16 +74,18 @@ func ReadMetadata(r io.Reader) ([]byte, error) { // Proxy provides shared functionality for protocol handlers. type Proxy struct { - DB *database.DB - Storage storage.Storage - Fetcher fetch.FetcherInterface - Resolver *fetch.Resolver - Logger *slog.Logger - Cooldown *cooldown.Config - CacheMetadata bool - MetadataTTL time.Duration - DirectServe bool - DirectServeTTL time.Duration + DB *database.DB + Storage storage.Storage + Fetcher fetch.FetcherInterface + Resolver *fetch.Resolver + Logger *slog.Logger + Cooldown *cooldown.Config + CacheMetadata bool + MetadataTTL time.Duration + GradleReadOnly bool + GradleMaxUploadSize int64 + DirectServe bool + DirectServeTTL time.Duration // DirectServeBaseURL, if set, replaces the scheme and host of presigned // URLs so clients receive a public address even when the proxy reaches // storage at an internal one. diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index b935628..1fe5388 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -286,6 +286,20 @@ index-url = ` + baseURL + `/pypi/simple/`), </mirror> </mirrors> </settings>`), + }, + { + ID: "gradle", + Name: "Gradle Build Cache", + Language: "Java/Kotlin", + Endpoint: "/gradle/", + Instructions: template.HTML(`

Configure Gradle to use the proxy for HttpBuildCache:

+
// In settings.gradle(.kts)
+buildCache {
+  remote<HttpBuildCache> {
+    url = uri("` + baseURL + `/gradle/")
+    push = true
+  }
+}
`), }, { ID: "nuget", diff --git a/internal/server/gradle_cache_eviction.go b/internal/server/gradle_cache_eviction.go new file mode 100644 index 0000000..1f5e95d --- /dev/null +++ b/internal/server/gradle_cache_eviction.go @@ -0,0 +1,156 @@ +package server + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/git-pkgs/proxy/internal/storage" +) + +const gradleBuildCacheStoragePrefix = "_gradle/http-build-cache/" + +type gradleBuildCacheLister interface { + ListPrefix(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) +} + +func (s *Server) startGradleBuildCacheEviction(ctx context.Context) { + maxAge := s.cfg.ParseGradleBuildCacheMaxAge() + maxSize := s.cfg.ParseGradleBuildCacheMaxSize() + if maxAge <= 0 && maxSize <= 0 { + return + } + + lister, ok := s.storage.(gradleBuildCacheLister) + if !ok { + s.logger.Warn("gradle cache eviction is enabled, but storage backend cannot list objects") + return + } + + interval := s.cfg.ParseGradleBuildCacheSweepInterval() + s.logger.Info("gradle cache eviction enabled", + "max_age", maxAge, + "max_size_bytes", maxSize, + "interval", interval) + + sweep := func() { + deletedCount, freedBytes, err := sweepGradleBuildCache(ctx, s.storage, lister, maxAge, maxSize, time.Now()) + if err != nil { + s.logger.Warn("gradle cache eviction sweep failed", "error", err) + return + } + if deletedCount > 0 { + s.logger.Info("gradle cache eviction sweep completed", + "deleted_entries", deletedCount, + "freed_bytes", freedBytes) + } + } + + sweep() + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweep() + } + } + }() +} + +func sweepGradleBuildCache( + ctx context.Context, + store storage.Storage, + lister gradleBuildCacheLister, + maxAge time.Duration, + maxSize int64, + now time.Time, +) (int, int64, error) { + entries, err := lister.ListPrefix(ctx, gradleBuildCacheStoragePrefix) + if err != nil { + return 0, 0, fmt.Errorf("listing gradle cache entries: %w", err) + } + + if len(entries) == 0 { + return 0, 0, nil + } + + sort.Slice(entries, func(i, j int) bool { + iTime := entries[i].ModTime + jTime := entries[j].ModTime + + switch { + case iTime.IsZero() && jTime.IsZero(): + return entries[i].Path < entries[j].Path + case iTime.IsZero(): + return true + case jTime.IsZero(): + return false + case iTime.Equal(jTime): + return entries[i].Path < entries[j].Path + default: + return iTime.Before(jTime) + } + }) + + deletedCount := 0 + freedBytes := int64(0) + var firstDeleteErr error + + deleteEntry := func(entry storage.ObjectInfo) bool { + if err := store.Delete(ctx, entry.Path); err != nil { + if firstDeleteErr == nil { + firstDeleteErr = err + } + return false + } + deletedCount++ + freedBytes += entry.Size + return true + } + + remaining := entries + if maxAge > 0 { + cutoff := now.Add(-maxAge) + kept := make([]storage.ObjectInfo, 0, len(entries)) + + for _, entry := range entries { + if !entry.ModTime.IsZero() && entry.ModTime.Before(cutoff) { + if deleteEntry(entry) { + continue + } + } + kept = append(kept, entry) + } + + remaining = kept + } + + if maxSize > 0 { + totalSize := int64(0) + for _, entry := range remaining { + totalSize += entry.Size + } + + for _, entry := range remaining { + if totalSize <= maxSize { + break + } + if deleteEntry(entry) { + totalSize -= entry.Size + } + } + } + + if firstDeleteErr != nil { + return deletedCount, freedBytes, fmt.Errorf("deleting gradle cache entries: %w", firstDeleteErr) + } + + return deletedCount, freedBytes, nil +} diff --git a/internal/server/gradle_cache_eviction_test.go b/internal/server/gradle_cache_eviction_test.go new file mode 100644 index 0000000..4e97507 --- /dev/null +++ b/internal/server/gradle_cache_eviction_test.go @@ -0,0 +1,138 @@ +package server + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/storage" +) + +type fakeGradleCacheStore struct { + objects map[string]storage.ObjectInfo +} + +func newFakeGradleCacheStore(objects []storage.ObjectInfo) *fakeGradleCacheStore { + m := make(map[string]storage.ObjectInfo, len(objects)) + for _, obj := range objects { + m[obj.Path] = obj + } + return &fakeGradleCacheStore{objects: m} +} + +func (s *fakeGradleCacheStore) Store(_ context.Context, path string, r io.Reader) (int64, string, error) { + data, _ := io.ReadAll(r) + s.objects[path] = storage.ObjectInfo{Path: path, Size: int64(len(data)), ModTime: time.Now()} + return int64(len(data)), "", nil +} + +func (s *fakeGradleCacheStore) Open(_ context.Context, path string) (io.ReadCloser, error) { + obj, ok := s.objects[path] + if !ok { + return nil, storage.ErrNotFound + } + return io.NopCloser(bytes.NewReader(make([]byte, obj.Size))), nil +} + +func (s *fakeGradleCacheStore) Exists(_ context.Context, path string) (bool, error) { + _, ok := s.objects[path] + return ok, nil +} + +func (s *fakeGradleCacheStore) Delete(_ context.Context, path string) error { + delete(s.objects, path) + return nil +} + +func (s *fakeGradleCacheStore) Size(_ context.Context, path string) (int64, error) { + obj, ok := s.objects[path] + if !ok { + return 0, storage.ErrNotFound + } + return obj.Size, nil +} + +func (s *fakeGradleCacheStore) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) { + return "", storage.ErrSignedURLUnsupported +} + +func (s *fakeGradleCacheStore) UsedSpace(_ context.Context) (int64, error) { + var total int64 + for _, obj := range s.objects { + total += obj.Size + } + return total, nil +} + +func (s *fakeGradleCacheStore) URL() string { return "mem://" } + +func (s *fakeGradleCacheStore) Close() error { return nil } + +func (s *fakeGradleCacheStore) ListPrefix(_ context.Context, prefix string) ([]storage.ObjectInfo, error) { + objects := make([]storage.ObjectInfo, 0) + for _, obj := range s.objects { + if strings.HasPrefix(obj.Path, prefix) { + objects = append(objects, obj) + } + } + return objects, nil +} + +func TestSweepGradleBuildCache_MaxAge(t *testing.T) { + now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) + store := newFakeGradleCacheStore([]storage.ObjectInfo{ + {Path: "_gradle/http-build-cache/old", Size: 10, ModTime: now.Add(-48 * time.Hour)}, + {Path: "_gradle/http-build-cache/new", Size: 10, ModTime: now.Add(-2 * time.Hour)}, + }) + + deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 24*time.Hour, 0, now) + if err != nil { + t.Fatalf("sweepGradleBuildCache() error = %v", err) + } + if deleted != 1 { + t.Fatalf("deleted entries = %d, want 1", deleted) + } + if freed != 10 { + t.Fatalf("freed bytes = %d, want 10", freed) + } + + if _, ok := store.objects["_gradle/http-build-cache/old"]; ok { + t.Fatal("old entry was not deleted") + } + if _, ok := store.objects["_gradle/http-build-cache/new"]; !ok { + t.Fatal("new entry should remain") + } +} + +func TestSweepGradleBuildCache_MaxSizeOldestFirst(t *testing.T) { + now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) + store := newFakeGradleCacheStore([]storage.ObjectInfo{ + {Path: "_gradle/http-build-cache/a", Size: 5, ModTime: now.Add(-3 * time.Hour)}, + {Path: "_gradle/http-build-cache/b", Size: 5, ModTime: now.Add(-2 * time.Hour)}, + {Path: "_gradle/http-build-cache/c", Size: 5, ModTime: now.Add(-1 * time.Hour)}, + }) + + deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 0, 10, now) + if err != nil { + t.Fatalf("sweepGradleBuildCache() error = %v", err) + } + if deleted != 1 { + t.Fatalf("deleted entries = %d, want 1", deleted) + } + if freed != 5 { + t.Fatalf("freed bytes = %d, want 5", freed) + } + + if _, ok := store.objects["_gradle/http-build-cache/a"]; ok { + t.Fatal("oldest entry was not deleted") + } + if _, ok := store.objects["_gradle/http-build-cache/b"]; !ok { + t.Fatal("middle entry should remain") + } + if _, ok := store.objects["_gradle/http-build-cache/c"]; !ok { + t.Fatal("newest entry should remain") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a168b4a..a0983e5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ // - /pub/* - pub.dev registry protocol // - /pypi/* - PyPI registry protocol // - /maven/* - Maven repository protocol +// - /gradle/* - Gradle HttpBuildCache protocol // - /nuget/* - NuGet V3 API protocol // - /composer/* - Composer/Packagist protocol // - /conan/* - Conan C/C++ protocol @@ -148,6 +149,8 @@ func (s *Server) Start() error { proxy.Cooldown = cd proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() + proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly + proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize() proxy.DirectServe = s.cfg.Storage.DirectServe proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL() proxy.DirectServeBaseURL = s.cfg.Storage.DirectServeBaseURL @@ -180,6 +183,7 @@ func (s *Server) Start() error { pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL) mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL) + gradleHandler := handler.NewGradleBuildCacheHandler(proxy) nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL) composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL) conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL) @@ -197,6 +201,7 @@ func (s *Server) Start() error { r.Mount("/pub", http.StripPrefix("/pub", pubHandler.Routes())) r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes())) r.Mount("/maven", http.StripPrefix("/maven", mavenHandler.Routes())) + r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes())) r.Mount("/nuget", http.StripPrefix("/nuget", nugetHandler.Routes())) r.Mount("/composer", http.StripPrefix("/composer", composerHandler.Routes())) r.Mount("/conan", http.StripPrefix("/conan", conanHandler.Routes())) @@ -238,6 +243,7 @@ func (s *Server) Start() error { // Start background context (used by mirror jobs and cleanup) bgCtx, bgCancel := context.WithCancel(context.Background()) s.cancel = bgCancel + s.startGradleBuildCacheEviction(bgCtx) // Mirror API endpoints (opt-in via mirror_api config or PROXY_MIRROR_API env) if s.cfg.MirrorAPI { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index be88bf6..574b6ba 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -72,12 +72,14 @@ func newTestServer(t *testing.T) *testServer { gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL) + gradleHandler := handler.NewGradleBuildCacheHandler(proxy) r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes())) r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes())) r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes())) r.Mount("/go", http.StripPrefix("/go", goHandler.Routes())) r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes())) + r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes())) // Create a minimal server struct for the handlers s := &Server{ @@ -344,6 +346,33 @@ func TestPyPISimple(t *testing.T) { } } +func TestGradleBuildCachePutGet(t *testing.T) { + ts := newTestServer(t) + defer ts.close() + + key := "abc123def456" + body := "build-cache-bytes" + + putReq := httptest.NewRequest(http.MethodPut, "/gradle/"+key, strings.NewReader(body)) + putW := httptest.NewRecorder() + ts.handler.ServeHTTP(putW, putReq) + + if putW.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", putW.Code, putW.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/gradle/"+key, nil) + getW := httptest.NewRecorder() + ts.handler.ServeHTTP(getW, getReq) + + if getW.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", getW.Code, getW.Body.String()) + } + if got := getW.Body.String(); got != body { + t.Fatalf("expected body %q, got %q", body, got) + } +} + func TestGemSpecs(t *testing.T) { ts := newTestServer(t) defer ts.close() diff --git a/internal/server/templates_test.go b/internal/server/templates_test.go index a8b67f8..c27363b 100644 --- a/internal/server/templates_test.go +++ b/internal/server/templates_test.go @@ -193,7 +193,7 @@ func TestInstallPage(t *testing.T) { body := w.Body.String() // Should contain instructions for all registries - registries := []string{"npm", "Cargo", "RubyGems", "Go Modules", "PyPI", "Maven", "NuGet", "Composer", "Conan", "Conda", "CRAN"} + registries := []string{"npm", "Cargo", "RubyGems", "Go Modules", "PyPI", "Maven", "Gradle Build Cache", "NuGet", "Composer", "Conan", "Conda", "CRAN"} for _, reg := range registries { if !strings.Contains(body, reg) { t.Errorf("install page should contain %s instructions", reg) diff --git a/internal/storage/blob.go b/internal/storage/blob.go index dc41668..67e91d0 100644 --- a/internal/storage/blob.go +++ b/internal/storage/blob.go @@ -184,6 +184,35 @@ func (b *Blob) UsedSpace(ctx context.Context) (int64, error) { return total, nil } +// ListPrefix returns object metadata for keys under a prefix. +func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { + iter := b.bucket.List(&blob.ListOptions{Prefix: prefix}) + objects := make([]ObjectInfo, 0) + + for { + obj, err := iter.Next(ctx) + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("listing objects: %w", err) + } + if obj.IsDir { + continue + } + + info := ObjectInfo{ + Path: obj.Key, + Size: obj.Size, + ModTime: obj.ModTime, + } + + objects = append(objects, info) + } + + return objects, nil +} + func (b *Blob) Close() error { return b.bucket.Close() } diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 53cff42..1e5a24f 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + fsys "io/fs" "os" "path/filepath" "strings" @@ -187,6 +188,54 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) { return total, nil } +// ListPrefix returns object metadata for paths under a prefix. +func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { + searchRoot, err := fs.fullPath(prefix) + if err != nil { + return nil, err + } + + if _, err := os.Stat(searchRoot); err != nil { + if os.IsNotExist(err) { + return []ObjectInfo{}, nil + } + return nil, fmt.Errorf("stat prefix: %w", err) + } + + objects := make([]ObjectInfo, 0) + err = filepath.WalkDir(searchRoot, func(path string, entry fsys.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + + info, err := entry.Info() + if err != nil { + return err + } + + relPath, err := filepath.Rel(fs.root, path) + if err != nil { + return err + } + + objects = append(objects, ObjectInfo{ + Path: filepath.ToSlash(relPath), + Size: info.Size(), + ModTime: info.ModTime(), + }) + + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking prefix: %w", err) + } + + return objects, nil +} + // Root returns the root directory of the storage. func (fs *Filesystem) Root() string { return fs.root diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0dba46a..e11db53 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -28,6 +28,13 @@ var ( ErrSignedURLUnsupported = errors.New("signed URLs not supported by storage backend") ) +// ObjectInfo contains metadata for a stored object. +type ObjectInfo struct { + Path string + Size int64 + ModTime time.Time +} + // Storage defines the interface for artifact storage backends. type Storage interface { // Store writes content from r to the given path.