From 1e090533f4dd8a32b0092c665535b636d600b3f6 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Thu, 22 Jan 2026 09:57:36 +1100 Subject: [PATCH 01/12] feat: Working goproxy implementation --- cachew.hcl | 4 + go.mod | 3 + go.sum | 8 ++ internal/strategy/gomod.go | 146 +++++++----------------------- internal/strategy/gomod_cacher.go | 115 +++++++++++++++++++++++ internal/strategy/gomod_test.go | 141 ++++++++++++++++++++--------- 6 files changed, 259 insertions(+), 158 deletions(-) create mode 100644 internal/strategy/gomod_cacher.go diff --git a/cachew.hcl b/cachew.hcl index 3534e99..57595f3 100644 --- a/cachew.hcl +++ b/cachew.hcl @@ -26,3 +26,7 @@ disk { limit-mb = 250000 max-ttl = "8h" } + +gomod { + proxy = "https://proxy.golang.org" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 7e41619..3dec05c 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.25.5 require ( github.com/alecthomas/hcl/v2 v2.3.1 github.com/alecthomas/kong v1.13.0 + github.com/goproxy/goproxy v0.25.0 github.com/lmittmann/tint v1.1.2 github.com/minio/minio-go/v7 v7.0.97 go.etcd.io/bbolt v1.4.3 ) require ( + github.com/aofei/backoff v1.1.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect @@ -28,6 +30,7 @@ require ( github.com/stretchr/testify v1.11.1 // indirect github.com/tinylib/msgp v1.3.0 // indirect golang.org/x/crypto v0.44.0 // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index cc6f574..b263fcc 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WS github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aofei/backoff v1.1.0 h1:7ey7Ydpx/eFIyyrBNKPbgvTzvIuUOHcwkR3gPjjY9ag= +github.com/aofei/backoff v1.1.0/go.mod h1:IHCkMdd5vGP6dcDHD+uLn6lVuBw7+rKYaS7e7QIQwYA= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +25,8 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goproxy/goproxy v0.25.0 h1:TujZjUbKCwpFYrm+j04HACs1EAcBbFSGLwLMn8ynTys= +github.com/goproxy/goproxy v0.25.0/go.mod h1:6RIssMPDpQ0IHZus17gPUyBtU62RoqblQDYWx2sz/qs= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -65,6 +69,8 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -73,6 +79,8 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 7b066e9..37942b8 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -6,50 +6,31 @@ import ( "log/slog" "net/http" "net/url" - "strings" "time" + "github.com/goproxy/goproxy" + "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" ) func init() { Register("gomod", "Caches Go module proxy requests.", 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 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. -// -// 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 + config GoModConfig + cache cache.Cache + logger *slog.Logger + proxy *url.URL + goproxy *goproxy.Goproxy } var _ Strategy = (*GoMod)(nil) @@ -64,105 +45,42 @@ func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, 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 the goproxy instance with our custom cacher adapter + g.goproxy = &goproxy.Goproxy{ + Fetcher: &goproxy.GoFetcher{ + // Configure to use the specified upstream proxy + Env: []string{ + "GOPROXY=" + config.Proxy, + "GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation + }, + MaxDirectFetches: 0, // Disable direct fetches entirely + }, + Cacher: &goproxyCacher{ + cache: cache, + mutableTTL: config.MutableTTL, + immutableTTL: config.ImmutableTTL, + }, + ProxiedSumDBs: []string{ + "sum.golang.org https://sum.golang.org", + }, + } - // Create handler with caching configuration - h := handler.New(g.client, g.cache). - CacheKey(func(r *http.Request) string { - return g.buildUpstreamURL(r).String() - }). - Transform(g.transformRequest). - TTL(g.calculateTTL) + g.logger.InfoContext(ctx, "Initialized Go module proxy strategy", + slog.String("proxy", g.proxy.String()), + slog.Duration("mutable_ttl", config.MutableTTL), + slog.Duration("immutable_ttl", config.ImmutableTTL)) // 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) { - h.ServeHTTP(w, r) - return - } - http.NotFound(w, r) - })) + // Strip the /gomod prefix and delegate to goproxy + mux.Handle("GET /gomod/{path...}", http.StripPrefix("/gomod", g.goproxy)) return g, nil } -// 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 - // - /@v/{version}.mod - // - /@v/{version}.zip - // - /@latest - return strings.HasSuffix(path, "/@v/list") || - strings.HasSuffix(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., /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 - } - - 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) - } - - 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.HasSuffix(path, "/@v/list") || strings.HasSuffix(path, "/@latest") { - return g.config.MutableTTL - } - - // Long TTL for immutable versioned content (.info, .mod, .zip) - return g.config.ImmutableTTL -} diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go new file mode 100644 index 0000000..c578b84 --- /dev/null +++ b/internal/strategy/gomod_cacher.go @@ -0,0 +1,115 @@ +package strategy + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "io/fs" + "net/textproto" + "strings" + "time" + + "github.com/block/cachew/internal/cache" +) + +// goproxyCacher adapts cachew's cache.Cache interface to work with goproxy's Cacher interface. +// It handles the translation between goproxy's file-based caching model and cachew's +// HTTP-response-based caching model. +type goproxyCacher struct { + cache cache.Cache + mutableTTL time.Duration + immutableTTL time.Duration +} + +// Get retrieves cached content by name from cachew's cache. +// It returns fs.ErrNotExist if the content is not found, which goproxy uses +// as a signal to fetch from upstream. +func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, error) { + // Hash the name to create a cache key that matches cachew's format + key := cache.Key(sha256.Sum256([]byte(name))) + + // Try to open the cached content + rc, _, err := g.cache.Open(ctx, key) + if err != nil { + // If the cache backend returns an error, treat it as not found + // This ensures goproxy will fetch from upstream + return nil, fs.ErrNotExist + } + + return rc, nil +} + +// Put stores content in cachew's cache with the appropriate TTL. +// The TTL is determined by inspecting the cache name to identify whether +// it represents mutable or immutable content. +func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { + // Hash the name to create a cache key + key := cache.Key(sha256.Sum256([]byte(name))) + + // Determine TTL based on the endpoint type + ttl := g.calculateTTL(name) + + // Determine Content-Type from the file extension + contentType := g.getContentType(name) + + // Create headers for the cached response + headers := make(textproto.MIMEHeader) + headers.Set("Content-Type", contentType) + + // Create the cache entry + wc, err := g.cache.Create(ctx, key, headers, ttl) + if err != nil { + return fmt.Errorf("create cache entry: %w", err) + } + defer wc.Close() + + // Reset the seeker to the beginning + if _, err := content.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek to start: %w", err) + } + + // Copy the content to the cache + if _, err := io.Copy(wc, content); err != nil { + return fmt.Errorf("write to cache: %w", err) + } + + // Close the writer to commit the cache entry + if err := wc.Close(); err != nil { + return fmt.Errorf("close cache entry: %w", err) + } + + return nil +} + +// calculateTTL determines the appropriate cache TTL based on the endpoint type. +// +// Mutable endpoints (list, latest) get short TTL. +// Immutable versioned content (info, mod, zip) gets long TTL. +func (g *goproxyCacher) calculateTTL(name string) time.Duration { + // Short TTL for mutable endpoints + if strings.HasSuffix(name, "/@v/list") || strings.HasSuffix(name, "/@latest") { + return g.mutableTTL + } + + // Long TTL for immutable versioned content (.info, .mod, .zip) + return g.immutableTTL +} + +// getContentType returns the appropriate Content-Type header based on the file extension. +func (g *goproxyCacher) getContentType(name string) string { + switch { + case strings.HasSuffix(name, ".info"): + return "application/json" + case strings.HasSuffix(name, ".mod"): + return "text/plain; charset=utf-8" + case strings.HasSuffix(name, ".zip"): + return "application/zip" + case strings.HasSuffix(name, "/@v/list"): + return "text/plain; charset=utf-8" + case strings.HasSuffix(name, "/@latest"): + return "application/json" + default: + return "application/octet-stream" + } +} diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index b60ac85..d4f09fd 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -1,11 +1,14 @@ package strategy_test import ( + "archive/zip" + "bytes" "context" "log/slog" "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -20,6 +23,7 @@ import ( type mockGoModServer struct { server *httptest.Server requestCount map[string]int // Track requests by path + mu sync.Mutex // Protects requestCount lastPath string responses map[string]mockResponse } @@ -36,26 +40,16 @@ func newMockGoModServer() *mockGoModServer { } // Set up default responses for common endpoints + // Note: goproxy fetches .zip files first to validate, then fetches .info and .mod 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 + content: "v1.0.0\nv1.0.1\nv1.1.0", } m.responses["/@latest"] = mockResponse{ status: http.StatusOK, content: `{"Version":"v1.1.0","Time":"2023-06-01T00:00:00Z"}`, } + // Other responses will be dynamically generated based on the path mux := http.NewServeMux() mux.HandleFunc("/", m.handleRequest) @@ -64,10 +58,48 @@ func newMockGoModServer() *mockGoModServer { return m } +// createModuleZip creates a valid Go module zip file with the correct structure +func createModuleZip(modulePath, version string) string { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // The zip file must have paths prefixed with modulePath@version/ + prefix := modulePath + "@" + version + "/" + + // Add a go.mod file to make it a valid Go module zip + f, err := w.Create(prefix + "go.mod") + if err != nil { + panic(err) + } + _, err = f.Write([]byte("module " + modulePath + "\n\ngo 1.21\n")) + if err != nil { + panic(err) + } + + // Add a dummy source file + f2, err := w.Create(prefix + "main.go") + if err != nil { + panic(err) + } + _, err = f2.Write([]byte("package main\n\nfunc main() {}\n")) + if err != nil { + panic(err) + } + + if err := w.Close(); err != nil { + panic(err) + } + + return buf.String() +} + func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + + m.mu.Lock() m.lastPath = path m.requestCount[path]++ + m.mu.Unlock() // Find matching response var resp mockResponse @@ -90,25 +122,35 @@ func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) // If still not found, try pattern matching for any version 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 - case strings.HasSuffix(path, ".mod"): - resp = mockResponse{ - status: http.StatusOK, - content: "module github.com/example/test\n\ngo 1.21\n", - } - found = true - case strings.HasSuffix(path, ".zip"): - resp = mockResponse{ - status: http.StatusOK, - content: "PK\x03\x04...", + // Extract module path and version from the request + // e.g., /github.com/example/test/@v/v1.0.0.info + parts := strings.Split(path, "/@v/") + if len(parts) == 2 { + modulePath := strings.TrimPrefix(parts[0], "/") + versionPart := parts[1] + + switch { + case strings.HasSuffix(path, ".info"): + version := strings.TrimSuffix(versionPart, ".info") + resp = mockResponse{ + status: http.StatusOK, + content: `{"Version":"` + version + `","Time":"2023-01-01T00:00:00Z"}`, + } + found = true + case strings.HasSuffix(path, ".mod"): + resp = mockResponse{ + status: http.StatusOK, + content: "module " + modulePath + "\n\ngo 1.21\n", + } + found = true + case strings.HasSuffix(path, ".zip"): + version := strings.TrimSuffix(versionPart, ".zip") + resp = mockResponse{ + status: http.StatusOK, + content: createModuleZip(modulePath, version), + } + found = true } - found = true } } @@ -133,6 +175,12 @@ func (m *mockGoModServer) setResponse(path string, status int, content string) { } } +func (m *mockGoModServer) getRequestCount(path string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.requestCount[path] +} + func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Context) { t.Helper() @@ -166,8 +214,12 @@ func TestGoModList(t *testing.T) { 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"]) + // goproxy may add a trailing newline or format the output + body := strings.TrimSpace(w.Body.String()) + assert.True(t, strings.Contains(body, "v1.0.0"), "response should contain v1.0.0") + assert.True(t, strings.Contains(body, "v1.0.1"), "response should contain v1.0.1") + assert.True(t, strings.Contains(body, "v1.1.0"), "response should contain v1.1.0") + assert.Equal(t, 1, mock.getRequestCount("/github.com/example/test/@v/list")) } func TestGoModInfo(t *testing.T) { @@ -181,7 +233,7 @@ func TestGoModInfo(t *testing.T) { 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"]) + assert.Equal(t, 1, mock.getRequestCount("/github.com/example/test/@v/v1.0.0.info")) } func TestGoModMod(t *testing.T) { @@ -195,7 +247,7 @@ func TestGoModMod(t *testing.T) { 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"]) + assert.Equal(t, 1, mock.getRequestCount("/github.com/example/test/@v/v1.0.0.mod")) } func TestGoModZip(t *testing.T) { @@ -208,8 +260,9 @@ func TestGoModZip(t *testing.T) { 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"]) + // Verify we get a valid zip file (starts with PK signature) + assert.True(t, strings.HasPrefix(w.Body.String(), "PK"), "response should be a valid zip file") + assert.True(t, mock.getRequestCount("/github.com/example/test/@v/v1.0.0.zip") >= 1, "should have fetched zip") } func TestGoModLatest(t *testing.T) { @@ -223,7 +276,7 @@ func TestGoModLatest(t *testing.T) { 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"]) + assert.Equal(t, 1, mock.getRequestCount("/github.com/example/test/@latest")) } func TestGoModCaching(t *testing.T) { @@ -239,7 +292,7 @@ func TestGoModCaching(t *testing.T) { mux.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) - assert.Equal(t, 1, mock.requestCount[upstreamPath]) + assert.Equal(t, 1, mock.getRequestCount(upstreamPath)) // Second request should hit cache req2 := httptest.NewRequest(http.MethodGet, path, nil) @@ -249,7 +302,7 @@ 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[upstreamPath], "second request should be served from cache") + assert.Equal(t, 1, mock.getRequestCount(upstreamPath), "second request should be served from cache") } func TestGoModComplexModulePath(t *testing.T) { @@ -263,7 +316,7 @@ func TestGoModComplexModulePath(t *testing.T) { 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"]) + assert.Equal(t, 1, mock.getRequestCount("/golang.org/x/tools/@v/v0.1.0.info")) } func TestGoModNonOKResponse(t *testing.T) { @@ -281,7 +334,7 @@ func TestGoModNonOKResponse(t *testing.T) { mux.ServeHTTP(w1, req1) assert.Equal(t, http.StatusNotFound, w1.Code) - assert.Equal(t, 1, mock.requestCount[upstreamPath]) + assert.Equal(t, 1, mock.getRequestCount(upstreamPath)) // Second request should also hit upstream (404s are not cached) req2 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) @@ -290,7 +343,7 @@ func TestGoModNonOKResponse(t *testing.T) { mux.ServeHTTP(w2, req2) assert.Equal(t, http.StatusNotFound, w2.Code) - assert.Equal(t, 2, mock.requestCount[upstreamPath], "404 responses should not be cached") + assert.Equal(t, 2, mock.getRequestCount(upstreamPath), "404 responses should not be cached") } func TestGoModMultipleConcurrentRequests(t *testing.T) { @@ -320,5 +373,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[upstreamPath] >= 1, "at least one request should have been made to upstream") + assert.True(t, mock.getRequestCount(upstreamPath) >= 1, "at least one request should have been made to upstream") } From 497e6292521b2775d9d8eb7eb375ceae104d198e Mon Sep 17 00:00:00 2001 From: John Murphy Date: Thu, 22 Jan 2026 15:13:34 +1100 Subject: [PATCH 02/12] fix: A bold faced lie --- internal/strategy/gomod.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 37942b8..35a75c5 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -57,7 +57,6 @@ func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, "GOPROXY=" + config.Proxy, "GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation }, - MaxDirectFetches: 0, // Disable direct fetches entirely }, Cacher: &goproxyCacher{ cache: cache, From ff941e5fd4f85c67c1c3e491196c139d5cf285d4 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 22 Jan 2026 21:14:37 +1100 Subject: [PATCH 03/12] fix: configure goproxy to use our logger --- internal/strategy/gomod.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 35a75c5..91961ac 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -51,6 +51,7 @@ func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, // Create the goproxy instance with our custom cacher adapter g.goproxy = &goproxy.Goproxy{ + Logger: g.logger, Fetcher: &goproxy.GoFetcher{ // Configure to use the specified upstream proxy Env: []string{ From 1a287d44eedc987e06eb9c911f0bd77d4bb82613 Mon Sep 17 00:00:00 2001 From: js-murph <7096301+js-murph@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:12:54 +1100 Subject: [PATCH 04/12] Update internal/strategy/gomod_cacher.go Co-authored-by: Alec Thomas --- internal/strategy/gomod_cacher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index c578b84..a1724ed 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -45,7 +45,7 @@ func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, er // it represents mutable or immutable content. func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { // Hash the name to create a cache key - key := cache.Key(sha256.Sum256([]byte(name))) + key := cache.NewKey(name) // Determine TTL based on the endpoint type ttl := g.calculateTTL(name) From d61cfb4384cba1e3a372c385f9cd2e8e27417e89 Mon Sep 17 00:00:00 2001 From: js-murph <7096301+js-murph@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:14:00 +1100 Subject: [PATCH 05/12] Update internal/strategy/gomod_cacher.go Co-authored-by: Alec Thomas --- internal/strategy/gomod_cacher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index a1724ed..29eca2f 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -27,7 +27,7 @@ type goproxyCacher struct { // as a signal to fetch from upstream. func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, error) { // Hash the name to create a cache key that matches cachew's format - key := cache.Key(sha256.Sum256([]byte(name))) + key := cache.NewKey(name) // Try to open the cached content rc, _, err := g.cache.Open(ctx, key) From 93f43afda877cc456c2405d3a17087878aa6a4cc Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 09:17:40 +1100 Subject: [PATCH 06/12] fix: Remove configurable TTL parameters --- internal/strategy/gomod.go | 18 +++--------------- internal/strategy/gomod_cacher.go | 30 ++++-------------------------- internal/strategy/gomod_test.go | 4 +--- 3 files changed, 8 insertions(+), 44 deletions(-) diff --git a/internal/strategy/gomod.go b/internal/strategy/gomod.go index 35a75c5..49a5b4c 100644 --- a/internal/strategy/gomod.go +++ b/internal/strategy/gomod.go @@ -6,7 +6,6 @@ import ( "log/slog" "net/http" "net/url" - "time" "github.com/goproxy/goproxy" @@ -20,9 +19,7 @@ func init() { } 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 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"` + Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"` } type GoMod struct { @@ -35,7 +32,6 @@ type GoMod struct { var _ Strategy = (*GoMod)(nil) -// NewGoMod creates a new Go module proxy strategy. func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, cache cache.Cache, mux Mux) (*GoMod, error) { parsedURL, err := url.Parse(config.Proxy) if err != nil { @@ -49,19 +45,15 @@ func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, proxy: parsedURL, } - // Create the goproxy instance with our custom cacher adapter g.goproxy = &goproxy.Goproxy{ Fetcher: &goproxy.GoFetcher{ - // Configure to use the specified upstream proxy Env: []string{ "GOPROXY=" + config.Proxy, "GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation }, }, Cacher: &goproxyCacher{ - cache: cache, - mutableTTL: config.MutableTTL, - immutableTTL: config.ImmutableTTL, + cache: cache, }, ProxiedSumDBs: []string{ "sum.golang.org https://sum.golang.org", @@ -69,12 +61,8 @@ func NewGoMod(ctx context.Context, config GoModConfig, _ jobscheduler.Scheduler, } g.logger.InfoContext(ctx, "Initialized Go module proxy strategy", - slog.String("proxy", g.proxy.String()), - slog.Duration("mutable_ttl", config.MutableTTL), - slog.Duration("immutable_ttl", config.ImmutableTTL)) + slog.String("proxy", g.proxy.String())) - // Register a namespaced handler for Go module proxy patterns - // Strip the /gomod prefix and delegate to goproxy mux.Handle("GET /gomod/{path...}", http.StripPrefix("/gomod", g.goproxy)) return g, nil diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index c578b84..ed7c3ca 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -8,7 +8,6 @@ import ( "io/fs" "net/textproto" "strings" - "time" "github.com/block/cachew/internal/cache" ) @@ -17,9 +16,7 @@ import ( // It handles the translation between goproxy's file-based caching model and cachew's // HTTP-response-based caching model. type goproxyCacher struct { - cache cache.Cache - mutableTTL time.Duration - immutableTTL time.Duration + cache cache.Cache } // Get retrieves cached content by name from cachew's cache. @@ -40,16 +37,11 @@ func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, er return rc, nil } -// Put stores content in cachew's cache with the appropriate TTL. -// The TTL is determined by inspecting the cache name to identify whether -// it represents mutable or immutable content. +// Put stores content in cachew's cache. func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { // Hash the name to create a cache key key := cache.Key(sha256.Sum256([]byte(name))) - // Determine TTL based on the endpoint type - ttl := g.calculateTTL(name) - // Determine Content-Type from the file extension contentType := g.getContentType(name) @@ -57,8 +49,8 @@ func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSee headers := make(textproto.MIMEHeader) headers.Set("Content-Type", contentType) - // Create the cache entry - wc, err := g.cache.Create(ctx, key, headers, ttl) + // Create the cache entry with zero TTL (cache handles TTL via its own config) + wc, err := g.cache.Create(ctx, key, headers, 0) if err != nil { return fmt.Errorf("create cache entry: %w", err) } @@ -82,20 +74,6 @@ func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSee return nil } -// calculateTTL determines the appropriate cache TTL based on the endpoint type. -// -// Mutable endpoints (list, latest) get short TTL. -// Immutable versioned content (info, mod, zip) gets long TTL. -func (g *goproxyCacher) calculateTTL(name string) time.Duration { - // Short TTL for mutable endpoints - if strings.HasSuffix(name, "/@v/list") || strings.HasSuffix(name, "/@latest") { - return g.mutableTTL - } - - // Long TTL for immutable versioned content (.info, .mod, .zip) - return g.immutableTTL -} - // getContentType returns the appropriate Content-Type header based on the file extension. func (g *goproxyCacher) getContentType(name string) string { switch { diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index d4f09fd..69ffccf 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -195,9 +195,7 @@ func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Con mux := http.NewServeMux() _, err = strategy.NewGoMod(ctx, strategy.GoModConfig{ - Proxy: mock.server.URL, - MutableTTL: 5 * time.Minute, - ImmutableTTL: 168 * time.Hour, + Proxy: mock.server.URL, }, jobscheduler.New(ctx, jobscheduler.Config{}), memCache, mux) assert.NoError(t, err) From 4016d77f36b1a278dd6e6a898fd276620ad24135 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 09:20:02 +1100 Subject: [PATCH 07/12] fix: unused import --- internal/strategy/gomod_cacher.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index e29ac7f..8643e7b 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -2,7 +2,6 @@ package strategy import ( "context" - "crypto/sha256" "fmt" "io" "io/fs" From 49fed12abb2b3b97a5eda6a37e61a6b818af4dec Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 09:23:30 +1100 Subject: [PATCH 08/12] fix: Remove superfluous comments --- internal/strategy/gomod_cacher.go | 19 +------------------ internal/strategy/gomod_test.go | 25 ------------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index 8643e7b..9878c38 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -11,61 +11,45 @@ import ( "github.com/block/cachew/internal/cache" ) -// goproxyCacher adapts cachew's cache.Cache interface to work with goproxy's Cacher interface. -// It handles the translation between goproxy's file-based caching model and cachew's -// HTTP-response-based caching model. type goproxyCacher struct { cache cache.Cache } -// Get retrieves cached content by name from cachew's cache. -// It returns fs.ErrNotExist if the content is not found, which goproxy uses -// as a signal to fetch from upstream. func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, error) { - // Hash the name to create a cache key that matches cachew's format + key := cache.NewKey(name) - // Try to open the cached content rc, _, err := g.cache.Open(ctx, key) if err != nil { - // If the cache backend returns an error, treat it as not found - // This ensures goproxy will fetch from upstream return nil, fs.ErrNotExist } return rc, nil } -// Put stores content in cachew's cache. func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { - // Hash the name to create a cache key key := cache.NewKey(name) // Determine Content-Type from the file extension contentType := g.getContentType(name) - // Create headers for the cached response headers := make(textproto.MIMEHeader) headers.Set("Content-Type", contentType) - // Create the cache entry with zero TTL (cache handles TTL via its own config) wc, err := g.cache.Create(ctx, key, headers, 0) if err != nil { return fmt.Errorf("create cache entry: %w", err) } defer wc.Close() - // Reset the seeker to the beginning if _, err := content.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("seek to start: %w", err) } - // Copy the content to the cache if _, err := io.Copy(wc, content); err != nil { return fmt.Errorf("write to cache: %w", err) } - // Close the writer to commit the cache entry if err := wc.Close(); err != nil { return fmt.Errorf("close cache entry: %w", err) } @@ -73,7 +57,6 @@ func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSee return nil } -// getContentType returns the appropriate Content-Type header based on the file extension. func (g *goproxyCacher) getContentType(name string) string { switch { case strings.HasSuffix(name, ".info"): diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index 69ffccf..51c2972 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -40,7 +40,6 @@ func newMockGoModServer() *mockGoModServer { } // Set up default responses for common endpoints - // Note: goproxy fetches .zip files first to validate, then fetches .info and .mod m.responses["/@v/list"] = mockResponse{ status: http.StatusOK, content: "v1.0.0\nv1.0.1\nv1.1.0", @@ -49,7 +48,6 @@ func newMockGoModServer() *mockGoModServer { status: http.StatusOK, content: `{"Version":"v1.1.0","Time":"2023-06-01T00:00:00Z"}`, } - // Other responses will be dynamically generated based on the path mux := http.NewServeMux() mux.HandleFunc("/", m.handleRequest) @@ -58,15 +56,12 @@ func newMockGoModServer() *mockGoModServer { return m } -// createModuleZip creates a valid Go module zip file with the correct structure func createModuleZip(modulePath, version string) string { var buf bytes.Buffer w := zip.NewWriter(&buf) - // The zip file must have paths prefixed with modulePath@version/ prefix := modulePath + "@" + version + "/" - // Add a go.mod file to make it a valid Go module zip f, err := w.Create(prefix + "go.mod") if err != nil { panic(err) @@ -76,7 +71,6 @@ func createModuleZip(modulePath, version string) string { panic(err) } - // Add a dummy source file f2, err := w.Create(prefix + "main.go") if err != nil { panic(err) @@ -101,16 +95,13 @@ func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) m.requestCount[path]++ m.mu.Unlock() - // 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 @@ -120,10 +111,7 @@ func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) } } - // If still not found, try pattern matching for any version if !found && strings.Contains(path, "/@v/") { - // Extract module path and version from the request - // e.g., /github.com/example/test/@v/v1.0.0.info parts := strings.Split(path, "/@v/") if len(parts) == 2 { modulePath := strings.TrimPrefix(parts[0], "/") @@ -212,7 +200,6 @@ func TestGoModList(t *testing.T) { mux.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - // goproxy may add a trailing newline or format the output body := strings.TrimSpace(w.Body.String()) assert.True(t, strings.Contains(body, "v1.0.0"), "response should contain v1.0.0") assert.True(t, strings.Contains(body, "v1.0.1"), "response should contain v1.0.1") @@ -258,7 +245,6 @@ func TestGoModZip(t *testing.T) { mux.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - // Verify we get a valid zip file (starts with PK signature) assert.True(t, strings.HasPrefix(w.Body.String(), "PK"), "response should be a valid zip file") assert.True(t, mock.getRequestCount("/github.com/example/test/@v/v1.0.0.zip") >= 1, "should have fetched zip") } @@ -283,7 +269,6 @@ func TestGoModCaching(t *testing.T) { 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) req1 = req1.WithContext(ctx) w1 := httptest.NewRecorder() @@ -292,7 +277,6 @@ func TestGoModCaching(t *testing.T) { assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, 1, mock.getRequestCount(upstreamPath)) - // Second request should hit cache req2 := httptest.NewRequest(http.MethodGet, path, nil) req2 = req2.WithContext(ctx) w2 := httptest.NewRecorder() @@ -306,7 +290,6 @@ func TestGoModCaching(t *testing.T) { func TestGoModComplexModulePath(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - // Test module path with multiple slashes req := httptest.NewRequest(http.MethodGet, "/gomod/golang.org/x/tools/@v/v0.1.0.info", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -320,12 +303,10 @@ func TestGoModComplexModulePath(t *testing.T) { func TestGoModNonOKResponse(t *testing.T) { mock, mux, ctx := setupGoModTest(t) - // Set up 404 response 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) req1 = req1.WithContext(ctx) w1 := httptest.NewRecorder() @@ -334,7 +315,6 @@ func TestGoModNonOKResponse(t *testing.T) { assert.Equal(t, http.StatusNotFound, w1.Code) assert.Equal(t, 1, mock.getRequestCount(upstreamPath)) - // Second request should also hit upstream (404s are not cached) req2 := httptest.NewRequest(http.MethodGet, notFoundPath, nil) req2 = req2.WithContext(ctx) w2 := httptest.NewRecorder() @@ -350,7 +330,6 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { 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) for range 3 { go func() { @@ -362,14 +341,10 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { }() } - // Collect results for range 3 { 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.getRequestCount(upstreamPath) >= 1, "at least one request should have been made to upstream") } From 824d56803db7cd98a41ea16c1153694231aa5595 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 09:31:28 +1100 Subject: [PATCH 09/12] fix: Remove unused Content-Type headers --- internal/strategy/gomod_cacher.go | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index 9878c38..369f8c4 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -5,8 +5,6 @@ import ( "fmt" "io" "io/fs" - "net/textproto" - "strings" "github.com/block/cachew/internal/cache" ) @@ -30,13 +28,7 @@ func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, er func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { key := cache.NewKey(name) - // Determine Content-Type from the file extension - contentType := g.getContentType(name) - - headers := make(textproto.MIMEHeader) - headers.Set("Content-Type", contentType) - - wc, err := g.cache.Create(ctx, key, headers, 0) + wc, err := g.cache.Create(ctx, key, nil, 0) if err != nil { return fmt.Errorf("create cache entry: %w", err) } @@ -56,20 +48,3 @@ func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSee return nil } - -func (g *goproxyCacher) getContentType(name string) string { - switch { - case strings.HasSuffix(name, ".info"): - return "application/json" - case strings.HasSuffix(name, ".mod"): - return "text/plain; charset=utf-8" - case strings.HasSuffix(name, ".zip"): - return "application/zip" - case strings.HasSuffix(name, "/@v/list"): - return "text/plain; charset=utf-8" - case strings.HasSuffix(name, "/@latest"): - return "application/json" - default: - return "application/octet-stream" - } -} From 64c058c7dcf8840825ded341de77544c459b4b05 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 09:32:33 +1100 Subject: [PATCH 10/12] fix: Linting --- internal/strategy/gomod_cacher.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index 369f8c4..8de6291 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -14,7 +14,6 @@ type goproxyCacher struct { } func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, error) { - key := cache.NewKey(name) rc, _, err := g.cache.Open(ctx, key) From 56c0656135e7720958141a36561ed4d123108697 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 10:02:00 +1100 Subject: [PATCH 11/12] fix: Don't cache endpoints that change --- internal/strategy/gomod_cacher.go | 5 ++++ internal/strategy/gomod_test.go | 48 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/strategy/gomod_cacher.go b/internal/strategy/gomod_cacher.go index 8de6291..c66a38a 100644 --- a/internal/strategy/gomod_cacher.go +++ b/internal/strategy/gomod_cacher.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "io/fs" + "strings" "github.com/block/cachew/internal/cache" ) @@ -25,6 +26,10 @@ func (g *goproxyCacher) Get(ctx context.Context, name string) (io.ReadCloser, er } func (g *goproxyCacher) Put(ctx context.Context, name string, content io.ReadSeeker) error { + if strings.HasSuffix(name, "/@v/list") || strings.HasSuffix(name, "/@latest") { + return nil + } + key := cache.NewKey(name) wc, err := g.cache.Create(ctx, key, nil, 0) diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index 51c2972..b128656 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -348,3 +348,51 @@ func TestGoModMultipleConcurrentRequests(t *testing.T) { assert.True(t, mock.getRequestCount(upstreamPath) >= 1, "at least one request should have been made to upstream") } + +func TestGoModListNotCached(t *testing.T) { + mock, mux, ctx := setupGoModTest(t) + + path := "/gomod/github.com/example/test/@v/list" + upstreamPath := "/github.com/example/test/@v/list" + + 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.getRequestCount(upstreamPath)) + + 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, 2, mock.getRequestCount(upstreamPath), "/@v/list endpoint should not be cached") +} + +func TestGoModLatestNotCached(t *testing.T) { + mock, mux, ctx := setupGoModTest(t) + + path := "/gomod/github.com/example/test/@latest" + upstreamPath := "/github.com/example/test/@latest" + + 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.getRequestCount(upstreamPath)) + + 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, 2, mock.getRequestCount(upstreamPath), "/@latest endpoint should not be cached") +} From 24da0376989488f5d4ed01e3d69bccbfd74c4f45 Mon Sep 17 00:00:00 2001 From: John Murphy Date: Fri, 23 Jan 2026 14:03:45 +1100 Subject: [PATCH 12/12] fix: Test assert.NoError instead of panic --- internal/strategy/gomod_test.go | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/internal/strategy/gomod_test.go b/internal/strategy/gomod_test.go index b128656..c30a18f 100644 --- a/internal/strategy/gomod_test.go +++ b/internal/strategy/gomod_test.go @@ -26,6 +26,7 @@ type mockGoModServer struct { mu sync.Mutex // Protects requestCount lastPath string responses map[string]mockResponse + t *testing.T } type mockResponse struct { @@ -33,10 +34,11 @@ type mockResponse struct { content string } -func newMockGoModServer() *mockGoModServer { +func newMockGoModServer(t *testing.T) *mockGoModServer { m := &mockGoModServer{ requestCount: make(map[string]int), responses: make(map[string]mockResponse), + t: t, } // Set up default responses for common endpoints @@ -56,33 +58,25 @@ func newMockGoModServer() *mockGoModServer { return m } -func createModuleZip(modulePath, version string) string { +func createModuleZip(t *testing.T, modulePath, version string) string { + t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) prefix := modulePath + "@" + version + "/" f, err := w.Create(prefix + "go.mod") - if err != nil { - panic(err) - } + assert.NoError(t, err) _, err = f.Write([]byte("module " + modulePath + "\n\ngo 1.21\n")) - if err != nil { - panic(err) - } + assert.NoError(t, err) f2, err := w.Create(prefix + "main.go") - if err != nil { - panic(err) - } + assert.NoError(t, err) _, err = f2.Write([]byte("package main\n\nfunc main() {}\n")) - if err != nil { - panic(err) - } + assert.NoError(t, err) - if err := w.Close(); err != nil { - panic(err) - } + err = w.Close() + assert.NoError(t, err) return buf.String() } @@ -135,7 +129,7 @@ func (m *mockGoModServer) handleRequest(w http.ResponseWriter, r *http.Request) version := strings.TrimSuffix(versionPart, ".zip") resp = mockResponse{ status: http.StatusOK, - content: createModuleZip(modulePath, version), + content: createModuleZip(m.t, modulePath, version), } found = true } @@ -172,7 +166,7 @@ func (m *mockGoModServer) getRequestCount(path string) int { func setupGoModTest(t *testing.T) (*mockGoModServer, *http.ServeMux, context.Context) { t.Helper() - mock := newMockGoModServer() + mock := newMockGoModServer(t) t.Cleanup(mock.close) _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})