From 98d4187dd6d02502480b924b3904299cf1a6e2ee Mon Sep 17 00:00:00 2001 From: John Murphy Date: Tue, 20 Jan 2026 15:51:53 +1100 Subject: [PATCH 1/6] feat: Add gomod proxy support --- internal/strategy/gomod.go | 184 +++++++++++++++ internal/strategy/gomod_test.go | 382 ++++++++++++++++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 internal/strategy/gomod.go create mode 100644 internal/strategy/gomod_test.go diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go new file mode 100644 index 0000000..a883137 --- /dev/null +++ b/internal/strategy/gomod.go @@ -0,0 +1,184 @@ +package strategy + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" + "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/strategy/handler" +) + +func init() { + Register("gomod", NewGoMod) +} + +// GoModConfig represents the configuration for the Go module proxy strategy. +// +// In HCL it looks like: +// +// gomod { +// proxy = "https://proxy.golang.org" +// } +type GoModConfig struct { + Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"` + MutableTTL string `hcl:"mutable-ttl,optional" help:"TTL for mutable Go module proxy endpoints (list, latest). Defaults to 5m." default:"5m"` + ImmutableTTL string `hcl:"immutable-ttl,optional" help:"TTL for immutable Go module proxy endpoints (versioned info, mod, zip). Defaults to 168h (7 days)." default:"168h"` +} + +// The GoMod strategy implements a caching proxy for the Go module proxy protocol. +// +// It supports all standard GOPROXY endpoints: +// - /$module/@v/list - Lists available versions +// - /$module/@v/$version.info - Version metadata JSON +// - /$module/@v/$version.mod - go.mod file +// - /$module/@v/$version.zip - Module source code +// - /$module/@latest - Latest version info +// +// The strategy uses differential caching: short TTL (5 minutes) for mutable +// endpoints (list, latest) and long TTL (7 days) for immutable versioned content. +type GoMod struct { + config GoModConfig + cache cache.Cache + client *http.Client + logger *slog.Logger + proxy *url.URL +} + +var _ Strategy = (*GoMod)(nil) + +// NewGoMod creates a new Go module proxy strategy. +func NewGoMod(ctx context.Context, config GoModConfig, cache cache.Cache, mux Mux) (*GoMod, error) { + parsedURL, err := url.Parse(config.Proxy) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL: %w", err) + } + + g := &GoMod{ + config: config, + cache: cache, + client: http.DefaultClient, + logger: logging.FromContext(ctx), + proxy: parsedURL, + } + + g.logger.InfoContext(ctx, "Initialized Go module proxy strategy", + slog.String("proxy", g.proxy.String())) + + // Create handler with caching configuration + h := handler.New(g.client, g.cache). + CacheKey(func(r *http.Request) string { + return g.buildUpstreamURL(r).String() + }). + Transform(func(r *http.Request) (*http.Request, error) { + return g.transformRequest(r) + }). + TTL(func(r *http.Request) time.Duration { + return g.calculateTTL(r) + }) + + // Register a catch-all handler that filters for Go module proxy patterns + mux.Handle("GET /{path...}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + // Check if this is a valid Go module proxy endpoint + if g.isGoModulePath(path) { + h.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + })) + + return g, nil +} + +// isGoModulePath checks if the path matches a valid Go module proxy endpoint pattern. +func (g *GoMod) isGoModulePath(path string) bool { + // Valid patterns: + // - /@v/list + // - /@v/{version}.info + // - /@v/{version}.mod + // - /@v/{version}.zip + // - /@latest + return strings.Contains(path, "/@v/list") || + strings.Contains(path, "/@latest") || + (strings.Contains(path, "/@v/") && + (strings.HasSuffix(path, ".info") || + strings.HasSuffix(path, ".mod") || + strings.HasSuffix(path, ".zip"))) +} + +func (g *GoMod) String() string { + return "gomod:" + g.proxy.Host +} + +// buildUpstreamURL constructs the full upstream URL from the incoming request. +func (g *GoMod) buildUpstreamURL(r *http.Request) *url.URL { + // The full path includes the module path and the endpoint + // e.g., /github.com/user/repo/@v/v1.0.0.info + path := r.URL.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + targetURL := *g.proxy + targetURL.Path = g.proxy.Path + path + targetURL.RawQuery = r.URL.RawQuery + + return &targetURL +} + +// transformRequest creates the upstream request to the Go module proxy. +func (g *GoMod) transformRequest(r *http.Request) (*http.Request, error) { + targetURL := g.buildUpstreamURL(r) + + g.logger.DebugContext(r.Context(), "Transforming Go module request", + slog.String("original_path", r.URL.Path), + slog.String("upstream_url", targetURL.String())) + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL.String(), nil) + if err != nil { + return nil, httputil.Errorf(http.StatusInternalServerError, "create upstream request: %w", err) + } + + // Go module proxies don't typically require authentication for public modules, + // but we'll pass through any authorization headers just in case + if auth := r.Header.Get("Authorization"); auth != "" { + req.Header.Set("Authorization", auth) + } + + return req, nil +} + +// calculateTTL returns the appropriate cache TTL based on the endpoint type. +// +// Mutable endpoints (list, latest) get short TTL (5 minutes). +// Immutable versioned content (info, mod, zip) gets long TTL (7 days). +func (g *GoMod) calculateTTL(r *http.Request) time.Duration { + path := r.URL.Path + + // Short TTL for mutable endpoints + if strings.Contains(path, "/@v/list") || strings.Contains(path, "/@latest") { + mutableTTL, err := time.ParseDuration(g.config.MutableTTL) + if err != nil { + mutableTTL = 5 * time.Minute + g.logger.WarnContext(r.Context(), "Invalid mutable TTL duration, defaulting to 5 minutes", + slog.String("provided", g.config.MutableTTL)) + } + return mutableTTL + } + + // Long TTL for immutable versioned content (.info, .mod, .zip) + immutableTTL, err := time.ParseDuration(g.config.ImmutableTTL) + if err != nil { + immutableTTL = 7 * 24 * time.Hour + g.logger.WarnContext(r.Context(), "Invalid immutable TTL duration, defaulting to 7 days", + slog.String("provided", g.config.ImmutableTTL)) + } + return immutableTTL +} diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go new file mode 100644 index 0000000..7b0de94 --- /dev/null +++ b/internal/strategy/gomod_test.go @@ -0,0 +1,382 @@ +package strategy_test + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/strategy" +) + +type mockGoModServer struct { + server *httptest.Server + requestCount map[string]int // Track requests by path + lastPath string + responses map[string]mockResponse +} + +type mockResponse struct { + status int + content string +} + +func newMockGoModServer() *mockGoModServer { + m := &mockGoModServer{ + requestCount: make(map[string]int), + responses: make(map[string]mockResponse), + } + + // Set up default responses for common endpoints + m.responses["/@v/list"] = mockResponse{ + status: http.StatusOK, + content: "v1.0.0\nv1.0.1\nv1.1.0\n", + } + m.responses["/@v/v1.0.0.info"] = mockResponse{ + status: http.StatusOK, + content: `{"Version":"v1.0.0","Time":"2023-01-01T00:00:00Z"}`, + } + m.responses["/@v/v1.0.0.mod"] = mockResponse{ + status: http.StatusOK, + content: "module github.com/example/test\n\ngo 1.21\n", + } + m.responses["/@v/v1.0.0.zip"] = mockResponse{ + status: http.StatusOK, + content: "PK\x03\x04...", // Mock zip content + } + m.responses["/@latest"] = mockResponse{ + status: http.StatusOK, + content: `{"Version":"v1.1.0","Time":"2023-06-01T00:00:00Z"}`, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", m.handleRequest) + m.server = httptest.NewServer(mux) + + return m +} + +func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + m.lastPath = path + m.requestCount[path]++ + + // Find matching response + var resp mockResponse + found := false + + // Try exact match first + if r, ok := m.responses[path]; ok { + resp = r + found = true + } else { + // Try suffix match for module paths + for suffix, r := range m.responses { + if len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix { + resp = r + found = true + break + } + } + } + + // If still not found, try pattern matching for any version + if !found { + if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".info") { + resp = mockResponse{ + status: http.StatusOK, + content: `{"Version":"v1.0.0","Time":"2023-01-01T00:00:00Z"}`, + } + found = true + } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".mod") { + resp = mockResponse{ + status: http.StatusOK, + content: "module github.com/example/test\n\ngo 1.21\n", + } + found = true + } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".zip") { + resp = mockResponse{ + status: http.StatusOK, + content: "PK\x03\x04...", + } + found = true + } + } + + if !found { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + + w.WriteHeader(resp.status) + _, _ = w.Write([]byte(resp.content)) +} + +func (m *mockGoModServer) close() { + m.server.Close() +} + +func (m *mockGoModServer) setResponse(path string, status int, content string) { + m.responses[path] = mockResponse{ + status: status, + content: content, + } +} + +func setupGoModTest(t *testing.T, config strategy.GoModConfig) (*mockGoModServer, *http.ServeMux, context.Context) { + t.Helper() + + mock := newMockGoModServer() + t.Cleanup(mock.close) + + // Point config to mock server and set defaults if not provided + config.Proxy = mock.server.URL + if config.MutableTTL == "" { + config.MutableTTL = "5m" + } + if config.ImmutableTTL == "" { + config.ImmutableTTL = "168h" + } + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) + assert.NoError(t, err) + t.Cleanup(func() { _ = memCache.Close() }) + + mux := http.NewServeMux() + _, err = strategy.NewGoMod(ctx, config, memCache, mux) + assert.NoError(t, err) + + return mock, mux, ctx +} + +func TestGoModList(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/list", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "v1.0.0\nv1.0.1\nv1.1.0\n", w.Body.String()) + assert.Equal(t, 1, mock.requestCount["/github.com/example/test/@v/list"]) +} + +func TestGoModInfo(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.info", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, `{"Version":"v1.0.0","Time":"2023-01-01T00:00:00Z"}`, w.Body.String()) + assert.Equal(t, 1, mock.requestCount["/github.com/example/test/@v/v1.0.0.info"]) +} + +func TestGoModMod(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.mod", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "module github.com/example/test\n\ngo 1.21\n", w.Body.String()) + assert.Equal(t, 1, mock.requestCount["/github.com/example/test/@v/v1.0.0.mod"]) +} + +func TestGoModZip(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.zip", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "PK\x03\x04...", w.Body.String()) + assert.Equal(t, 1, mock.requestCount["/github.com/example/test/@v/v1.0.0.zip"]) +} + +func TestGoModLatest(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@latest", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, `{"Version":"v1.1.0","Time":"2023-06-01T00:00:00Z"}`, w.Body.String()) + assert.Equal(t, 1, mock.requestCount["/github.com/example/test/@latest"]) +} + +func TestGoModCaching(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + path := "/github.com/example/test/@v/v1.0.0.info" + + // First request + req1 := httptest.NewRequest(http.MethodGet, path, nil) + req1 = req1.WithContext(ctx) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, 1, mock.requestCount[path]) + + // Second request should hit cache + req2 := httptest.NewRequest(http.MethodGet, path, nil) + req2 = req2.WithContext(ctx) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) + assert.Equal(t, 1, mock.requestCount[path], "second request should be served from cache") +} + +func TestGoModComplexModulePath(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + // Test module path with multiple slashes + req := httptest.NewRequest(http.MethodGet, "/golang.org/x/tools/@v/v0.1.0.info", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 1, mock.requestCount["/golang.org/x/tools/@v/v0.1.0.info"]) +} + +func TestGoModNonOKResponse(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + // Set up 404 response + notFoundPath := "/github.com/example/nonexistent/@v/v99.0.0.info" + mock.setResponse(notFoundPath, http.StatusNotFound, "not found") + + // First request should return 404 + req1 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) + req1 = req1.WithContext(ctx) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + + assert.Equal(t, http.StatusNotFound, w1.Code) + assert.Equal(t, 1, mock.requestCount[notFoundPath]) + + // Second request should also hit upstream (404s are not cached) + req2 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) + req2 = req2.WithContext(ctx) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusNotFound, w2.Code) + assert.Equal(t, 2, mock.requestCount[notFoundPath], "404 responses should not be cached") +} + +func TestGoModMultipleConcurrentRequests(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + path := "/github.com/example/test/@v/v1.0.0.zip" + + // Make multiple concurrent requests + results := make(chan *httptest.ResponseRecorder, 3) + for i := 0; i < 3; i++ { + go func() { + req := httptest.NewRequest(http.MethodGet, path, nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + results <- w + }() + } + + // Collect results + for i := 0; i < 3; i++ { + w := <-results + assert.Equal(t, http.StatusOK, w.Code) + } + + // First request should have created the cache entry + // Subsequent requests might hit cache or might be in-flight + // We just verify all requests succeeded + assert.True(t, mock.requestCount[path] >= 1, "at least one request should have been made to upstream") +} + +func TestGoModDefaultProxy(t *testing.T) { + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) + assert.NoError(t, err) + defer memCache.Close() + + mux := http.NewServeMux() + + // Create strategy with default proxy + gomod, err := strategy.NewGoMod(ctx, strategy.GoModConfig{ + Proxy: "https://proxy.golang.org", + MutableTTL: "5m", + ImmutableTTL: "168h", + }, memCache, mux) + assert.NoError(t, err) + assert.Equal(t, "gomod:proxy.golang.org", gomod.String()) +} + +func TestGoModCustomProxy(t *testing.T) { + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) + assert.NoError(t, err) + defer memCache.Close() + + mux := http.NewServeMux() + + // Create strategy with custom proxy + gomod, err := strategy.NewGoMod(ctx, strategy.GoModConfig{ + Proxy: "https://goproxy.example.com", + }, memCache, mux) + assert.NoError(t, err) + assert.Equal(t, "gomod:goproxy.example.com", gomod.String()) +} + +func TestGoModAuthorizationHeader(t *testing.T) { + mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + + // Create a custom handler to check if Authorization header is passed through + authReceived := "" + originalHandler := mock.server.Config.Handler + mock.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authReceived = r.Header.Get("Authorization") + originalHandler.ServeHTTP(w, r) + }) + + req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/list", nil) + req.Header.Set("Authorization", "Bearer test-token") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Bearer test-token", authReceived, "Authorization header should be passed to upstream") +} From 5ed1d718c58f9d52ef97ef9b88ae2e1095ab3992 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Tue, 20 Jan 2026 15:55:21 +1100 Subject: [PATCH 2/6] fix: Linting --- internal/strategy/gomod_test.go | 50 +++++++++++++++------------------ 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index 7b0de94..7ba1337 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -88,20 +88,21 @@ func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) } // If still not found, try pattern matching for any version - if !found { - if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".info") { + if !found && strings.Contains(path, "/@v/") { + switch { + case strings.HasSuffix(path, ".info"): resp = mockResponse{ status: http.StatusOK, content: `{"Version":"v1.0.0","Time":"2023-01-01T00:00:00Z"}`, } found = true - } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".mod") { + case strings.HasSuffix(path, ".mod"): resp = mockResponse{ status: http.StatusOK, content: "module github.com/example/test\n\ngo 1.21\n", } found = true - } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".zip") { + case strings.HasSuffix(path, ".zip"): resp = mockResponse{ status: http.StatusOK, content: "PK\x03\x04...", @@ -131,21 +132,12 @@ func (m *mockGoModServer) setResponse(path string, status int, content string) { } } -func setupGoModTest(t *testing.T, config strategy.GoModConfig) (*mockGoModServer, *http.ServeMux, context.Context) { +func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Context) { t.Helper() mock := newMockGoModServer() t.Cleanup(mock.close) - // Point config to mock server and set defaults if not provided - config.Proxy = mock.server.URL - if config.MutableTTL == "" { - config.MutableTTL = "5m" - } - if config.ImmutableTTL == "" { - config.ImmutableTTL = "168h" - } - _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) @@ -153,14 +145,18 @@ func setupGoModTest(t *testing.T, config strategy.GoModConfig) (*mockGoModServer t.Cleanup(func() { _ = memCache.Close() }) mux := http.NewServeMux() - _, err = strategy.NewGoMod(ctx, config, memCache, mux) + _, err = strategy.NewGoMod(ctx, strategy.GoModConfig{ + Proxy: mock.server.URL, + MutableTTL: "5m", + ImmutableTTL: "168h", + }, memCache, mux) assert.NoError(t, err) return mock, mux, ctx } func TestGoModList(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/list", nil) req = req.WithContext(ctx) @@ -174,7 +170,7 @@ func TestGoModList(t *testing.T) { } func TestGoModInfo(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.info", nil) req = req.WithContext(ctx) @@ -188,7 +184,7 @@ func TestGoModInfo(t *testing.T) { } func TestGoModMod(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.mod", nil) req = req.WithContext(ctx) @@ -202,7 +198,7 @@ func TestGoModMod(t *testing.T) { } func TestGoModZip(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.zip", nil) req = req.WithContext(ctx) @@ -216,7 +212,7 @@ func TestGoModZip(t *testing.T) { } func TestGoModLatest(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@latest", nil) req = req.WithContext(ctx) @@ -230,7 +226,7 @@ func TestGoModLatest(t *testing.T) { } func TestGoModCaching(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) path := "/github.com/example/test/@v/v1.0.0.info" @@ -255,7 +251,7 @@ func TestGoModCaching(t *testing.T) { } func TestGoModComplexModulePath(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) // Test module path with multiple slashes req := httptest.NewRequest(http.MethodGet, "/golang.org/x/tools/@v/v0.1.0.info", nil) @@ -269,7 +265,7 @@ func TestGoModComplexModulePath(t *testing.T) { } func TestGoModNonOKResponse(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) // Set up 404 response notFoundPath := "/github.com/example/nonexistent/@v/v99.0.0.info" @@ -295,13 +291,13 @@ func TestGoModNonOKResponse(t *testing.T) { } func TestGoModMultipleConcurrentRequests(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) path := "/github.com/example/test/@v/v1.0.0.zip" // Make multiple concurrent requests results := make(chan *httptest.ResponseRecorder, 3) - for i := 0; i < 3; i++ { + for range 3 { go func() { req := httptest.NewRequest(http.MethodGet, path, nil) req = req.WithContext(ctx) @@ -312,7 +308,7 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { } // Collect results - for i := 0; i < 3; i++ { + for range 3 { w := <-results assert.Equal(t, http.StatusOK, w.Code) } @@ -360,7 +356,7 @@ func TestGoModCustomProxy(t *testing.T) { } func TestGoModAuthorizationHeader(t *testing.T) { - mock, mux, ctx := setupGoModTest(t, strategy.GoModConfig{}) + mock, mux, ctx := setupGoModTest(t) // Create a custom handler to check if Authorization header is passed through authReceived := "" From c0c656b092c5fd94cee02660c9accbb5242aa3ef Mon Sep 17 00:00:00 2001 From: js-murph <7096301+js-murph@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:35:36 +1100 Subject: [PATCH 3/6] Update internal/strategy/gomod.go Co-authored-by: Alec Thomas --- internal/strategy/gomod.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index a883137..3c0d5c3 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -76,12 +76,8 @@ func NewGoMod(ctx context.Context, config GoModConfig, cache cache.Cache, mux Mu CacheKey(func(r *http.Request) string { return g.buildUpstreamURL(r).String() }). - Transform(func(r *http.Request) (*http.Request, error) { - return g.transformRequest(r) - }). - TTL(func(r *http.Request) time.Duration { - return g.calculateTTL(r) - }) + Transform(g.transformRequest). + TTL(g.calculateTTL) // Register a catch-all handler that filters for Go module proxy patterns mux.Handle("GET /{path...}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 9ebfe7d4ab7bc123c37a2c0aef05a4ad0a22fc20 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Wed, 21 Jan 2026 10:34:59 +1100 Subject: [PATCH 4/6] fix: Various code quality issues --- internal/strategy/gomod.go | 37 +++++-------------- internal/strategy/gomod_test.go | 65 ++------------------------------- 2 files changed, 14 insertions(+), 88 deletions(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 3c0d5c3..e249764 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -11,6 +11,7 @@ import ( "github.com/block/cachew/internal/cache" "github.com/block/cachew/internal/httputil" + "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy/handler" ) @@ -27,9 +28,9 @@ func init() { // proxy = "https://proxy.golang.org" // } type GoModConfig struct { - Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"` - MutableTTL string `hcl:"mutable-ttl,optional" help:"TTL for mutable Go module proxy endpoints (list, latest). Defaults to 5m." default:"5m"` - ImmutableTTL string `hcl:"immutable-ttl,optional" help:"TTL for immutable Go module proxy endpoints (versioned info, mod, zip). Defaults to 168h (7 days)." default:"168h"` + Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"` + MutableTTL time.Duration `hcl:"mutable-ttl,optional" help:"TTL for mutable Go module proxy endpoints (list, latest). Defaults to 5m." default:"5m"` + ImmutableTTL time.Duration `hcl:"immutable-ttl,optional" help:"TTL for immutable Go module proxy endpoints (versioned info, mod, zip). Defaults to 168h (7 days)." default:"168h"` } // The GoMod strategy implements a caching proxy for the Go module proxy protocol. @@ -54,7 +55,7 @@ type GoMod struct { var _ Strategy = (*GoMod)(nil) // NewGoMod creates a new Go module proxy strategy. -func NewGoMod(ctx context.Context, config GoModConfig, cache cache.Cache, mux Mux) (*GoMod, error) { +func NewGoMod(ctx context.Context, scheduler jobscheduler.Scheduler, config GoModConfig, cache cache.Cache, mux Mux) (*GoMod, error) { parsedURL, err := url.Parse(config.Proxy) if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) @@ -101,8 +102,8 @@ func (g *GoMod) isGoModulePath(path string) bool { // - /@v/{version}.mod // - /@v/{version}.zip // - /@latest - return strings.Contains(path, "/@v/list") || - strings.Contains(path, "/@latest") || + return strings.HasSuffix(path, "/@v/list") || + strings.HasSuffix(path, "/@latest") || (strings.Contains(path, "/@v/") && (strings.HasSuffix(path, ".info") || strings.HasSuffix(path, ".mod") || @@ -142,12 +143,6 @@ func (g *GoMod) transformRequest(r *http.Request) (*http.Request, error) { return nil, httputil.Errorf(http.StatusInternalServerError, "create upstream request: %w", err) } - // Go module proxies don't typically require authentication for public modules, - // but we'll pass through any authorization headers just in case - if auth := r.Header.Get("Authorization"); auth != "" { - req.Header.Set("Authorization", auth) - } - return req, nil } @@ -159,22 +154,10 @@ func (g *GoMod) calculateTTL(r *http.Request) time.Duration { path := r.URL.Path // Short TTL for mutable endpoints - if strings.Contains(path, "/@v/list") || strings.Contains(path, "/@latest") { - mutableTTL, err := time.ParseDuration(g.config.MutableTTL) - if err != nil { - mutableTTL = 5 * time.Minute - g.logger.WarnContext(r.Context(), "Invalid mutable TTL duration, defaulting to 5 minutes", - slog.String("provided", g.config.MutableTTL)) - } - return mutableTTL + if strings.HasSuffix(path, "/@v/list") || strings.HasSuffix(path, "/@latest") { + return g.config.MutableTTL } // Long TTL for immutable versioned content (.info, .mod, .zip) - immutableTTL, err := time.ParseDuration(g.config.ImmutableTTL) - if err != nil { - immutableTTL = 7 * 24 * time.Hour - g.logger.WarnContext(r.Context(), "Invalid immutable TTL duration, defaulting to 7 days", - slog.String("provided", g.config.ImmutableTTL)) - } - return immutableTTL + return g.config.ImmutableTTL } diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index 7ba1337..43cbebe 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -12,6 +12,7 @@ import ( "github.com/alecthomas/assert/v2" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy" ) @@ -145,10 +146,10 @@ func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Con t.Cleanup(func() { _ = memCache.Close() }) mux := http.NewServeMux() - _, err = strategy.NewGoMod(ctx, strategy.GoModConfig{ + _, err = strategy.NewGoMod(ctx, jobscheduler.New(ctx, jobscheduler.Config{}), strategy.GoModConfig{ Proxy: mock.server.URL, - MutableTTL: "5m", - ImmutableTTL: "168h", + MutableTTL: 5 * time.Minute, + ImmutableTTL: 168 * time.Hour, }, memCache, mux) assert.NoError(t, err) @@ -318,61 +319,3 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { // We just verify all requests succeeded assert.True(t, mock.requestCount[path] >= 1, "at least one request should have been made to upstream") } - -func TestGoModDefaultProxy(t *testing.T) { - _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) - - memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) - assert.NoError(t, err) - defer memCache.Close() - - mux := http.NewServeMux() - - // Create strategy with default proxy - gomod, err := strategy.NewGoMod(ctx, strategy.GoModConfig{ - Proxy: "https://proxy.golang.org", - MutableTTL: "5m", - ImmutableTTL: "168h", - }, memCache, mux) - assert.NoError(t, err) - assert.Equal(t, "gomod:proxy.golang.org", gomod.String()) -} - -func TestGoModCustomProxy(t *testing.T) { - _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) - - memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 24 * time.Hour}) - assert.NoError(t, err) - defer memCache.Close() - - mux := http.NewServeMux() - - // Create strategy with custom proxy - gomod, err := strategy.NewGoMod(ctx, strategy.GoModConfig{ - Proxy: "https://goproxy.example.com", - }, memCache, mux) - assert.NoError(t, err) - assert.Equal(t, "gomod:goproxy.example.com", gomod.String()) -} - -func TestGoModAuthorizationHeader(t *testing.T) { - mock, mux, ctx := setupGoModTest(t) - - // Create a custom handler to check if Authorization header is passed through - authReceived := "" - originalHandler := mock.server.Config.Handler - mock.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authReceived = r.Header.Get("Authorization") - originalHandler.ServeHTTP(w, r) - }) - - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/list", nil) - req.Header.Set("Authorization", "Bearer test-token") - req = req.WithContext(ctx) - w := httptest.NewRecorder() - - mux.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "Bearer test-token", authReceived, "Authorization header should be passed to upstream") -} From 605ac9784399164f151a9c3c46b6d1af415bb67a Mon Sep 17 00:00:00 2001 From: John Murphy Date: Wed, 21 Jan 2026 14:38:10 +1100 Subject: [PATCH 5/6] fix: Namespace the gomod endpoint --- internal/strategy/gomod.go | 11 ++++++++--- internal/strategy/gomod_test.go | 33 ++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index e249764..5ce984f 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -80,8 +80,8 @@ func NewGoMod(ctx context.Context, scheduler jobscheduler.Scheduler, config GoMo Transform(g.transformRequest). TTL(g.calculateTTL) - // Register a catch-all handler that filters for Go module proxy patterns - mux.Handle("GET /{path...}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Register a namespaced handler for Go module proxy patterns + mux.Handle("GET /gomod/{path...}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Check if this is a valid Go module proxy endpoint if g.isGoModulePath(path) { @@ -96,6 +96,9 @@ func NewGoMod(ctx context.Context, scheduler jobscheduler.Scheduler, config GoMo // isGoModulePath checks if the path matches a valid Go module proxy endpoint pattern. func (g *GoMod) isGoModulePath(path string) bool { + // Strip the /gomod prefix before checking the pattern + path = strings.TrimPrefix(path, "/gomod") + // Valid patterns: // - /@v/list // - /@v/{version}.info @@ -117,8 +120,10 @@ func (g *GoMod) String() string { // buildUpstreamURL constructs the full upstream URL from the incoming request. func (g *GoMod) buildUpstreamURL(r *http.Request) *url.URL { // The full path includes the module path and the endpoint - // e.g., /github.com/user/repo/@v/v1.0.0.info + // e.g., /gomod/github.com/user/repo/@v/v1.0.0.info + // We need to strip the /gomod prefix before forwarding to the upstream proxy path := r.URL.Path + path = strings.TrimPrefix(path, "/gomod") if !strings.HasPrefix(path, "/") { path = "/" + path } diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index 43cbebe..bd9b001 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -159,7 +159,7 @@ func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Con func TestGoModList(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/list", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/github.com/example/test/@v/list", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -173,7 +173,7 @@ func TestGoModList(t *testing.T) { func TestGoModInfo(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.info", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/github.com/example/test/@v/v1.0.0.info", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -187,7 +187,7 @@ func TestGoModInfo(t *testing.T) { func TestGoModMod(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.mod", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/github.com/example/test/@v/v1.0.0.mod", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -201,7 +201,7 @@ func TestGoModMod(t *testing.T) { func TestGoModZip(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@v/v1.0.0.zip", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/github.com/example/test/@v/v1.0.0.zip", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -215,7 +215,7 @@ func TestGoModZip(t *testing.T) { func TestGoModLatest(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - req := httptest.NewRequest(http.MethodGet, "/github.com/example/test/@latest", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/github.com/example/test/@latest", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -229,7 +229,8 @@ func TestGoModLatest(t *testing.T) { func TestGoModCaching(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - path := "/github.com/example/test/@v/v1.0.0.info" + path := "/gomod/github.com/example/test/@v/v1.0.0.info" + upstreamPath := "/github.com/example/test/@v/v1.0.0.info" // First request req1 := httptest.NewRequest(http.MethodGet, path, nil) @@ -238,7 +239,7 @@ func TestGoModCaching(t *testing.T) { mux.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) - assert.Equal(t, 1, mock.requestCount[path]) + assert.Equal(t, 1, mock.requestCount[upstreamPath]) // Second request should hit cache req2 := httptest.NewRequest(http.MethodGet, path, nil) @@ -248,14 +249,14 @@ func TestGoModCaching(t *testing.T) { assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, w1.Body.String(), w2.Body.String()) - assert.Equal(t, 1, mock.requestCount[path], "second request should be served from cache") + assert.Equal(t, 1, mock.requestCount[upstreamPath], "second request should be served from cache") } func TestGoModComplexModulePath(t *testing.T) { mock, mux, ctx := setupGoModTest(t) // Test module path with multiple slashes - req := httptest.NewRequest(http.MethodGet, "/golang.org/x/tools/@v/v0.1.0.info", nil) + req := httptest.NewRequest(http.MethodGet, "/gomod/golang.org/x/tools/@v/v0.1.0.info", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -269,8 +270,9 @@ func TestGoModNonOKResponse(t *testing.T) { mock, mux, ctx := setupGoModTest(t) // Set up 404 response - notFoundPath := "/github.com/example/nonexistent/@v/v99.0.0.info" - mock.setResponse(notFoundPath, http.StatusNotFound, "not found") + upstreamPath := "/github.com/example/nonexistent/@v/v99.0.0.info" + notFoundPath := "/gomod" + upstreamPath + mock.setResponse(upstreamPath, http.StatusNotFound, "not found") // First request should return 404 req1 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) @@ -279,7 +281,7 @@ func TestGoModNonOKResponse(t *testing.T) { mux.ServeHTTP(w1, req1) assert.Equal(t, http.StatusNotFound, w1.Code) - assert.Equal(t, 1, mock.requestCount[notFoundPath]) + assert.Equal(t, 1, mock.requestCount[upstreamPath]) // Second request should also hit upstream (404s are not cached) req2 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) @@ -288,13 +290,14 @@ func TestGoModNonOKResponse(t *testing.T) { mux.ServeHTTP(w2, req2) assert.Equal(t, http.StatusNotFound, w2.Code) - assert.Equal(t, 2, mock.requestCount[notFoundPath], "404 responses should not be cached") + assert.Equal(t, 2, mock.requestCount[upstreamPath], "404 responses should not be cached") } func TestGoModMultipleConcurrentRequests(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - path := "/github.com/example/test/@v/v1.0.0.zip" + path := "/gomod/github.com/example/test/@v/v1.0.0.zip" + upstreamPath := "/github.com/example/test/@v/v1.0.0.zip" // Make multiple concurrent requests results := make(chan *httptest.ResponseRecorder, 3) @@ -317,5 +320,5 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { // First request should have created the cache entry // Subsequent requests might hit cache or might be in-flight // We just verify all requests succeeded - assert.True(t, mock.requestCount[path] >= 1, "at least one request should have been made to upstream") + assert.True(t, mock.requestCount[upstreamPath] >= 1, "at least one request should have been made to upstream") } From c3285e466869d9e140da6866d52e4ada6768ec72 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Wed, 21 Jan 2026 15:06:54 +1100 Subject: [PATCH 6/6] fix: Issues with endpoint registration --- internal/strategy/gomod.go | 4 ++-- internal/strategy/gomod_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 5ce984f..2380f85 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -17,7 +17,7 @@ import ( ) func init() { - Register("gomod", NewGoMod) + Register("gomod", "Caches Go module proxy requests.", NewGoMod) } // GoModConfig represents the configuration for the Go module proxy strategy. @@ -55,7 +55,7 @@ type GoMod struct { var _ Strategy = (*GoMod)(nil) // NewGoMod creates a new Go module proxy strategy. -func NewGoMod(ctx context.Context, scheduler jobscheduler.Scheduler, config GoModConfig, cache cache.Cache, mux Mux) (*GoMod, error) { +func NewGoMod(ctx context.Context, config GoModConfig, scheduler jobscheduler.Scheduler, cache cache.Cache, mux Mux) (*GoMod, error) { parsedURL, err := url.Parse(config.Proxy) if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index bd9b001..b60ac85 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -146,11 +146,11 @@ func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Con t.Cleanup(func() { _ = memCache.Close() }) mux := http.NewServeMux() - _, err = strategy.NewGoMod(ctx, jobscheduler.New(ctx, jobscheduler.Config{}), strategy.GoModConfig{ + _, err = strategy.NewGoMod(ctx, strategy.GoModConfig{ Proxy: mock.server.URL, MutableTTL: 5 * time.Minute, ImmutableTTL: 168 * time.Hour, - }, memCache, mux) + }, jobscheduler.New(ctx, jobscheduler.Config{}), memCache, mux) assert.NoError(t, err) return mock, mux, ctx