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
This commit is contained in:
morluto
2026-10-23 17:01:53 +08:00
parent 60981e0801
commit 45a411e8f8
4 changed files with 142 additions and 7 deletions
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -211,7 +211,7 @@ export const projectPyzMember = (
return {
entry: {
path: path,
kind: "directory",
kind: "metadata",
declaredSize: 0,
compressedSize: item.length,
executable: false,
@@ -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: {
+135 -1
View File
@@ -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<string> => {
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, [