Configure Gradle to use the proxy for HttpBuildCache:
-// In settings.gradle(.kts)
-buildCache {
- remote<HttpBuildCache> {
- url = uri("` + baseURL + `/gradle/")
- push = true
- }
-}`),
},
{
ID: "nuget",
@@ -380,17 +363,6 @@ local({
r["CRAN"] <- "` + baseURL + `/cran"
options(repos = r)
})`),
- },
- {
- ID: "julia",
- Name: "Julia",
- Language: "Julia",
- Endpoint: "/julia/",
- Instructions: template.HTML(`Set the Pkg server before starting Julia:
-export JULIA_PKG_SERVER=` + baseURL + `/julia
-Or inside a running session:
-ENV["JULIA_PKG_SERVER"] = "` + baseURL + `/julia"
-using Pkg; Pkg.update()`),
},
{
ID: "oci",
diff --git a/internal/server/errors.go b/internal/server/errors.go
deleted file mode 100644
index 474ecd7..0000000
--- a/internal/server/errors.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
-)
-
-// Error codes returned in API error responses. These are stable identifiers
-// that clients can match on; the message text is for humans and may change.
-const (
- ErrCodeBadRequest = "BAD_REQUEST"
- ErrCodeNotFound = "NOT_FOUND"
- ErrCodeUpstream = "UPSTREAM_ERROR"
- ErrCodeInternal = "INTERNAL_ERROR"
-)
-
-// ErrorResponse is the JSON body returned for API errors.
-type ErrorResponse struct {
- Code string `json:"code"`
- Message string `json:"message"`
-}
-
-// writeError sends a JSON error response with the given status, code and
-// user-facing message. Internal error details should be logged separately
-// by the caller, never passed as the message.
-func writeError(w http.ResponseWriter, status int, code, message string) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(ErrorResponse{Code: code, Message: message})
-}
-
-func badRequest(w http.ResponseWriter, message string) {
- writeError(w, http.StatusBadRequest, ErrCodeBadRequest, message)
-}
-
-func notFound(w http.ResponseWriter, message string) {
- writeError(w, http.StatusNotFound, ErrCodeNotFound, message)
-}
-
-func internalError(w http.ResponseWriter, message string) {
- writeError(w, http.StatusInternalServerError, ErrCodeInternal, message)
-}
diff --git a/internal/server/errors_test.go b/internal/server/errors_test.go
deleted file mode 100644
index c660ae2..0000000
--- a/internal/server/errors_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestWriteError(t *testing.T) {
- tests := []struct {
- name string
- fn func(w http.ResponseWriter)
- status int
- code string
- message string
- }{
- {
- name: "badRequest",
- fn: func(w http.ResponseWriter) { badRequest(w, "missing field") },
- status: http.StatusBadRequest,
- code: ErrCodeBadRequest,
- message: "missing field",
- },
- {
- name: "notFound",
- fn: func(w http.ResponseWriter) { notFound(w, "package not found") },
- status: http.StatusNotFound,
- code: ErrCodeNotFound,
- message: "package not found",
- },
- {
- name: "internalError",
- fn: func(w http.ResponseWriter) { internalError(w, "boom") },
- status: http.StatusInternalServerError,
- code: ErrCodeInternal,
- message: "boom",
- },
- {
- name: "upstream",
- fn: func(w http.ResponseWriter) {
- writeError(w, http.StatusBadGateway, ErrCodeUpstream, "registry unreachable")
- },
- status: http.StatusBadGateway,
- code: ErrCodeUpstream,
- message: "registry unreachable",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- w := httptest.NewRecorder()
- tt.fn(w)
-
- if w.Code != tt.status {
- t.Errorf("status = %d, want %d", w.Code, tt.status)
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", ct)
- }
-
- var resp ErrorResponse
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("response body is not valid JSON: %v (body: %q)", err, w.Body.String())
- }
- if resp.Code != tt.code {
- t.Errorf("code = %q, want %q", resp.Code, tt.code)
- }
- if resp.Message != tt.message {
- t.Errorf("message = %q, want %q", resp.Message, tt.message)
- }
- })
- }
-}
-
-func TestAPIErrorResponseShape(t *testing.T) {
- w := httptest.NewRecorder()
- badRequest(w, "x")
-
- var raw map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
- t.Fatalf("invalid JSON: %v", err)
- }
- if _, ok := raw["code"]; !ok {
- t.Error("response missing 'code' field")
- }
- if _, ok := raw["message"]; !ok {
- t.Error("response missing 'message' field")
- }
- if len(raw) != 2 {
- t.Errorf("response has unexpected fields: %v", raw)
- }
-}
diff --git a/internal/server/eviction.go b/internal/server/eviction.go
deleted file mode 100644
index 4173bd5..0000000
--- a/internal/server/eviction.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package server
-
-import (
- "context"
- "log/slog"
- "time"
-
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- evictionInterval = 1 * time.Minute
- evictionBatch = 50
-)
-
-func (s *Server) startEvictionLoop(ctx context.Context) {
- maxSize := s.cfg.ParseMaxSize()
- if maxSize <= 0 {
- return
- }
-
- s.logger.Info("cache eviction enabled", "max_size", s.cfg.Storage.MaxSize)
-
- ticker := time.NewTicker(evictionInterval)
- defer ticker.Stop()
-
- s.runEviction(ctx, maxSize)
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- s.runEviction(ctx, maxSize)
- }
- }
-}
-
-func (s *Server) runEviction(ctx context.Context, maxSize int64) {
- evictLRU(ctx, s.db, s.storage, s.logger, maxSize)
-}
-
-func evictLRU(ctx context.Context, db *database.DB, store storage.Storage, logger *slog.Logger, maxSize int64) {
- totalSize, err := db.GetTotalCacheSize()
- if err != nil {
- logger.Warn("eviction: failed to get cache size", "error", err)
- return
- }
-
- if totalSize <= maxSize {
- return
- }
-
- logger.Info("eviction: cache size exceeds limit, evicting",
- "current_size", totalSize, "max_size", maxSize)
-
- evicted := 0
- freedBytes := int64(0)
-
- for totalSize-freedBytes > maxSize {
- artifacts, err := db.GetLeastRecentlyUsedArtifacts(evictionBatch)
- if err != nil {
- logger.Warn("eviction: failed to get LRU artifacts", "error", err)
- return
- }
- if len(artifacts) == 0 {
- break
- }
-
- for _, art := range artifacts {
- if totalSize-freedBytes <= maxSize {
- break
- }
-
- if !art.StoragePath.Valid {
- continue
- }
-
- if err := store.Delete(ctx, art.StoragePath.String); err != nil {
- logger.Warn("eviction: failed to delete from storage",
- "path", art.StoragePath.String, "error", err)
- continue
- }
-
- if err := db.ClearArtifactCache(art.VersionPURL, art.Filename); err != nil {
- logger.Warn("eviction: failed to clear artifact record",
- "version_purl", art.VersionPURL, "filename", art.Filename, "error", err)
- continue
- }
-
- size := int64(0)
- if art.Size.Valid {
- size = art.Size.Int64
- }
- freedBytes += size
- evicted++
- }
- }
-
- if evicted > 0 {
- logger.Info("eviction: completed",
- "evicted", evicted, "freed_bytes", freedBytes)
- }
-}
diff --git a/internal/server/eviction_test.go b/internal/server/eviction_test.go
deleted file mode 100644
index 9fa9e6b..0000000
--- a/internal/server/eviction_test.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package server
-
-import (
- "context"
- "database/sql"
- "io"
- "log/slog"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/proxy/internal/database"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-func setupEvictionTest(t *testing.T) (*database.DB, *storage.Filesystem) {
- t.Helper()
-
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
- storagePath := filepath.Join(tempDir, "artifacts")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("failed to create database: %v", err)
- }
-
- store, err := storage.NewFilesystem(storagePath)
- if err != nil {
- _ = db.Close()
- t.Fatalf("failed to create storage: %v", err)
- }
-
- t.Cleanup(func() {
- _ = db.Close()
- })
-
- return db, store
-}
-
-func seedArtifact(t *testing.T, ctx context.Context, db *database.DB, store storage.Storage, name string, dataSize int, accessedAt time.Time) {
- t.Helper()
-
- pkgPURL := "pkg:npm/" + name
- versionPURL := pkgPURL + "@1.0.0"
- filename := name + "-1.0.0.tgz"
-
- if err := db.UpsertPackage(&database.Package{
- PURL: pkgPURL,
- Ecosystem: "npm",
- Name: name,
- }); err != nil {
- t.Fatalf("failed to upsert package: %v", err)
- }
-
- if err := db.UpsertVersion(&database.Version{
- PURL: versionPURL,
- PackagePURL: pkgPURL,
- }); err != nil {
- t.Fatalf("failed to upsert version: %v", err)
- }
-
- storagePath := storage.ArtifactPath("npm", "", name, "1.0.0", filename)
- data := strings.NewReader(strings.Repeat("x", dataSize))
- size, hash, err := store.Store(ctx, storagePath, data)
- if err != nil {
- t.Fatalf("failed to store artifact: %v", err)
- }
-
- if err := db.UpsertArtifact(&database.Artifact{
- VersionPURL: versionPURL,
- Filename: filename,
- UpstreamURL: "https://example.com/" + filename,
- StoragePath: sql.NullString{String: storagePath, Valid: true},
- ContentHash: sql.NullString{String: hash, Valid: true},
- Size: sql.NullInt64{Int64: size, Valid: true},
- ContentType: sql.NullString{String: "application/gzip", Valid: true},
- FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
- LastAccessedAt: sql.NullTime{Time: accessedAt, Valid: true},
- }); err != nil {
- t.Fatalf("failed to upsert artifact: %v", err)
- }
-}
-
-func TestEvictLRU_NoEvictionWhenUnderLimit(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- seedArtifact(t, ctx, db, store, "pkg-a", 100, time.Now())
-
- evictLRU(ctx, db, store, logger, 1024)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 1 {
- t.Errorf("expected 1 cached artifact, got %d", count)
- }
-}
-
-func TestEvictLRU_EvictsOldestFirst(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- now := time.Now()
- seedArtifact(t, ctx, db, store, "old-pkg", 500, now.Add(-3*time.Hour))
- seedArtifact(t, ctx, db, store, "mid-pkg", 500, now.Add(-1*time.Hour))
- seedArtifact(t, ctx, db, store, "new-pkg", 500, now)
-
- // Total is 1500 bytes, limit to 1100 so only the oldest gets evicted
- evictLRU(ctx, db, store, logger, 1100)
-
- // old-pkg should be evicted
- art, err := db.GetArtifact("pkg:npm/old-pkg@1.0.0", "old-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if art.StoragePath.Valid {
- t.Error("expected old-pkg to be evicted (storage_path should be NULL)")
- }
-
- // mid-pkg and new-pkg should remain
- art, err = db.GetArtifact("pkg:npm/mid-pkg@1.0.0", "mid-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if !art.StoragePath.Valid {
- t.Error("expected mid-pkg to remain cached")
- }
-
- art, err = db.GetArtifact("pkg:npm/new-pkg@1.0.0", "new-pkg-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if !art.StoragePath.Valid {
- t.Error("expected new-pkg to remain cached")
- }
-
- // Storage file should be removed for old-pkg
- storagePath := storage.ArtifactPath("npm", "", "old-pkg", "1.0.0", "old-pkg-1.0.0.tgz")
- exists, _ := store.Exists(ctx, storagePath)
- if exists {
- t.Error("expected old-pkg file to be deleted from storage")
- }
-}
-
-func TestEvictLRU_EvictsMultipleToGetUnderLimit(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- now := time.Now()
- seedArtifact(t, ctx, db, store, "pkg-1", 400, now.Add(-4*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-2", 400, now.Add(-3*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-3", 400, now.Add(-2*time.Hour))
- seedArtifact(t, ctx, db, store, "pkg-4", 400, now)
-
- // Total is 1600 bytes, limit to 900 so pkg-1 and pkg-2 get evicted
- evictLRU(ctx, db, store, logger, 900)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 2 {
- t.Errorf("expected 2 cached artifacts remaining, got %d", count)
- }
-
- // Verify the right ones remain
- for _, name := range []string{"pkg-3", "pkg-4"} {
- art, err := db.GetArtifact("pkg:npm/"+name+"@1.0.0", name+"-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact %s: %v", name, err)
- }
- if !art.StoragePath.Valid {
- t.Errorf("expected %s to remain cached", name)
- }
- }
-}
-
-func TestEvictLRU_NothingToEvictWhenEmpty(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- // Should not panic or error with no artifacts
- evictLRU(ctx, db, store, logger, 1024)
-
- count, err := db.GetCachedArtifactCount()
- if err != nil {
- t.Fatalf("failed to get count: %v", err)
- }
- if count != 0 {
- t.Errorf("expected 0 cached artifacts, got %d", count)
- }
-}
-
-func TestEvictLRU_StorageFileDeleted(t *testing.T) {
- db, store := setupEvictionTest(t)
- ctx := context.Background()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- seedArtifact(t, ctx, db, store, "delete-me", 1000, time.Now().Add(-1*time.Hour))
-
- storagePath := storage.ArtifactPath("npm", "", "delete-me", "1.0.0", "delete-me-1.0.0.tgz")
- exists, _ := store.Exists(ctx, storagePath)
- if !exists {
- t.Fatal("expected artifact file to exist before eviction")
- }
-
- evictLRU(ctx, db, store, logger, 500)
-
- exists, _ = store.Exists(ctx, storagePath)
- if exists {
- t.Error("expected artifact file to be deleted after eviction")
- }
-
- art, err := db.GetArtifact("pkg:npm/delete-me@1.0.0", "delete-me-1.0.0.tgz")
- if err != nil {
- t.Fatalf("failed to get artifact: %v", err)
- }
- if art.StoragePath.Valid {
- t.Error("expected storage_path to be NULL after eviction")
- }
- if art.Size.Valid {
- t.Error("expected size to be NULL after eviction")
- }
-}
-
-func TestStartEvictionLoop_UnlimitedSkips(t *testing.T) {
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
- storagePath := filepath.Join(tempDir, "artifacts")
-
- db, err := database.Create(dbPath)
- if err != nil {
- t.Fatalf("failed to create database: %v", err)
- }
- defer func() { _ = db.Close() }()
-
- store, err := storage.NewFilesystem(storagePath)
- if err != nil {
- t.Fatalf("failed to create storage: %v", err)
- }
-
- cfg := defaultTestConfig(storagePath, dbPath)
-
- s := &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cancel()
-
- // Should return immediately since max_size is empty (unlimited)
- done := make(chan struct{})
- go func() {
- s.startEvictionLoop(ctx)
- close(done)
- }()
-
- select {
- case <-done:
- // Good, returned immediately
- case <-time.After(1 * time.Second):
- t.Error("startEvictionLoop should return immediately when max_size is unlimited")
- cancel()
- }
-}
-
-func defaultTestConfig(storagePath, dbPath string) *config.Config {
- return &config.Config{
- Listen: ":8080",
- BaseURL: "http://localhost:8080",
- Storage: config.StorageConfig{Path: storagePath, MaxSize: ""},
- Database: config.DatabaseConfig{
- Driver: "sqlite",
- Path: dbPath,
- },
- Log: config.LogConfig{Level: "info", Format: "text"},
- }
-}
diff --git a/internal/server/gradle_cache_eviction.go b/internal/server/gradle_cache_eviction.go
deleted file mode 100644
index 7f546d1..0000000
--- a/internal/server/gradle_cache_eviction.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package server
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const gradleBuildCacheStoragePrefix = "_gradle/http-build-cache/"
-
-type gradleBuildCacheLister interface {
- ListPrefix(ctx context.Context, prefix string) ([]storage.ObjectInfo, error)
-}
-
-func (s *Server) startGradleBuildCacheEviction(ctx context.Context) {
- maxAge := s.cfg.ParseGradleBuildCacheMaxAge()
- maxSize := s.cfg.ParseGradleBuildCacheMaxSize()
- if maxAge <= 0 && maxSize <= 0 {
- return
- }
-
- lister, ok := s.storage.(gradleBuildCacheLister)
- if !ok {
- s.logger.Warn("gradle cache eviction is enabled, but storage backend cannot list objects")
- return
- }
-
- interval := s.cfg.ParseGradleBuildCacheSweepInterval()
- s.logger.Info("gradle cache eviction enabled",
- "max_age", maxAge,
- "max_size_bytes", maxSize,
- "interval", interval)
-
- sweep := func() {
- deletedCount, freedBytes, err := sweepGradleBuildCache(ctx, s.storage, lister, maxAge, maxSize, time.Now())
- if err != nil {
- s.logger.Warn("gradle cache eviction sweep failed", "error", err)
- return
- }
- if deletedCount > 0 {
- s.logger.Info("gradle cache eviction sweep completed",
- "deleted_entries", deletedCount,
- "freed_bytes", freedBytes)
- }
- }
-
- sweep()
-
- go func() {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- sweep()
- }
- }
- }()
-}
-
-func sweepGradleBuildCache(
- ctx context.Context,
- store storage.Storage,
- lister gradleBuildCacheLister,
- maxAge time.Duration,
- maxSize int64,
- now time.Time,
-) (int, int64, error) {
- entries, err := lister.ListPrefix(ctx, gradleBuildCacheStoragePrefix)
- if err != nil {
- return 0, 0, fmt.Errorf("listing gradle cache entries: %w", err)
- }
-
- if len(entries) == 0 {
- return 0, 0, nil
- }
-
- sortOldestFirst(entries)
-
- deletedCount := 0
- freedBytes := int64(0)
- var firstDeleteErr error
-
- deleteEntry := func(entry storage.ObjectInfo) bool {
- if err := store.Delete(ctx, entry.Path); err != nil {
- if firstDeleteErr == nil {
- firstDeleteErr = err
- }
- return false
- }
- deletedCount++
- freedBytes += entry.Size
- return true
- }
-
- remaining := entries
- if maxAge > 0 {
- cutoff := now.Add(-maxAge)
- kept := make([]storage.ObjectInfo, 0, len(entries))
-
- for _, entry := range entries {
- if !entry.ModTime.IsZero() && entry.ModTime.Before(cutoff) {
- if deleteEntry(entry) {
- continue
- }
- }
- kept = append(kept, entry)
- }
-
- remaining = kept
- }
-
- if maxSize > 0 {
- totalSize := int64(0)
- for _, entry := range remaining {
- totalSize += entry.Size
- }
-
- for _, entry := range remaining {
- if totalSize <= maxSize {
- break
- }
- if deleteEntry(entry) {
- totalSize -= entry.Size
- }
- }
- }
-
- if firstDeleteErr != nil {
- return deletedCount, freedBytes, fmt.Errorf("deleting gradle cache entries: %w", firstDeleteErr)
- }
-
- return deletedCount, freedBytes, nil
-}
-
-func sortOldestFirst(entries []storage.ObjectInfo) {
- sort.Slice(entries, func(i, j int) bool {
- if entries[i].ModTime.Equal(entries[j].ModTime) {
- return entries[i].Path < entries[j].Path
- }
- return entries[i].ModTime.Before(entries[j].ModTime)
- })
-}
diff --git a/internal/server/gradle_cache_eviction_test.go b/internal/server/gradle_cache_eviction_test.go
deleted file mode 100644
index 4e97507..0000000
--- a/internal/server/gradle_cache_eviction_test.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "io"
- "strings"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-type fakeGradleCacheStore struct {
- objects map[string]storage.ObjectInfo
-}
-
-func newFakeGradleCacheStore(objects []storage.ObjectInfo) *fakeGradleCacheStore {
- m := make(map[string]storage.ObjectInfo, len(objects))
- for _, obj := range objects {
- m[obj.Path] = obj
- }
- return &fakeGradleCacheStore{objects: m}
-}
-
-func (s *fakeGradleCacheStore) Store(_ context.Context, path string, r io.Reader) (int64, string, error) {
- data, _ := io.ReadAll(r)
- s.objects[path] = storage.ObjectInfo{Path: path, Size: int64(len(data)), ModTime: time.Now()}
- return int64(len(data)), "", nil
-}
-
-func (s *fakeGradleCacheStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
- obj, ok := s.objects[path]
- if !ok {
- return nil, storage.ErrNotFound
- }
- return io.NopCloser(bytes.NewReader(make([]byte, obj.Size))), nil
-}
-
-func (s *fakeGradleCacheStore) Exists(_ context.Context, path string) (bool, error) {
- _, ok := s.objects[path]
- return ok, nil
-}
-
-func (s *fakeGradleCacheStore) Delete(_ context.Context, path string) error {
- delete(s.objects, path)
- return nil
-}
-
-func (s *fakeGradleCacheStore) Size(_ context.Context, path string) (int64, error) {
- obj, ok := s.objects[path]
- if !ok {
- return 0, storage.ErrNotFound
- }
- return obj.Size, nil
-}
-
-func (s *fakeGradleCacheStore) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
- return "", storage.ErrSignedURLUnsupported
-}
-
-func (s *fakeGradleCacheStore) UsedSpace(_ context.Context) (int64, error) {
- var total int64
- for _, obj := range s.objects {
- total += obj.Size
- }
- return total, nil
-}
-
-func (s *fakeGradleCacheStore) URL() string { return "mem://" }
-
-func (s *fakeGradleCacheStore) Close() error { return nil }
-
-func (s *fakeGradleCacheStore) ListPrefix(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
- objects := make([]storage.ObjectInfo, 0)
- for _, obj := range s.objects {
- if strings.HasPrefix(obj.Path, prefix) {
- objects = append(objects, obj)
- }
- }
- return objects, nil
-}
-
-func TestSweepGradleBuildCache_MaxAge(t *testing.T) {
- now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
- store := newFakeGradleCacheStore([]storage.ObjectInfo{
- {Path: "_gradle/http-build-cache/old", Size: 10, ModTime: now.Add(-48 * time.Hour)},
- {Path: "_gradle/http-build-cache/new", Size: 10, ModTime: now.Add(-2 * time.Hour)},
- })
-
- deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 24*time.Hour, 0, now)
- if err != nil {
- t.Fatalf("sweepGradleBuildCache() error = %v", err)
- }
- if deleted != 1 {
- t.Fatalf("deleted entries = %d, want 1", deleted)
- }
- if freed != 10 {
- t.Fatalf("freed bytes = %d, want 10", freed)
- }
-
- if _, ok := store.objects["_gradle/http-build-cache/old"]; ok {
- t.Fatal("old entry was not deleted")
- }
- if _, ok := store.objects["_gradle/http-build-cache/new"]; !ok {
- t.Fatal("new entry should remain")
- }
-}
-
-func TestSweepGradleBuildCache_MaxSizeOldestFirst(t *testing.T) {
- now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
- store := newFakeGradleCacheStore([]storage.ObjectInfo{
- {Path: "_gradle/http-build-cache/a", Size: 5, ModTime: now.Add(-3 * time.Hour)},
- {Path: "_gradle/http-build-cache/b", Size: 5, ModTime: now.Add(-2 * time.Hour)},
- {Path: "_gradle/http-build-cache/c", Size: 5, ModTime: now.Add(-1 * time.Hour)},
- })
-
- deleted, freed, err := sweepGradleBuildCache(context.Background(), store, store, 0, 10, now)
- if err != nil {
- t.Fatalf("sweepGradleBuildCache() error = %v", err)
- }
- if deleted != 1 {
- t.Fatalf("deleted entries = %d, want 1", deleted)
- }
- if freed != 5 {
- t.Fatalf("freed bytes = %d, want 5", freed)
- }
-
- if _, ok := store.objects["_gradle/http-build-cache/a"]; ok {
- t.Fatal("oldest entry was not deleted")
- }
- if _, ok := store.objects["_gradle/http-build-cache/b"]; !ok {
- t.Fatal("middle entry should remain")
- }
- if _, ok := store.objects["_gradle/http-build-cache/c"]; !ok {
- t.Fatal("newest entry should remain")
- }
-}
diff --git a/internal/server/health.go b/internal/server/health.go
deleted file mode 100644
index f4e4847..0000000
--- a/internal/server/health.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Package server implements the proxy HTTP server.
-package server
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "strconv"
- "sync"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
-)
-
-const (
- probePathPrefix = ".healthcheck/"
- probeMarker = "proxy-healthcheck:"
- probeSuffixBytes = 8
- defaultProbeTTL = 30 * time.Second
- defaultProbeTimeout = 10 * time.Second
-)
-
-// HealthResponse is the JSON payload returned by /health.
-type HealthResponse struct {
- Status string `json:"status"`
- Checks map[string]HealthCheck `json:"checks"`
-}
-
-// HealthCheck reports the status of a single subsystem check.
-type HealthCheck struct {
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
- Step string `json:"step,omitempty"`
-}
-
-// probeError tags a storage probe failure with the step that failed.
-type probeError struct {
- step string
- err error
-}
-
-func (e *probeError) Error() string { return e.step + ": " + e.err.Error() }
-func (e *probeError) Unwrap() error { return e.err }
-
-// storageProbe runs a write → size-check → read → verify → delete round-trip
-// against the storage backend. Returns nil on success or a *probeError on failure.
-func storageProbe(ctx context.Context, s storage.Storage) (err error) {
- suffix, suffixErr := randomSuffix()
- if suffixErr != nil {
- return &probeError{step: "write", err: fmt.Errorf("generating random suffix: %w", suffixErr)}
- }
- path := probePathPrefix + strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + suffix
- payload := []byte(probeMarker + suffix)
-
- // 1. Store
- size, _, storeErr := s.Store(ctx, path, bytes.NewReader(payload))
- if storeErr != nil {
- return &probeError{step: "write", err: storeErr}
- }
- // After Store succeeds, always attempt to delete on the way out so probe
- // objects don't accumulate when a later step (size/open/read/verify) fails.
- // Delete is reported as the primary error only if no earlier failure
- // already set one.
- defer func() {
- if delErr := s.Delete(ctx, path); delErr != nil && err == nil {
- err = &probeError{step: "delete", err: delErr}
- }
- }()
- // 2. Size check
- if size != int64(len(payload)) {
- return &probeError{step: "size", err: fmt.Errorf("wrote %d bytes, expected %d", size, len(payload))}
- }
- // 3. Open
- rc, openErr := s.Open(ctx, path)
- if openErr != nil {
- return &probeError{step: "read", err: openErr}
- }
- // 4. Read all (classify mid-stream errors as read, not verify).
- // Close explicitly (not deferred) so the file handle is released before
- // Delete — on Windows, an open handle prevents deletion.
- data, readErr := io.ReadAll(rc)
- _ = rc.Close()
- if readErr != nil {
- return &probeError{step: "read", err: readErr}
- }
- // 5. Verify
- if !bytes.Equal(data, payload) {
- return &probeError{step: "verify", err: fmt.Errorf("content mismatch")}
- }
- // 6. Delete is handled via the deferred cleanup above.
- return nil
-}
-
-// randomSuffix returns 8 cryptographically random bytes hex-encoded.
-func randomSuffix() (string, error) {
- b := make([]byte, probeSuffixBytes)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- return hex.EncodeToString(b), nil
-}
-
-// healthCache memoizes the result of storageProbe for a configurable TTL.
-// It is safe for concurrent use.
-type healthCache struct {
- storage storage.Storage
- interval time.Duration
- probeTimeout time.Duration
- logger *slog.Logger
-
- mu sync.Mutex
- lastAt time.Time
- lastErr error
-}
-
-// newHealthCache builds a cache, parsing the interval from a duration string.
-// Empty interval string defaults to 30s. "0" or "0s" disables caching.
-func newHealthCache(s storage.Storage, intervalStr string, logger *slog.Logger) (*healthCache, error) {
- interval := defaultProbeTTL
- if intervalStr != "" {
- d, err := time.ParseDuration(intervalStr)
- if err != nil {
- return nil, fmt.Errorf("parsing storage_probe_interval %q: %w", intervalStr, err)
- }
- interval = d
- }
- return &healthCache{
- storage: s,
- interval: interval,
- probeTimeout: defaultProbeTimeout,
- logger: logger,
- }, nil
-}
-
-// Check returns the cached probe result if still fresh, otherwise runs a fresh probe.
-// The probe runs under a context derived from context.Background() with a fixed
-// timeout so that caller cancellation (e.g. client disconnect) cannot poison the
-// cache with context.Canceled.
-func (c *healthCache) Check() error {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- // Cache hit
- if c.interval > 0 && !c.lastAt.IsZero() && time.Since(c.lastAt) < c.interval {
- return c.lastErr
- }
-
- // Fresh probe under a detached context
- probeCtx, cancel := context.WithTimeout(context.Background(), c.probeTimeout)
- defer cancel()
- err := storageProbe(probeCtx, c.storage)
-
- // Transition logging and metric increment happen only on the fresh-probe path.
- c.logTransition(c.lastErr, err)
- if err != nil {
- var pe *probeError
- if errors.As(err, &pe) {
- metrics.RecordHealthProbeFailure(pe.step)
- } else {
- metrics.RecordHealthProbeFailure("unknown")
- }
- }
-
- c.lastErr = err
- c.lastAt = time.Now()
- return err
-}
-
-func (c *healthCache) logTransition(prev, curr error) {
- switch {
- case prev != nil && curr == nil:
- c.logger.Info("storage probe recovered")
- case prev == nil && curr != nil:
- c.logger.Error("storage probe failed", "error", curr.Error())
- }
-}
diff --git a/internal/server/health_test.go b/internal/server/health_test.go
deleted file mode 100644
index c0f70c9..0000000
--- a/internal/server/health_test.go
+++ /dev/null
@@ -1,448 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "log/slog"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/git-pkgs/proxy/internal/metrics"
- "github.com/git-pkgs/proxy/internal/storage"
- "github.com/prometheus/client_golang/prometheus/testutil"
-)
-
-// fakeStorage is a minimal storage.Storage for probe tests with per-step failure injection.
-type fakeStorage struct {
- mu sync.Mutex
-
- storeCalls atomic.Int64
- openCalls atomic.Int64
- closeCalls atomic.Int64
- deleteCalls atomic.Int64
-
- paths []string
- payloads [][]byte
-
- // Failure injection.
- storeErr error
- openErr error
- readErr error // returned by the io.ReadCloser.Read after partial bytes
- deleteErr error
-
- // Misbehavior knobs.
- sizeDelta int64 // added to the reported size from Store
- readOverride []byte // if non-nil, Open returns a reader yielding these bytes instead of stored content
-
- // storeBlock, if non-nil, causes Store to block until the channel is closed or ctx is done.
- storeBlock chan struct{}
-
- stored map[string][]byte
-}
-
-func newFakeStorage() *fakeStorage { return &fakeStorage{stored: map[string][]byte{}} }
-
-func (f *fakeStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- f.storeCalls.Add(1)
- if f.storeErr != nil {
- return 0, "", f.storeErr
- }
- if f.storeBlock != nil {
- select {
- case <-f.storeBlock:
- case <-ctx.Done():
- return 0, "", ctx.Err()
- }
- }
- data, err := io.ReadAll(r)
- if err != nil {
- return 0, "", err
- }
- f.mu.Lock()
- f.stored[path] = data
- f.paths = append(f.paths, path)
- f.payloads = append(f.payloads, data)
- f.mu.Unlock()
- return int64(len(data)) + f.sizeDelta, "fakehash", nil
-}
-
-type fakeReadCloser struct {
- data []byte
- pos int
- readErr error
- closed *atomic.Int64
-}
-
-func (rc *fakeReadCloser) Read(p []byte) (int, error) {
- if rc.pos >= len(rc.data) {
- if rc.readErr != nil {
- return 0, rc.readErr
- }
- return 0, io.EOF
- }
- n := copy(p, rc.data[rc.pos:])
- rc.pos += n
- if rc.pos >= len(rc.data) && rc.readErr != nil {
- return n, rc.readErr
- }
- return n, nil
-}
-
-func (rc *fakeReadCloser) Close() error { rc.closed.Add(1); return nil }
-
-func (f *fakeStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- f.openCalls.Add(1)
- if f.openErr != nil {
- return nil, f.openErr
- }
- f.mu.Lock()
- data := f.stored[path]
- f.mu.Unlock()
- if f.readOverride != nil {
- data = f.readOverride
- }
- return &fakeReadCloser{data: data, readErr: f.readErr, closed: &f.closeCalls}, nil
-}
-
-func (f *fakeStorage) Exists(ctx context.Context, path string) (bool, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- _, ok := f.stored[path]
- return ok, nil
-}
-
-func (f *fakeStorage) Delete(ctx context.Context, path string) error {
- f.deleteCalls.Add(1)
- if f.deleteErr != nil {
- return f.deleteErr
- }
- f.mu.Lock()
- delete(f.stored, path)
- f.mu.Unlock()
- return nil
-}
-
-func (f *fakeStorage) Size(ctx context.Context, path string) (int64, error) { return 0, nil }
-func (f *fakeStorage) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
- return "", storage.ErrSignedURLUnsupported
-}
-func (f *fakeStorage) UsedSpace(ctx context.Context) (int64, error) { return 0, nil }
-func (f *fakeStorage) URL() string { return "fake://" }
-func (f *fakeStorage) Close() error { return nil }
-
-// --- Tests follow. First test: happy path ---
-
-func TestStorageProbe_HappyPath(t *testing.T) {
- fs := newFakeStorage()
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("Store calls = %d, want 1", got)
- }
- if got := fs.openCalls.Load(); got != 1 {
- t.Errorf("Open calls = %d, want 1", got)
- }
- if got := fs.closeCalls.Load(); got != 1 {
- t.Errorf("Close calls = %d, want 1", got)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("Delete calls = %d, want 1", got)
- }
- if len(fs.paths) != 1 || !strings.HasPrefix(fs.paths[0], ".healthcheck/") {
- t.Errorf("unexpected probe path: %v", fs.paths)
- }
-}
-
-func TestStorageProbe_WriteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("disk full")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) {
- t.Fatalf("expected *probeError, got %T: %v", err, err)
- }
- if pe.step != "write" {
- t.Errorf("step = %q, want write", pe.step)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after write failure")
- }
-}
-
-func TestStorageProbe_SizeMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.sizeDelta = -1 // Report 1 byte fewer than actually written
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "size" {
- t.Fatalf("step = %v, want size; err = %v", pe, err)
- }
- if fs.openCalls.Load() != 0 {
- t.Errorf("Open should not be called after size mismatch")
- }
-}
-
-func TestStorageProbe_OpenFails(t *testing.T) {
- fs := newFakeStorage()
- fs.openErr = errors.New("access denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ReadMidStreamFails(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("connection reset")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "read" {
- t.Fatalf("step = %v, want read (NOT verify); err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_ContentMismatch(t *testing.T) {
- fs := newFakeStorage()
- fs.readOverride = []byte("wrong content")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "verify" {
- t.Fatalf("step = %v, want verify; err = %v", pe, err)
- }
-}
-
-func TestStorageProbe_DeleteFails(t *testing.T) {
- fs := newFakeStorage()
- fs.deleteErr = errors.New("permission denied")
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != "delete" {
- t.Fatalf("step = %v, want delete; err = %v", pe, err)
- }
-}
-
-// TestStorageProbe_CleanupOnNonDeleteFailure asserts that the probe object is
-// deleted even when a step after Store (size/open/read/verify) fails, so
-// probe artifacts don't accumulate in the storage backend.
-func TestStorageProbe_CleanupOnNonDeleteFailure(t *testing.T) {
- cases := []struct {
- name string
- inject func(*fakeStorage)
- wantErr string
- }{
- {"size mismatch", func(fs *fakeStorage) { fs.sizeDelta = -1 }, "size"},
- {"open fails", func(fs *fakeStorage) { fs.openErr = errors.New("open boom") }, "read"},
- {"read mid-stream", func(fs *fakeStorage) { fs.readErr = errors.New("mid-stream boom") }, "read"},
- {"content mismatch", func(fs *fakeStorage) { fs.readOverride = []byte("wrong") }, "verify"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- fs := newFakeStorage()
- tc.inject(fs)
- err := storageProbe(context.Background(), fs)
- var pe *probeError
- if !errors.As(err, &pe) || pe.step != tc.wantErr {
- t.Fatalf("step = %v, want %q; err = %v", pe, tc.wantErr, err)
- }
- if got := fs.deleteCalls.Load(); got != 1 {
- t.Errorf("deleteCalls = %d, want 1 (cleanup should run on non-delete failures)", got)
- }
- })
- }
-}
-
-func TestStorageProbe_ReaderClosedOnReadFailure(t *testing.T) {
- fs := newFakeStorage()
- fs.readErr = errors.New("read error")
- _ = storageProbe(context.Background(), fs)
- if got := fs.closeCalls.Load(); got != fs.openCalls.Load() {
- t.Errorf("closeCalls = %d, openCalls = %d (should match)", got, fs.openCalls.Load())
- }
-}
-
-func TestStorageProbe_PathUniqueness(t *testing.T) {
- fs := newFakeStorage()
- for i := 0; i < 100; i++ {
- if err := storageProbe(context.Background(), fs); err != nil {
- t.Fatalf("probe %d: %v", i, err)
- }
- }
- seen := make(map[string]bool)
- for _, p := range fs.paths {
- if !strings.HasPrefix(p, ".healthcheck/") {
- t.Errorf("path missing prefix: %q", p)
- }
- if seen[p] {
- t.Errorf("duplicate path: %q", p)
- }
- seen[p] = true
- }
-}
-
-// helper: a healthCache wired to a fakeStorage and a discard logger.
-func newTestCache(fs *fakeStorage, interval time.Duration) *healthCache {
- return &healthCache{
- storage: fs,
- interval: interval,
- probeTimeout: 5 * time.Second,
- logger: discardLogger(),
- }
-}
-
-func discardLogger() *slog.Logger {
- return slog.New(slog.NewTextHandler(io.Discard, nil))
-}
-
-func TestHealthCache_CacheHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- if err := c.Check(); err != nil {
- t.Fatalf("first check: %v", err)
- }
- if err := c.Check(); err != nil {
- t.Fatalf("second check: %v", err)
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 (second call should be cached)", got)
- }
-}
-
-func TestHealthCache_MissAfterTTL(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 10*time.Millisecond)
- _ = c.Check()
- time.Sleep(20 * time.Millisecond)
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_Disabled(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 0) // interval = 0 means probe every call
- _ = c.Check()
- _ = c.Check()
- if got := fs.storeCalls.Load(); got != 2 {
- t.Errorf("storeCalls = %d, want 2", got)
- }
-}
-
-func TestHealthCache_LastAtNotAdvancedOnHit(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- for i := 0; i < 100; i++ {
- _ = c.Check()
- }
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 across 100 hits", got)
- }
-}
-
-func TestHealthCache_ConcurrentSingleFlight(t *testing.T) {
- fs := newFakeStorage()
- c := newTestCache(fs, 30*time.Second)
- var wg sync.WaitGroup
- for i := 0; i < 20; i++ {
- wg.Add(1)
- go func() { defer wg.Done(); _ = c.Check() }()
- }
- wg.Wait()
- if got := fs.storeCalls.Load(); got != 1 {
- t.Errorf("storeCalls = %d, want 1 with 20 concurrent callers", got)
- }
-}
-
-func TestHealthCache_FailureCounterIncrement(t *testing.T) {
- fs := newFakeStorage()
- fs.storeErr = errors.New("boom")
- c := newTestCache(fs, 30*time.Second)
-
- before := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
-
- // First call: fresh probe → counter +1
- _ = c.Check()
- afterFirst := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterFirst-before != 1 {
- t.Errorf("counter delta after first call = %v, want 1", afterFirst-before)
- }
-
- // Second call: cache hit → counter NOT re-incremented
- _ = c.Check()
- afterSecond := testutil.ToFloat64(metrics.HealthProbeFailures.WithLabelValues("write"))
- if afterSecond != afterFirst {
- t.Errorf("counter changed on cache hit: %v → %v", afterFirst, afterSecond)
- }
-}
-
-func TestHealthCache_ProbeTimeout(t *testing.T) {
- fs := newFakeStorage()
- fs.storeBlock = make(chan struct{}) // Store will block until channel is closed (or never)
- t.Cleanup(func() { close(fs.storeBlock) })
-
- c := &healthCache{
- storage: fs,
- interval: 30 * time.Second,
- probeTimeout: 50 * time.Millisecond,
- logger: discardLogger(),
- }
- start := time.Now()
- err := c.Check()
- elapsed := time.Since(start)
-
- if err == nil {
- t.Fatal("expected timeout error, got nil")
- }
- if elapsed > 500*time.Millisecond {
- t.Errorf("probe took %v, expected ~50ms (timeout not respected)", elapsed)
- }
-}
-
-func TestHealthCache_TransitionLogging(t *testing.T) {
- fs := newFakeStorage()
- var buf bytes.Buffer
- logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
- c := &healthCache{
- storage: fs,
- interval: 0, // probe every call
- probeTimeout: 5 * time.Second,
- logger: logger,
- }
-
- // Steady ok state — should not log
- _ = c.Check()
- _ = c.Check()
- if got := strings.Count(buf.String(), "storage probe"); got != 0 {
- t.Errorf("steady-state logs = %d, want 0; output: %s", got, buf.String())
- }
-
- // ok → err transition: exactly one Error log
- buf.Reset()
- fs.storeErr = errors.New("boom")
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe failed") {
- t.Errorf("missing failure log on transition; output: %s", buf.String())
- }
-
- // err steady state — should not log again
- buf.Reset()
- _ = c.Check()
- if buf.Len() != 0 {
- t.Errorf("steady-err logs = %q, want empty", buf.String())
- }
-
- // err → ok transition: exactly one Info log
- buf.Reset()
- fs.storeErr = nil
- _ = c.Check()
- if !strings.Contains(buf.String(), "storage probe recovered") {
- t.Errorf("missing recovery log on transition; output: %s", buf.String())
- }
-}
diff --git a/internal/server/mirror_api.go b/internal/server/mirror_api.go
index 028d4e0..6a6a6ca 100644
--- a/internal/server/mirror_api.go
+++ b/internal/server/mirror_api.go
@@ -20,16 +20,19 @@ func NewMirrorAPIHandler(jobs *mirror.JobStore) *MirrorAPIHandler {
// HandleCreate starts a new mirror job.
func (h *MirrorAPIHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req mirror.JobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- badRequest(w, "invalid request body")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ writeJSON(w, map[string]string{"error": "invalid request body"})
return
}
id, err := h.jobs.Create(req)
if err != nil {
- badRequest(w, "invalid mirror job request")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ writeJSON(w, map[string]string{"error": err.Error()})
return
}
@@ -43,10 +46,13 @@ func (h *MirrorAPIHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
job := h.jobs.Get(id)
if job == nil {
- notFound(w, "job not found")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ writeJSON(w, map[string]string{"error": "job not found"})
return
}
+ w.Header().Set("Content-Type", "application/json")
writeJSON(w, job)
}
@@ -54,8 +60,11 @@ func (h *MirrorAPIHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
func (h *MirrorAPIHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if h.jobs.Cancel(id) {
+ w.Header().Set("Content-Type", "application/json")
writeJSON(w, map[string]string{"status": "canceled"})
} else {
- notFound(w, "job not found or not running")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ writeJSON(w, map[string]string{"error": "job not found or not running"})
}
}
diff --git a/internal/server/mirror_api_test.go b/internal/server/mirror_api_test.go
index 73b8731..0e84da1 100644
--- a/internal/server/mirror_api_test.go
+++ b/internal/server/mirror_api_test.go
@@ -71,19 +71,6 @@ func TestMirrorAPICreateJob(t *testing.T) {
}
}
-func TestMirrorAPICreateOversizedBody(t *testing.T) {
- h := setupMirrorAPI(t)
-
- body := bytes.Repeat([]byte("x"), int(maxBodySize)+1)
- req := httptest.NewRequest("POST", "/api/mirror", bytes.NewReader(body))
- w := httptest.NewRecorder()
- h.HandleCreate(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
- }
-}
-
func TestMirrorAPICreateInvalidBody(t *testing.T) {
h := setupMirrorAPI(t)
diff --git a/internal/server/resolve.go b/internal/server/resolve.go
index 51f203d..479ede6 100644
--- a/internal/server/resolve.go
+++ b/internal/server/resolve.go
@@ -1,39 +1,11 @@
package server
import (
- "fmt"
"strings"
- "unicode"
"github.com/git-pkgs/proxy/internal/database"
)
-// maxPackagePathLen bounds the wildcard portion of package routes (name plus
-// version and any suffix). npm caps names at 214 and Maven coordinates can be
-// longer, so 512 leaves room without admitting pathological inputs.
-const maxPackagePathLen = 512
-
-// validatePackagePath rejects wildcard package paths that cannot be valid in
-// any supported ecosystem. It is a coarse filter applied before database or
-// enrichment lookups; ecosystem-specific name rules are layered on top.
-func validatePackagePath(path string) error {
- if path == "" {
- return fmt.Errorf("package name required")
- }
- if len(path) > maxPackagePathLen {
- return fmt.Errorf("package path exceeds %d bytes", maxPackagePathLen)
- }
- for _, r := range path {
- if r == 0 {
- return fmt.Errorf("package path contains null byte")
- }
- if unicode.IsControl(r) {
- return fmt.Errorf("package path contains control character %#U", r)
- }
- }
- return nil
-}
-
// resolvePackageName determines the package name from a wildcard path by
// checking the database. This handles namespaced packages like Composer's
// vendor/name format where the package name contains a slash.
diff --git a/internal/server/resolve_test.go b/internal/server/resolve_test.go
index dd7d2dc..427c2cb 100644
--- a/internal/server/resolve_test.go
+++ b/internal/server/resolve_test.go
@@ -3,7 +3,6 @@ package server
import (
"os"
"path/filepath"
- "strings"
"testing"
"github.com/git-pkgs/proxy/internal/database"
@@ -119,36 +118,3 @@ func TestSplitWildcardPath(t *testing.T) {
}
}
}
-
-func TestValidatePackagePath(t *testing.T) {
- tests := []struct {
- name string
- path string
- wantErr bool
- }{
- {"simple", "lodash", false},
- {"with version", "lodash/4.17.21", false},
- {"npm scoped", "@babel/core/7.0.0", false},
- {"composer namespaced", "symfony/console/6.0.0", false},
- {"maven coordinates", "org.apache.commons/commons-lang3/3.12.0", false},
- {"unicode", "café/1.0.0", false},
- {"empty", "", true},
- {"null byte", "lodash\x00/4.17.21", true},
- {"null byte suffix", "lodash\x00", true},
- {"newline", "lodash\n4.17.21", true},
- {"carriage return", "lodash\r", true},
- {"escape", "lodash\x1b[31m", true},
- {"delete", "lodash\x7f", true},
- {"too long", strings.Repeat("a", maxPackagePathLen+1), true},
- {"at limit", strings.Repeat("a", maxPackagePathLen), false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := validatePackagePath(tt.path)
- if (err != nil) != tt.wantErr {
- t.Errorf("validatePackagePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
- }
- })
- }
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index 7de5041..5d544a2 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -9,13 +9,11 @@
// - /pub/* - pub.dev registry protocol
// - /pypi/* - PyPI registry protocol
// - /maven/* - Maven repository protocol
-// - /gradle/* - Gradle HttpBuildCache protocol
// - /nuget/* - NuGet V3 API protocol
// - /composer/* - Composer/Packagist protocol
// - /conan/* - Conan C/C++ protocol
// - /conda/* - Conda/Anaconda protocol
// - /cran/* - CRAN (R) protocol
-// - /julia/* - Julia Pkg server protocol
// - /v2/* - OCI/Docker container registry protocol
// - /debian/* - Debian/APT repository protocol
// - /rpm/* - RPM/Yum repository protocol
@@ -41,7 +39,6 @@ import (
"context"
"database/sql"
"encoding/json"
- "errors"
"fmt"
"log/slog"
"net/http"
@@ -51,7 +48,7 @@ import (
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
"github.com/git-pkgs/proxy/internal/config"
- "github.com/git-pkgs/cooldown"
+ "github.com/git-pkgs/proxy/internal/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/enrichment"
"github.com/git-pkgs/proxy/internal/handler"
@@ -81,8 +78,7 @@ type Server struct {
logger *slog.Logger
http *http.Server
templates *Templates
- cancel context.CancelFunc
- healthCache *healthCache
+ cancel context.CancelFunc
}
// New creates a new Server with the given configuration.
@@ -128,20 +124,12 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("verifying storage connectivity: %w", err)
}
- hc, err := newHealthCache(store, cfg.Health.StorageProbeInterval, logger)
- if err != nil {
- _ = store.Close()
- _ = db.Close()
- return nil, fmt.Errorf("initializing health cache: %w", err)
- }
-
return &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
}, nil
}
@@ -160,12 +148,6 @@ func (s *Server) Start() error {
proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
- proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize()
- proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly
- proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()
- proxy.DirectServe = s.cfg.Storage.DirectServe
- proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL()
- proxy.DirectServeBaseURL = s.cfg.Storage.DirectServeBaseURL
// Create router with Chi
r := chi.NewRouter()
@@ -194,19 +176,12 @@ func (s *Server) Start() error {
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL)
- mavenHandler := handler.NewMavenHandler(
- proxy,
- s.cfg.BaseURL,
- s.cfg.Upstream.Maven,
- s.cfg.Upstream.GradlePluginPortal,
- )
- gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
+ mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL)
nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL)
composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL)
conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL)
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
- juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL)
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL)
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL)
@@ -219,13 +194,11 @@ func (s *Server) Start() error {
r.Mount("/pub", http.StripPrefix("/pub", pubHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
r.Mount("/maven", http.StripPrefix("/maven", mavenHandler.Routes()))
- r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
r.Mount("/nuget", http.StripPrefix("/nuget", nugetHandler.Routes()))
r.Mount("/composer", http.StripPrefix("/composer", composerHandler.Routes()))
r.Mount("/conan", http.StripPrefix("/conan", conanHandler.Routes()))
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
- r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
@@ -262,7 +235,6 @@ func (s *Server) Start() error {
// Start background context (used by mirror jobs and cleanup)
bgCtx, bgCancel := context.WithCancel(context.Background())
s.cancel = bgCancel
- s.startGradleBuildCacheEviction(bgCtx)
// Mirror API endpoints (opt-in via mirror_api config or PROXY_MIRROR_API env)
if s.cfg.MirrorAPI {
@@ -289,7 +261,6 @@ func (s *Server) Start() error {
"storage", s.storage.URL(),
"database", s.cfg.Database.Path)
go s.updateCacheStatsMetrics()
- go s.startEvictionLoop(bgCtx)
return s.http.ListenAndServe()
}
@@ -640,10 +611,6 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
wildcard := chi.URLParam(r, "*")
- if err := validatePackagePath(wildcard); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
segments := splitWildcardPath(wildcard)
if ecosystem == "" || len(segments) == 0 {
@@ -818,49 +785,23 @@ func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, version
}
}
-// handleHealth responds with a structured JSON health report.
-//
+// handleHealth responds with a simple health check.
// @Summary Health check
// @Tags meta
-// @Produce json
-// @Success 200 {object} HealthResponse
-// @Failure 503 {object} HealthResponse
+// @Produce plain
+// @Success 200 {string} string
+// @Failure 503 {string} string
// @Router /health [get]
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
-
- resp := HealthResponse{Status: "ok", Checks: map[string]HealthCheck{}}
-
- // Database check (short-circuit; do not waste a storage probe call when DB is down).
- // On DB failure the storage entry reports "skipped" rather than being omitted so
- // the response always carries the same key set for monitors that expect it.
+ // Check database connectivity
if _, err := s.db.SchemaVersion(); err != nil {
- resp.Status = "error"
- resp.Checks["database"] = HealthCheck{Status: "error", Error: err.Error()}
- resp.Checks["storage"] = HealthCheck{Status: "skipped"}
w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprint(w, "database error")
return
}
- resp.Checks["database"] = HealthCheck{Status: "ok"}
-
- // Storage probe (via cache).
- if err := s.healthCache.Check(); err != nil {
- resp.Status = "error"
- sc := HealthCheck{Status: "error", Error: err.Error()}
- var pe *probeError
- if errors.As(err, &pe) {
- sc.Step = pe.step
- }
- resp.Checks["storage"] = sc
- w.WriteHeader(http.StatusServiceUnavailable)
- _ = json.NewEncoder(w).Encode(resp)
- return
- }
- resp.Checks["storage"] = HealthCheck{Status: "ok"}
w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(resp)
+ _, _ = fmt.Fprint(w, "ok")
}
// StatsResponse contains cache statistics.
@@ -877,20 +818,20 @@ type StatsResponse struct {
// @Tags meta
// @Produce json
// @Success 200 {object} StatsResponse
-// @Failure 500 {object} ErrorResponse
+// @Failure 500 {string} string
// @Router /stats [get]
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
count, err := s.db.GetCachedArtifactCount()
if err != nil {
- internalError(w, "failed to get artifact count")
+ http.Error(w, "failed to get artifact count", http.StatusInternalServerError)
return
}
size, err := s.db.GetTotalCacheSize()
if err != nil {
- internalError(w, "failed to get cache size")
+ http.Error(w, "failed to get cache size", http.StatusInternalServerError)
return
}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index e2dc1c2..be88bf6 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -72,30 +72,20 @@ func newTestServer(t *testing.T) *testServer {
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
- gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes()))
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
- r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
-
- hc, err := newHealthCache(store, "30s", logger)
- if err != nil {
- _ = db.Close()
- _ = os.RemoveAll(tempDir)
- t.Fatalf("failed to create health cache: %v", err)
- }
// Create a minimal server struct for the handlers
s := &Server{
- cfg: cfg,
- db: db,
- storage: store,
- logger: logger,
- templates: &Templates{},
- healthCache: hc,
+ cfg: cfg,
+ db: db,
+ storage: store,
+ logger: logger,
+ templates: &Templates{},
}
r.Get("/health", s.handleHealth)
@@ -187,55 +177,12 @@ func TestHealthEndpoint(t *testing.T) {
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ t.Errorf("expected status 200, got %d", w.Code)
}
- if got := w.Header().Get("Content-Type"); got != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", got)
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding response: %v", err)
- }
- if resp.Status != "ok" {
- t.Errorf("status = %q, want ok", resp.Status)
- }
- if resp.Checks["database"].Status != "ok" {
- t.Errorf("database check = %+v, want ok", resp.Checks["database"])
- }
- if resp.Checks["storage"].Status != "ok" {
- t.Errorf("storage check = %+v, want ok", resp.Checks["storage"])
- }
-}
-func TestHealthEndpoint_DBFailureShortCircuits(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- // Force DB failure by closing the connection.
- _ = ts.db.Close()
-
- req := httptest.NewRequest("GET", "/health", nil)
- w := httptest.NewRecorder()
- ts.handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String())
- }
- var resp HealthResponse
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decoding: %v", err)
- }
- if resp.Status != "error" {
- t.Errorf("status = %q, want error", resp.Status)
- }
- if resp.Checks["database"].Status != "error" {
- t.Errorf("database check = %+v, want error", resp.Checks["database"])
- }
- storage, present := resp.Checks["storage"]
- if !present {
- t.Error("storage key should be present (with status=skipped) on DB short-circuit")
- } else if storage.Status != "skipped" {
- t.Errorf("storage check = %+v, want status=skipped", storage)
+ body := w.Body.String()
+ if body != "ok" {
+ t.Errorf("expected body 'ok', got %q", body)
}
}
@@ -397,33 +344,6 @@ func TestPyPISimple(t *testing.T) {
}
}
-func TestGradleBuildCachePutGet(t *testing.T) {
- ts := newTestServer(t)
- defer ts.close()
-
- key := "abc123def456"
- body := "build-cache-bytes"
-
- putReq := httptest.NewRequest(http.MethodPut, "/gradle/"+key, strings.NewReader(body))
- putW := httptest.NewRecorder()
- ts.handler.ServeHTTP(putW, putReq)
-
- if putW.Code != http.StatusCreated {
- t.Fatalf("expected status 201, got %d: %s", putW.Code, putW.Body.String())
- }
-
- getReq := httptest.NewRequest(http.MethodGet, "/gradle/"+key, nil)
- getW := httptest.NewRecorder()
- ts.handler.ServeHTTP(getW, getReq)
-
- if getW.Code != http.StatusOK {
- t.Fatalf("expected status 200, got %d: %s", getW.Code, getW.Body.String())
- }
- if got := getW.Body.String(); got != body {
- t.Fatalf("expected body %q, got %q", body, got)
- }
-}
-
func TestGemSpecs(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
diff --git a/internal/server/templates_test.go b/internal/server/templates_test.go
index c27363b..e19244e 100644
--- a/internal/server/templates_test.go
+++ b/internal/server/templates_test.go
@@ -193,7 +193,7 @@ func TestInstallPage(t *testing.T) {
body := w.Body.String()
// Should contain instructions for all registries
- registries := []string{"npm", "Cargo", "RubyGems", "Go Modules", "PyPI", "Maven", "Gradle Build Cache", "NuGet", "Composer", "Conan", "Conda", "CRAN"}
+ registries := []string{"npm", "Cargo", "RubyGems", "Go Modules", "PyPI", "Maven", "NuGet", "Composer", "Conan", "Conda", "CRAN"}
for _, reg := range registries {
if !strings.Contains(body, reg) {
t.Errorf("install page should contain %s instructions", reg)
@@ -335,6 +335,7 @@ func TestSearchPage_EcosystemFilter(t *testing.T) {
}
}
+
func TestEcosystemBadgeLabel(t *testing.T) {
tests := []struct {
ecosystem string
diff --git a/internal/storage/blob.go b/internal/storage/blob.go
index 67e91d0..2d6af46 100644
--- a/internal/storage/blob.go
+++ b/internal/storage/blob.go
@@ -6,15 +6,12 @@ import (
"encoding/hex"
"fmt"
"io"
- "net/http"
"os"
"path/filepath"
"runtime"
"strings"
- "time"
"gocloud.dev/blob"
- _ "gocloud.dev/blob/azureblob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/s3blob"
"gocloud.dev/gcerrors"
@@ -141,20 +138,6 @@ func (b *Blob) Delete(ctx context.Context, path string) error {
return nil
}
-func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
- url, err := b.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{
- Method: http.MethodGet,
- Expiry: expiry,
- })
- if err != nil {
- if gcerrors.Code(err) == gcerrors.Unimplemented {
- return "", ErrSignedURLUnsupported
- }
- return "", fmt.Errorf("signing URL: %w", err)
- }
- return url, nil
-}
-
func (b *Blob) Size(ctx context.Context, path string) (int64, error) {
attrs, err := b.bucket.Attributes(ctx, path)
if err != nil {
@@ -184,35 +167,6 @@ func (b *Blob) UsedSpace(ctx context.Context) (int64, error) {
return total, nil
}
-// ListPrefix returns object metadata for keys under a prefix.
-func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) {
- iter := b.bucket.List(&blob.ListOptions{Prefix: prefix})
- objects := make([]ObjectInfo, 0)
-
- for {
- obj, err := iter.Next(ctx)
- if err == io.EOF {
- break
- }
- if err != nil {
- return nil, fmt.Errorf("listing objects: %w", err)
- }
- if obj.IsDir {
- continue
- }
-
- info := ObjectInfo{
- Path: obj.Key,
- Size: obj.Size,
- ModTime: obj.ModTime,
- }
-
- objects = append(objects, info)
- }
-
- return objects, nil
-}
-
func (b *Blob) Close() error {
return b.bucket.Close()
}
diff --git a/internal/storage/blob_test.go b/internal/storage/blob_test.go
index d80290b..bb2d089 100644
--- a/internal/storage/blob_test.go
+++ b/internal/storage/blob_test.go
@@ -10,7 +10,6 @@ import (
"runtime"
"strings"
"testing"
- "time"
)
func TestOpenBucket(t *testing.T) {
@@ -189,18 +188,6 @@ func TestBlobLargeFile(t *testing.T) {
assertLargeFileRoundTrip(t, createTestBlob(t))
}
-func TestBlobSignedURLUnsupported(t *testing.T) {
- b := createTestBlob(t)
- ctx := context.Background()
-
- // fileblob has no URL signer configured, so this must surface as
- // ErrSignedURLUnsupported rather than a generic error.
- _, err := b.SignedURL(ctx, "test/file.txt", time.Minute)
- if !errors.Is(err, ErrSignedURLUnsupported) {
- t.Errorf("SignedURL on fileblob = %v, want ErrSignedURLUnsupported", err)
- }
-}
-
func TestBlobOverwrite(t *testing.T) {
b := createTestBlob(t)
ctx := context.Background()
diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go
index 1e5a24f..cf6a1fe 100644
--- a/internal/storage/filesystem.go
+++ b/internal/storage/filesystem.go
@@ -6,11 +6,8 @@ import (
"encoding/hex"
"fmt"
"io"
- fsys "io/fs"
"os"
"path/filepath"
- "strings"
- "time"
)
// Filesystem implements Storage using the local filesystem.
@@ -33,19 +30,12 @@ func NewFilesystem(root string) (*Filesystem, error) {
return &Filesystem{root: absRoot}, nil
}
-func (fs *Filesystem) fullPath(path string) (string, error) {
- full := filepath.Clean(filepath.Join(fs.root, filepath.FromSlash(path)))
- if full != fs.root && !strings.HasPrefix(full, fs.root+string(filepath.Separator)) {
- return "", fmt.Errorf("%w: path escapes storage root", ErrNotFound)
- }
- return full, nil
+func (fs *Filesystem) fullPath(path string) string {
+ return filepath.Join(fs.root, filepath.FromSlash(path))
}
func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
- fullPath, err := fs.fullPath(path)
- if err != nil {
- return 0, "", err
- }
+ fullPath := fs.fullPath(path)
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, dirPermissions); err != nil {
@@ -92,10 +82,7 @@ func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int6
}
func (fs *Filesystem) Open(ctx context.Context, path string) (io.ReadCloser, error) {
- fullPath, err := fs.fullPath(path)
- if err != nil {
- return nil, err
- }
+ fullPath := fs.fullPath(path)
f, err := os.Open(fullPath)
if err != nil {
@@ -109,12 +96,9 @@ func (fs *Filesystem) Open(ctx context.Context, path string) (io.ReadCloser, err
}
func (fs *Filesystem) Exists(ctx context.Context, path string) (bool, error) {
- fullPath, err := fs.fullPath(path)
- if err != nil {
- return false, err
- }
+ fullPath := fs.fullPath(path)
- _, err = os.Stat(fullPath)
+ _, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
@@ -126,12 +110,9 @@ func (fs *Filesystem) Exists(ctx context.Context, path string) (bool, error) {
}
func (fs *Filesystem) Delete(ctx context.Context, path string) error {
- fullPath, err := fs.fullPath(path)
- if err != nil {
- return err
- }
+ fullPath := fs.fullPath(path)
- err = os.Remove(fullPath)
+ err := os.Remove(fullPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing file: %w", err)
}
@@ -148,15 +129,8 @@ func (fs *Filesystem) Delete(ctx context.Context, path string) error {
return nil
}
-func (fs *Filesystem) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
- return "", ErrSignedURLUnsupported
-}
-
func (fs *Filesystem) Size(ctx context.Context, path string) (int64, error) {
- fullPath, err := fs.fullPath(path)
- if err != nil {
- return 0, err
- }
+ fullPath := fs.fullPath(path)
info, err := os.Stat(fullPath)
if err != nil {
@@ -188,54 +162,6 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) {
return total, nil
}
-// ListPrefix returns object metadata for paths under a prefix.
-func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) {
- searchRoot, err := fs.fullPath(prefix)
- if err != nil {
- return nil, err
- }
-
- if _, err := os.Stat(searchRoot); err != nil {
- if os.IsNotExist(err) {
- return []ObjectInfo{}, nil
- }
- return nil, fmt.Errorf("stat prefix: %w", err)
- }
-
- objects := make([]ObjectInfo, 0)
- err = filepath.WalkDir(searchRoot, func(path string, entry fsys.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if entry.IsDir() {
- return nil
- }
-
- info, err := entry.Info()
- if err != nil {
- return err
- }
-
- relPath, err := filepath.Rel(fs.root, path)
- if err != nil {
- return err
- }
-
- objects = append(objects, ObjectInfo{
- Path: filepath.ToSlash(relPath),
- Size: info.Size(),
- ModTime: info.ModTime(),
- })
-
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("walking prefix: %w", err)
- }
-
- return objects, nil
-}
-
// Root returns the root directory of the storage.
func (fs *Filesystem) Root() string {
return fs.root
@@ -243,8 +169,7 @@ func (fs *Filesystem) Root() string {
// FullPath returns the full filesystem path for a storage path.
// Useful for serving files directly or debugging.
-// Returns an error if the resulting path would escape the storage root.
-func (fs *Filesystem) FullPath(path string) (string, error) {
+func (fs *Filesystem) FullPath(path string) string {
return fs.fullPath(path)
}
diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go
index 332dfbf..7fbba10 100644
--- a/internal/storage/filesystem_test.go
+++ b/internal/storage/filesystem_test.go
@@ -10,7 +10,6 @@ import (
"path/filepath"
"strings"
"testing"
- "time"
)
func TestNewFilesystem(t *testing.T) {
@@ -52,10 +51,7 @@ func TestFilesystemStore(t *testing.T) {
}
// Verify file exists on disk
- fullPath, err := fs.FullPath("npm/lodash/4.17.21/lodash.tgz")
- if err != nil {
- t.Fatalf("FullPath failed: %v", err)
- }
+ fullPath := fs.FullPath("npm/lodash/4.17.21/lodash.tgz")
data, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("reading stored file: %v", err)
@@ -166,10 +162,7 @@ func TestFilesystemDelete(t *testing.T) {
}
// Empty parent directories should be cleaned up
- nestedDir, err := fs.FullPath("test/delete/nested")
- if err != nil {
- t.Fatalf("FullPath failed: %v", err)
- }
+ nestedDir := fs.FullPath("test/delete/nested")
if _, err := os.Stat(nestedDir); !os.IsNotExist(err) {
t.Error("empty nested directory not cleaned up")
}
@@ -226,9 +219,9 @@ func TestFilesystemUsedSpace(t *testing.T) {
}
// Add some files
- _, _, _ = fs.Store(ctx, "a.txt", strings.NewReader("aaaa")) // 4 bytes
- _, _, _ = fs.Store(ctx, "b.txt", strings.NewReader("bbbbbb")) // 6 bytes
- _, _, _ = fs.Store(ctx, "c/d.txt", strings.NewReader("ccccc")) // 5 bytes
+ _, _, _ = fs.Store(ctx, "a.txt", strings.NewReader("aaaa")) // 4 bytes
+ _, _, _ = fs.Store(ctx, "b.txt", strings.NewReader("bbbbbb")) // 6 bytes
+ _, _, _ = fs.Store(ctx, "c/d.txt", strings.NewReader("ccccc")) // 5 bytes
used, err = fs.UsedSpace(ctx)
if err != nil {
@@ -243,31 +236,6 @@ func TestFilesystemLargeFile(t *testing.T) {
assertLargeFileRoundTrip(t, createTestFilesystem(t))
}
-func TestFilesystemRejectsTraversal(t *testing.T) {
- tmp := t.TempDir()
- fs, err := NewFilesystem(tmp)
- if err != nil {
- t.Fatal(err)
- }
- for _, p := range []string{"../etc/passwd", "../../etc/passwd", "a/../../etc/passwd"} {
- if _, err := fs.Open(context.Background(), p); err == nil {
- t.Errorf("Open(%q) should reject traversal", p)
- }
- if _, _, err := fs.Store(context.Background(), p, strings.NewReader("x")); err == nil {
- t.Errorf("Store(%q) should reject traversal", p)
- }
- }
-}
-
-func TestFilesystemSignedURLUnsupported(t *testing.T) {
- fs := createTestFilesystem(t)
-
- _, err := fs.SignedURL(context.Background(), "test/file.txt", time.Minute)
- if !errors.Is(err, ErrSignedURLUnsupported) {
- t.Errorf("SignedURL = %v, want ErrSignedURLUnsupported", err)
- }
-}
-
func createTestFilesystem(t *testing.T) *Filesystem {
t.Helper()
dir := t.TempDir()
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index e11db53..8a9026c 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -15,26 +15,14 @@ import (
"encoding/hex"
"errors"
"io"
- "time"
)
const dirPermissions = 0755
var (
ErrNotFound = errors.New("artifact not found")
-
- // ErrSignedURLUnsupported is returned by SignedURL when the backend
- // cannot generate presigned URLs (e.g. local filesystem).
- ErrSignedURLUnsupported = errors.New("signed URLs not supported by storage backend")
)
-// ObjectInfo contains metadata for a stored object.
-type ObjectInfo struct {
- Path string
- Size int64
- ModTime time.Time
-}
-
// Storage defines the interface for artifact storage backends.
type Storage interface {
// Store writes content from r to the given path.
@@ -57,10 +45,6 @@ type Storage interface {
// Returns ErrNotFound if the path does not exist.
Size(ctx context.Context, path string) (int64, error)
- // SignedURL returns a presigned URL granting time-limited GET access to path.
- // Returns ErrSignedURLUnsupported if the backend cannot generate presigned URLs.
- SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error)
-
// UsedSpace returns the total bytes used by all stored content.
UsedSpace(ctx context.Context) (int64, error)