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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | ✓ |
Expand Down
70 changes: 67 additions & 3 deletions internal/handler/cargo.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package handler

import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/git-pkgs/purl"
)

const (
Expand Down Expand Up @@ -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"`
}

Expand Down Expand Up @@ -120,8 +124,68 @@ 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)
Comment thread
silvenlily marked this conversation as resolved.
return
}

// 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.
Expand Down
63 changes: 61 additions & 2 deletions internal/handler/cargo_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"},
}

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

}