diff --git a/internal/strategy/hermit.go b/internal/strategy/hermit.go index 2398b6c..c584b43 100644 --- a/internal/strategy/hermit.go +++ b/internal/strategy/hermit.go @@ -5,7 +5,6 @@ import ( "log/slog" "net/http" "net/url" - "regexp" "strings" "time" @@ -25,14 +24,9 @@ func RegisterHermit(r *Registry) { const defaultGitHubBaseURL = "http://127.0.0.1:8080/github.com" -// hermitBinaryPattern matches the Hermit self-update binary (e.g. hermit-linux-amd64.gz). -// Unlike versioned package downloads, this URL is mutable — the same path serves -// different versions over time — so it needs a short cache TTL. -var hermitBinaryPattern = regexp.MustCompile(`/hermit-[a-z]+-[a-z0-9]+\.gz$`) - type HermitConfig struct { GitHubBaseURL string `hcl:"github-base-url" help:"Base URL for GitHub release redirects" default:"http://127.0.0.1:8080/github.com"` - BinaryTTL time.Duration `hcl:"binary-ttl,optional" help:"Cache TTL for the mutable Hermit self-update binary" default:"1h"` + BootstrapTTL time.Duration `hcl:"bootstrap-ttl,optional" help:"Cache TTL for mutable Hermit bootstrap files (binary, install script, install hash)" default:"1h"` } // Hermit caches Hermit package downloads. @@ -84,14 +78,14 @@ func (s *Hermit) createDirectHandler(c cache.Cache) http.Handler { CacheKey(func(r *http.Request) string { return s.buildOriginalURL(r) }). + Transform(func(r *http.Request) (*http.Request, error) { + return s.buildDirectRequest(r) + }). TTL(func(r *http.Request) time.Duration { - if hermitBinaryPattern.MatchString(r.URL.Path) { - return s.config.BinaryTTL + if isHermitBootstrapFile(r.PathValue("path")) { + return s.config.BootstrapTTL } return 0 - }). - Transform(func(r *http.Request) (*http.Request, error) { - return s.buildDirectRequest(r) }) } @@ -163,6 +157,33 @@ func buildURL(scheme, host, path, query string) string { return u.String() } +// isHermitBootstrapFile returns true if the path refers to the hermit binary, +// install script, or install hash. These files are mutable (content changes on +// new releases without the URL changing) so they need a short cache TTL. +// +// The bootstrap files are: +// - hermit-{os}-{arch}.gz: the hermit binary, downloaded by install.sh +// (see cashapp/hermit files/install.sh.tmpl) +// - install.sh: the installer script generated by geninstaller +// (see cashapp/hermit cmd/geninstaller/main.go) +// - install_hash: SHA-256 digest of install.sh, used by some distribution +// channels to detect when the installer has changed +// +// The path includes the channel prefix, e.g. "stable/hermit-linux-amd64.gz". +// Note: public hermit (GitHub releases) goes through the redirect handler, +// not the direct handler, so this only affects non-GitHub distribution hosts. +func isHermitBootstrapFile(path string) bool { + base := path + if i := strings.LastIndex(path, "/"); i >= 0 { + base = path[i+1:] + } + // hermit-{os}-{arch}.gz binary pattern from install.sh.tmpl + if strings.HasPrefix(base, "hermit-") && strings.HasSuffix(base, ".gz") { + return true + } + return base == "install.sh" || base == "install_hash" +} + func ensureLeadingSlash(path string) string { if !strings.HasPrefix(path, "/") { return "/" + path diff --git a/internal/strategy/hermit_test.go b/internal/strategy/hermit_test.go index 1f94e55..68eec10 100644 --- a/internal/strategy/hermit_test.go +++ b/internal/strategy/hermit_test.go @@ -60,7 +60,7 @@ func TestHermitBinaryShortTTL(t *testing.T) { mux := http.NewServeMux() _, err = strategy.NewHermit(ctx, strategy.HermitConfig{ GitHubBaseURL: "http://localhost:8080", - BinaryTTL: 100 * time.Millisecond, + BootstrapTTL: 100 * time.Millisecond, }, nil, memCache, mux) assert.NoError(t, err) @@ -264,6 +264,66 @@ func TestHermitGitHubArchive(t *testing.T) { assert.Equal(t, 1, callCount) } +func TestHermitBootstrapFileExpires(t *testing.T) { + httpTransportMutexHermit.Lock() + defer httpTransportMutexHermit.Unlock() + + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hermit-v0.50.1")) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hermit-v0.52.1")) + } + })) + defer backend.Close() + + originalTransport := http.DefaultTransport + defer func() { http.DefaultTransport = originalTransport }() //nolint:reassign + http.DefaultTransport = &mockTransport{backend: backend, originalTransport: originalTransport} //nolint:reassign + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour}) + assert.NoError(t, err) + t.Cleanup(func() { memCache.Close() }) + + mux := http.NewServeMux() + _, err = strategy.NewHermit(ctx, strategy.HermitConfig{ + GitHubBaseURL: "http://localhost:8080", + BootstrapTTL: 100 * time.Millisecond, + }, nil, memCache, mux) + assert.NoError(t, err) + + // First request caches the bootstrap binary + req1 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/stable/hermit-linux-amd64.gz", nil) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, "hermit-v0.50.1", w1.Body.String()) + assert.Equal(t, 1, callCount) + + // Immediate second request should be cached + req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/stable/hermit-linux-amd64.gz", nil) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + assert.Equal(t, "hermit-v0.50.1", w2.Body.String()) + assert.Equal(t, 1, callCount, "should serve from cache") + + // Wait for TTL to expire + time.Sleep(150 * time.Millisecond) + + // Third request should fetch fresh from upstream + req3 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/hermit/example.com/stable/hermit-linux-amd64.gz", nil) + w3 := httptest.NewRecorder() + mux.ServeHTTP(w3, req3) + assert.Equal(t, http.StatusOK, w3.Code) + assert.Equal(t, "hermit-v0.52.1", w3.Body.String()) + assert.Equal(t, 2, callCount, "should re-fetch after TTL expires") +} + func TestHermitCacheKeyGeneration(t *testing.T) { tests := []struct { name string