From 45a411e8f8314b844bc20aef57afb59991c4a637 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:01:53 +0800 Subject: [PATCH] fix(pyinstaller): preserve namespace declarations Project PYZ namespace packages as metadata so their query paths do not claim output directories. This lets independent CArchive files retain the same logical path and remain inspectable and extractable. Fixes #6539 --- docs/python-packaged-applications.md | 3 +- src/artifacts/pyinstaller/CArchiveMembers.ts | 2 +- .../PyInstallerFormatBoundaries.test.ts | 8 +- tests/acceptance/pyinstallerArchive.test.ts | 136 +++++++++++++++++- 4 files changed, 142 insertions(+), 7 deletions(-) diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md index 5785def1fb..e4d8d5aaef 100644 --- a/docs/python-packaged-applications.md +++ b/docs/python-packaged-applications.md @@ -19,7 +19,8 @@ the decoded PYZ image for PYZ rows. A CArchive directory record starts at `archive_start + toc_offset + directory_record_offset`; its size is `directory_record_size`. PYZ row directory extents remain unknown. -Namespace packages remain directories. Their `relative_offset` and +Namespace packages remain metadata declarations at their projected query path; +they do not claim a destination directory. Their `relative_offset` and `compressed_size` preserve any payload declared by older producers, while `declared_size` is zero because no bytecode is materialized for a namespace. Every declared span must end before the PYZ table of contents. diff --git a/src/artifacts/pyinstaller/CArchiveMembers.ts b/src/artifacts/pyinstaller/CArchiveMembers.ts index 2047f6e006..ff9bacd190 100644 --- a/src/artifacts/pyinstaller/CArchiveMembers.ts +++ b/src/artifacts/pyinstaller/CArchiveMembers.ts @@ -211,7 +211,7 @@ export const projectPyzMember = ( return { entry: { path: path, - kind: "directory", + kind: "metadata", declaredSize: 0, compressedSize: item.length, executable: false, diff --git a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts index 08cfb4f884..5f92a3702a 100644 --- a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts @@ -661,7 +661,7 @@ it("retains legacy namespace payload extents without materializing them", async expect( inventory.occurrences.find((row) => row.logical_path === "PYZ.pyz/ns"), ).toMatchObject({ - entry_kind: "directory", + entry_kind: "metadata", declared_size: 0, compressed_size: length, format_metadata: { @@ -785,7 +785,7 @@ it.each([false, true])( (row) => row.logical_path === "PYZ.pyz/legacy", ), ).toMatchObject({ - entry_kind: "directory", + entry_kind: "metadata", encrypted: marker, compressed_size: expect.any(Number), format_metadata: { @@ -797,7 +797,7 @@ it.each([false, true])( (row) => row.logical_path === "PYZ.pyz/empty", ), ).toMatchObject({ - entry_kind: "directory", + entry_kind: "metadata", encrypted: false, compressed_size: 0, format_metadata: { encryption_source: null }, @@ -904,7 +904,7 @@ it("retains the producer's nonzero namespace offset without inventing data", asy expect( inventory.occurrences.find((row) => row.logical_path === "PYZ.pyz/ns"), ).toMatchObject({ - entry_kind: "directory", + entry_kind: "metadata", declared_size: 0, compressed_size: 0, format_metadata: { diff --git a/tests/acceptance/pyinstallerArchive.test.ts b/tests/acceptance/pyinstallerArchive.test.ts index 67d4082815..d2926756bb 100644 --- a/tests/acceptance/pyinstallerArchive.test.ts +++ b/tests/acceptance/pyinstallerArchive.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; -import { readFile, readdir, writeFile } from "node:fs/promises"; +import { readFile, readdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -88,6 +88,42 @@ const onefile = async ( return path; }; +const namespacePathCollision = async (): Promise => { + const directory = await createTestTempDirectory("rea-pyinstaller-namespace-"); + const path = join(directory, "collision"); + await writeFile( + path, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 309, + members: [ + { + name: "PYZ.pyz", + typeCode: "z", + payload: pyzArchive({ + magic: PYTHON_39_MAGIC, + modules: [{ name: "ns", typeCode: 3, body: Buffer.alloc(0) }], + }), + compress: false, + }, + { + name: "PYZ.pyz/ns", + typeCode: "x", + payload: Buffer.from("outer data"), + compress: false, + }, + { + name: "healthy.txt", + typeCode: "x", + payload: Buffer.from("healthy sibling"), + compress: false, + }, + ], + }), + ); + return path; +}; + it("lists CArchive members and the Python version through inspect-artifact", async () => { const path = await onefile(); const result = await execute(process.execPath, [ @@ -180,6 +216,104 @@ it("extracts CArchive members through extract-artifact", async () => { ).toBe(false); }); +it("preserves a PYZ namespace declaration beside a physical CArchive file at the same path", async () => { + const path = await namespacePathCollision(); + const inspectResult = await execute(process.execPath, [ + cli, + "inspect-artifact", + path, + "--json", + ]); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(JSON.parse(inspectResult.stdout)).normalized_result, + ); + const inventory = inspection.substeps[0]?.evidence.normalized_result; + expect(inventory?.coverage.status).toBe("complete"); + const namespace = inventory?.occurrences.find( + (row) => + row.logical_path === "PYZ.pyz/ns" && + row.format_metadata?.kind === "pyinstaller-entry" && + row.format_metadata.type_code === 3, + ); + const data = inventory?.occurrences.find( + (row) => + row.logical_path === "PYZ.pyz/ns" && + row.entry_kind === "file" && + row.format_metadata?.kind === "pyinstaller-entry" && + row.format_metadata.type_code === "x", + ); + expect(namespace).toMatchObject({ + entry_kind: "metadata", + artifact_id: null, + format_metadata: { + container: "pyz", + container_name: "PYZ.pyz", + index: 0, + source_name: "ns", + type_code: 3, + }, + }); + expect(data).toMatchObject({ + entry_kind: "file", + hash_status: "verified", + format_metadata: { + container: "carchive", + index: 1, + source_name: "PYZ.pyz/ns", + type_code: "x", + }, + }); + expect(namespace?.occurrence_id).not.toBe(data?.occurrence_id); + const dataNode = inventory?.nodes.find( + (node) => node.artifact_id === data?.artifact_id, + ); + expect(dataNode).toMatchObject({ + sha256: createHash("sha256").update("outer data").digest("hex"), + size: Buffer.byteLength("outer data"), + }); + const sibling = inventory?.occurrences.find( + (row) => row.logical_path === "healthy.txt", + ); + expect(sibling).toMatchObject({ + hash_status: "verified", + format_metadata: { + container: "carchive", + index: 2, + source_name: "healthy.txt", + type_code: "x", + }, + }); + expect( + inventory?.nodes.find((node) => node.artifact_id === sibling?.artifact_id), + ).toMatchObject({ + sha256: createHash("sha256").update("healthy sibling").digest("hex"), + size: Buffer.byteLength("healthy sibling"), + }); + + const extractResult = await execute(process.execPath, [ + cli, + "extract-artifact", + path, + "--json", + ]); + const extracted = artifactExtractionResultSchema.parse( + parseEvidence(JSON.parse(extractResult.stdout)).normalized_result, + ); + try { + expect(extracted.artifacts.map((item) => item.relative_path)).toEqual( + expect.arrayContaining(["PYZ.pyz/ns", "healthy.txt"]), + ); + expect( + await readFile(join(extracted.output_root, "PYZ.pyz", "ns")), + ).toEqual(Buffer.from("outer data")); + expect(await readFile(join(extracted.output_root, "healthy.txt"))).toEqual( + Buffer.from("healthy sibling"), + ); + } finally { + await rm(extracted.output_root, { recursive: true, force: true }); + } +}); + it("reports unknown traversal through the CLI for an opaque PYZ", async () => { const path = await onefile(Buffer.alloc(4), true, true); const result = await execute(process.execPath, [