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, [