2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
2026-03-04 19:00:31 +00:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-04-18 07:43:22 -04:00
|
|
|
pypiUpstream = "https://pypi.org"
|
|
|
|
|
minWheelParts = 5 // name + version + python + abi + platform
|
|
|
|
|
minSubmatchParts = 2 // full match + first capture group
|
|
|
|
|
minPyPIPathParts = 3 // hash_prefix + hash + filename
|
2026-08-03 12:14:03 +02:00
|
|
|
minEggParts = 3 // name + version + python tag
|
|
|
|
|
|
|
|
|
|
// PyPIMetadataSuffix is the PEP 658 core-metadata sidecar suffix that pip
|
|
|
|
|
// appends to a distribution URL when the index advertises core metadata.
|
|
|
|
|
// A sidecar resolves to the same name and version as the distribution it
|
|
|
|
|
// describes, so it is cached alongside it; consumers that expect an openable
|
|
|
|
|
// archive must skip these.
|
|
|
|
|
PyPIMetadataSuffix = ".metadata"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// PyPIHandler handles PyPI registry protocol requests.
|
|
|
|
|
type PyPIHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewPyPIHandler creates a new PyPI protocol handler.
|
|
|
|
|
func NewPyPIHandler(proxy *Proxy, proxyURL string) *PyPIHandler {
|
|
|
|
|
return &PyPIHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
upstreamURL: pypiUpstream,
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Routes returns the HTTP handler for PyPI requests.
|
|
|
|
|
func (h *PyPIHandler) Routes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// Simple API (used by pip)
|
|
|
|
|
mux.HandleFunc("GET /simple/", h.handleSimpleIndex)
|
|
|
|
|
mux.HandleFunc("GET /simple/{name}/", h.handleSimplePackage)
|
|
|
|
|
|
|
|
|
|
// JSON API
|
|
|
|
|
mux.HandleFunc("GET /pypi/{name}/json", h.handleJSON)
|
|
|
|
|
mux.HandleFunc("GET /pypi/{name}/{version}/json", h.handleVersionJSON)
|
|
|
|
|
|
|
|
|
|
// Package downloads (cache these)
|
|
|
|
|
mux.HandleFunc("GET /packages/{path...}", h.handleDownload)
|
|
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleSimpleIndex serves the simple API index.
|
|
|
|
|
func (h *PyPIHandler) handleSimpleIndex(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// Just proxy the index through
|
|
|
|
|
h.proxySimple(w, r, "/simple/")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleSimplePackage serves the simple API package page with rewritten links.
|
|
|
|
|
func (h *PyPIHandler) handleSimplePackage(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
if name == "" {
|
|
|
|
|
http.Error(w, "invalid package name", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("pypi simple request", "package", name)
|
|
|
|
|
|
|
|
|
|
upstreamURL := fmt.Sprintf("%s/simple/%s/", h.upstreamURL, name)
|
2026-03-19 21:06:02 +00:00
|
|
|
cacheKey := name + "/simple"
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL, "text/html")
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
// When cooldown is enabled, fetch JSON metadata to get version timestamps
|
|
|
|
|
var filteredVersions map[string]bool
|
|
|
|
|
if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
|
|
|
|
|
filteredVersions = h.fetchFilteredVersions(r, name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rewritten := h.rewriteSimpleHTML(body, filteredVersions)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = w.Write(rewritten)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
// fetchFilteredVersions fetches JSON metadata and returns a set of version strings
|
|
|
|
|
// that should be filtered out due to cooldown.
|
|
|
|
|
func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) map[string]bool {
|
|
|
|
|
jsonURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
|
|
|
|
|
|
2026-08-15 01:59:53 -07:00
|
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", name+"/json", jsonURL)
|
2026-03-04 19:00:31 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var metadata map[string]any
|
2026-08-15 01:59:53 -07:00
|
|
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
2026-03-04 19:00:31 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
releases, ok := metadata["releases"].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:11:47 -07:00
|
|
|
packagePURL := canonicalPackagePURL("pypi", name)
|
2026-03-04 19:00:31 +00:00
|
|
|
filtered := make(map[string]bool)
|
|
|
|
|
|
|
|
|
|
for version, files := range releases {
|
|
|
|
|
filesArr, ok := files.([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
publishedAt := h.newestUploadTime(filesArr)
|
|
|
|
|
if !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt) {
|
|
|
|
|
filtered[version] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(filtered) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return filtered
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// rewriteSimpleHTML rewrites package URLs in simple API HTML to point at this proxy.
|
2026-03-04 19:00:31 +00:00
|
|
|
// If filteredVersions is non-nil, links for those versions are removed entirely.
|
|
|
|
|
func (h *PyPIHandler) rewriteSimpleHTML(body []byte, filteredVersions map[string]bool) []byte {
|
|
|
|
|
// If cooldown filtering is active, remove entire <a> tags for filtered versions
|
|
|
|
|
if len(filteredVersions) > 0 {
|
|
|
|
|
// Match full anchor tags: <a ...href="...">filename</a>
|
|
|
|
|
linkRe := regexp.MustCompile(`<a[^>]+href="[^"]*"[^>]*>[^<]+</a>`)
|
|
|
|
|
body = linkRe.ReplaceAllFunc(body, func(match []byte) []byte {
|
|
|
|
|
// Extract filename from between tags
|
|
|
|
|
innerRe := regexp.MustCompile(`>([^<]+)</a>`)
|
|
|
|
|
innerMatch := innerRe.FindSubmatch(match)
|
2026-03-18 10:59:29 +00:00
|
|
|
if len(innerMatch) < minSubmatchParts {
|
2026-03-04 19:00:31 +00:00
|
|
|
return match
|
|
|
|
|
}
|
|
|
|
|
filename := string(innerMatch[1])
|
|
|
|
|
_, version := h.parseFilename(strings.TrimSpace(filename))
|
|
|
|
|
if version != "" && filteredVersions[version] {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return match
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Match href attributes pointing to packages
|
|
|
|
|
// PyPI URLs look like: https://files.pythonhosted.org/packages/...
|
|
|
|
|
re := regexp.MustCompile(`href="(https://files\.pythonhosted\.org/packages/[^"]+)"`)
|
|
|
|
|
|
|
|
|
|
return re.ReplaceAllFunc(body, func(match []byte) []byte {
|
|
|
|
|
submatch := re.FindSubmatch(match)
|
2026-03-18 10:59:29 +00:00
|
|
|
if len(submatch) < minSubmatchParts {
|
2026-01-20 21:52:44 +00:00
|
|
|
return match
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
origURL := string(submatch[1])
|
|
|
|
|
|
|
|
|
|
u, err := url.Parse(origURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return match
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
|
|
|
|
|
return []byte(fmt.Sprintf(`href="%s"`, newURL))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleJSON serves the JSON API package metadata.
|
|
|
|
|
func (h *PyPIHandler) handleJSON(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
if name == "" {
|
|
|
|
|
http.Error(w, "invalid package name", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("pypi json request", "package", name)
|
|
|
|
|
|
|
|
|
|
upstreamURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/json")
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleVersionJSON serves the JSON API version metadata.
|
|
|
|
|
func (h *PyPIHandler) handleVersionJSON(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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("pypi version json request", "package", name, "version", version)
|
|
|
|
|
|
|
|
|
|
upstreamURL := fmt.Sprintf("%s/pypi/%s/%s/json", h.upstreamURL, name, version)
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/"+version)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// proxyAndRewriteJSON fetches JSON metadata and rewrites download URLs.
|
2026-03-19 21:06:02 +00:00
|
|
|
func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL, cacheKey string) {
|
|
|
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rewritten, err := h.rewriteJSONMetadata(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_, _ = w.Write(body)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_, _ = w.Write(rewritten)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteJSONMetadata rewrites download URLs in PyPI JSON metadata.
|
2026-03-04 19:00:31 +00:00
|
|
|
// If cooldown is enabled, versions published too recently are filtered out.
|
2026-01-20 21:52:44 +00:00
|
|
|
func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
|
|
|
|
|
var metadata map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
packageName, _ := extractPyPIName(metadata)
|
|
|
|
|
packagePURL := ""
|
|
|
|
|
if packageName != "" {
|
2026-07-17 18:11:47 -07:00
|
|
|
packagePURL = canonicalPackagePURL("pypi", packageName)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
h.filterAndRewriteReleases(metadata, packageName, packagePURL)
|
|
|
|
|
h.filterAndRewriteURLs(metadata, packagePURL)
|
2026-03-04 19:00:31 +00:00
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
return json.Marshal(metadata)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// filterAndRewriteReleases applies cooldown filtering and URL rewriting to the
|
|
|
|
|
// releases map in PyPI metadata.
|
|
|
|
|
func (h *PyPIHandler) filterAndRewriteReleases(metadata map[string]any, packageName, packagePURL string) {
|
|
|
|
|
releases, ok := metadata["releases"].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for version, files := range releases {
|
|
|
|
|
if h.shouldFilterRelease(packagePURL, files) {
|
|
|
|
|
h.proxy.Logger.Info("cooldown: filtering pypi version",
|
|
|
|
|
"package", packageName, "version", version)
|
|
|
|
|
delete(releases, version)
|
|
|
|
|
continue
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
|
|
|
|
|
h.rewriteFileEntries(files)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// shouldFilterRelease returns true if a release should be excluded due to cooldown.
|
|
|
|
|
func (h *PyPIHandler) shouldFilterRelease(packagePURL string, files any) bool {
|
|
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() || packagePURL == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filesArr, ok := files.([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publishedAt := h.newestUploadTime(filesArr)
|
|
|
|
|
return !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 12:27:03 +04:00
|
|
|
// versionInCooldown reports whether a version is still inside the cooldown
|
|
|
|
|
// window. Filtering the simple index is not enough on its own: file URLs are
|
|
|
|
|
// recorded in lockfiles and requirements pins, so pip can reach the download
|
|
|
|
|
// path without ever reading the index.
|
|
|
|
|
//
|
|
|
|
|
// A release whose upload time cannot be determined is allowed through, matching
|
|
|
|
|
// how fetchFilteredVersions treats it.
|
|
|
|
|
func (h *PyPIHandler) versionInCooldown(r *http.Request, name, version string) bool {
|
|
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return h.fetchFilteredVersions(r, name)[version]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// rewriteFileEntries rewrites URLs in a list of file entries.
|
|
|
|
|
func (h *PyPIHandler) rewriteFileEntries(files any) {
|
|
|
|
|
filesArr, ok := files.([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, f := range filesArr {
|
|
|
|
|
if fmap, ok := f.(map[string]any); ok {
|
|
|
|
|
h.rewriteURLEntry(fmap)
|
2026-03-04 19:00:31 +00:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-04 19:00:31 +00:00
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// filterAndRewriteURLs applies cooldown filtering and URL rewriting to the
|
|
|
|
|
// urls array (current version files) in PyPI metadata.
|
|
|
|
|
func (h *PyPIHandler) filterAndRewriteURLs(metadata map[string]any, packagePURL string) {
|
|
|
|
|
urls, ok := metadata["urls"].([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.shouldFilterRelease(packagePURL, urls) {
|
|
|
|
|
metadata["urls"] = []any{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if urls, ok := metadata["urls"].([]any); ok {
|
|
|
|
|
for _, u := range urls {
|
|
|
|
|
if umap, ok := u.(map[string]any); ok {
|
|
|
|
|
h.rewriteURLEntry(umap)
|
2026-03-04 19:00:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
// extractPyPIName extracts the package name from PyPI JSON metadata.
|
|
|
|
|
func extractPyPIName(metadata map[string]any) (string, bool) {
|
|
|
|
|
info, ok := metadata["info"].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
name, ok := info["name"].(string)
|
|
|
|
|
return name, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newestUploadTime returns the most recent upload_time_iso_8601 from a list of file entries.
|
|
|
|
|
func (h *PyPIHandler) newestUploadTime(files []any) time.Time {
|
|
|
|
|
var newest time.Time
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
fmap, ok := f.(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
ts, ok := fmap["upload_time_iso_8601"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
t, err := time.Parse(time.RFC3339, ts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if t.After(newest) {
|
|
|
|
|
newest = t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return newest
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// rewriteURLEntry rewrites a single URL entry in PyPI metadata.
|
|
|
|
|
func (h *PyPIHandler) rewriteURLEntry(entry map[string]any) {
|
|
|
|
|
urlStr, ok := entry["url"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
u, err := url.Parse(urlStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only rewrite pythonhosted.org URLs
|
|
|
|
|
if u.Host == "files.pythonhosted.org" {
|
|
|
|
|
newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
|
|
|
|
|
entry["url"] = newURL
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleDownload serves a package file, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
path := r.PathValue("path")
|
|
|
|
|
if path == "" {
|
|
|
|
|
http.Error(w, "invalid path", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Path format: /packages/{hash_prefix}/{hash}/{filename}
|
|
|
|
|
// e.g., /packages/ab/cd/abc123.../requests-2.31.0.tar.gz
|
|
|
|
|
parts := strings.Split(path, "/")
|
2026-03-18 10:59:29 +00:00
|
|
|
if len(parts) < minPyPIPathParts {
|
2026-01-20 21:52:44 +00:00
|
|
|
http.Error(w, "invalid path", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := parts[len(parts)-1]
|
|
|
|
|
name, version := h.parseFilename(filename)
|
|
|
|
|
|
2026-08-10 12:27:03 +04:00
|
|
|
if name != "" && h.versionInCooldown(r, name, version) {
|
|
|
|
|
h.proxy.Logger.Info("cooldown: withholding pypi file",
|
|
|
|
|
"name", name, "version", version, "filename", filename)
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if name == "" {
|
|
|
|
|
// Can't determine name/version, use hash as identifier
|
|
|
|
|
name = fmt.Sprintf("_hash_%s", hashPath(path))
|
|
|
|
|
version = "0"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("pypi download request",
|
|
|
|
|
"name", name, "version", version, "filename", filename)
|
|
|
|
|
|
2026-04-01 09:58:08 -04:00
|
|
|
// Construct upstream URL; the incoming path starts with
|
|
|
|
|
// '/packages' so there is no need to include it in the format
|
|
|
|
|
// string
|
|
|
|
|
upstreamURL := fmt.Sprintf("https://files.pythonhosted.org/%s", path)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ServeArtifact(w, result)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 12:14:03 +02:00
|
|
|
// archiveExtensions are sdist formats of the form {name}-{version}{ext}. They
|
|
|
|
|
// carry no trailing tags, but legacy sdist names may contain hyphens.
|
|
|
|
|
var archiveExtensions = []string{".tar.gz", ".tar.bz2", ".tar.xz", ".tar.Z", ".tgz", ".tar", ".zip"}
|
|
|
|
|
|
|
|
|
|
// windowsInstallerExtensions are the legacy distutils bdist_wininst and
|
|
|
|
|
// bdist_msi formats, which share a filename layout.
|
|
|
|
|
var windowsInstallerExtensions = []string{".exe", ".msi"}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// parseFilename extracts package name and version from a PyPI filename.
|
2026-08-03 12:14:03 +02:00
|
|
|
// Handles wheels, sdists and legacy bdist formats:
|
2026-01-20 21:52:44 +00:00
|
|
|
// - requests-2.31.0-py3-none-any.whl
|
|
|
|
|
// - requests-2.31.0.tar.gz
|
2026-08-03 12:14:03 +02:00
|
|
|
// - numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg
|
|
|
|
|
// - numpy-1.8.0.win32-py2.7.exe
|
2026-01-20 21:52:44 +00:00
|
|
|
func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
|
2026-08-03 12:14:03 +02:00
|
|
|
// PEP 658/714 core-metadata sidecars are the distribution filename plus
|
|
|
|
|
// ".metadata"; they describe the same name and version. Without this, pip's
|
|
|
|
|
// metadata-only fetches fall back to a hash-derived package identifier.
|
|
|
|
|
filename = strings.TrimSuffix(filename, PyPIMetadataSuffix)
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasSuffix(filename, ".whl"):
|
|
|
|
|
return parseWheelFilename(strings.TrimSuffix(filename, ".whl"))
|
|
|
|
|
case strings.HasSuffix(filename, ".egg"):
|
|
|
|
|
return parseEggFilename(strings.TrimSuffix(filename, ".egg"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ext := range windowsInstallerExtensions {
|
|
|
|
|
if strings.HasSuffix(filename, ext) {
|
|
|
|
|
return parseWindowsInstallerFilename(strings.TrimSuffix(filename, ext))
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 12:14:03 +02:00
|
|
|
for _, ext := range archiveExtensions {
|
2026-01-20 21:52:44 +00:00
|
|
|
if strings.HasSuffix(filename, ext) {
|
2026-08-03 12:14:03 +02:00
|
|
|
return splitNameVersion(strings.TrimSuffix(filename, ext))
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 12:14:03 +02:00
|
|
|
// parseWheelFilename parses the PEP 427 layout
|
|
|
|
|
// {name}-{version}(-{build})?-{python}-{abi}-{platform}, base being the
|
|
|
|
|
// filename without its ".whl" suffix. The spec escapes every hyphen in the name
|
|
|
|
|
// and version to '_', so the first two fields are authoritative even when the
|
|
|
|
|
// optional build tag is present.
|
|
|
|
|
func parseWheelFilename(base string) (name, version string) {
|
|
|
|
|
parts := strings.Split(base, "-")
|
|
|
|
|
if len(parts) < minWheelParts {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parts[0], parts[1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseEggFilename parses the setuptools bdist_egg layout
|
|
|
|
|
// {name}-{version}-py{X.Y}(-{platform})?, base being the filename without its
|
|
|
|
|
// ".egg" suffix. setuptools escapes hyphens in the name and version to '_', but
|
|
|
|
|
// eggs built by other tooling do not always, so the version is located relative
|
|
|
|
|
// to the interpreter field rather than assumed to be the second field.
|
|
|
|
|
func parseEggFilename(base string) (name, version string) {
|
|
|
|
|
parts := strings.Split(base, "-")
|
|
|
|
|
// Scan from the end: the trailing platform fields never look like an
|
|
|
|
|
// interpreter tag, so the last match is the real one even when the package
|
|
|
|
|
// name itself carries a "py{N}" component. Stop before index 1, since a tag
|
|
|
|
|
// any earlier would leave no room for both a name and a version.
|
|
|
|
|
for i := len(parts) - 1; i >= minEggParts-1; i-- {
|
|
|
|
|
if !isEggPythonTag(parts[i]) || !isVersionField(parts[i-1]) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return strings.Join(parts[:i-1], "-"), parts[i-1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No interpreter field: {name}-{version}.
|
|
|
|
|
return splitNameVersion(base)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseWindowsInstallerFilename parses the distutils bdist_wininst and
|
|
|
|
|
// bdist_msi layout {name}-{version}.{platform}(-py{X.Y})?, base being the
|
|
|
|
|
// filename without its ".exe" or ".msi" suffix. The platform is joined to the
|
|
|
|
|
// version with a '.' rather than a '-' and may itself contain a hyphen
|
|
|
|
|
// ("win-amd64"), so both trailing fields are stripped before the name and
|
|
|
|
|
// version are split apart.
|
|
|
|
|
func parseWindowsInstallerFilename(base string) (name, version string) {
|
|
|
|
|
if i := strings.LastIndex(base, "-py"); i >= 0 && isDottedNumber(base[i+len("-py"):]) {
|
|
|
|
|
base = base[:i]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The platform is the final '.'-separated field. Requiring it to start with
|
|
|
|
|
// a non-digit keeps a dotted version from being truncated when a filename
|
|
|
|
|
// carries no platform tag.
|
|
|
|
|
i := strings.LastIndex(base, ".")
|
|
|
|
|
if i < 0 || i+1 >= len(base) || isVersionStart(base[i+1]) {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return splitFullname(base[:i])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// splitFullname splits the distutils fullname {name}-{version} that precedes a
|
|
|
|
|
// Windows installer's platform field. Unlike an sdist, a wininst fullname may
|
|
|
|
|
// carry a trailing build variant ("cx_Oracle-5.1.2-11g"), which belongs to
|
|
|
|
|
// neither the name nor the version, so the first purely numeric field wins and
|
|
|
|
|
// anything after it is discarded.
|
|
|
|
|
func splitFullname(fullname string) (name, version string) {
|
|
|
|
|
parts := strings.Split(fullname, "-")
|
|
|
|
|
for i := 1; i < len(parts); i++ {
|
|
|
|
|
if isDottedNumber(parts[i]) {
|
|
|
|
|
return strings.Join(parts[:i], "-"), parts[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No purely numeric field, e.g. a prerelease version like "1.0b1".
|
|
|
|
|
return splitNameVersion(fullname)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// splitNameVersion splits a {name}-{version} pair at the last hyphen that
|
|
|
|
|
// starts a version, leaving hyphens inside the name intact.
|
|
|
|
|
func splitNameVersion(base string) (name, version string) {
|
|
|
|
|
for i := len(base) - 1; i >= 0; i-- {
|
|
|
|
|
if base[i] == '-' && i+1 < len(base) && isVersionStart(base[i+1]) {
|
|
|
|
|
return base[:i], base[i+1:]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isEggPythonTag reports whether field is the py{X.Y} interpreter field that
|
|
|
|
|
// setuptools places directly after the version in an egg filename.
|
|
|
|
|
func isEggPythonTag(field string) bool {
|
|
|
|
|
const prefix = "py"
|
|
|
|
|
|
|
|
|
|
return len(field) > len(prefix) && strings.HasPrefix(field, prefix) && isVersionStart(field[len(prefix)])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isVersionField reports whether field can be a version, i.e. it is non-empty
|
|
|
|
|
// and starts with a digit as every PEP 440 release segment does.
|
|
|
|
|
func isVersionField(field string) bool {
|
|
|
|
|
return field != "" && isVersionStart(field[0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isDottedNumber reports whether s is a dotted numeric version such as "2.7".
|
|
|
|
|
func isDottedNumber(s string) bool {
|
|
|
|
|
if s == "" || !isVersionStart(s[0]) {
|
2026-01-20 21:52:44 +00:00
|
|
|
return false
|
|
|
|
|
}
|
2026-08-03 12:14:03 +02:00
|
|
|
|
|
|
|
|
for i := range len(s) {
|
|
|
|
|
if !isVersionStart(s[i]) && s[i] != '.' {
|
|
|
|
|
return false
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-03 12:14:03 +02:00
|
|
|
|
|
|
|
|
return true
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isVersionStart(c byte) bool {
|
|
|
|
|
return c >= '0' && c <= '9'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hashPath(path string) string {
|
|
|
|
|
h := sha256.Sum256([]byte(path))
|
|
|
|
|
return hex.EncodeToString(h[:8])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// proxySimple proxies a simple API request.
|
|
|
|
|
func (h *PyPIHandler) proxySimple(w http.ResponseWriter, r *http.Request, path string) {
|
|
|
|
|
upstreamURL := h.upstreamURL + path
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Accept", "text/html")
|
|
|
|
|
|
2026-03-13 07:44:11 +00:00
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
for k, vv := range resp.Header {
|
|
|
|
|
for _, v := range vv {
|
|
|
|
|
w.Header().Add(k, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
_, _ = io.Copy(w, resp.Body)
|
|
|
|
|
}
|