From 563ea00c854e61ce70bb5053ed298d0d9579a4a6 Mon Sep 17 00:00:00 2001 From: Lily Young Date: Tue, 31 Mar 2026 15:42:07 -0600 Subject: [PATCH 1/3] Add Cargo cooldown support - Added support for cooldowns for cargo - Added a test to test cooldowns with cargo --- internal/handler/cargo.go | 69 ++++++++++++++++++++++++++++++++-- internal/handler/cargo_test.go | 63 ++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index b5a3fb0..adf7327 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -1,11 +1,15 @@ package handler import ( + "bufio" "encoding/json" "fmt" "io" "net/http" "strings" + "time" + + "github.com/git-pkgs/purl" ) const ( @@ -60,7 +64,7 @@ func (h *CargoHandler) Routes() http.Handler { // CargoConfig is the registry configuration returned by config.json. type CargoConfig struct { - DL string `json:"dl"` + DL string `json:"dl"` API string `json:"api,omitempty"` } @@ -120,8 +124,67 @@ func (h *CargoHandler) handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Last-Modified", lastMod) } - w.WriteHeader(http.StatusOK) - _, _ = io.Copy(w, resp.Body) + h.applyCooldownFiltering(w, resp.Body) + +} + +type crateIndexEntry struct { + Name string `json:"name"` + Version string `json:"vers"` + PublishTime string `json:"pubtime,omitempty"` +} + +func (h *CargoHandler) applyCooldownFiltering(downstreamResponse io.Writer, upstreamBody io.Reader) { + if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { + // not using cooldowns, just copy the upstream to the downstream + _, _ = io.Copy(downstreamResponse, upstreamBody) + } + + // create a scanner on the body of the http response + requestScanner := bufio.NewScanner(upstreamBody) + + // the response is newline-delimited JSON, loop through each line + for requestScanner.Scan() { + line := requestScanner.Text() + + // decode the line + var crate crateIndexEntry + err := json.Unmarshal([]byte(line), &crate) + + if err != nil { + // if there is an error parsing this line then exclude it and move to the next entry + h.proxy.Logger.Error("failed to parse json entry in index", "error", err) + continue + } + + // parse publish time + publishedAt, err := time.Parse(time.RFC3339, crate.PublishTime) + + if crate.PublishTime == "" || err != nil { + // publish time is empty/missing/invalid, presumably was published before pubtime was added as a field + // write line to response + _, _ = downstreamResponse.Write([]byte(line + "\n")) + continue + } + + // make PURL + cratePURL := purl.MakePURLString("cargo", crate.Name, "") + + if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { + // crate is not allowed, move to next crate + h.proxy.Logger.Info("cooldown: filtering cargo version", + "crate", crate.Name, "version", crate.Version, + "published", crate.PublishTime) + continue + } + + // crate passes, write to response + _, _ = downstreamResponse.Write([]byte(line + "\n")) + } + + if err := requestScanner.Err(); err != nil { + h.proxy.Logger.Error("error reading index response", "error", err) + } } // buildIndexPath builds the sparse index path for a crate name. diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go index 3fe0307..5e7f2e4 100644 --- a/internal/handler/cargo_test.go +++ b/internal/handler/cargo_test.go @@ -1,11 +1,16 @@ package handler import ( + "bytes" "encoding/json" "log/slog" "net/http" "net/http/httptest" + "strings" "testing" + "time" + + "github.com/git-pkgs/proxy/internal/cooldown" ) func cargoTestProxy() *Proxy { @@ -28,8 +33,8 @@ func TestCargoBuildIndexPath(t *testing.T) { {"abcd", "ab/cd/abcd"}, {"serde", "se/rd/serde"}, {"tokio", "to/ki/tokio"}, - {"A", "1/a"}, // lowercase - {"SERDE", "se/rd/serde"}, // lowercase + {"A", "1/a"}, // lowercase + {"SERDE", "se/rd/serde"}, // lowercase {"rand_core", "ra/nd/rand_core"}, } @@ -146,3 +151,57 @@ func TestCargoRoutes(t *testing.T) { t.Errorf("config.json status = %d, want %d", w.Code, http.StatusOK) } } + +type filterTestCase struct { + line string + expected bool +} + +func TestCargoCooldown(t *testing.T) { + now := time.Now() + + createCase := func(name string, version string, age time.Duration, expected bool) filterTestCase { + return filterTestCase{line: `{"name":"` + name + `","vers":"` + version + `","cksum":"abcd","features":{},"yanked":false,"pubtime":"` + now.Add(-1*age).Format(time.RFC3339) + `"}`, expected: expected} + } + + testCases := []filterTestCase{ + // one week ago + createCase("serde", "1.0.0", 168*time.Hour, true), + // one hour ago + createCase("serde", "1.0.1", 1*time.Hour, false), + // two hours ago with custom filter (1h) + createCase("tokio", "1.0.0", 2*time.Hour, true), + // one hour ago with custom filter (1h) + createCase("tokio", "1.0.0", 1*time.Minute, false), + } + + var testInput strings.Builder + var expectedOutput strings.Builder + + for _, testCase := range testCases { + testInput.WriteString(testCase.line + "\n") + if testCase.expected { + expectedOutput.WriteString(testCase.line + "\n") + } + } + + proxy := testProxy() + proxy.Cooldown = &cooldown.Config{ + Default: "3d", + Packages: map[string]string{"pkg:cargo/tokio": "1h"}, + } + + h := &CargoHandler{ + proxy: proxy, + proxyURL: "http://localhost:8080", + } + + var outputBuffer bytes.Buffer + h.applyCooldownFiltering(&outputBuffer, strings.NewReader(testInput.String())) + output := outputBuffer.String() + + if output != expectedOutput.String() { + t.Errorf("output = %q, want %q", output, expectedOutput.String()) + } + +} From df39d234332015138c80549f8c072d1cb85f971a Mon Sep 17 00:00:00 2001 From: Lily Young Date: Tue, 31 Mar 2026 19:42:45 -0600 Subject: [PATCH 2/3] Update README.md add cargo to registry's with support for cooldowns --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff35672..0590838 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,14 @@ A 3-day cooldown means that when `lodash` publishes version `4.18.0`, your build Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default and carve out exceptions for packages where you need faster updates. -Currently works with npm, PyPI, pub.dev, and Composer, which all include publish timestamps in their metadata. See [docs/configuration.md](docs/configuration.md) for the full config reference. +Currently works with npm, PyPI, pub.dev, Composer, and Cargo, which all include publish timestamps in their metadata. See [docs/configuration.md](docs/configuration.md) for the full config reference. ## Supported Registries | Registry | Language/Platform | Cooldown | Completed | |----------|-------------------|:--------:|:---------:| | npm | JavaScript | Yes | ✓ | -| Cargo | Rust | | ✓ | +| Cargo | Rust | Yes | ✓ | | RubyGems | Ruby | | ✓ | | Go proxy | Go | | ✓ | | Hex | Elixir | | ✓ | From 842fca73308c55811b328136c204c0a29e415f71 Mon Sep 17 00:00:00 2001 From: Lily Young Date: Wed, 1 Apr 2026 11:04:15 -0600 Subject: [PATCH 3/3] Apply suggestion from @andrew yep Co-authored-by: Andrew Nesbitt --- internal/handler/cargo.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index adf7327..9602fe6 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -138,6 +138,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse io.Writer, upst if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { // not using cooldowns, just copy the upstream to the downstream _, _ = io.Copy(downstreamResponse, upstreamBody) + return } // create a scanner on the body of the http response