fix: install managed Python on Linux filesystems
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Managed Python installation rejected valid case-distinct terminfo symlinks in the pinned Astral archives on Linux x64 and arm64. Archive uniqueness now follows the target filesystem: Linux preserves case-distinct paths, while every target still rejects exact duplicates and unsafe entries.

Move the unpublished 0.12.0 bilingual release notes to the immutable-tag recovery version 0.12.1 and record the failed tag evidence.

Release note: 修复 Linux x64/ARM64 上托管 Python 因官方归档包含大小写不同路径而无法安装的问题。
This commit is contained in:
mesalogo
2026-09-03 10:31:14 +08:00
parent fb5a9c6f43
commit 3b8d129693
10 changed files with 152 additions and 38 deletions
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gzipSync, zipSync } from 'fflate'
@@ -29,7 +29,7 @@ function tarField(value: string | number, length: number): Buffer {
function tarArchive(entries: {
path: string
type?: '0' | '2'
type?: '0' | '2' | '5'
content?: string
linkPath?: string
mode?: number
@@ -77,7 +77,11 @@ describe('Managed Python archive extraction', () => {
}))
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
artifact: {
platform: 'win32',
archiveFormat: 'nuget-zip',
payloadRoot: 'tools'
},
archivePath,
destinationDirectory
})
@@ -98,7 +102,11 @@ describe('Managed Python archive extraction', () => {
'tools/../escaped': Buffer.from('bad')
}))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
artifact: {
platform: 'win32',
archiveFormat: 'nuget-zip',
payloadRoot: 'tools'
},
archivePath,
destinationDirectory: join(directory, 'install')
})).rejects.toThrow(/Unsafe archive path/u)
@@ -121,7 +129,11 @@ describe('Managed Python archive extraction', () => {
}, ['python'])
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
artifact: {
platform: 'linux',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath,
destinationDirectory
})
@@ -129,6 +141,58 @@ describe('Managed Python archive extraction', () => {
.toBe('python')
})
it('uses target filesystem case semantics for TAR paths', async () => {
const directory = await temporaryDirectory()
const archivePath = join(directory, 'case-sensitive.tar.gz')
await writeFile(archivePath, gzipSync(tarArchive([
{ path: 'python/share/terminfo/2/2621A', type: '5' },
{ path: 'python/share/terminfo/2/2621a', type: '5' }
])))
const destinationDirectory = join(directory, 'linux-install')
await extractPythonArtifact({
artifact: {
platform: 'linux',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath,
destinationDirectory
})
expect(
(await lstat(join(destinationDirectory, 'share/terminfo/2/2621A')))
.isDirectory()
).toBe(true)
expect(
(await lstat(join(destinationDirectory, 'share/terminfo/2/2621a')))
.isDirectory()
).toBe(true)
await expect(extractPythonArtifact({
artifact: {
platform: 'darwin',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath,
destinationDirectory: join(directory, 'darwin-install')
})).rejects.toThrow(/duplicate path/u)
const duplicateArchivePath = join(directory, 'duplicate.tar.gz')
await writeFile(duplicateArchivePath, gzipSync(tarArchive([
{ path: 'python/lib/duplicate', content: 'first' },
{ path: 'python/lib/duplicate', content: 'second' }
])))
await expect(extractPythonArtifact({
artifact: {
platform: 'linux',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath: duplicateArchivePath,
destinationDirectory: join(directory, 'duplicate-install')
})).rejects.toThrow(/duplicate path/u)
})
it('validates relative symlinks and rejects escaping links', async () => {
const directory = await temporaryDirectory()
if (process.platform !== 'win32') {
@@ -143,7 +207,11 @@ describe('Managed Python archive extraction', () => {
])))
const destinationDirectory = join(directory, 'safe-install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
artifact: {
platform: 'linux',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath: safeArchive,
destinationDirectory
})
@@ -160,7 +228,11 @@ describe('Managed Python archive extraction', () => {
}
])))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
artifact: {
platform: 'linux',
archiveFormat: 'tar.gz',
payloadRoot: 'python'
},
archivePath: unsafeArchive,
destinationDirectory: join(directory, 'unsafe-install')
})).rejects.toThrow(/escapes payload root/u)
@@ -88,14 +88,19 @@ function ensureSafeSymlink(relative: string, target: string): void {
}
}
function verifyEntrySet(entries: SafeEntry[]): void {
function verifyEntrySet(
entries: SafeEntry[],
platform: PythonArtifact['platform']
): void {
if (entries.length === 0 || entries.length > MAX_ENTRIES) {
throw new Error('Archive has an invalid number of payload entries')
}
let expanded = 0
const seen = new Set<string>()
for (const entry of entries) {
const key = entry.outputPath.toLowerCase()
const key = platform === 'linux'
? entry.outputPath
: entry.outputPath.toLowerCase()
if (seen.has(key)) {
throw new Error(`Archive contains duplicate path: ${entry.outputPath}`)
}
@@ -107,7 +112,11 @@ function verifyEntrySet(entries: SafeEntry[]): void {
}
}
function readZipEntries(data: Buffer, root: string): SafeEntry[] {
function readZipEntries(
data: Buffer,
root: string,
platform: PythonArtifact['platform']
): SafeEntry[] {
const entries: SafeEntry[] = []
for (let offset = 0; offset + 46 <= data.length;) {
if (data.readUInt32LE(offset) !== zipSignature) {
@@ -146,7 +155,7 @@ function readZipEntries(data: Buffer, root: string): SafeEntry[] {
}
offset = end
}
verifyEntrySet(entries)
verifyEntrySet(entries, platform)
return entries
}
@@ -154,11 +163,12 @@ async function extractNuget(
archivePath: string,
destination: string,
root: string,
platform: PythonArtifact['platform'],
signal?: AbortSignal
): Promise<void> {
ensureNotAborted(signal)
const data = await readFile(archivePath)
const entries = readZipEntries(data, root)
const entries = readZipEntries(data, root, platform)
const payloadPaths = new Set(entries.map((entry) => entry.archivePath))
const unzipped = unzipSync(data, {
filter: ({ name }) => payloadPaths.has(name)
@@ -217,6 +227,7 @@ async function extractTar(
archivePath: string,
destination: string,
root: string,
platform: PythonArtifact['platform'],
signal?: AbortSignal
): Promise<void> {
const entries: SafeEntry[] = []
@@ -243,7 +254,7 @@ async function extractTar(
if (validationError) {
throw validationError
}
verifyEntrySet(entries)
verifyEntrySet(entries, platform)
const allowed = new Map(entries.map((entry) => [entry.archivePath, entry]))
await extract({
file: archivePath,
@@ -278,7 +289,10 @@ async function extractTar(
}
export async function extractPythonArtifact(options: {
artifact: Pick<PythonArtifact, 'archiveFormat' | 'payloadRoot'>
artifact: Pick<
PythonArtifact,
'platform' | 'archiveFormat' | 'payloadRoot'
>
archivePath: string
destinationDirectory: string
signal?: AbortSignal
@@ -289,6 +303,7 @@ export async function extractPythonArtifact(options: {
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.artifact.platform,
options.signal
)
} else {
@@ -296,6 +311,7 @@ export async function extractPythonArtifact(options: {
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.artifact.platform,
options.signal
)
}
+1 -1
View File
@@ -36,7 +36,7 @@ describe('packaged release notes', () => {
}
}
const releaseWithLongScenario = parsed.releases.find(
(release) => release.version === '0.12.0'
(release) => release.version === packageSource.version
)
expect(
releaseWithLongScenario?.notes['en-US'].features.some(