mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 20:34:56 -04:00
* resolve name and version for PEP 658 metadata sidecars
* fix(pypi): parse Windows installer and egg filenames separately
The bdist_wininst and bdist_msi layout joins the platform to the version
with a '.' rather than a '-', so treating .exe/.msi like a wheel folded
the platform into the version: foo-1.0.win32-py2.0.exe resolved to
version "1.0.win32". Eggs shared the problem, as setuptools' hyphen
escaping is not universal: aws-sdk-1.0.0-py3.11.egg resolved to name
"aws", version "sdk".
Give each format its own parser. Wheels keep the PEP 427
spec-guaranteed field positions, eggs locate the version relative to the
py{X.Y} interpreter field, and Windows installers strip the platform and
interpreter fields before splitting name from version.
A PEP 658 sidecar resolves to the same name and version as the
distribution it describes, so it is cached under that version. Browse and
compare took the first cached artifact without checking its extension,
handing openArchive plain text: a version pip had only fetched metadata
for reported hasCached and then 500'd.
Add firstBrowsableArtifact, replacing five duplicated selection loops,
and export PyPIMetadataSuffix so the suffix has a single definition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
8.2 KiB
Go
238 lines
8.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
func TestPyPIParseFilename(t *testing.T) {
|
|
h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
|
|
|
|
tests := []struct {
|
|
filename string
|
|
wantName string
|
|
wantVersion string
|
|
}{
|
|
// Sdist formats
|
|
{"requests-2.31.0.tar.gz", "requests", "2.31.0"},
|
|
{"Django-4.2.7.tar.gz", "Django", "4.2.7"},
|
|
{"aws-sdk-1.0.0.tar.gz", "aws-sdk", "1.0.0"},
|
|
{"zipp-3.17.0.zip", "zipp", "3.17.0"},
|
|
|
|
// Additional sdist archive formats
|
|
{"lxml-4.9.3.tar.xz", "lxml", "4.9.3"},
|
|
{"docutils-0.20.1.tgz", "docutils", "0.20.1"},
|
|
{"psycopg2-2.9.9.tar.bz2", "psycopg2", "2.9.9"},
|
|
|
|
// Wheel formats
|
|
{"requests-2.31.0-py3-none-any.whl", "requests", "2.31.0"},
|
|
{"numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.whl", "numpy", "1.26.2"},
|
|
{"cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", "cryptography", "41.0.5"},
|
|
|
|
// Wheels with a build tag must not fold the tag into the version
|
|
{"foo-1.0-1-py3-none-any.whl", "foo", "1.0"},
|
|
{"tensorflow-2.15.0-2-cp311-cp311-manylinux_2_17_x86_64.whl", "tensorflow", "2.15.0"},
|
|
|
|
// PEP 658 core-metadata sidecars resolve to the distribution they describe
|
|
{"backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata", "backports_asyncio_runner", "1.2.0"},
|
|
{"requests-2.31.0-py3-none-any.whl.metadata", "requests", "2.31.0"},
|
|
{"requests-2.31.0.tar.gz.metadata", "requests", "2.31.0"},
|
|
|
|
// Eggs: {name}-{version}-py{X.Y}(-{platform})?.egg. Unescaped hyphens in
|
|
// the name must not be mistaken for the field separator before the version.
|
|
{"numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg", "numpy", "1.8.0"},
|
|
{"aws-sdk-1.0.0-py3.11.egg", "aws-sdk", "1.0.0"},
|
|
{"aws-sdk-1.0.0-py2.7-macosx-10.9-x86_64.egg", "aws-sdk", "1.0.0"},
|
|
{"aws-sdk-1.0.0.egg", "aws-sdk", "1.0.0"},
|
|
// A "py{N}" component inside the name is not the interpreter field, so
|
|
// the interpreter must be located from the end of the filename.
|
|
{"django-rest-py3-1.0-py3.6.egg", "django-rest-py3", "1.0"},
|
|
|
|
// Windows installers: {name}-{version}.{platform}(-py{X.Y})?.{exe,msi}.
|
|
// The platform is not part of the version, and may contain a hyphen.
|
|
{"foo-1.0.win32-py2.0.exe", "foo", "1.0"},
|
|
{"pywin32-223.win32-py2.7.exe", "pywin32", "223"},
|
|
{"numpy-1.8.0.win-amd64-py2.7.exe", "numpy", "1.8.0"},
|
|
{"aws-sdk-1.0.0.win32-py2.7.exe", "aws-sdk", "1.0.0"},
|
|
{"pywin32-223.win32.exe", "pywin32", "223"},
|
|
{"cx_Oracle-5.1.2.win32-py2.7.msi", "cx_Oracle", "5.1.2"},
|
|
{"numpy-1.8.0.win-amd64.msi", "numpy", "1.8.0"},
|
|
// A trailing build variant belongs to neither the name nor the version.
|
|
{"cx_Oracle-5.1.2-11g.win32-py2.7.exe", "cx_Oracle", "5.1.2"},
|
|
// A prerelease version has no purely numeric field to anchor on.
|
|
{"foo-1.0b1.win32-py2.7.exe", "foo", "1.0b1"},
|
|
|
|
// Invalid
|
|
{"invalid", "", ""},
|
|
{"invalid.metadata", "", ""},
|
|
{"backports.ssl_match_hostname-3.4.0.2-py2.7.whl", "", ""},
|
|
{"invalid.exe", "", ""},
|
|
{"foo-1.0.exe", "", ""},
|
|
// An egg with an interpreter field but no version must not promote the
|
|
// trailing component of a hyphenated name to the version.
|
|
{"aws-sdk-py2.7.egg", "", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
name, version := h.parseFilename(tt.filename)
|
|
if name != tt.wantName || version != tt.wantVersion {
|
|
t.Errorf("parseFilename(%q) = (%q, %q), want (%q, %q)",
|
|
tt.filename, name, version, tt.wantName, tt.wantVersion)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPyPIRewriteJSONMetadataCooldown(t *testing.T) {
|
|
now := time.Now()
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
proxy := &Proxy{Logger: slog.Default()}
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
h := &PyPIHandler{
|
|
proxy: proxy,
|
|
proxyURL: "http://localhost:8080",
|
|
}
|
|
|
|
input := `{
|
|
"info": {"name": "requests"},
|
|
"releases": {
|
|
"2.30.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.30.0.tar.gz", "upload_time_iso_8601": "` + old + `"}],
|
|
"2.31.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
|
|
},
|
|
"urls": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
|
|
}`
|
|
|
|
output, err := h.rewriteJSONMetadata([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("rewriteJSONMetadata failed: %v", err)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
}
|
|
|
|
releases := result["releases"].(map[string]any)
|
|
|
|
if _, ok := releases["2.30.0"]; !ok {
|
|
t.Error("version 2.30.0 should not be filtered")
|
|
}
|
|
if _, ok := releases["2.31.0"]; ok {
|
|
t.Error("version 2.31.0 should be filtered by cooldown")
|
|
}
|
|
|
|
// urls array should be empty since the current version is filtered
|
|
urls := result["urls"].([]any)
|
|
if len(urls) != 0 {
|
|
t.Errorf("urls should be empty, got %d entries", len(urls))
|
|
}
|
|
}
|
|
|
|
// TestPyPIParseFilenameNoHashFallback guards the identifier used for caching:
|
|
// a filename that parses to an empty name makes handleDownload fall back to a
|
|
// "_hash_<digest>" package name, which surfaces as a bogus PURL in the package
|
|
// overview.
|
|
func TestPyPIParseFilenameNoHashFallback(t *testing.T) {
|
|
h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
|
|
|
|
filenames := []string{
|
|
"backports_asyncio_runner-1.2.0-py3-none-any.whl",
|
|
"backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata",
|
|
"backports_asyncio_runner-1.2.0.tar.gz",
|
|
}
|
|
|
|
for _, filename := range filenames {
|
|
name, version := h.parseFilename(filename)
|
|
if name != "backports_asyncio_runner" || version != "1.2.0" {
|
|
t.Errorf("parseFilename(%q) = (%q, %q), want (%q, %q)",
|
|
filename, name, version, "backports_asyncio_runner", "1.2.0")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPyPIHandler_DownloadUpstreamURL(t *testing.T) {
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
fetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("wheel data")),
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
h := NewPyPIHandler(proxy, "http://localhost")
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
// The path wildcard {path...} captures everything after /packages/,
|
|
// which includes "packages/" from the rewritten URL. The upstream URL
|
|
// must not double the "packages" segment.
|
|
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if !fetcher.fetchCalled {
|
|
t.Fatal("expected fetcher to be called on cache miss")
|
|
}
|
|
|
|
want := "https://files.pythonhosted.org/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl"
|
|
if fetcher.fetchedURL != want {
|
|
t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
|
|
}
|
|
}
|
|
|
|
func TestPyPIHandler_DownloadCacheHit(t *testing.T) {
|
|
proxy, db, store, _ := setupTestProxy(t)
|
|
seedPackage(t, db, store, "pypi", "requests", "2.31.0",
|
|
"requests-2.31.0-py3-none-any.whl", "wheel binary data")
|
|
|
|
h := NewPyPIHandler(proxy, "http://localhost")
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != "wheel binary data" {
|
|
t.Errorf("body = %q, want %q", body, "wheel binary data")
|
|
}
|
|
}
|
|
|
|
func TestPyPIHandler_DownloadCacheMiss(t *testing.T) {
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
fetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("fetched wheel")),
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
h := NewPyPIHandler(proxy, "http://localhost")
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if !fetcher.fetchCalled {
|
|
t.Error("expected fetcher to be called on cache miss")
|
|
}
|
|
}
|