mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Show cooldown report for pinned version installs When a user installs a package with an explicit version (e.g. npm install foo@1.2.3) and that version falls within the dependency cooldown window, the cooldown block is now recorded and shown in the report. Previously, the report only appeared when ALL versions of a package were in cooldown (remaining == 0), causing pinned version installs to fail with a confusing "version not found" error from the package manager instead of a clear cooldown explanation. Introduces InterceptorContext to carry per-execution data (pinned versions) from the CLI command through the interceptor layer, keeping it separate from long-lived dependencies like the analyzer and cache. * fix: Normalize PyPI pinned version keys for cooldown lookup CLI-provided package names (e.g. Flask_Cors) don't match the URL-parsed form (flask-cors). Normalize keys once at construction time so cooldown lookups match correctly. * fix: Handle dots in PyPI package name normalization per PEP 503 denormalizePyPIPackageName already documented [-_.] replacement but only handled underscores. Now also replaces dots with hyphens so names like zope.interface match the URL-parsed form zope-interface. * refactor: Extract shared cooldown stats recording into helper Deduplicate identical stats-recording blocks from npm_cooldown.go and pypi_cooldown.go into recordCooldownStats in cooldown.go. * fix: Distinguish explicit version pins from auto-resolved versions PyPI parsers resolve all packages to concrete versions (even without a user-specified constraint), so HasVersion() was always true. Add IsExplicitVersion to PackageInstallTarget, set it only when the user provided an explicit constraint. Use it in proxy_flow.go to avoid false pinned-version cooldown reports.
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// pypiPackageInfo represents parsed package information from a PyPI registry URL
|
|
type pypiPackageInfo struct {
|
|
name string
|
|
version string
|
|
isDownload bool // True if this is a file download (sdist or wheel)
|
|
fileType string // "sdist", "wheel", or empty for non-download requests
|
|
}
|
|
|
|
// Ensure pypiPackageInfo implements packageInfo interface
|
|
var _ packageInfo = (*pypiPackageInfo)(nil)
|
|
|
|
// GetName returns the package name
|
|
func (p *pypiPackageInfo) GetName() string {
|
|
return p.name
|
|
}
|
|
|
|
// GetVersion returns the package version
|
|
func (p *pypiPackageInfo) GetVersion() string {
|
|
return p.version
|
|
}
|
|
|
|
// IsFileDownload returns true if this is a file download (sdist or wheel)
|
|
func (p *pypiPackageInfo) IsFileDownload() bool {
|
|
return p.isDownload
|
|
}
|
|
|
|
// FileType returns the file type ("sdist", "wheel", or empty)
|
|
func (p *pypiPackageInfo) FileType() string {
|
|
return p.fileType
|
|
}
|
|
|
|
// pypiFilesParser parses URLs from files.pythonhosted.org
|
|
// This is where PyPI serves package files (sdists and wheels)
|
|
type pypiFilesParser struct{}
|
|
|
|
// Ensure pypiFilesParser implements RegistryURLParser interface
|
|
var _ registryURLParser = pypiFilesParser{}
|
|
|
|
// ParseURL parses files.pythonhosted.org URL paths
|
|
// URL patterns:
|
|
// - /packages/{hash_dirs}/{filename}
|
|
// Where filename can be:
|
|
// - {name}-{version}.tar.gz (sdist)
|
|
// - {name}-{version}.zip (sdist)
|
|
// - {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl (wheel)
|
|
func (p pypiFilesParser) ParseURL(urlPath string) (packageInfo, error) {
|
|
// Remove leading and trailing slashes
|
|
urlPath = strings.Trim(urlPath, "/")
|
|
|
|
if urlPath == "" {
|
|
return nil, fmt.Errorf("empty URL path")
|
|
}
|
|
|
|
// Split path into segments
|
|
segments := strings.Split(urlPath, "/")
|
|
|
|
// files.pythonhosted.org paths start with "packages"
|
|
// Format: packages/{hash_prefix}/{filename}
|
|
// The hash prefix can be variable length (typically 2-3 directory levels)
|
|
if len(segments) < 2 {
|
|
return nil, fmt.Errorf("invalid PyPI files URL: not enough segments")
|
|
}
|
|
|
|
// The filename is always the last segment
|
|
filename := segments[len(segments)-1]
|
|
|
|
// Check if it's a packages download path
|
|
if segments[0] != "packages" {
|
|
return nil, fmt.Errorf("invalid PyPI files URL: expected 'packages' prefix, got %s", segments[0])
|
|
}
|
|
|
|
return parseFilename(filename)
|
|
}
|
|
|
|
// pypiOrgParser parses URLs from pypi.org (Simple API and JSON API)
|
|
type pypiOrgParser struct{}
|
|
|
|
// Ensure pypiOrgParser implements RegistryURLParser interface
|
|
var _ registryURLParser = pypiOrgParser{}
|
|
|
|
// ParseURL parses pypi.org URL paths
|
|
// URL patterns:
|
|
// - /simple/{package}/ (Simple API - package index)
|
|
// - /simple/{package}/{filename} (Simple API - file redirect, rare)
|
|
// - /pypi/{package}/json (JSON API - package metadata)
|
|
// - /pypi/{package}/{version}/json (JSON API - version metadata)
|
|
func (p pypiOrgParser) ParseURL(urlPath string) (packageInfo, error) {
|
|
// Remove leading and trailing slashes
|
|
urlPath = strings.Trim(urlPath, "/")
|
|
|
|
if urlPath == "" {
|
|
return nil, fmt.Errorf("empty URL path")
|
|
}
|
|
|
|
// Split path into segments
|
|
segments := strings.Split(urlPath, "/")
|
|
|
|
if len(segments) < 2 {
|
|
return nil, fmt.Errorf("invalid pypi.org URL: not enough segments")
|
|
}
|
|
|
|
switch segments[0] {
|
|
case "simple":
|
|
// Simple API: /simple/{package}/ or /simple/{package}/{filename}
|
|
return parseSimpleAPIURL(segments[1:])
|
|
case "pypi":
|
|
// JSON API: /pypi/{package}/json or /pypi/{package}/{version}/json
|
|
return parseJSONAPIURL(segments[1:])
|
|
default:
|
|
return nil, fmt.Errorf("unknown pypi.org path prefix: %s", segments[0])
|
|
}
|
|
}
|
|
|
|
// parseSimpleAPIURL parses Simple API URL paths
|
|
func parseSimpleAPIURL(segments []string) (*pypiPackageInfo, error) {
|
|
if len(segments) == 0 {
|
|
return nil, fmt.Errorf("invalid Simple API URL: missing package name")
|
|
}
|
|
|
|
packageName := segments[0]
|
|
|
|
// Simple API index request: /simple/{package}/
|
|
if len(segments) == 1 {
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(packageName),
|
|
isDownload: false,
|
|
}, nil
|
|
}
|
|
|
|
// Simple API might include filename (for redirects): /simple/{package}/{filename}
|
|
if len(segments) == 2 {
|
|
filename := segments[1]
|
|
info, err := parseFilename(filename)
|
|
if err != nil {
|
|
// If we can't parse the filename, treat it as a non-download request
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(packageName),
|
|
isDownload: false,
|
|
}, nil
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid Simple API URL format: too many segments")
|
|
}
|
|
|
|
// parseJSONAPIURL parses JSON API URL paths
|
|
func parseJSONAPIURL(segments []string) (*pypiPackageInfo, error) {
|
|
if len(segments) == 0 {
|
|
return nil, fmt.Errorf("invalid JSON API URL: missing package name")
|
|
}
|
|
|
|
packageName := segments[0]
|
|
|
|
// /pypi/{package}/json - package metadata (no specific version)
|
|
if len(segments) == 2 && segments[1] == "json" {
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(packageName),
|
|
isDownload: false,
|
|
}, nil
|
|
}
|
|
|
|
// /pypi/{package}/{version}/json - version metadata
|
|
if len(segments) == 3 && segments[2] == "json" {
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(packageName),
|
|
version: segments[1],
|
|
isDownload: false,
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid JSON API URL format")
|
|
}
|
|
|
|
// parseFilename extracts package name and version from a PyPI distribution filename
|
|
func parseFilename(filename string) (*pypiPackageInfo, error) {
|
|
// Try to parse as wheel first
|
|
if strings.HasSuffix(filename, ".whl") {
|
|
return parseWheelFilename(filename)
|
|
}
|
|
|
|
// Try to parse as sdist (tar.gz or zip)
|
|
if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".zip") {
|
|
return parseSdistFilename(filename)
|
|
}
|
|
|
|
// Check for other archive formats that PyPI might serve
|
|
if strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".tgz") {
|
|
return parseSdistFilename(filename)
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported file type: %s", filename)
|
|
}
|
|
|
|
// parseWheelFilename parses a wheel filename to extract package info
|
|
// Wheel filename format: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
|
|
// Examples:
|
|
// - requests-2.28.0-py3-none-any.whl
|
|
// - numpy-1.24.0-cp311-cp311-linux_x86_64.whl
|
|
// - package_name-1.0.0-1-py3-none-any.whl (with build tag)
|
|
func parseWheelFilename(filename string) (*pypiPackageInfo, error) {
|
|
// Remove .whl extension
|
|
basename := strings.TrimSuffix(filename, ".whl")
|
|
|
|
// Split by '-' to get components
|
|
// Minimum: name-version-python-abi-platform (5 parts)
|
|
// With build tag: name-version-build-python-abi-platform (6 parts)
|
|
parts := strings.Split(basename, "-")
|
|
|
|
if len(parts) < 5 {
|
|
return nil, fmt.Errorf("invalid wheel filename: not enough components in %s", filename)
|
|
}
|
|
|
|
// The last 3 parts are always: python_tag, abi_tag, platform_tag
|
|
// Before that is either: name, version OR name, version, build_tag
|
|
// We need to find where the version is
|
|
|
|
// Work backwards: last 3 are tags
|
|
// If 6+ parts, could have build tag
|
|
// If 5 parts, no build tag
|
|
|
|
var name, version string
|
|
|
|
if len(parts) == 5 {
|
|
// name-version-python-abi-platform
|
|
name = parts[0]
|
|
version = parts[1]
|
|
} else if len(parts) == 6 {
|
|
// Could be:
|
|
// - name-version-build-python-abi-platform (6 parts, with build tag)
|
|
// - name_with_underscore-version-python-abi-platform (can't be this, underscores in names are normalized)
|
|
// Build tags are numeric (PEP 427)
|
|
if isBuildTag(parts[2]) {
|
|
name = parts[0]
|
|
version = parts[1]
|
|
} else {
|
|
// The name might contain a hyphen that wasn't normalized
|
|
// This shouldn't happen with properly normalized names, but handle it
|
|
name = parts[0] + "_" + parts[1]
|
|
version = parts[2]
|
|
}
|
|
} else {
|
|
// More than 6 parts - name contains hyphens or there's a build tag
|
|
// Try to find version by looking for semver-like pattern
|
|
name, version = extractNameVersionFromParts(parts[:len(parts)-3])
|
|
if name == "" || version == "" {
|
|
return nil, fmt.Errorf("could not parse wheel filename: %s", filename)
|
|
}
|
|
}
|
|
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(name),
|
|
version: version,
|
|
isDownload: true,
|
|
fileType: "wheel",
|
|
}, nil
|
|
}
|
|
|
|
// isBuildTag checks if a string looks like a wheel build tag (numeric)
|
|
func isBuildTag(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parseSdistFilename parses a source distribution filename to extract package info
|
|
// Sdist filename format: {name}-{version}.tar.gz or {name}-{version}.zip
|
|
// Examples:
|
|
// - requests-2.28.0.tar.gz
|
|
// - Flask-RESTful-0.3.10.tar.gz (note: hyphens in name)
|
|
func parseSdistFilename(filename string) (*pypiPackageInfo, error) {
|
|
// Remove extension
|
|
basename := filename
|
|
for _, ext := range []string{".tar.gz", ".tar.bz2", ".tgz", ".zip"} {
|
|
if strings.HasSuffix(basename, ext) {
|
|
basename = strings.TrimSuffix(basename, ext)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Find the version by looking for the last hyphen followed by a version-like string
|
|
// This is tricky because package names can contain hyphens
|
|
name, version := extractNameVersionFromSdist(basename)
|
|
if name == "" || version == "" {
|
|
return nil, fmt.Errorf("could not parse sdist filename: %s", filename)
|
|
}
|
|
|
|
return &pypiPackageInfo{
|
|
name: denormalizePyPIPackageName(name),
|
|
version: version,
|
|
isDownload: true,
|
|
fileType: "sdist",
|
|
}, nil
|
|
}
|
|
|
|
// extractNameVersionFromSdist extracts name and version from a sdist basename
|
|
// The challenge is that package names can contain hyphens, so we need to find
|
|
// where the name ends and the version begins
|
|
func extractNameVersionFromSdist(basename string) (string, string) {
|
|
// Version pattern: starts with a digit, may contain digits, dots, and pre-release suffixes
|
|
versionPattern := regexp.MustCompile(`^\d+(\.\d+)*([._-]?(a|alpha|b|beta|c|rc|pre|post|dev|final)\.?\d*)*(\+[a-zA-Z0-9._-]+)?$`)
|
|
|
|
// Split by hyphen and try to find where version starts
|
|
parts := strings.Split(basename, "-")
|
|
|
|
// Try from the end, looking for version-like parts
|
|
for i := len(parts) - 1; i > 0; i-- {
|
|
potentialVersion := strings.Join(parts[i:], "-")
|
|
// Check if this could be a version
|
|
if versionPattern.MatchString(potentialVersion) {
|
|
name := strings.Join(parts[:i], "-")
|
|
return name, potentialVersion
|
|
}
|
|
|
|
// Also try just the single part as version
|
|
if versionPattern.MatchString(parts[i]) {
|
|
name := strings.Join(parts[:i], "-")
|
|
return name, parts[i]
|
|
}
|
|
}
|
|
|
|
return "", ""
|
|
}
|
|
|
|
// extractNameVersionFromParts extracts name and version from wheel filename parts
|
|
// (excluding the python-abi-platform tags)
|
|
func extractNameVersionFromParts(parts []string) (string, string) {
|
|
if len(parts) < 2 {
|
|
return "", ""
|
|
}
|
|
|
|
// Version pattern for wheels
|
|
versionPattern := regexp.MustCompile(`^\d+(\.\d+)*([._]?(a|alpha|b|beta|c|rc|pre|post|dev|final)\d*)*(\+[a-zA-Z0-9._]+)?$`)
|
|
|
|
// Try from the end, looking for version-like parts
|
|
for i := len(parts) - 1; i > 0; i-- {
|
|
if versionPattern.MatchString(parts[i]) {
|
|
// Check if next part is a build tag (numeric only)
|
|
if i+1 < len(parts) && isBuildTag(parts[i+1]) {
|
|
// This is the version, parts[i+1] is build tag
|
|
name := strings.Join(parts[:i], "_")
|
|
return name, parts[i]
|
|
}
|
|
name := strings.Join(parts[:i], "_")
|
|
return name, parts[i]
|
|
}
|
|
}
|
|
|
|
// Fallback: assume first part is name, second is version
|
|
return parts[0], parts[1]
|
|
}
|
|
|
|
// denormalizePyPIPackageName converts a normalized package name back to a more canonical form
|
|
// PyPI normalizes names by replacing [-_.] with - and lowercasing
|
|
// We can't fully reverse this, but we keep the normalized form which works for lookups
|
|
func denormalizePyPIPackageName(name string) string {
|
|
// Convert underscores to hyphens (common PyPI convention)
|
|
// Keep lowercase as that's the normalized form
|
|
name = strings.ToLower(name)
|
|
name = strings.ReplaceAll(name, "_", "-")
|
|
name = strings.ReplaceAll(name, ".", "-")
|
|
return name
|
|
}
|