2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-01 11:15:07 -06:00
|
|
|
"bufio"
|
2026-01-20 21:52:44 +00:00
|
|
|
"encoding/json"
|
2026-03-19 21:06:02 +00:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
2026-07-26 19:11:24 +01:00
|
|
|
"net/url"
|
2026-01-20 21:52:44 +00:00
|
|
|
"strings"
|
2026-04-01 11:15:07 -06:00
|
|
|
"time"
|
2026-10-10 22:10:31 +02:00
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
cargoUpstream = "https://index.crates.io"
|
|
|
|
|
cargoDownloadBase = "https://static.crates.io/crates"
|
2026-03-18 10:59:29 +00:00
|
|
|
|
|
|
|
|
cargoIndexLen1 = 1
|
|
|
|
|
cargoIndexLen2 = 2
|
|
|
|
|
cargoIndexLen3 = 3
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-10 22:10:31 +02:00
|
|
|
// 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")})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// CargoHandler handles cargo registry protocol requests.
|
|
|
|
|
type CargoHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
indexURL string
|
|
|
|
|
downloadURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewCargoHandler creates a new cargo protocol handler.
|
2026-07-26 19:11:24 +01:00
|
|
|
func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler {
|
|
|
|
|
if strings.TrimSpace(indexURL) == "" {
|
|
|
|
|
indexURL = cargoUpstream
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(downloadURL) == "" {
|
|
|
|
|
downloadURL = cargoDownloadBase
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
return &CargoHandler{
|
|
|
|
|
proxy: proxy,
|
2026-07-26 19:11:24 +01:00
|
|
|
indexURL: strings.TrimSuffix(indexURL, "/"),
|
|
|
|
|
downloadURL: strings.TrimSuffix(downloadURL, "/"),
|
2026-01-20 21:52:44 +00:00
|
|
|
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 {
|
2026-04-01 11:15:07 -06:00
|
|
|
DL string `json:"dl"`
|
2026-01-20 21:52:44 +00:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/json")
|
2026-01-20 21:52:44 +00:00
|
|
|
_ = 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)
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "cargo", name, upstreamURL, "text/plain")
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-03-19 21:06:02 +00:00
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
h.proxy.Logger.Error("failed to fetch upstream index", "error", err)
|
|
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
if contentType == "" {
|
|
|
|
|
contentType = "text/plain; charset=utf-8"
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-04-01 11:15:07 -06:00
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, contentType)
|
2026-03-19 21:06:02 +00:00
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
h.applyCooldownFiltering(w, body)
|
2026-04-01 11:15:07 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type crateIndexEntry struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Version string `json:"vers"`
|
|
|
|
|
PublishTime string `json:"pubtime,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWriter, body []byte) {
|
2026-09-20 14:42:30 +05:30
|
|
|
cooldownEnabled := h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
|
|
|
|
|
if !cooldownEnabled && h.proxy.Denylist == nil {
|
2026-03-19 21:06:02 +00:00
|
|
|
_, _ = downstreamResponse.Write(body)
|
2026-04-01 11:15:07 -06:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
2026-09-20 14:42:30 +05:30
|
|
|
// 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)
|
2026-04-01 11:15:07 -06:00
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := scanner.Text()
|
2026-04-01 11:15:07 -06:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 14:42:30 +05:30
|
|
|
if h.proxy.versionDenied("cargo", crate.Name, crate.Version) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-01 11:15:07 -06:00
|
|
|
publishedAt, err := time.Parse(time.RFC3339, crate.PublishTime)
|
|
|
|
|
|
2026-09-20 14:42:30 +05:30
|
|
|
if !cooldownEnabled || crate.PublishTime == "" || err != nil {
|
2026-04-01 11:15:07 -06:00
|
|
|
_, _ = downstreamResponse.Write([]byte(line + "\n"))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:11:47 -07:00
|
|
|
cratePURL := canonicalPackagePURL("cargo", crate.Name)
|
2026-04-01 11:15:07 -06:00
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
if err := scanner.Err(); err != nil {
|
2026-04-01 11:15:07 -06:00
|
|
|
h.proxy.Logger.Error("error reading index response", "error", err)
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildIndexPath builds the sparse index path for a crate name.
|
|
|
|
|
func (h *CargoHandler) buildIndexPath(name string) string {
|
|
|
|
|
name = strings.ToLower(name)
|
|
|
|
|
|
|
|
|
|
switch len(name) {
|
2026-03-18 10:59:29 +00:00
|
|
|
case cargoIndexLen1:
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Sprintf("1/%s", name)
|
2026-03-18 10:59:29 +00:00
|
|
|
case cargoIndexLen2:
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Sprintf("2/%s", name)
|
2026-03-18 10:59:29 +00:00
|
|
|
case cargoIndexLen3:
|
2026-01-20 21:52:44 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-07-26 19:11:24 +01:00
|
|
|
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,
|
|
|
|
|
)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch crate")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|