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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions internal/strategy/hermit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"

Expand All @@ -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.
Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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"
Comment on lines +181 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict bootstrap TTL matching to Hermit channel files

isHermitBootstrapFile() matches only on the basename (hermit-*.gz, install.sh, install_hash), but this function is now applied to every direct Hermit proxy request (/hermit/{host}/{path...} via createDirectHandler). That means unrelated package URLs that happen to end with those names will now be forced to a 1-hour TTL, even when their URLs are versioned/immutable and should stay cached long-term. This widens the change beyond Hermit bootstrap artifacts and can materially reduce cache hit rate and reproducibility for non-bootstrap downloads.

Useful? React with 👍 / 👎.

}

func ensureLeadingSlash(path string) string {
if !strings.HasPrefix(path, "/") {
return "/" + path
Expand Down
62 changes: 61 additions & 1 deletion internal/strategy/hermit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down