Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/cargo.go
Giles Westwood b2955ef458 Keep holds through late successes and away from the circuit breaker
A response arriving during a hold was sent before it began, so a late
success no longer releases the host; only one after the hold has ended
does.

Artifact fetches check RateLimitGate.Held before calling the fetcher, so
held cache misses fail with the HeldError without counting as failures
in the host's circuit breaker.

Every handler that answers an upstream fetch error with 502 now answers
a held request with 503 and Retry-After instead.

OnHold and RecordUpstreamRateLimitHold drop the unused duration.
2026-10-15 13:35:12 +01:00

235 lines
6.4 KiB
Go

package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/git-pkgs/proxy/internal/retention"
)
const (
cargoUpstream = "https://index.crates.io"
cargoDownloadBase = "https://static.crates.io/crates"
cargoIndexLen1 = 1
cargoIndexLen2 = 2
cargoIndexLen3 = 3
)
// Crates are stored under pkg:cargo/{name}, with the name cargo puts in the
// download URL, which is the name as published. purl keeps cargo names as
// they are, so package rules use the default key mapping and have to match
// that name exactly.
func init() {
retention.Default.Register(retention.Spec{Key: "cargo", CanonicalPackage: retention.DefaultCanonical("cargo")})
}
// 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, indexURL, downloadURL string) *CargoHandler {
if strings.TrimSpace(indexURL) == "" {
indexURL = cargoUpstream
}
if strings.TrimSpace(downloadURL) == "" {
downloadURL = cargoDownloadBase
}
return &CargoHandler{
proxy: proxy,
indexURL: strings.TrimSuffix(indexURL, "/"),
downloadURL: strings.TrimSuffix(downloadURL, "/"),
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(headerContentType, "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)
indexPath := h.buildIndexPath(name)
upstreamURL := fmt.Sprintf("%s/%s", h.indexURL, indexPath)
body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "cargo", name, upstreamURL, "text/plain")
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch upstream index", "error", err)
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
if contentType == "" {
contentType = "text/plain; charset=utf-8"
}
w.Header().Set(headerContentType, contentType)
w.WriteHeader(http.StatusOK)
h.applyCooldownFiltering(w, body)
}
type crateIndexEntry struct {
Name string `json:"name"`
Version string `json:"vers"`
PublishTime string `json:"pubtime,omitempty"`
}
func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWriter, body []byte) {
cooldownEnabled := h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
if !cooldownEnabled && h.proxy.Denylist == nil {
_, _ = downstreamResponse.Write(body)
return
}
scanner := bufio.NewScanner(strings.NewReader(string(body)))
// The response is already size-bounded by FetchOrCacheMetadata. Crate
// entries with many dependencies can exceed Scanner's default token limit.
scanner.Buffer(nil, len(body)+1)
for scanner.Scan() {
line := scanner.Text()
var crate crateIndexEntry
err := json.Unmarshal([]byte(line), &crate)
if err != nil {
h.proxy.Logger.Error("failed to parse json entry in index", "error", err)
continue
}
if h.proxy.versionDenied("cargo", crate.Name, crate.Version) {
continue
}
publishedAt, err := time.Parse(time.RFC3339, crate.PublishTime)
if !cooldownEnabled || crate.PublishTime == "" || err != nil {
_, _ = downstreamResponse.Write([]byte(line + "\n"))
continue
}
cratePURL := canonicalPackagePURL("cargo", crate.Name)
if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering cargo version",
"crate", crate.Name, "version", crate.Version,
"published", crate.PublishTime)
continue
}
_, _ = downstreamResponse.Write([]byte(line + "\n"))
}
if err := scanner.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)
downloadURL := fmt.Sprintf(
"%s/%s/%s",
h.downloadURL,
url.PathEscape(name),
url.PathEscape(filename),
)
result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "cargo", name, version, filename, downloadURL,
)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch crate")
return
}
ServeArtifactRequest(w, r, result)
}