Skip to content

Commit 813ab27

Browse files
Fix Python for Security Only Releases (#57)
1 parent 5ca5bed commit 813ab27

2 files changed

Lines changed: 91 additions & 133 deletions

File tree

internal/runtimes/python/python.go

Lines changed: 19 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -281,61 +281,6 @@ func (a *PythonAdapter) GetLatestVersion(ctx context.Context, opts runtime.Versi
281281
return filtered[0], nil
282282
}
283283

284-
// findLastVersionWithBinaries finds the last patch version that has Windows/macOS binary installers.
285-
// For security-only releases, Python stops publishing binary installers, so we need to find
286-
// the last version that had them by checking HEAD requests to python.org.
287-
// Returns the version string (e.g., "3.11.9") or empty string if none found.
288-
func (a *PythonAdapter) findLastVersionWithBinaries(ctx context.Context, majorMinor string, latestPatch string) string {
289-
// Parse the patch number from latestPatch (e.g., "3.11.14" -> 14)
290-
parts := strings.Split(latestPatch, ".")
291-
if len(parts) != 3 {
292-
return ""
293-
}
294-
295-
var patchNum int
296-
if _, err := fmt.Sscanf(parts[2], "%d", &patchNum); err != nil {
297-
return ""
298-
}
299-
300-
// Get base URL
301-
baseURL := "https://www.python.org/ftp/python"
302-
if a.config != nil && a.config.Download.BaseURL != "" {
303-
baseURL = a.config.Download.BaseURL
304-
}
305-
306-
// Create HTTP client with timeout
307-
client := &http.Client{
308-
Timeout: 10 * time.Second,
309-
}
310-
311-
// Search backwards from latest patch to find last version with Windows binary
312-
for patch := patchNum; patch >= 0; patch-- {
313-
testVersion := fmt.Sprintf("%s.%d", majorMinor, patch)
314-
testURL := fmt.Sprintf("%s/%s/python-%s-amd64.exe", baseURL, testVersion, testVersion)
315-
316-
req, err := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
317-
if err != nil {
318-
continue
319-
}
320-
321-
resp, err := client.Do(req)
322-
if err != nil {
323-
continue
324-
}
325-
resp.Body.Close()
326-
327-
if resp.StatusCode == http.StatusOK {
328-
a.stdout.Info("found last Python version with binaries",
329-
"major_minor", majorMinor,
330-
"version_with_binaries", testVersion,
331-
"latest_patch", latestPatch)
332-
return testVersion
333-
}
334-
}
335-
336-
return ""
337-
}
338-
339284
// CreateDownloadTasks generates download tasks for the specified Python version and platforms.
340285
func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platforms []platform.Platform, outputDir string) ([]runtime.DownloadTask, error) {
341286
// 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
355300
platforms = a.GetSupportedPlatforms()
356301
}
357302

358-
// For security-only releases, find the last version that has Windows/macOS binaries
359-
var lastVersionWithBinaries string
303+
// For security-only (EOAS) Python releases, upstream stops publishing the
304+
// Windows .exe and macOS .pkg installers. We deliberately do NOT fall back
305+
// to a previous patch's installer because that produced misleading entries
306+
// in the published index (e.g., python-3.12.10-macos11.pkg listed under
307+
// version 3.12.12). Linux is still built from source for the security
308+
// patch, so only Windows/macOS are skipped.
360309
if version.IsEOAS {
361-
a.stdout.Info("processing security-only Python release",
310+
a.stdout.Info("processing security-only Python release; skipping windows/mac binaries",
362311
"version", version.Version,
363312
"latest_patch", version.LatestPatch,
364313
"is_eoas", version.IsEOAS)
365-
366-
// Find the last version that has Windows/macOS binary installers
367-
lastVersionWithBinaries = a.findLastVersionWithBinaries(context.Background(), version.Version, version.LatestPatch)
368-
if lastVersionWithBinaries != "" {
369-
a.stdout.Info("will use last version with binaries for Windows/macOS",
370-
"version", version.Version,
371-
"binary_version", lastVersionWithBinaries,
372-
"source_version", version.LatestPatch)
373-
}
374314
}
375315

376316
// Fix platform file extensions to match Python's specific requirements
@@ -409,16 +349,18 @@ func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platf
409349

410350
// Create tasks for main binary/source files
411351
for _, plat := range fixedPlatforms {
412-
// Determine which version to use for this platform
413-
downloadVersion := version.LatestPatch
414-
415-
// For security-only releases, use last version with binaries for Windows/macOS
416-
if version.IsEOAS && lastVersionWithBinaries != "" {
417-
if plat.OS == "windows" || plat.OS == "mac" {
418-
downloadVersion = lastVersionWithBinaries
419-
}
352+
// Skip Windows/macOS for security-only releases: upstream does not
353+
// publish installers for these patch versions and we will not
354+
// substitute an older patch's installer.
355+
if version.IsEOAS && (plat.OS == "windows" || plat.OS == "mac") {
356+
a.stdout.Info("skipping platform for security-only Python release",
357+
"platform_os", plat.OS,
358+
"version", version.LatestPatch)
359+
continue
420360
}
421361

362+
downloadVersion := version.LatestPatch
363+
422364
url := a.constructDownloadURL(downloadVersion, plat)
423365
if url == "" {
424366
continue // Skip unsupported platform combinations
@@ -469,9 +411,8 @@ type platformVersion struct {
469411
version string
470412
}
471413

472-
// createVerificationTasksWithVersions creates download tasks for verification files
473-
// using specific versions per platform (needed for security-only releases where
474-
// Windows/macOS use an older version than Linux)
414+
// createVerificationTasksWithVersions creates download tasks for verification
415+
// files using the specific version recorded for each platform's main artifact.
475416
func (a *PythonAdapter) createVerificationTasksWithVersions(platformVersions []platformVersion, outputDir, userAgent string) []runtime.DownloadTask {
476417
var tasks []runtime.DownloadTask
477418

internal/sitegen/loader.go

Lines changed: 72 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -65,90 +65,107 @@ func LoadReleases(reader ReleaseReader) ([]ReleaseWithArtifacts, error) {
6565
return result, nil
6666
}
6767

68-
// filterArtifactsForVersion filters artifacts to only include those matching the specified version.
69-
// For security-only releases where binaries use older patch versions (e.g., 3.12.10 for version 3.12.12),
70-
// this function matches by major.minor prefix as a fallback.
68+
// filterArtifactsForVersion filters artifacts to only include those whose
69+
// filenames contain an exact match for the specified version.
70+
//
71+
// For aggregated security-only releases, upstream sometimes only ships a binary
72+
// for the latest patch in a minor line (e.g., python.org publishes
73+
// python-3.12.10-macos11.pkg as the macOS installer for the entire 3.12.x line).
74+
// We deliberately do NOT attach such binaries to other advertised patch
75+
// versions (e.g., 3.12.12), because doing so produced misleading entries like
76+
// `mac/python/3.12/3.12.12/python-3.12.10-macos11.pkg` with version "3.12.12".
77+
// If no binary in the release exactly matches the advertised version for a
78+
// platform, that platform is simply omitted from this version's entries.
7179
func filterArtifactsForVersion(artifacts storage.ReleaseArtifacts, version string) storage.ReleaseArtifacts {
7280
filtered := storage.ReleaseArtifacts{
7381
Platforms: []storage.PlatformArtifact{},
7482
CommonFiles: artifacts.CommonFiles, // Keep common files for all versions
7583
Metadata: artifacts.Metadata,
7684
}
7785

78-
// Extract major.minor from version for fallback matching
79-
// e.g., "3.12.12" -> "3.12"
80-
majorMinor := extractMajorMinor(version)
81-
82-
// Filter platform artifacts by version in filename
8386
for _, platform := range artifacts.Platforms {
84-
// Check if any artifact in this platform matches the version
8587
hasMatchingArtifact := false
8688

87-
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) {
89+
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) {
8890
hasMatchingArtifact = true
8991
}
90-
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) {
92+
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) {
9193
hasMatchingArtifact = true
9294
}
93-
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) {
95+
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) {
9496
hasMatchingArtifact = true
9597
}
9698

97-
if hasMatchingArtifact {
98-
// Create a filtered copy of the platform with only matching artifacts
99-
filteredPlatform := storage.PlatformArtifact{
100-
Platform: platform.Platform,
101-
PlatformOS: platform.PlatformOS,
102-
PlatformArch: platform.PlatformArch,
103-
}
99+
if !hasMatchingArtifact {
100+
continue
101+
}
104102

105-
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) {
106-
filteredPlatform.Binary = platform.Binary
107-
}
108-
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) {
109-
filteredPlatform.Audit = platform.Audit
110-
}
111-
if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version, majorMinor) {
112-
filteredPlatform.Signature = platform.Signature
113-
}
114-
if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version, majorMinor) {
115-
filteredPlatform.Certificate = platform.Certificate
116-
}
117-
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) {
118-
filteredPlatform.MetadataFile = platform.MetadataFile
119-
}
103+
filteredPlatform := storage.PlatformArtifact{
104+
Platform: platform.Platform,
105+
PlatformOS: platform.PlatformOS,
106+
PlatformArch: platform.PlatformArch,
107+
}
120108

121-
filtered.Platforms = append(filtered.Platforms, filteredPlatform)
109+
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) {
110+
filteredPlatform.Binary = platform.Binary
122111
}
112+
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) {
113+
filteredPlatform.Audit = platform.Audit
114+
}
115+
if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version) {
116+
filteredPlatform.Signature = platform.Signature
117+
}
118+
if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version) {
119+
filteredPlatform.Certificate = platform.Certificate
120+
}
121+
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) {
122+
filteredPlatform.MetadataFile = platform.MetadataFile
123+
}
124+
125+
filtered.Platforms = append(filtered.Platforms, filteredPlatform)
123126
}
124127

125128
return filtered
126129
}
127130

128-
// extractMajorMinor extracts the major.minor portion from a semver version.
129-
// e.g., "3.12.12" -> "3.12", "22.15.0" -> "22.15"
130-
func extractMajorMinor(version string) string {
131-
parts := strings.Split(version, ".")
132-
if len(parts) >= 2 {
133-
return parts[0] + "." + parts[1]
131+
// matchesVersion reports whether a filename advertises the given exact version.
132+
//
133+
// Matching is intentionally strict: only an exact substring match on the full
134+
// version string counts. We previously fell back to a major.minor prefix
135+
// match, but that caused mac/windows installers from one patch version to be
136+
// re-published under unrelated patch versions in the same security-only
137+
// aggregated release (see filterArtifactsForVersion for context).
138+
//
139+
// To avoid false positives such as filenames containing "3.12.123" matching
140+
// version "3.12.12", the version must be bounded on both sides by either a
141+
// non-digit character or the start/end of the filename.
142+
func matchesVersion(filename, version string) bool {
143+
idx := 0
144+
for {
145+
i := strings.Index(filename[idx:], version)
146+
if i < 0 {
147+
return false
148+
}
149+
start := idx + i
150+
end := start + len(version)
151+
if !isVersionDigit(filename, start-1) && !isVersionDigit(filename, end) {
152+
return true
153+
}
154+
idx = start + 1
155+
if idx >= len(filename) {
156+
return false
157+
}
134158
}
135-
return version
136159
}
137160

138-
// matchesVersion checks if a filename matches the given version.
139-
// First tries exact version match, then falls back to major.minor match.
140-
// This handles security-only releases where binaries use older patch versions.
141-
func matchesVersion(filename, version, majorMinor string) bool {
142-
// First try exact version match
143-
if strings.Contains(filename, version) {
144-
return true
145-
}
146-
// Fallback to major.minor match for security-only releases
147-
// e.g., "python-3.12.10-amd64.exe" matches version "3.12.12" via "3.12"
148-
if strings.Contains(filename, majorMinor+".") {
149-
return true
161+
// isVersionDigit reports whether the byte at position i in s is an ASCII digit.
162+
// Out-of-bounds positions return false (treated as a non-digit boundary).
163+
func isVersionDigit(s string, i int) bool {
164+
if i < 0 || i >= len(s) {
165+
return false
150166
}
151-
return false
167+
c := s[i]
168+
return c >= '0' && c <= '9'
152169
}
153170

154171
// ReleaseWithArtifacts combines a Release with its parsed artifacts.

0 commit comments

Comments
 (0)