From 6e3de7fc89d378a7f2847e44079192e03950f49e Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Wed, 15 Apr 2026 11:00:15 +0200 Subject: [PATCH 1/9] add Gradle Build Cache support with handler and tests --- internal/handler/gradle.go | 149 +++++++++++++++++++++++++ internal/handler/gradle_test.go | 173 ++++++++++++++++++++++++++++++ internal/server/dashboard.go | 14 +++ internal/server/server.go | 3 + internal/server/server_test.go | 29 +++++ internal/server/templates_test.go | 3 +- 6 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 internal/handler/gradle.go create mode 100644 internal/handler/gradle_test.go diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go new file mode 100644 index 0000000..9fd501a --- /dev/null +++ b/internal/handler/gradle.go @@ -0,0 +1,149 @@ +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" + gradleBuildCachePathPrefix = "cache/" + gradleBuildCacheStorageRoot = "_gradle/http-build-cache" +) + +var gradleBuildCacheKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// GradleBuildCacheHandler handles Gradle HttpBuildCache GET/HEAD/PUT requests. +// +// Gradle clients commonly use paths like /cache/{key}, but this handler also +// accepts /{key} so it can be mounted under flexible base URLs. +type GradleBuildCacheHandler struct { + proxy *Proxy +} + +// NewGradleBuildCacheHandler creates a Gradle HttpBuildCache handler. +func NewGradleBuildCacheHandler(proxy *Proxy, _ string) *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 { + 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 strings.HasPrefix(keyPath, gradleBuildCachePathPrefix) { + keyPath = strings.TrimPrefix(keyPath, gradleBuildCachePathPrefix) + } + + 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) + + 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.Header().Set("Content-Type", gradleBuildCacheContentType) + 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) + if r.Method == http.MethodHead { + return + } + + _, _ = io.Copy(w, reader) +} + +func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Request, key string) { + storagePath := h.cacheStoragePath(key) + + 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 write cache entry", http.StatusInternalServerError) + return + } + + defer func() { _ = r.Body.Close() }() + size, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) + if err != nil { + 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.Header().Set("X-Cache-Size", strconv.FormatInt(size, 10)) + + if exists { + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusCreated) +} diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go new file mode 100644 index 0000000..ea26771 --- /dev/null +++ b/internal/handler/gradle_test.go @@ -0,0 +1,173 @@ +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy, "http://localhost") + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + key := "a1b2c3d4e5f6" + payload := "cache entry content" + + putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/cache/"+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 + "/cache/" + 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+"/cache/"+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, "http://localhost") + 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 + "/cache/" + 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, "http://localhost") + srv := httptest.NewServer(h.Routes()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/cache/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, "http://localhost") + + req := httptest.NewRequest(http.MethodPost, "/cache/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, "http://localhost") + + req := httptest.NewRequest(http.MethodGet, "/cache/../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_PutOverwriteReturnsOK(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewGradleBuildCacheHandler(proxy, "http://localhost") + 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+"/cache/"+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 i == 1 { + want = http.StatusOK + } + if resp.StatusCode != want { + t.Fatalf("PUT #%d status = %d, want %d", i+1, resp.StatusCode, want) + } + } +} diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index b935628..78cb49b 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/cache/", + Instructions: template.HTML(`

Configure Gradle to use the proxy for HttpBuildCache:

+
// In settings.gradle(.kts)
+buildCache {
+  remote(HttpBuildCache) {
+    url = uri("` + baseURL + `/gradle/cache/")
+    push = true
+  }
+}
`), }, { ID: "nuget", diff --git a/internal/server/server.go b/internal/server/server.go index 5d544a2..401ba7a 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 @@ -177,6 +178,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, s.cfg.BaseURL) nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL) composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL) conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL) @@ -194,6 +196,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())) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index be88bf6..4c589a3 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, cfg.BaseURL) 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/cache/"+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/cache/"+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 e19244e..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) @@ -335,7 +335,6 @@ func TestSearchPage_EcosystemFilter(t *testing.T) { } } - func TestEcosystemBadgeLabel(t *testing.T) { tests := []struct { ecosystem string From d6363a32000ac713b221d5302cc1a485c63d9ee8 Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Wed, 15 Apr 2026 14:04:03 +0200 Subject: [PATCH 2/9] linting issue --- internal/handler/gradle.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index 9fd501a..79430e9 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -71,9 +71,7 @@ func (h *GradleBuildCacheHandler) parseCacheKey(urlPath string) (string, int) { return "", http.StatusBadRequest } - if strings.HasPrefix(keyPath, gradleBuildCachePathPrefix) { - keyPath = strings.TrimPrefix(keyPath, gradleBuildCachePathPrefix) - } + keyPath = strings.TrimPrefix(keyPath, gradleBuildCachePathPrefix) if keyPath == "" || strings.Contains(keyPath, "/") { return "", http.StatusNotFound From c23fe93208e5170e473b7de73983dbb4f2f4500d Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Mon, 20 Apr 2026 08:11:23 +0200 Subject: [PATCH 3/9] MR Suggestions: Add Gradle HTTP Build Cache configuration to README --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 411f25c..9831644 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,28 @@ 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 + } +} +``` + +The proxy accepts both Gradle cache URL styles: +- `http://localhost:8080/gradle/cache/` +- `http://localhost:8080/gradle/` + +This keeps compatibility with clients that include or omit the `cache/` path segment. + ### NuGet Configure in `nuget.config`: From 88394654380fcf382aa97b375417ff5b55882f2f Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Mon, 27 Apr 2026 22:19:36 +0200 Subject: [PATCH 4/9] implement minor stuff: Refactor Gradle handler to remove unnecessary URL parameter and update related tests Co-authored-by: Copilot --- README.md | 6 ----- internal/handler/gradle.go | 44 +++++++++++++++++---------------- internal/handler/gradle_test.go | 17 ++++++------- internal/server/dashboard.go | 6 ++--- internal/server/server.go | 2 +- internal/server/server_test.go | 2 +- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 9831644..22beaaf 100644 --- a/README.md +++ b/README.md @@ -225,12 +225,6 @@ buildCache { } ``` -The proxy accepts both Gradle cache URL styles: -- `http://localhost:8080/gradle/cache/` -- `http://localhost:8080/gradle/` - -This keeps compatibility with clients that include or omit the `cache/` path segment. - ### NuGet Configure in `nuget.config`: diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index 79430e9..117b34f 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -28,7 +28,7 @@ type GradleBuildCacheHandler struct { } // NewGradleBuildCacheHandler creates a Gradle HttpBuildCache handler. -func NewGradleBuildCacheHandler(proxy *Proxy, _ string) *GradleBuildCacheHandler { +func NewGradleBuildCacheHandler(proxy *Proxy) *GradleBuildCacheHandler { return &GradleBuildCacheHandler{proxy: proxy} } @@ -90,6 +90,27 @@ func (h *GradleBuildCacheHandler) cacheStoragePath(key string) string { 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 { @@ -103,31 +124,18 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http } defer func() { _ = reader.Close() }() - w.Header().Set("Content-Type", gradleBuildCacheContentType) 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) - if r.Method == http.MethodHead { - return - } - _, _ = io.Copy(w, reader) } func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Request, key string) { storagePath := h.cacheStoragePath(key) - 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 write cache entry", http.StatusInternalServerError) - return - } - - defer func() { _ = r.Body.Close() }() - size, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) + _, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) if err != nil { 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) @@ -136,12 +144,6 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque w.Header().Set("Content-Length", "0") w.Header().Set("ETag", `"`+hash+`"`) - w.Header().Set("X-Cache-Size", strconv.FormatInt(size, 10)) - - if exists { - w.WriteHeader(http.StatusOK) - return - } w.WriteHeader(http.StatusCreated) } diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go index ea26771..707abd1 100644 --- a/internal/handler/gradle_test.go +++ b/internal/handler/gradle_test.go @@ -10,7 +10,7 @@ import ( func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -70,7 +70,7 @@ func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -102,7 +102,7 @@ func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -119,7 +119,7 @@ func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) req := httptest.NewRequest(http.MethodPost, "/cache/key", nil) w := httptest.NewRecorder() @@ -132,7 +132,7 @@ func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) req := httptest.NewRequest(http.MethodGet, "/cache/../secret", nil) w := httptest.NewRecorder() @@ -143,9 +143,9 @@ func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { } } -func TestGradleBuildCacheHandler_PutOverwriteReturnsOK(t *testing.T) { +func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -163,9 +163,6 @@ func TestGradleBuildCacheHandler_PutOverwriteReturnsOK(t *testing.T) { _ = resp.Body.Close() want := http.StatusCreated - if i == 1 { - want = http.StatusOK - } if resp.StatusCode != want { t.Fatalf("PUT #%d status = %d, want %d", i+1, resp.StatusCode, want) } diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 78cb49b..1fe5388 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -291,12 +291,12 @@ index-url = ` + baseURL + `/pypi/simple/`), ID: "gradle", Name: "Gradle Build Cache", Language: "Java/Kotlin", - Endpoint: "/gradle/cache/", + Endpoint: "/gradle/", Instructions: template.HTML(`

Configure Gradle to use the proxy for HttpBuildCache:

// In settings.gradle(.kts)
 buildCache {
-  remote(HttpBuildCache) {
-    url = uri("` + baseURL + `/gradle/cache/")
+  remote<HttpBuildCache> {
+    url = uri("` + baseURL + `/gradle/")
     push = true
   }
 }
`), diff --git a/internal/server/server.go b/internal/server/server.go index 401ba7a..aafc716 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,7 +178,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, 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) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4c589a3..8bdca20 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -72,7 +72,7 @@ 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, cfg.BaseURL) + gradleHandler := handler.NewGradleBuildCacheHandler(proxy) r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes())) r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes())) From fd5d4d58d08bcc4b4624a6b368aa6334c22c0063 Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Mon, 27 Apr 2026 22:50:59 +0200 Subject: [PATCH 5/9] Add Gradle build cache configuration and eviction support - Introduced configuration options for Gradle build cache in config files and documentation. - Implemented read-only mode and upload size limits for the Gradle build cache. - Added cache eviction logic based on age and size, with corresponding tests. - Enhanced storage interfaces to support listing objects by prefix. --- cmd/proxy/main.go | 10 ++ config.example.yaml | 20 +++ docs/configuration.md | 24 +++ internal/config/config.go | 146 ++++++++++++++++ internal/config/config_test.go | 96 +++++++++++ internal/handler/gradle.go | 18 ++ internal/handler/gradle_test.go | 46 ++++++ internal/handler/handler.go | 20 ++- internal/server/gradle_cache_eviction.go | 156 ++++++++++++++++++ internal/server/gradle_cache_eviction_test.go | 134 +++++++++++++++ internal/server/server.go | 3 + internal/storage/blob.go | 36 ++++ internal/storage/filesystem.go | 39 +++++ internal/storage/storage.go | 8 + 14 files changed, 747 insertions(+), 9 deletions(-) create mode 100644 internal/server/gradle_cache_eviction.go create mode 100644 internal/server/gradle_cache_eviction_test.go 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 ea17d15..289baf4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -91,6 +91,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 ad0acc0..fbc7478 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,6 +98,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. @@ -135,6 +138,34 @@ type StorageConfig struct { MaxSize string `json:"max_size" yaml:"max_size"` } +// 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". @@ -244,6 +275,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", + }, + }, } } @@ -330,6 +370,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. @@ -386,10 +441,49 @@ 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 defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default +const defaultGradleBuildCacheMaxUploadSize = 100 << 20 +const defaultGradleBuildCacheSweepInterval = 10 * time.Minute // ParseMetadataTTL returns the metadata TTL duration. // Returns 5 minutes if unset, 0 if explicitly disabled. @@ -407,6 +501,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 +} + // ParseSize parses a human-readable size string (e.g., "10GB", "500MB"). // Returns the size in bytes. func ParseSize(s string) (int64, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6e8c3a0..e75aa58 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) { @@ -355,3 +416,38 @@ func TestLoadMetadataTTLFromEnv(t *testing.T) { t.Errorf("MetadataTTL = %q, want %q", cfg.MetadataTTL, "10m") } } + +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) + } +} diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index 117b34f..fad8fd4 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -15,6 +15,7 @@ const ( gradleBuildCacheContentType = "application/vnd.gradle.build-cache-artifact.v2" gradleBuildCachePathPrefix = "cache/" gradleBuildCacheStorageRoot = "_gradle/http-build-cache" + defaultGradleMaxUploadSize = 100 << 20 ) var gradleBuildCacheKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -53,6 +54,10 @@ func (h *GradleBuildCacheHandler) Routes() http.Handler { } 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 } @@ -134,9 +139,22 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http 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) + defer func() { _ = r.Body.Close() }() _, 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 diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go index 707abd1..55d1413 100644 --- a/internal/handler/gradle_test.go +++ b/internal/handler/gradle_test.go @@ -168,3 +168,49 @@ func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) { } } } + +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+"/cache/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+"/cache/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 d7d79c9..7e85a57 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -61,15 +61,17 @@ 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 - HTTPClient *http.Client + DB *database.DB + Storage storage.Storage + Fetcher fetch.FetcherInterface + Resolver *fetch.Resolver + Logger *slog.Logger + Cooldown *cooldown.Config + CacheMetadata bool + MetadataTTL time.Duration + GradleReadOnly bool + GradleMaxUploadSize int64 + HTTPClient *http.Client } // NewProxy creates a new Proxy with the given dependencies. 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..43f42f0 --- /dev/null +++ b/internal/server/gradle_cache_eviction_test.go @@ -0,0 +1,134 @@ +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) 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 aafc716..d77ee28 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -149,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() // Create router with Chi r := chi.NewRouter() @@ -238,6 +240,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/storage/blob.go b/internal/storage/blob.go index 2d6af46..e9575d7 100644 --- a/internal/storage/blob.go +++ b/internal/storage/blob.go @@ -167,6 +167,42 @@ 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, + } + + // Some providers may omit ModTime in list results; fetch attributes as fallback. + if info.ModTime.IsZero() { + if attrs, err := b.bucket.Attributes(ctx, obj.Key); err == nil { + info.ModTime = attrs.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 cf6a1fe..5ccdb70 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -162,6 +162,45 @@ 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 := fs.fullPath(prefix) + 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.Walk(searchRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + 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 8a9026c..2bad26a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -15,6 +15,7 @@ import ( "encoding/hex" "errors" "io" + "time" ) const dirPermissions = 0755 @@ -23,6 +24,13 @@ var ( ErrNotFound = errors.New("artifact not found") ) +// 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. From 95a792775c031b2c2933d23a6b2271892244211a Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Mon, 27 Apr 2026 22:19:36 +0200 Subject: [PATCH 6/9] implement minor stuff: Refactor Gradle handler to remove unnecessary URL parameter and update related tests --- README.md | 6 ----- internal/handler/gradle.go | 44 +++++++++++++++++---------------- internal/handler/gradle_test.go | 17 ++++++------- internal/server/dashboard.go | 6 ++--- internal/server/server.go | 2 +- internal/server/server_test.go | 2 +- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 9831644..22beaaf 100644 --- a/README.md +++ b/README.md @@ -225,12 +225,6 @@ buildCache { } ``` -The proxy accepts both Gradle cache URL styles: -- `http://localhost:8080/gradle/cache/` -- `http://localhost:8080/gradle/` - -This keeps compatibility with clients that include or omit the `cache/` path segment. - ### NuGet Configure in `nuget.config`: diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index 79430e9..117b34f 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -28,7 +28,7 @@ type GradleBuildCacheHandler struct { } // NewGradleBuildCacheHandler creates a Gradle HttpBuildCache handler. -func NewGradleBuildCacheHandler(proxy *Proxy, _ string) *GradleBuildCacheHandler { +func NewGradleBuildCacheHandler(proxy *Proxy) *GradleBuildCacheHandler { return &GradleBuildCacheHandler{proxy: proxy} } @@ -90,6 +90,27 @@ func (h *GradleBuildCacheHandler) cacheStoragePath(key string) string { 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 { @@ -103,31 +124,18 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http } defer func() { _ = reader.Close() }() - w.Header().Set("Content-Type", gradleBuildCacheContentType) 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) - if r.Method == http.MethodHead { - return - } - _, _ = io.Copy(w, reader) } func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Request, key string) { storagePath := h.cacheStoragePath(key) - 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 write cache entry", http.StatusInternalServerError) - return - } - - defer func() { _ = r.Body.Close() }() - size, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) + _, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) if err != nil { 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) @@ -136,12 +144,6 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque w.Header().Set("Content-Length", "0") w.Header().Set("ETag", `"`+hash+`"`) - w.Header().Set("X-Cache-Size", strconv.FormatInt(size, 10)) - - if exists { - w.WriteHeader(http.StatusOK) - return - } w.WriteHeader(http.StatusCreated) } diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go index ea26771..707abd1 100644 --- a/internal/handler/gradle_test.go +++ b/internal/handler/gradle_test.go @@ -10,7 +10,7 @@ import ( func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -70,7 +70,7 @@ func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -102,7 +102,7 @@ func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -119,7 +119,7 @@ func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) req := httptest.NewRequest(http.MethodPost, "/cache/key", nil) w := httptest.NewRecorder() @@ -132,7 +132,7 @@ func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) req := httptest.NewRequest(http.MethodGet, "/cache/../secret", nil) w := httptest.NewRecorder() @@ -143,9 +143,9 @@ func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { } } -func TestGradleBuildCacheHandler_PutOverwriteReturnsOK(t *testing.T) { +func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) - h := NewGradleBuildCacheHandler(proxy, "http://localhost") + h := NewGradleBuildCacheHandler(proxy) srv := httptest.NewServer(h.Routes()) defer srv.Close() @@ -163,9 +163,6 @@ func TestGradleBuildCacheHandler_PutOverwriteReturnsOK(t *testing.T) { _ = resp.Body.Close() want := http.StatusCreated - if i == 1 { - want = http.StatusOK - } if resp.StatusCode != want { t.Fatalf("PUT #%d status = %d, want %d", i+1, resp.StatusCode, want) } diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 78cb49b..1fe5388 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -291,12 +291,12 @@ index-url = ` + baseURL + `/pypi/simple/`), ID: "gradle", Name: "Gradle Build Cache", Language: "Java/Kotlin", - Endpoint: "/gradle/cache/", + Endpoint: "/gradle/", Instructions: template.HTML(`

Configure Gradle to use the proxy for HttpBuildCache:

// In settings.gradle(.kts)
 buildCache {
-  remote(HttpBuildCache) {
-    url = uri("` + baseURL + `/gradle/cache/")
+  remote<HttpBuildCache> {
+    url = uri("` + baseURL + `/gradle/")
     push = true
   }
 }
`), diff --git a/internal/server/server.go b/internal/server/server.go index 401ba7a..aafc716 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,7 +178,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, 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) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4c589a3..8bdca20 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -72,7 +72,7 @@ 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, cfg.BaseURL) + gradleHandler := handler.NewGradleBuildCacheHandler(proxy) r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes())) r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes())) From 375af158ca768768f168639df57b681ad317c616 Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Tue, 28 Apr 2026 22:40:41 +0200 Subject: [PATCH 7/9] last finding fix --- internal/handler/gradle.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index fad8fd4..e005947 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -145,7 +145,6 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque } r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) - defer func() { _ = r.Body.Close() }() _, hash, err := h.proxy.Storage.Store(r.Context(), storagePath, r.Body) if err != nil { From 569731cf0d2fde482c068a4e12f72b42a06aa9f8 Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Sun, 3 May 2026 22:05:53 +0200 Subject: [PATCH 8/9] fix tests and implement PR suggestions Co-authored-by: Copilot --- internal/config/config_test.go | 3 +++ internal/handler/gradle.go | 4 ---- internal/server/gradle_cache_eviction_test.go | 4 ++++ internal/storage/blob.go | 7 ------- internal/storage/filesystem.go | 16 +++++++++++++--- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 978e326..26a0fc6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -474,6 +474,9 @@ func TestParseGradleBuildCacheConfig(t *testing.T) { } 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 index e005947..4b9ae2b 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -129,10 +129,6 @@ func (h *GradleBuildCacheHandler) handleGetOrHead(w http.ResponseWriter, r *http } defer func() { _ = reader.Close() }() - 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) _, _ = io.Copy(w, reader) } diff --git a/internal/server/gradle_cache_eviction_test.go b/internal/server/gradle_cache_eviction_test.go index 43f42f0..4e97507 100644 --- a/internal/server/gradle_cache_eviction_test.go +++ b/internal/server/gradle_cache_eviction_test.go @@ -55,6 +55,10 @@ func (s *fakeGradleCacheStore) Size(_ context.Context, path string) (int64, erro 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 { diff --git a/internal/storage/blob.go b/internal/storage/blob.go index 1f11cca..67e91d0 100644 --- a/internal/storage/blob.go +++ b/internal/storage/blob.go @@ -207,13 +207,6 @@ func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, err ModTime: obj.ModTime, } - // Some providers may omit ModTime in list results; fetch attributes as fallback. - if info.ModTime.IsZero() { - if attrs, err := b.bucket.Attributes(ctx, obj.Key); err == nil { - info.ModTime = attrs.ModTime - } - } - objects = append(objects, info) } diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 101de61..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" @@ -189,7 +190,11 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) { // ListPrefix returns object metadata for paths under a prefix. func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { - searchRoot := fs.fullPath(prefix) + 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 @@ -198,14 +203,19 @@ func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectIn } objects := make([]ObjectInfo, 0) - err := filepath.Walk(searchRoot, func(path string, info os.FileInfo, err error) error { + err = filepath.WalkDir(searchRoot, func(path string, entry fsys.DirEntry, err error) error { if err != nil { return err } - if info.IsDir() { + 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 From 5687d22d79a4b687ca69f2e9c6e9059090b2cc7f Mon Sep 17 00:00:00 2001 From: "Mateusz (Mati) Kepa" Date: Sun, 3 May 2026 22:15:02 +0200 Subject: [PATCH 9/9] unify path --- internal/handler/gradle.go | 6 +----- internal/handler/gradle_test.go | 33 +++++++++++++++++++++++---------- internal/server/server_test.go | 4 ++-- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index 4b9ae2b..41c9f76 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -13,7 +13,6 @@ import ( const ( gradleBuildCacheContentType = "application/vnd.gradle.build-cache-artifact.v2" - gradleBuildCachePathPrefix = "cache/" gradleBuildCacheStorageRoot = "_gradle/http-build-cache" defaultGradleMaxUploadSize = 100 << 20 ) @@ -22,8 +21,7 @@ var gradleBuildCacheKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]* // GradleBuildCacheHandler handles Gradle HttpBuildCache GET/HEAD/PUT requests. // -// Gradle clients commonly use paths like /cache/{key}, but this handler also -// accepts /{key} so it can be mounted under flexible base URLs. +// This handler accepts /{key} when mounted under a base URL. type GradleBuildCacheHandler struct { proxy *Proxy } @@ -76,8 +74,6 @@ func (h *GradleBuildCacheHandler) parseCacheKey(urlPath string) (string, int) { return "", http.StatusBadRequest } - keyPath = strings.TrimPrefix(keyPath, gradleBuildCachePathPrefix) - if keyPath == "" || strings.Contains(keyPath, "/") { return "", http.StatusNotFound } diff --git a/internal/handler/gradle_test.go b/internal/handler/gradle_test.go index 55d1413..f002a51 100644 --- a/internal/handler/gradle_test.go +++ b/internal/handler/gradle_test.go @@ -17,7 +17,7 @@ func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { key := "a1b2c3d4e5f6" payload := "cache entry content" - putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/cache/"+key, strings.NewReader(payload)) + putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload)) if err != nil { t.Fatalf("failed to create PUT request: %v", err) } @@ -31,7 +31,7 @@ func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated) } - getResp, err := http.Get(srv.URL + "/cache/" + key) + getResp, err := http.Get(srv.URL + "/" + key) if err != nil { t.Fatalf("GET request failed: %v", err) } @@ -49,7 +49,7 @@ func TestGradleBuildCacheHandler_PutGetHead(t *testing.T) { t.Fatalf("GET body = %q, want %q", body, payload) } - headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/cache/"+key, nil) + headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/"+key, nil) if err != nil { t.Fatalf("failed to create HEAD request: %v", err) } @@ -89,7 +89,7 @@ func TestGradleBuildCacheHandler_RootKeyPath(t *testing.T) { t.Fatalf("PUT status = %d, want %d", putResp.StatusCode, http.StatusCreated) } - getResp, err := http.Get(srv.URL + "/cache/" + key) + getResp, err := http.Get(srv.URL + "/" + key) if err != nil { t.Fatalf("GET request failed: %v", err) } @@ -106,7 +106,7 @@ func TestGradleBuildCacheHandler_GetMiss(t *testing.T) { srv := httptest.NewServer(h.Routes()) defer srv.Close() - resp, err := http.Get(srv.URL + "/cache/missing-key") + resp, err := http.Get(srv.URL + "/missing-key") if err != nil { t.Fatalf("GET request failed: %v", err) } @@ -121,7 +121,7 @@ func TestGradleBuildCacheHandler_MethodNotAllowed(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) h := NewGradleBuildCacheHandler(proxy) - req := httptest.NewRequest(http.MethodPost, "/cache/key", nil) + req := httptest.NewRequest(http.MethodPost, "/key", nil) w := httptest.NewRecorder() h.Routes().ServeHTTP(w, req) @@ -134,7 +134,7 @@ func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) h := NewGradleBuildCacheHandler(proxy) - req := httptest.NewRequest(http.MethodGet, "/cache/../secret", nil) + req := httptest.NewRequest(http.MethodGet, "/../secret", nil) w := httptest.NewRecorder() h.Routes().ServeHTTP(w, req) @@ -143,6 +143,19 @@ func TestGradleBuildCacheHandler_PathTraversalRejected(t *testing.T) { } } +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) @@ -152,7 +165,7 @@ func TestGradleBuildCacheHandler_PutOverwriteReturnsCreated(t *testing.T) { key := "overwrite-key" for i, payload := range []string{"first", "second"} { - req, err := http.NewRequest(http.MethodPut, srv.URL+"/cache/"+key, strings.NewReader(payload)) + req, err := http.NewRequest(http.MethodPut, srv.URL+"/"+key, strings.NewReader(payload)) if err != nil { t.Fatalf("failed to create PUT request: %v", err) } @@ -177,7 +190,7 @@ func TestGradleBuildCacheHandler_PutReadOnly(t *testing.T) { srv := httptest.NewServer(h.Routes()) defer srv.Close() - req, err := http.NewRequest(http.MethodPut, srv.URL+"/cache/readonly-key", strings.NewReader("payload")) + 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) } @@ -200,7 +213,7 @@ func TestGradleBuildCacheHandler_PutTooLarge(t *testing.T) { srv := httptest.NewServer(h.Routes()) defer srv.Close() - req, err := http.NewRequest(http.MethodPut, srv.URL+"/cache/oversized-key", strings.NewReader("12345")) + 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) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8bdca20..574b6ba 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -353,7 +353,7 @@ func TestGradleBuildCachePutGet(t *testing.T) { key := "abc123def456" body := "build-cache-bytes" - putReq := httptest.NewRequest(http.MethodPut, "/gradle/cache/"+key, strings.NewReader(body)) + putReq := httptest.NewRequest(http.MethodPut, "/gradle/"+key, strings.NewReader(body)) putW := httptest.NewRecorder() ts.handler.ServeHTTP(putW, putReq) @@ -361,7 +361,7 @@ func TestGradleBuildCachePutGet(t *testing.T) { t.Fatalf("expected status 201, got %d: %s", putW.Code, putW.Body.String()) } - getReq := httptest.NewRequest(http.MethodGet, "/gradle/cache/"+key, nil) + getReq := httptest.NewRequest(http.MethodGet, "/gradle/"+key, nil) getW := httptest.NewRecorder() ts.handler.ServeHTTP(getW, getReq)