Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions internal/handler/gradle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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
}

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)
}
173 changes: 173 additions & 0 deletions internal/handler/gradle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
14 changes: 14 additions & 0 deletions internal/server/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ index-url = ` + baseURL + `/pypi/simple/</code></pre>`),
&lt;/mirror&gt;
&lt;/mirrors&gt;
&lt;/settings&gt;</code></pre>`),
},
{
ID: "gradle",
Name: "Gradle Build Cache",
Language: "Java/Kotlin",
Endpoint: "/gradle/cache/",
Instructions: template.HTML(`<p class="config-note">Configure Gradle to use the proxy for HttpBuildCache:</p>
<pre><code>// In settings.gradle(.kts)
buildCache {
remote(HttpBuildCache) {
url = uri("` + baseURL + `/gradle/cache/")
push = true
}
}</code></pre>`),
},
{
ID: "nuget",
Expand Down
3 changes: 3 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()))
Expand Down
Loading