diff --git a/docker/Dockerfile b/docker/Dockerfile index 90ce866..dfe25cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,9 +6,9 @@ ARG TARGETARCH SHELL ["/bin/sh", "-o", "pipefail", "-c"] # Install runtime dependencies for git operations and TLS -RUN apk add --no-cache ca-certificates git git-daemon tzdata && \ - addgroup -g 1000 cachew && \ - adduser -D -u 1000 -G cachew cachew +RUN apk add --no-cache ca-certificates git git-daemon tzdata zstd && \ + addgroup -g 1000 cachew && \ + adduser -D -u 1000 -G cachew cachew # Set working directory (config uses relative paths like ./state/cache) WORKDIR /app @@ -21,7 +21,7 @@ COPY cachew.hcl /app/cachew.hcl # Create state directory with proper permissions RUN mkdir -p /app/state/cache && \ - chown -R cachew:cachew /app + chown -R cachew:cachew /app # Switch to non-root user USER cachew diff --git a/docker/Justfile b/docker/Justfile index 5c73757..a5100b0 100644 --- a/docker/Justfile +++ b/docker/Justfile @@ -1,6 +1,9 @@ set positional-arguments := true set shell := ["bash", "-c"] +_help: + @just -l + # Configuration ROOT := `git rev-parse --show-toplevel 2>/dev/null || echo "."` @@ -39,8 +42,7 @@ build-multi: @echo "✓ Built multi-arch image (in cache)" # Run in Docker (usage: just docker run [log_level]) -run log_level="info": - @just build +run log_level="info": build @echo "→ Starting cachew at http://localhost:8080 (log-level={{ log_level }})" @docker run --rm -it -p 8080:8080 -e CACHEW_LOG_LEVEL={{ log_level }} -v {{ ROOT }}/state:/app/state --name cachew cachew:local diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index 7bd38f5..a53d977 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httputil" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -59,6 +60,17 @@ func New( cloneManagerProvider gitclone.ManagerProvider, tokenManagerProvider githubapp.TokenManagerProvider, ) (*Strategy, error) { + if _, err := exec.LookPath("git"); err != nil { + return nil, errors.New("git is required but not found in PATH") + } + if config.SnapshotInterval > 0 { + for _, bin := range []string{"tar", "zstd"} { + if _, err := exec.LookPath(bin); err != nil { + return nil, errors.Errorf("%s is required for snapshots (snapshot-interval > 0) but not found in PATH", bin) + } + } + } + logger := logging.FromContext(ctx) // Get GitHub App token manager if configured diff --git a/internal/strategy/git/git_test.go b/internal/strategy/git/git_test.go index 02f6587..fb64fa7 100644 --- a/internal/strategy/git/git_test.go +++ b/internal/strategy/git/git_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "testing" "github.com/alecthomas/assert/v2" @@ -190,6 +191,73 @@ func TestIntegrationWithMockUpstream(t *testing.T) { assert.NotZero(t, mux.handlers["POST /git/{host}/{path...}"]) } +// fakeBin creates a minimal executable file in dir with the given name. +func fakeBin(t *testing.T, dir, name string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755) + assert.NoError(t, err) +} + +// TestNewMissingGitBinary verifies that New returns an error when git is not in PATH. +func TestNewMissingGitBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH manipulation for binary checks not supported on Windows") + } + _, ctx := logging.Configure(context.Background(), logging.Config{}) + tmpDir := t.TempDir() + t.Setenv("PATH", t.TempDir()) + + mux := newTestMux() + cm := gitclone.NewManagerProvider(ctx, gitclone.Config{ + MirrorRoot: filepath.Join(tmpDir, "clones"), + FetchInterval: 15, + }, nil) + _, err := git.New(ctx, git.Config{}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil + assert.Error(t, err) + assert.Contains(t, err.Error(), "git") +} + +// TestNewMissingSnapshotBinaries verifies that New returns an error when tar or zstd are +// not in PATH and snapshot-interval > 0. +func TestNewMissingSnapshotBinaries(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH manipulation for binary checks not supported on Windows") + } + _, ctx := logging.Configure(context.Background(), logging.Config{}) + tmpDir := t.TempDir() + + t.Run("MissingTar", func(t *testing.T) { + binDir := t.TempDir() + fakeBin(t, binDir, "git") + t.Setenv("PATH", binDir) + + mux := newTestMux() + cm := gitclone.NewManagerProvider(ctx, gitclone.Config{ + MirrorRoot: filepath.Join(tmpDir, "clones-missing-tar"), + FetchInterval: 15, + }, nil) + _, err := git.New(ctx, git.Config{SnapshotInterval: 1}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil + assert.Error(t, err) + assert.Contains(t, err.Error(), "tar") + }) + + t.Run("MissingZstd", func(t *testing.T) { + binDir := t.TempDir() + fakeBin(t, binDir, "git") + fakeBin(t, binDir, "tar") + t.Setenv("PATH", binDir) + + mux := newTestMux() + cm := gitclone.NewManagerProvider(ctx, gitclone.Config{ + MirrorRoot: filepath.Join(tmpDir, "clones-missing-zstd"), + FetchInterval: 15, + }, nil) + _, err := git.New(ctx, git.Config{SnapshotInterval: 1}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil + assert.Error(t, err) + assert.Contains(t, err.Error(), "zstd") + }) +} + func TestParseGitRefs(t *testing.T) { _, ctx := logging.Configure(context.Background(), logging.Config{}) _ = ctx diff --git a/internal/strategy/gomod/gomod.go b/internal/strategy/gomod/gomod.go index 0a07d14..08332c6 100644 --- a/internal/strategy/gomod/gomod.go +++ b/internal/strategy/gomod/gomod.go @@ -2,10 +2,12 @@ package gomod import ( "context" + "errors" "fmt" "log/slog" "net/http" "net/url" + "os/exec" "github.com/goproxy/goproxy" @@ -38,6 +40,12 @@ type Strategy struct { var _ strategy.Strategy = (*Strategy)(nil) func New(ctx context.Context, config Config, cache cache.Cache, mux strategy.Mux, cloneManagerProvider gitclone.ManagerProvider) (*Strategy, error) { + if len(config.PrivatePaths) > 0 { + if _, err := exec.LookPath("git"); err != nil { + return nil, errors.New("git is required for private module support but not found in PATH") + } + } + parsedURL, err := url.Parse(config.Proxy) if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) diff --git a/internal/strategy/gomod/gomod_test.go b/internal/strategy/gomod/gomod_test.go index d50edb7..1f3a3d7 100644 --- a/internal/strategy/gomod/gomod_test.go +++ b/internal/strategy/gomod/gomod_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "runtime" "strings" "sync" "testing" @@ -371,6 +372,45 @@ func TestGoModListNotCached(t *testing.T) { assert.Equal(t, 2, mock.getRequestCount(upstreamPath), "/@v/list endpoint should not be cached") } +// TestNewMissingGitBinaryForPrivatePaths verifies that New returns an error when git is not +// in PATH and private-paths are configured, but succeeds when no private paths are set. +func TestNewMissingGitBinaryForPrivatePaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH manipulation for binary checks not supported on Windows") + } + _, 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() }) + + cm := gitclone.NewManagerProvider(ctx, gitclone.Config{MirrorRoot: t.TempDir()}, nil) + + t.Run("PrivatePathsRequireGit", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + mux := http.NewServeMux() + _, err := gomod.New(ctx, gomod.Config{ + Proxy: "https://proxy.golang.org", + PrivatePaths: []string{"github.com/myorg"}, + }, memCache, mux, cm) + assert.Error(t, err) + assert.Contains(t, err.Error(), "git") + }) + + t.Run("NoPrivatePathsDoNotRequireGit", func(t *testing.T) { + mock := newMockGoModServer(t) + t.Cleanup(mock.close) + t.Setenv("PATH", t.TempDir()) + + mux := http.NewServeMux() + _, err := gomod.New(ctx, gomod.Config{ + Proxy: mock.server.URL, + }, memCache, mux, cm) + assert.NoError(t, err) + }) +} + func TestGoModLatestNotCached(t *testing.T) { mock, mux, ctx := setupGoModTest(t)