From fe9d90eac18fa62028e26a08aac4af51f979af89 Mon Sep 17 00:00:00 2001 From: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:03:50 -0500 Subject: [PATCH] Fix Python for Security Only Releases --- internal/runtimes/python/python.go | 97 +++++----------------- internal/sitegen/loader.go | 127 ++++++++++++++++------------- 2 files changed, 91 insertions(+), 133 deletions(-) diff --git a/internal/runtimes/python/python.go b/internal/runtimes/python/python.go index 8ab3da7..b88c10f 100644 --- a/internal/runtimes/python/python.go +++ b/internal/runtimes/python/python.go @@ -281,61 +281,6 @@ func (a *PythonAdapter) GetLatestVersion(ctx context.Context, opts runtime.Versi return filtered[0], nil } -// findLastVersionWithBinaries finds the last patch version that has Windows/macOS binary installers. -// For security-only releases, Python stops publishing binary installers, so we need to find -// the last version that had them by checking HEAD requests to python.org. -// Returns the version string (e.g., "3.11.9") or empty string if none found. -func (a *PythonAdapter) findLastVersionWithBinaries(ctx context.Context, majorMinor string, latestPatch string) string { - // Parse the patch number from latestPatch (e.g., "3.11.14" -> 14) - parts := strings.Split(latestPatch, ".") - if len(parts) != 3 { - return "" - } - - var patchNum int - if _, err := fmt.Sscanf(parts[2], "%d", &patchNum); err != nil { - return "" - } - - // Get base URL - baseURL := "https://www.python.org/ftp/python" - if a.config != nil && a.config.Download.BaseURL != "" { - baseURL = a.config.Download.BaseURL - } - - // Create HTTP client with timeout - client := &http.Client{ - Timeout: 10 * time.Second, - } - - // Search backwards from latest patch to find last version with Windows binary - for patch := patchNum; patch >= 0; patch-- { - testVersion := fmt.Sprintf("%s.%d", majorMinor, patch) - testURL := fmt.Sprintf("%s/%s/python-%s-amd64.exe", baseURL, testVersion, testVersion) - - req, err := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil) - if err != nil { - continue - } - - resp, err := client.Do(req) - if err != nil { - continue - } - resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - a.stdout.Info("found last Python version with binaries", - "major_minor", majorMinor, - "version_with_binaries", testVersion, - "latest_patch", latestPatch) - return testVersion - } - } - - return "" -} - // CreateDownloadTasks generates download tasks for the specified Python version and platforms. func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platforms []platform.Platform, outputDir string) ([]runtime.DownloadTask, error) { // POLICY VALIDATION: Check if the version is supported or under_review before creating download tasks @@ -355,22 +300,17 @@ func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platf platforms = a.GetSupportedPlatforms() } - // For security-only releases, find the last version that has Windows/macOS binaries - var lastVersionWithBinaries string + // For security-only (EOAS) Python releases, upstream stops publishing the + // Windows .exe and macOS .pkg installers. We deliberately do NOT fall back + // to a previous patch's installer because that produced misleading entries + // in the published index (e.g., python-3.12.10-macos11.pkg listed under + // version 3.12.12). Linux is still built from source for the security + // patch, so only Windows/macOS are skipped. if version.IsEOAS { - a.stdout.Info("processing security-only Python release", + a.stdout.Info("processing security-only Python release; skipping windows/mac binaries", "version", version.Version, "latest_patch", version.LatestPatch, "is_eoas", version.IsEOAS) - - // Find the last version that has Windows/macOS binary installers - lastVersionWithBinaries = a.findLastVersionWithBinaries(context.Background(), version.Version, version.LatestPatch) - if lastVersionWithBinaries != "" { - a.stdout.Info("will use last version with binaries for Windows/macOS", - "version", version.Version, - "binary_version", lastVersionWithBinaries, - "source_version", version.LatestPatch) - } } // Fix platform file extensions to match Python's specific requirements @@ -409,16 +349,18 @@ func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platf // Create tasks for main binary/source files for _, plat := range fixedPlatforms { - // Determine which version to use for this platform - downloadVersion := version.LatestPatch - - // For security-only releases, use last version with binaries for Windows/macOS - if version.IsEOAS && lastVersionWithBinaries != "" { - if plat.OS == "windows" || plat.OS == "mac" { - downloadVersion = lastVersionWithBinaries - } + // Skip Windows/macOS for security-only releases: upstream does not + // publish installers for these patch versions and we will not + // substitute an older patch's installer. + if version.IsEOAS && (plat.OS == "windows" || plat.OS == "mac") { + a.stdout.Info("skipping platform for security-only Python release", + "platform_os", plat.OS, + "version", version.LatestPatch) + continue } + downloadVersion := version.LatestPatch + url := a.constructDownloadURL(downloadVersion, plat) if url == "" { continue // Skip unsupported platform combinations @@ -469,9 +411,8 @@ type platformVersion struct { version string } -// createVerificationTasksWithVersions creates download tasks for verification files -// using specific versions per platform (needed for security-only releases where -// Windows/macOS use an older version than Linux) +// createVerificationTasksWithVersions creates download tasks for verification +// files using the specific version recorded for each platform's main artifact. func (a *PythonAdapter) createVerificationTasksWithVersions(platformVersions []platformVersion, outputDir, userAgent string) []runtime.DownloadTask { var tasks []runtime.DownloadTask diff --git a/internal/sitegen/loader.go b/internal/sitegen/loader.go index c6128ec..bdf6b13 100644 --- a/internal/sitegen/loader.go +++ b/internal/sitegen/loader.go @@ -65,9 +65,17 @@ func LoadReleases(reader ReleaseReader) ([]ReleaseWithArtifacts, error) { return result, nil } -// filterArtifactsForVersion filters artifacts to only include those matching the specified version. -// For security-only releases where binaries use older patch versions (e.g., 3.12.10 for version 3.12.12), -// this function matches by major.minor prefix as a fallback. +// filterArtifactsForVersion filters artifacts to only include those whose +// filenames contain an exact match for the specified version. +// +// For aggregated security-only releases, upstream sometimes only ships a binary +// for the latest patch in a minor line (e.g., python.org publishes +// python-3.12.10-macos11.pkg as the macOS installer for the entire 3.12.x line). +// We deliberately do NOT attach such binaries to other advertised patch +// versions (e.g., 3.12.12), because doing so produced misleading entries like +// `mac/python/3.12/3.12.12/python-3.12.10-macos11.pkg` with version "3.12.12". +// If no binary in the release exactly matches the advertised version for a +// platform, that platform is simply omitted from this version's entries. func filterArtifactsForVersion(artifacts storage.ReleaseArtifacts, version string) storage.ReleaseArtifacts { filtered := storage.ReleaseArtifacts{ Platforms: []storage.PlatformArtifact{}, @@ -75,80 +83,89 @@ func filterArtifactsForVersion(artifacts storage.ReleaseArtifacts, version strin Metadata: artifacts.Metadata, } - // Extract major.minor from version for fallback matching - // e.g., "3.12.12" -> "3.12" - majorMinor := extractMajorMinor(version) - - // Filter platform artifacts by version in filename for _, platform := range artifacts.Platforms { - // Check if any artifact in this platform matches the version hasMatchingArtifact := false - if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) { + if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) { hasMatchingArtifact = true } - if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) { + if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) { hasMatchingArtifact = true } - if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) { + if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) { hasMatchingArtifact = true } - if hasMatchingArtifact { - // Create a filtered copy of the platform with only matching artifacts - filteredPlatform := storage.PlatformArtifact{ - Platform: platform.Platform, - PlatformOS: platform.PlatformOS, - PlatformArch: platform.PlatformArch, - } + if !hasMatchingArtifact { + continue + } - if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) { - filteredPlatform.Binary = platform.Binary - } - if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) { - filteredPlatform.Audit = platform.Audit - } - if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version, majorMinor) { - filteredPlatform.Signature = platform.Signature - } - if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version, majorMinor) { - filteredPlatform.Certificate = platform.Certificate - } - if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) { - filteredPlatform.MetadataFile = platform.MetadataFile - } + filteredPlatform := storage.PlatformArtifact{ + Platform: platform.Platform, + PlatformOS: platform.PlatformOS, + PlatformArch: platform.PlatformArch, + } - filtered.Platforms = append(filtered.Platforms, filteredPlatform) + if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) { + filteredPlatform.Binary = platform.Binary } + if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) { + filteredPlatform.Audit = platform.Audit + } + if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version) { + filteredPlatform.Signature = platform.Signature + } + if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version) { + filteredPlatform.Certificate = platform.Certificate + } + if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) { + filteredPlatform.MetadataFile = platform.MetadataFile + } + + filtered.Platforms = append(filtered.Platforms, filteredPlatform) } return filtered } -// extractMajorMinor extracts the major.minor portion from a semver version. -// e.g., "3.12.12" -> "3.12", "22.15.0" -> "22.15" -func extractMajorMinor(version string) string { - parts := strings.Split(version, ".") - if len(parts) >= 2 { - return parts[0] + "." + parts[1] +// matchesVersion reports whether a filename advertises the given exact version. +// +// Matching is intentionally strict: only an exact substring match on the full +// version string counts. We previously fell back to a major.minor prefix +// match, but that caused mac/windows installers from one patch version to be +// re-published under unrelated patch versions in the same security-only +// aggregated release (see filterArtifactsForVersion for context). +// +// To avoid false positives such as filenames containing "3.12.123" matching +// version "3.12.12", the version must be bounded on both sides by either a +// non-digit character or the start/end of the filename. +func matchesVersion(filename, version string) bool { + idx := 0 + for { + i := strings.Index(filename[idx:], version) + if i < 0 { + return false + } + start := idx + i + end := start + len(version) + if !isVersionDigit(filename, start-1) && !isVersionDigit(filename, end) { + return true + } + idx = start + 1 + if idx >= len(filename) { + return false + } } - return version } -// matchesVersion checks if a filename matches the given version. -// First tries exact version match, then falls back to major.minor match. -// This handles security-only releases where binaries use older patch versions. -func matchesVersion(filename, version, majorMinor string) bool { - // First try exact version match - if strings.Contains(filename, version) { - return true - } - // Fallback to major.minor match for security-only releases - // e.g., "python-3.12.10-amd64.exe" matches version "3.12.12" via "3.12" - if strings.Contains(filename, majorMinor+".") { - return true +// isVersionDigit reports whether the byte at position i in s is an ASCII digit. +// Out-of-bounds positions return false (treated as a non-digit boundary). +func isVersionDigit(s string, i int) bool { + if i < 0 || i >= len(s) { + return false } - return false + c := s[i] + return c >= '0' && c <= '9' } // ReleaseWithArtifacts combines a Release with its parsed artifacts.