pkg-proxy/internal/handler/cargo.go
Lily Young 922d44b34e
Add Cargo cooldown support (#48)
* Add Cargo cooldown support

- Added support for cooldowns for cargo
- Added a test to test cooldowns with cargo

* Update README.md

add cargo to registry's with support for cooldowns

* Apply suggestion from @andrew

Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-04-01 18:15:07 +01:00

230 lines
6.4 KiB
Go

package handler
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/git-pkgs/purl"
)
const (
cargoUpstream = "https://index.crates.io"
cargoDownloadBase = "https://static.crates.io/crates"
cargoIndexLen1 = 1
cargoIndexLen2 = 2
cargoIndexLen3 = 3
)
// CargoHandler handles cargo registry protocol requests.
type CargoHandler struct {
proxy *Proxy
indexURL string
downloadURL string
proxyURL string
}
// NewCargoHandler creates a new cargo protocol handler.
func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler {
return &CargoHandler{
proxy: proxy,
indexURL: cargoUpstream,
downloadURL: cargoDownloadBase,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// Routes returns the HTTP handler for cargo requests.
// Mount this at /cargo on your router.
func (h *CargoHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Config endpoint
mux.HandleFunc("GET /config.json", h.handleConfig)
// Sparse index endpoints
// Crate names 1-2 chars: /1/{name} or /2/{name}
// Crate names 3 chars: /3/{first_char}/{name}
// Crate names 4+ chars: /{first_two}/{second_two}/{name}
mux.HandleFunc("GET /1/{name}", h.handleIndex)
mux.HandleFunc("GET /2/{name}", h.handleIndex)
mux.HandleFunc("GET /3/{a}/{name}", h.handleIndex)
mux.HandleFunc("GET /{a}/{b}/{name}", h.handleIndex)
// Download endpoint
mux.HandleFunc("GET /crates/{name}/{version}/download", h.handleDownload)
return mux
}
// CargoConfig is the registry configuration returned by config.json.
type CargoConfig struct {
DL string `json:"dl"`
API string `json:"api,omitempty"`
}
// handleConfig returns the registry configuration.
func (h *CargoHandler) handleConfig(w http.ResponseWriter, r *http.Request) {
config := CargoConfig{
DL: h.proxyURL + "/cargo/crates/{crate}/{version}/download",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(config)
}
// handleIndex proxies the crate index from upstream.
func (h *CargoHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
h.proxy.Logger.Info("cargo index request", "crate", name)
// Build the index path
indexPath := h.buildIndexPath(name)
upstreamURL := fmt.Sprintf("%s/%s", h.indexURL, indexPath)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.proxy.Logger.Error("failed to fetch upstream index", "error", err)
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
http.Error(w, "not found", http.StatusNotFound)
return
}
if resp.StatusCode != http.StatusOK {
http.Error(w, fmt.Sprintf("upstream returned %d", resp.StatusCode), http.StatusBadGateway)
return
}
// Copy headers and body
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if etag := resp.Header.Get("ETag"); etag != "" {
w.Header().Set("ETag", etag)
}
if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
w.Header().Set("Last-Modified", lastMod)
}
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)
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.
func (h *CargoHandler) buildIndexPath(name string) string {
name = strings.ToLower(name)
switch len(name) {
case cargoIndexLen1:
return fmt.Sprintf("1/%s", name)
case cargoIndexLen2:
return fmt.Sprintf("2/%s", name)
case cargoIndexLen3:
return fmt.Sprintf("3/%c/%s", name[0], name)
default:
return fmt.Sprintf("%s/%s/%s", name[0:2], name[2:4], name)
}
}
// handleDownload serves a crate file, fetching and caching from upstream if needed.
func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
version := r.PathValue("version")
if name == "" || version == "" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
filename := fmt.Sprintf("%s-%s.crate", name, version)
h.proxy.Logger.Info("cargo download request",
"crate", name, "version", version, "filename", filename)
result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename)
if err != nil {
h.proxy.Logger.Error("failed to get artifact", "error", err)
http.Error(w, "failed to fetch crate", http.StatusBadGateway)
return
}
ServeArtifact(w, result)
}