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
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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user