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/pypi.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

843 lines
26 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/git-pkgs/proxy/internal/retention"
)
const (
pypiUpstream = "https://pypi.org"
pypiDownloadUpstream = "https://files.pythonhosted.org"
pypiSimpleJSON = "application/vnd.pypi.simple.v1+json"
pypiSimpleHTML = "application/vnd.pypi.simple.v1+html"
pypiSimpleLatestJSON = "application/vnd.pypi.simple.latest+json"
pypiSimpleLatestHTML = "application/vnd.pypi.simple.latest+html"
pypiLegacyHTML = "text/html"
pypiExactSpecificity = 2
minWheelParts = 5 // name + version + python + abi + platform
minSubmatchParts = 2 // full match + first capture group
minPyPIPathParts = 3 // hash_prefix + hash + filename
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"
)
// PyPIHandler handles PyPI registry protocol requests.
type PyPIHandler struct {
proxy *Proxy
upstreamURL string
downloadURL string
downloadHrefRe *regexp.Regexp
proxyURL string
}
// PyPI files are stored under pkg:pypi/{name}, the name taken from the
// filename and normalized the way purl does it: lowercased, "_" turned into
// "-". Package rules use the default key mapping, so pkg:pypi/Typing_Extensions
// matches typing-extensions. Dots and runs of separators are kept, so files
// named zope.interface-* and zope_interface-* end up under two packages.
func init() {
retention.Default.Register(retention.Spec{Key: "pypi", CanonicalPackage: retention.DefaultCanonical("pypi")})
}
// NewPyPIHandler creates a new PyPI protocol handler.
func NewPyPIHandler(proxy *Proxy, proxyURL string) *PyPIHandler {
return NewPyPIHandlerWithUpstreams(proxy, proxyURL, "", "")
}
// NewPyPIHandlerWithUpstreams creates a PyPI handler with custom API and
// package download upstreams.
func NewPyPIHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, downloadURL string) *PyPIHandler {
h := &PyPIHandler{
proxy: proxy,
upstreamURL: configuredUpstreamURL(upstreamURL, pypiUpstream),
downloadURL: configuredUpstreamURL(downloadURL, pypiDownloadUpstream),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
h.downloadHrefRe = regexp.MustCompile(`href="(` + regexp.QuoteMeta(h.downloadURL) + `/packages/[^"]+)"`)
return h
}
// Routes returns the HTTP handler for PyPI requests.
func (h *PyPIHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Simple API
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)
accept := selectPyPISimpleRepresentation(r.Header.Get("Accept"))
cacheKey := pypiSimpleCacheKey(name, accept)
body, contentType, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL, accept)
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("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
// 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)
}
for version := range h.proxy.Denylist.Versions(canonicalPackagePURL("pypi", name)) {
if filteredVersions == nil {
filteredVersions = make(map[string]bool)
}
filteredVersions[version] = true
}
var rewritten []byte
if isJSONMediaType(contentType) {
rewritten, err = h.rewriteSimpleJSON(body, filteredVersions)
if err != nil {
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("pypi", name))) != 0 {
http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
return
}
h.proxy.Logger.Warn("failed to rewrite pypi simple json, proxying original", "error", err)
rewritten = body
}
} else {
body = h.filterSimpleHTMLLinks(body, filteredVersions)
rewritten = h.rewriteSimpleHTML(body, filteredVersions)
}
w.Header().Set(headerContentType, contentType)
ensureVaryAccept(w.Header())
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
func selectPyPISimpleRepresentation(accept string) string {
if strings.TrimSpace(accept) == "" {
return pypiLegacyHTML
}
type score struct {
quality float64
specificity int
matched bool
}
scores := map[string]score{
pypiSimpleJSON: {},
pypiSimpleHTML: {},
pypiLegacyHTML: {},
}
update := func(representation string, quality float64, specificity int) {
current := scores[representation]
if !current.matched || specificity > current.specificity ||
(specificity == current.specificity && quality > current.quality) {
scores[representation] = score{quality: quality, specificity: specificity, matched: true}
}
}
for part := range strings.SplitSeq(accept, ",") {
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(part))
if err != nil {
continue
}
quality := 1.0
if value, ok := params["q"]; ok {
quality, err = strconv.ParseFloat(value, 64)
if err != nil || quality < 0 || quality > 1 {
continue
}
}
switch mediaType {
case pypiSimpleJSON, pypiSimpleLatestJSON:
update(pypiSimpleJSON, quality, pypiExactSpecificity)
case pypiSimpleHTML, pypiSimpleLatestHTML:
update(pypiSimpleHTML, quality, pypiExactSpecificity)
case pypiLegacyHTML:
update(pypiLegacyHTML, quality, pypiExactSpecificity)
case "application/*":
update(pypiSimpleJSON, quality, 1)
update(pypiSimpleHTML, quality, 1)
case "text/*":
update(pypiLegacyHTML, quality, 1)
case "*/*":
update(pypiSimpleJSON, quality, 0)
update(pypiSimpleHTML, quality, 0)
update(pypiLegacyHTML, quality, 0)
}
}
bestMediaType := ""
bestScore := score{}
for _, mediaType := range []string{pypiSimpleJSON, pypiSimpleHTML, pypiLegacyHTML} {
candidate := scores[mediaType]
if !candidate.matched || candidate.quality == 0 {
continue
}
if bestMediaType == "" || candidate.quality > bestScore.quality ||
(candidate.quality == bestScore.quality && candidate.specificity > bestScore.specificity) {
bestMediaType = mediaType
bestScore = candidate
}
}
if bestMediaType == "" || bestScore.specificity == 0 {
return pypiLegacyHTML
}
return bestMediaType
}
func pypiSimpleCacheKey(name, mediaType string) string {
switch mediaType {
case pypiSimpleJSON:
return name + "/simple/json"
case pypiSimpleHTML:
return name + "/simple/html"
default:
return name + "/simple"
}
}
func isJSONMediaType(contentType string) bool {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return false
}
return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")
}
func ensureVaryAccept(header http.Header) {
for _, value := range header.Values("Vary") {
for field := range strings.SplitSeq(value, ",") {
if strings.EqualFold(strings.TrimSpace(field), "Accept") {
return
}
}
}
header.Add("Vary", "Accept")
}
// 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)
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", name+"/json", jsonURL)
if err != nil {
return nil
}
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil
}
releases, ok := metadata["releases"].(map[string]any)
if !ok {
return nil
}
packagePURL := canonicalPackagePURL("pypi", name)
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
}
// rewriteSimpleHTML rewrites package URLs in simple API HTML to point at this proxy.
// 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)
if len(innerMatch) < minSubmatchParts {
return match
}
filename := string(innerMatch[1])
_, version := h.parseFilename(strings.TrimSpace(filename))
if version != "" && filteredVersions[version] {
return nil
}
return match
})
}
// Match href attributes pointing to packages on the configured download host.
return h.downloadHrefRe.ReplaceAllFunc(body, func(match []byte) []byte {
submatch := h.downloadHrefRe.FindSubmatch(match)
if len(submatch) < minSubmatchParts {
return match
}
origURL := string(submatch[1])
newURL := h.proxyURL + "/pypi/packages" + strings.TrimPrefix(origURL, h.downloadURL)
return []byte(fmt.Sprintf(`href="%s"`, newURL))
})
}
func (h *PyPIHandler) rewriteSimpleJSON(body []byte, filteredVersions map[string]bool) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
files, ok := metadata["files"].([]any)
if !ok {
return nil, errors.New("pypi simple json response has no files array")
}
rewrittenFiles := make([]any, 0, len(files))
for _, file := range files {
entry, ok := file.(map[string]any)
if !ok {
rewrittenFiles = append(rewrittenFiles, file)
continue
}
if filename, ok := entry["filename"].(string); ok {
_, version := h.parseFilename(filename)
if version != "" && filteredVersions[version] {
continue
}
}
h.rewriteURLEntry(entry)
rewrittenFiles = append(rewrittenFiles, entry)
}
metadata["files"] = rewrittenFiles
return json.Marshal(metadata)
}
// 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)
h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/json")
}
// 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
}
if h.proxy.versionDenied("pypi", name, version) {
http.Error(w, "not found", http.StatusNotFound)
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)
h.proxyAndRewriteJSON(w, r, upstreamURL, name+"/"+version)
}
// proxyAndRewriteJSON fetches JSON metadata and rewrites download URLs.
func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL, cacheKey string) {
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "pypi", cacheKey, upstreamURL)
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("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
rewritten, err := h.rewriteJSONMetadata(body)
if err != nil {
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("pypi", r.PathValue("name")))) != 0 {
http.Error(w, "failed to filter package metadata", http.StatusBadGateway)
return
}
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(body)
return
}
w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(rewritten)
}
// rewriteJSONMetadata rewrites download URLs in PyPI JSON metadata.
// If cooldown is enabled, versions published too recently are filtered out.
func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
packageName, _ := extractPyPIName(metadata)
packagePURL := ""
if packageName != "" {
packagePURL = canonicalPackagePURL("pypi", packageName)
}
h.filterAndRewriteReleases(metadata, packageName, packagePURL)
h.filterAndRewriteURLs(metadata, packagePURL)
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.proxy.versionDenied("pypi", packageName, version) || h.shouldFilterRelease(packagePURL, files) {
h.proxy.Logger.Info("policy: filtering pypi version",
"package", packageName, "version", version)
delete(releases, version)
continue
}
h.rewriteFileEntries(files)
}
}
// 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)
}
// 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]
}
// 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)
}
}
}
// 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
}
info, _ := metadata["info"].(map[string]any)
name, _ := info["name"].(string)
version, _ := info["version"].(string)
if h.proxy.versionDenied("pypi", name, version) || 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)
}
}
}
}
// 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
}
// 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
}
if strings.HasPrefix(urlStr, h.downloadURL+"/packages/") {
entry["url"] = h.proxyURL + "/pypi/packages" + strings.TrimPrefix(urlStr, h.downloadURL)
}
}
// 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, "/")
if len(parts) < minPyPIPathParts {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
filename := parts[len(parts)-1]
name, version := h.parseFilename(filename)
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
}
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)
// The path value starts with 'packages/' (no leading slash), so add
// the separator here.
upstreamURL := fmt.Sprintf("%s/%s", h.downloadURL, path)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, version, filename, upstreamURL)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
ServeArtifactRequest(w, r, result)
}
// 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"}
// parseFilename extracts package name and version from a PyPI filename.
// Handles wheels, sdists and legacy bdist formats:
// - requests-2.31.0-py3-none-any.whl
// - requests-2.31.0.tar.gz
// - numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg
// - numpy-1.8.0.win32-py2.7.exe
func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
// 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))
}
}
for _, ext := range archiveExtensions {
if strings.HasSuffix(filename, ext) {
return splitNameVersion(strings.TrimSuffix(filename, ext))
}
}
return "", ""
}
// 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]) {
return false
}
for i := range len(s) {
if !isVersionStart(s[i]) && s[i] != '.' {
return false
}
}
return true
}
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", selectPyPISimpleRepresentation(r.Header.Get("Accept")))
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
h.proxy.relayResponse(w, r, resp, func(dst, src http.Header) {
copyRelayHeaders(dst, src)
ensureVaryAccept(dst)
})
}