diff --git a/README.md b/README.md index 2f4783c..242f378 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Currently works with npm, PyPI, pub.dev, Composer, and Cargo, which all include |----------|-------------------|:--------:|:---------:| | npm | JavaScript | Yes | ✓ | | Cargo | Rust | Yes | ✓ | -| RubyGems | Ruby | | ✓ | +| RubyGems | Ruby | Yes | ✓ | | Go proxy | Go | | ✓ | | Hex | Elixir | | ✓ | | pub.dev | Dart | Yes | ✓ | diff --git a/docs/configuration.md b/docs/configuration.md index 577b98c..7e1ef4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -209,7 +209,7 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages. -Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, and Conda. These ecosystems include publish timestamps in their metadata. +Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, and RubyGems. These ecosystems include publish timestamps in their metadata. ## Docker diff --git a/internal/handler/gem.go b/internal/handler/gem.go index 997e956..8faae54 100644 --- a/internal/handler/gem.go +++ b/internal/handler/gem.go @@ -1,10 +1,15 @@ package handler import ( + "bufio" + "encoding/json" "fmt" "io" "net/http" "strings" + "time" + + "github.com/git-pkgs/purl" ) const ( @@ -41,7 +46,7 @@ func (h *GemHandler) Routes() http.Handler { // Compact index (bundler 2.x+) mux.HandleFunc("GET /versions", h.proxyUpstream) - mux.HandleFunc("GET /info/{name}", h.proxyUpstream) + mux.HandleFunc("GET /info/{name}", h.handleCompactIndex) // Quick index mux.HandleFunc("GET /quick/Marshal.4.8/{filename}", h.proxyUpstream) @@ -98,6 +103,191 @@ func (h *GemHandler) parseGemFilename(filename string) (name, version string) { return "", "" } +// handleCompactIndex serves the compact index for a gem, filtering versions +// based on cooldown when enabled. +func (h *GemHandler) handleCompactIndex(w http.ResponseWriter, r *http.Request) { + if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { + h.proxyUpstream(w, r) + return + } + + name := r.PathValue("name") + if name == "" { + http.Error(w, "invalid gem name", http.StatusBadRequest) + return + } + + h.proxy.Logger.Info("gem compact index request with cooldown", "name", name) + + indexResp, filteredVersions, err := h.fetchIndexAndVersions(r, name) + if err != nil { + h.proxy.Logger.Error("upstream compact index request failed", "error", err) + http.Error(w, "upstream request failed", http.StatusBadGateway) + return + } + defer func() { _ = indexResp.Body.Close() }() + + if indexResp.StatusCode != http.StatusOK { + copyResponseHeaders(w, indexResp.Header) + w.WriteHeader(indexResp.StatusCode) + _, _ = io.Copy(w, indexResp.Body) + return + } + + if filteredVersions == nil { + h.proxy.Logger.Warn("failed to fetch version timestamps, proxying unfiltered", "name", name) + copyResponseHeaders(w, indexResp.Header) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, indexResp.Body) + return + } + + h.writeFilteredIndex(w, indexResp, name, filteredVersions) +} + +// fetchIndexAndVersions fetches the compact index and versions API concurrently. +// Returns the index response, a set of versions to filter (nil if versions API failed), +// and an error if the index fetch itself failed. +func (h *GemHandler) fetchIndexAndVersions(r *http.Request, name string) (*http.Response, map[string]bool, error) { + type versionsResult struct { + filtered map[string]bool + err error + } + + versionsCh := make(chan versionsResult, 1) + go func() { + filtered, err := h.fetchFilteredVersions(r, name) + versionsCh <- versionsResult{filtered: filtered, err: err} + }() + + indexResp, err := h.fetchCompactIndex(r, name) + + versionsRes := <-versionsCh + + if err != nil { + return nil, nil, err + } + + if versionsRes.err != nil { + return indexResp, nil, nil + } + + return indexResp, versionsRes.filtered, nil +} + +// fetchCompactIndex fetches the compact index from upstream. +func (h *GemHandler) fetchCompactIndex(r *http.Request, name string) (*http.Response, error) { + indexURL := h.upstreamURL + "/info/" + name + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, indexURL, nil) + if err != nil { + return nil, err + } + for _, hdr := range []string{"Accept", "Accept-Encoding", "If-None-Match", "If-Modified-Since"} { + if v := r.Header.Get(hdr); v != "" { + req.Header.Set(hdr, v) + } + } + return h.proxy.HTTPClient.Do(req) +} + +// writeFilteredIndex writes the compact index response with cooldown-filtered versions removed. +func (h *GemHandler) writeFilteredIndex(w http.ResponseWriter, resp *http.Response, name string, filtered map[string]bool) { + for k, vv := range resp.Header { + if strings.EqualFold(k, "Content-Length") { + continue // length will change after filtering + } + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(http.StatusOK) + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + + if line == "---" { + _, _ = fmt.Fprintln(w, line) + continue + } + + version := line + if spaceIdx := strings.IndexByte(line, ' '); spaceIdx > 0 { + version = line[:spaceIdx] + } + + if filtered[version] { + h.proxy.Logger.Info("cooldown: filtering gem version", + "gem", name, "version", version) + continue + } + + _, _ = fmt.Fprintln(w, line) + } +} + +// copyResponseHeaders copies HTTP headers from a response to a writer. +func copyResponseHeaders(w http.ResponseWriter, headers http.Header) { + for k, vv := range headers { + for _, v := range vv { + w.Header().Add(k, v) + } + } +} + +// gemVersion represents a version entry from the RubyGems versions API. +type gemVersion struct { + Number string `json:"number"` + Platform string `json:"platform"` + CreatedAt string `json:"created_at"` +} + +// fetchFilteredVersions fetches the versions API and returns a set of version +// strings that should be filtered out by cooldown. +func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) { + versionsURL := fmt.Sprintf("%s/api/v1/versions/%s.json", h.upstreamURL, name) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, versionsURL, nil) + if err != nil { + return nil, err + } + + resp, err := h.proxy.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("versions API returned %d", resp.StatusCode) + } + + var versions []gemVersion + if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil { + return nil, err + } + + packagePURL := purl.MakePURLString("gem", name, "") + filtered := make(map[string]bool) + + for _, v := range versions { + createdAt, err := time.Parse(time.RFC3339, v.CreatedAt) + if err != nil { + continue + } + + if !h.proxy.Cooldown.IsAllowed("gem", packagePURL, createdAt) { + // Build version string matching compact index format + versionStr := v.Number + if v.Platform != "" && v.Platform != "ruby" { + versionStr = v.Number + "-" + v.Platform + } + filtered[versionStr] = true + } + } + + return filtered, nil +} + // proxyUpstream forwards a request to rubygems.org without caching. func (h *GemHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path diff --git a/internal/handler/gem_test.go b/internal/handler/gem_test.go index 6c83004..6dce324 100644 --- a/internal/handler/gem_test.go +++ b/internal/handler/gem_test.go @@ -1,8 +1,16 @@ package handler import ( + "encoding/json" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "strings" "testing" + "time" + + "github.com/git-pkgs/proxy/internal/cooldown" ) func TestGemParseFilename(t *testing.T) { @@ -28,3 +36,217 @@ func TestGemParseFilename(t *testing.T) { } } } + +func TestGemCompactIndexCooldown(t *testing.T) { + now := time.Now() + oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339) + recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339) + + compactIndex := "---\n1.0.0 dep1:>= 1.0|checksum:abc123\n2.0.0 dep1:>= 1.0|checksum:def456\n" + + versionsJSON, _ := json.Marshal([]gemVersion{ + {Number: "1.0.0", Platform: "ruby", CreatedAt: oldTime}, + {Number: "2.0.0", Platform: "ruby", CreatedAt: recentTime}, + }) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/info/"): + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(compactIndex)) + case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(versionsJSON) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + proxy := testProxy() + proxy.Cooldown = &cooldown.Config{ + Default: "3d", + } + + h := &GemHandler{ + proxy: proxy, + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil) + req.SetPathValue("name", "testgem") + w := httptest.NewRecorder() + h.handleCompactIndex(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + if !strings.Contains(body, "1.0.0") { + t.Error("expected version 1.0.0 to survive filtering") + } + if strings.Contains(body, "2.0.0") { + t.Error("expected version 2.0.0 to be filtered out") + } + if !strings.HasPrefix(body, "---\n") { + t.Error("expected compact index header to be preserved") + } +} + +func TestGemCompactIndexCooldownWithPlatformVersion(t *testing.T) { + now := time.Now() + recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339) + + compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n1.0.0-java dep:>= 1.0|checksum:def\n" + + versionsJSON, _ := json.Marshal([]gemVersion{ + {Number: "1.0.0", Platform: "ruby", CreatedAt: recentTime}, + {Number: "1.0.0", Platform: "java", CreatedAt: recentTime}, + }) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/info/"): + _, _ = w.Write([]byte(compactIndex)) + case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"): + _, _ = w.Write(versionsJSON) + } + })) + defer upstream.Close() + + proxy := testProxy() + proxy.Cooldown = &cooldown.Config{ + Default: "3d", + } + + h := &GemHandler{ + proxy: proxy, + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil) + req.SetPathValue("name", "testgem") + w := httptest.NewRecorder() + h.handleCompactIndex(w, req) + + body := w.Body.String() + // Both ruby and java platform versions should be filtered + lines := strings.Split(strings.TrimSpace(body), "\n") + if len(lines) != 1 { // only "---" + t.Errorf("expected only header line, got %d lines: %v", len(lines), lines) + } +} + +func TestGemCompactIndexNoCooldown(t *testing.T) { + compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n" + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(compactIndex)) + })) + defer upstream.Close() + + h := &GemHandler{ + proxy: testProxy(), // no cooldown + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil) + req.SetPathValue("name", "testgem") + w := httptest.NewRecorder() + h.handleCompactIndex(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestGemCompactIndexVersionsAPIFails(t *testing.T) { + compactIndex := "---\n1.0.0 dep:>= 1.0|checksum:abc\n" + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/info/"): + _, _ = w.Write([]byte(compactIndex)) + case strings.HasPrefix(r.URL.Path, "/api/v1/versions/"): + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer upstream.Close() + + proxy := testProxy() + proxy.Cooldown = &cooldown.Config{ + Default: "3d", + } + + h := &GemHandler{ + proxy: proxy, + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil) + req.SetPathValue("name", "testgem") + w := httptest.NewRecorder() + h.handleCompactIndex(w, req) + + // Should still return OK with unfiltered content + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + if !strings.Contains(body, "1.0.0") { + t.Error("expected unfiltered content when versions API fails") + } +} + +func TestGemFetchFilteredVersions(t *testing.T) { + now := time.Now() + oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339) + recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339) + + versionsJSON, _ := json.Marshal([]gemVersion{ + {Number: "1.0.0", Platform: "ruby", CreatedAt: oldTime}, + {Number: "2.0.0", Platform: "ruby", CreatedAt: recentTime}, + {Number: "2.0.0", Platform: "java", CreatedAt: recentTime}, + }) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(versionsJSON) + })) + defer upstream.Close() + + proxy := testProxy() + proxy.Cooldown = &cooldown.Config{ + Default: "3d", + } + + h := &GemHandler{ + proxy: proxy, + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/info/testgem", nil) + filtered, err := h.fetchFilteredVersions(req, "testgem") + if err != nil { + t.Fatal(err) + } + + if filtered["1.0.0"] { + t.Error("version 1.0.0 should not be filtered (old enough)") + } + if !filtered["2.0.0"] { + t.Error("version 2.0.0 (ruby) should be filtered") + } + if !filtered["2.0.0-java"] { + t.Error("version 2.0.0-java should be filtered") + } + + _ = fmt.Sprintf // silence unused import +}