2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-25 21:12:00 +05:30
|
|
|
"compress/gzip"
|
2026-04-06 13:16:00 +01:00
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
2026-01-20 21:52:44 +00:00
|
|
|
"net/http"
|
2026-10-10 23:37:32 +02:00
|
|
|
"slices"
|
2026-01-20 21:52:44 +00:00
|
|
|
"strings"
|
2026-04-06 13:16:00 +01:00
|
|
|
"time"
|
2026-10-10 23:37:32 +02:00
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
|
|
|
|
"github.com/git-pkgs/purl"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
condaUpstream = "https://conda.anaconda.org"
|
2026-03-18 10:59:29 +00:00
|
|
|
minCondaParts = 3 // name-version-build requires at least 3 hyphen-separated parts
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-10 23:37:32 +02:00
|
|
|
// Packages are stored under the name {channel}/{name}, which purl writes as
|
|
|
|
|
// pkg:conda/{channel}%2F{name}. Package rules name the channel as the
|
|
|
|
|
// namespace, pkg:conda/conda-forge/numpy, and are mapped to that form.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "conda", CanonicalPackage: condaRetentionKey})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// condaRetentionKey maps a package rule to the stored package PURL. It
|
|
|
|
|
// takes the channel from the namespace, or from the name in the stored
|
2026-10-12 19:59:35 +02:00
|
|
|
// form. Downloads are routed as /{channel}/{arch}/{filename}, so the channel
|
|
|
|
|
// is always one path segment; a key without a channel, with more segments
|
|
|
|
|
// or with an empty one is refused, since no stored package looks like that.
|
|
|
|
|
// The package name is lowercased the way conda treats it; the channel stays
|
|
|
|
|
// as the URL had it.
|
2026-10-10 23:37:32 +02:00
|
|
|
func condaRetentionKey(p *purl.PURL) (string, bool) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
full := p.Name
|
|
|
|
|
if p.Namespace != "" {
|
|
|
|
|
full = p.Namespace + "/" + p.Name
|
|
|
|
|
}
|
|
|
|
|
segments := strings.Split(full, "/")
|
2026-10-12 19:59:35 +02:00
|
|
|
if len(segments) != 2 || slices.Contains(segments, "") {
|
2026-10-10 23:37:32 +02:00
|
|
|
return "", false
|
|
|
|
|
}
|
2026-10-12 19:59:35 +02:00
|
|
|
channel, name := segments[0], strings.ToLower(segments[1])
|
2026-10-10 23:37:32 +02:00
|
|
|
return retention.DefaultCanonical("conda")(purl.New(p.Type, "", channel+"/"+name, "", nil))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// CondaHandler handles Conda/Anaconda registry protocol requests.
|
|
|
|
|
type CondaHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewCondaHandler creates a new Conda protocol handler.
|
|
|
|
|
func NewCondaHandler(proxy *Proxy, proxyURL string) *CondaHandler {
|
|
|
|
|
return &CondaHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
upstreamURL: condaUpstream,
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
// NewCondaHandlerWithUpstream creates a Conda handler with a custom upstream.
|
|
|
|
|
func NewCondaHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *CondaHandler {
|
|
|
|
|
h := NewCondaHandler(proxy, proxyURL)
|
|
|
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, condaUpstream)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Routes returns the HTTP handler for Conda requests.
|
|
|
|
|
func (h *CondaHandler) Routes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// Channel index (repodata)
|
2026-04-06 13:16:00 +01:00
|
|
|
mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.handleRepodata)
|
2026-03-19 21:06:02 +00:00
|
|
|
mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyCached)
|
2026-04-06 13:16:00 +01:00
|
|
|
mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.handleRepodata)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
// Package downloads (cache these)
|
|
|
|
|
mux.HandleFunc("GET /{channel}/{arch}/{filename}", h.handleDownload)
|
|
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleDownload serves a package file, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
channel := r.PathValue("channel")
|
|
|
|
|
arch := r.PathValue("arch")
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
|
|
|
|
|
// Only cache actual package files
|
|
|
|
|
if !h.isPackageFile(filename) {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name, version := h.parseFilename(filename)
|
|
|
|
|
if name == "" {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Include channel in package name
|
|
|
|
|
packageName := channel + "/" + name
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("conda download request",
|
|
|
|
|
"channel", channel, "arch", arch, "name", name, "version", version)
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
|
|
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isPackageFile returns true if the filename is a Conda package.
|
|
|
|
|
func (h *CondaHandler) isPackageFile(filename string) bool {
|
|
|
|
|
return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseFilename extracts name and version from a Conda package filename.
|
|
|
|
|
// Conda filenames are: {name}-{version}-{build}.{ext}
|
|
|
|
|
// e.g., "numpy-1.24.0-py311h64a7726_0.conda"
|
|
|
|
|
func (h *CondaHandler) parseFilename(filename string) (name, version string) {
|
|
|
|
|
// Remove extension
|
|
|
|
|
base := filename
|
|
|
|
|
for _, ext := range []string{".conda", ".tar.bz2"} {
|
|
|
|
|
if strings.HasSuffix(base, ext) {
|
|
|
|
|
base = strings.TrimSuffix(base, ext)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Split by hyphens, the format is name-version-build
|
|
|
|
|
// The name can contain hyphens, so we need to find version-build at the end
|
|
|
|
|
parts := strings.Split(base, "-")
|
2026-03-18 10:59:29 +00:00
|
|
|
if len(parts) < minCondaParts {
|
2026-01-20 21:52:44 +00:00
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build is the last part, version is second to last
|
|
|
|
|
// Everything before is the name
|
|
|
|
|
build := parts[len(parts)-1]
|
|
|
|
|
version = parts[len(parts)-2]
|
|
|
|
|
name = strings.Join(parts[:len(parts)-2], "-")
|
|
|
|
|
|
|
|
|
|
// Version should start with a digit
|
|
|
|
|
if len(version) == 0 || version[0] < '0' || version[0] > '9' {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Include build in version for uniqueness
|
|
|
|
|
_ = build
|
|
|
|
|
|
|
|
|
|
return name, version
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:16:00 +01:00
|
|
|
// handleRepodata proxies repodata.json, applying cooldown filtering when enabled.
|
|
|
|
|
func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyCached(w, r)
|
2026-04-06 13:16:00 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Debug("fetching repodata for cooldown filtering", "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-05 10:25:17 +01:00
|
|
|
req.Header.Set(headerAcceptEncoding, "gzip")
|
2026-04-06 13:16:00 +01:00
|
|
|
|
|
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-06 13:16:00 +01:00
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-09-26 15:44:28 +05:30
|
|
|
h.proxy.relayResponse(w, r, resp, nil)
|
2026-04-06 13:16:00 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 21:12:00 +05:30
|
|
|
// Explicitly requesting gzip disables the transport's automatic decoding.
|
|
|
|
|
// Cooldown needs JSON, so decode here and bound the decompressed size too.
|
|
|
|
|
var reader io.Reader = resp.Body
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(resp.Header.Get(headerContentEncoding)), "gzip") {
|
|
|
|
|
decoded, err := gzip.NewReader(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to decode response", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = decoded.Close() }()
|
|
|
|
|
reader = decoded
|
|
|
|
|
}
|
|
|
|
|
body, err := h.proxy.ReadMetadata(reader)
|
2026-04-06 13:16:00 +01:00
|
|
|
if err != nil {
|
2026-09-25 21:12:00 +05:30
|
|
|
http.Error(w, "failed to read response", http.StatusBadGateway)
|
2026-04-06 13:16:00 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filtered, err := h.applyCooldownFiltering(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to filter repodata, proxying original", "error", err)
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/json")
|
2026-04-06 13:16:00 +01:00
|
|
|
_, _ = w.Write(body)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/json")
|
2026-04-06 13:16:00 +01:00
|
|
|
_, _ = w.Write(filtered)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// condaTimestampDivisor converts Conda's millisecond timestamps to seconds.
|
|
|
|
|
const condaTimestampDivisor = 1000
|
|
|
|
|
|
|
|
|
|
// applyCooldownFiltering removes entries from repodata.json that were
|
|
|
|
|
// published too recently based on their timestamp field.
|
|
|
|
|
func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
|
|
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
|
|
|
return body, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var repodata map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &repodata); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, key := range []string{"packages", "packages.conda"} {
|
|
|
|
|
packages, ok := repodata[key].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for filename, entry := range packages {
|
|
|
|
|
entryMap, ok := entry.(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ts, ok := entryMap["timestamp"].(float64)
|
|
|
|
|
if !ok || ts == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publishedAt := time.Unix(int64(ts)/condaTimestampDivisor, 0)
|
|
|
|
|
|
|
|
|
|
name, _ := entryMap["name"].(string)
|
|
|
|
|
if name == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:11:47 -07:00
|
|
|
packagePURL := canonicalPackagePURL("conda", name)
|
2026-04-06 13:16:00 +01:00
|
|
|
|
|
|
|
|
if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
|
|
|
|
|
version, _ := entryMap["version"].(string)
|
|
|
|
|
h.proxy.Logger.Info("cooldown: filtering conda package",
|
|
|
|
|
"name", name, "version", version, "filename", filename)
|
|
|
|
|
delete(packages, filename)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return json.Marshal(repodata)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// proxyCached forwards a metadata request with caching.
|
|
|
|
|
func (h *CondaHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
|
2026-09-25 21:12:00 +05:30
|
|
|
// Large JSON indexes can exceed the metadata limit uncompressed. Conda
|
|
|
|
|
// clients decode Content-Encoding, so cache and relay gzip verbatim. The
|
|
|
|
|
// already-compressed repodata.json.bz2 route must stay on identity.
|
|
|
|
|
acceptEncoding := "identity"
|
|
|
|
|
if strings.HasSuffix(r.URL.Path, ".json") {
|
|
|
|
|
acceptEncoding = "gzip"
|
|
|
|
|
}
|
|
|
|
|
h.proxy.proxyCachedWithEncoding(w, r, h.upstreamURL+r.URL.Path, "conda", cacheKey, acceptEncoding, "*/*")
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// proxyUpstream forwards a request to Anaconda without caching.
|
|
|
|
|
func (h *CondaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
|
2026-05-05 10:25:17 +01:00
|
|
|
h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{headerAcceptEncoding})
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|