mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(pyinstaller): retain colliding archive observations
Keep source-distinct CArchive rows and decoded PYZ children in inventory. Refuse only when extraction reaches a destination conflict. Fixes #6674
This commit is contained in:
@@ -44,12 +44,18 @@ root deltas. Missing graph members yield `truncated`; unknown traversal coverage
|
||||
or unavailable occurrence hashes retain uncertainty. Integrity mismatches remain
|
||||
contradictions. Static artifact equality does not establish unchanged runtime
|
||||
behavior.
|
||||
Source-map relation deltas include the content identity of the mapped source,
|
||||
so an unchanged map file is reported when its target source bytes change.
|
||||
|
||||
Exhausted traversals with deliberately omitted filesystem entries retain
|
||||
`unknown` coverage and identify each omitted path/type in `limitations`; FIFO,
|
||||
socket and device entries are not opened. Case-distinct archive members remain
|
||||
separate observations. Their case-insensitive materialization diagnostic does
|
||||
not reduce traversal coverage when both spellings were read.
|
||||
PyInstaller inventory also preserves distinct table rows that share a logical
|
||||
path, including decoded children beneath a colliding file/directory path.
|
||||
Inventory coverage describes observed archive facts; extraction still refuses
|
||||
destinations where those paths cannot coexist.
|
||||
|
||||
Current inventory Evidence requires scoped `coverage` with a `complete` or
|
||||
`unknown` status. Consumers use this producer-owned fact and reject records
|
||||
|
||||
@@ -18,6 +18,13 @@ path rules; the source name retains its spelling, including backslashes.
|
||||
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.
|
||||
CArchive type `b` retains PyInstaller's executable-member declaration in the
|
||||
generic `executable` fact; type `x` remains non-executable. This comes from the
|
||||
archive type code, not the filename or member contents.
|
||||
Distinct CArchive rows retain their table-index identities even when they
|
||||
share a logical path. Inventory can preserve both a file and a decoded PYZ
|
||||
directory at that path, including the PYZ children; extraction refuses a
|
||||
destination conflict instead of silently omitting those children.
|
||||
|
||||
Namespace packages remain metadata declarations at their projected query path;
|
||||
they do not claim a destination directory. Their `relative_offset` and
|
||||
@@ -105,7 +112,8 @@ returns a resource failure.
|
||||
|
||||
Supported final Python bytecode magic values cover Python 3.7 through 3.14.
|
||||
Supported marshal directory values are lists, tuples, strings, integers and
|
||||
bytes. Directory values follow the identified CPython marshal semantics; for
|
||||
bytes, including interned Unicode strings used for module names. Directory
|
||||
values follow the identified CPython marshal semantics; for
|
||||
example, a long integer with a zero most-significant digit is malformed and is
|
||||
not normalized into a valid table value. An unsupported magic or malformed or
|
||||
unsupported directory stays an opaque member with a limitation and
|
||||
|
||||
@@ -123,6 +123,11 @@ const materializeArtifactInventoryInScope = async (
|
||||
const regularPaths = new Set(
|
||||
selectedOccurrences.map(({ logical_path: path }) => path),
|
||||
);
|
||||
const directoryPaths = new Set(
|
||||
snapshot.occurrences
|
||||
.filter(({ entry_kind }) => entry_kind === "directory")
|
||||
.map(({ logical_path }) => logical_path),
|
||||
);
|
||||
const activeOccurrences = selectedOccurrences.filter(({ logical_path }) => {
|
||||
// Nested members remain represented by their containing regular file;
|
||||
// only entries exposed by the active reader are materialized.
|
||||
@@ -133,7 +138,7 @@ const materializeArtifactInventoryInScope = async (
|
||||
slash = path.lastIndexOf("/")
|
||||
) {
|
||||
path = path.slice(0, slash);
|
||||
if (regularPaths.has(path)) return false;
|
||||
if (regularPaths.has(path) && !directoryPaths.has(path)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -198,10 +198,14 @@ const visitArtifactEntries = async (
|
||||
: `${frame.prefix}/${entry.path}`,
|
||||
);
|
||||
const expandableAsar = isExpandableAsar(entry, logicalPath);
|
||||
context.registry.add(
|
||||
logicalPath,
|
||||
expandableAsar ? "directory" : entry.kind,
|
||||
);
|
||||
// PyInstaller TOC rows are source-distinct even when their logical
|
||||
// destinations collide. Inventory retains those observations; the
|
||||
// extraction path registry remains the authority for materialization.
|
||||
if (frame.reader.format !== "pyinstaller")
|
||||
context.registry.add(
|
||||
logicalPath,
|
||||
expandableAsar ? "directory" : entry.kind,
|
||||
);
|
||||
const duplicateOrdinal =
|
||||
context.occurrenceCountByPath.get(logicalPath) ?? 0;
|
||||
context.occurrenceCountByPath.set(logicalPath, duplicateOrdinal + 1);
|
||||
|
||||
@@ -320,7 +320,6 @@ const validateCArchiveMembers = (
|
||||
entries: readonly CArchiveEntry[],
|
||||
tocOffset: number,
|
||||
): Result<null, PyInstallerFailure> => {
|
||||
const names = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (isCArchiveDeclaration(entry)) {
|
||||
if (
|
||||
@@ -334,15 +333,6 @@ const validateCArchiveMembers = (
|
||||
`CArchive declaration at table index ${String(entry.index)} has local payload or compression`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (names.has(entry.name))
|
||||
return err(
|
||||
pyInstallerFailure(
|
||||
"integrity",
|
||||
`Duplicate CArchive member name at table index ${String(entry.index)}`,
|
||||
),
|
||||
);
|
||||
names.add(entry.name);
|
||||
}
|
||||
if (
|
||||
entry.compressionFlag === 0 &&
|
||||
|
||||
@@ -26,6 +26,44 @@ const sha256 = (bytes: Buffer): string =>
|
||||
const derived = (payload: Buffer): Buffer =>
|
||||
Buffer.concat([reconstructedPycHeader(PYTHON_39_MAGIC), payload]);
|
||||
|
||||
const internedString = (value: string): Buffer => {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeInt32LE(bytes.length);
|
||||
return Buffer.concat([Buffer.from([0x74]), length, bytes]);
|
||||
};
|
||||
|
||||
const marshalInteger = (value: number): Buffer => {
|
||||
const integer = Buffer.alloc(5);
|
||||
integer[0] = 0x69;
|
||||
integer.writeInt32LE(value, 1);
|
||||
return integer;
|
||||
};
|
||||
|
||||
const marshalTuple = (values: readonly Buffer[]): Buffer =>
|
||||
Buffer.concat([Buffer.from([0x29, values.length]), ...values]);
|
||||
|
||||
const pyzWithInternedModuleName = (name: string, body: Buffer): Buffer => {
|
||||
const ordinary = pyzArchive({
|
||||
magic: PYTHON_39_MAGIC,
|
||||
modules: [{ name: "cafe", typeCode: 0, body }],
|
||||
});
|
||||
const tableOffset = ordinary.readUInt32BE(8);
|
||||
const table = Buffer.concat([
|
||||
Buffer.from([0x5b]),
|
||||
marshalInteger(1),
|
||||
marshalTuple([
|
||||
internedString(name),
|
||||
marshalTuple([
|
||||
marshalInteger(0),
|
||||
marshalInteger(12),
|
||||
marshalInteger(tableOffset - 12),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
return Buffer.concat([ordinary.subarray(0, tableOffset), table]);
|
||||
};
|
||||
|
||||
const writeArchive = async (name: string, bytes: Buffer): Promise<string> => {
|
||||
const directory = await createTestTempDirectory("rea-pyinstaller-");
|
||||
const path = join(directory, name);
|
||||
@@ -131,6 +169,81 @@ it("inventories a 2.1 archive, nested PYZ modules, and derived bytecode", async
|
||||
expect(scriptEdge?.relation).toBe("contains");
|
||||
});
|
||||
|
||||
it("decodes an interned Unicode module name from the PYZ table", async () => {
|
||||
const path = await writeArchive(
|
||||
"interned-unicode-module",
|
||||
cArchiveExecutable({
|
||||
layout: "2.1",
|
||||
pythonVersion: 313,
|
||||
members: [
|
||||
{
|
||||
name: "PYZ.pyz",
|
||||
typeCode: "z",
|
||||
payload: pyzWithInternedModuleName(
|
||||
"café",
|
||||
Buffer.from("value = 42\n"),
|
||||
),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const snapshot = await scanCanonicalArtifactInventory(path);
|
||||
const module = snapshot.occurrences.find(
|
||||
({ logical_path }) => logical_path === "PYZ.pyz/café.pyc",
|
||||
);
|
||||
expect(snapshot.coverage.status).toBe("complete");
|
||||
expect(module).toMatchObject({
|
||||
entry_kind: "file",
|
||||
artifact_id: expect.any(String),
|
||||
format_metadata: {
|
||||
kind: "pyinstaller-entry",
|
||||
source_name: "café",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the CArchive binary type as executable metadata", async () => {
|
||||
const path = await writeArchive(
|
||||
"executable-member",
|
||||
cArchiveExecutable({
|
||||
layout: "2.1",
|
||||
pythonVersion: 314,
|
||||
members: [
|
||||
{
|
||||
name: "helper.sh",
|
||||
typeCode: "b",
|
||||
payload: Buffer.from("#!/bin/sh\nexit 0\n"),
|
||||
compress: false,
|
||||
},
|
||||
{
|
||||
name: "healthy.txt",
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("healthy\n"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const snapshot = await scanCanonicalArtifactInventory(path);
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
({ logical_path }) => logical_path === "helper.sh",
|
||||
),
|
||||
).toMatchObject({
|
||||
executable: true,
|
||||
format_metadata: { type_code: "b" },
|
||||
});
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
({ logical_path }) => logical_path === "healthy.txt",
|
||||
),
|
||||
).toMatchObject({
|
||||
executable: false,
|
||||
format_metadata: { type_code: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records a 2.0 cookie without a Python library name", async () => {
|
||||
const path = await writeArchive(
|
||||
"legacy",
|
||||
@@ -1040,7 +1153,8 @@ it("uses known PYZ magic for separate CArchive scripts when the marshal table st
|
||||
expect(await readFile(join(output, "hello"))).toEqual(derived(script));
|
||||
});
|
||||
|
||||
it("rejects duplicate CArchive member names before projecting PYZ children", async () => {
|
||||
it("retains colliding CArchive rows and PYZ children but refuses extraction", async () => {
|
||||
const data = Buffer.from("same-name data member");
|
||||
const image = pyzArchive({
|
||||
magic: PYTHON_39_MAGIC,
|
||||
modules: [{ name: "child", typeCode: 0, body: Buffer.from("child") }],
|
||||
@@ -1055,16 +1169,78 @@ it("rejects duplicate CArchive member names before projecting PYZ children", asy
|
||||
{
|
||||
name: "PYZ.pyz",
|
||||
typeCode: "x",
|
||||
payload: Buffer.alloc(0),
|
||||
payload: data,
|
||||
compress: false,
|
||||
},
|
||||
{
|
||||
name: "sibling.txt",
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("sibling"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(scanCanonicalArtifactInventory(path)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message: "Duplicate CArchive member name at table index 1",
|
||||
const snapshot = await scanCanonicalArtifactInventory(path);
|
||||
const samePath = snapshot.occurrences.filter(
|
||||
({ logical_path }) => logical_path === "PYZ.pyz",
|
||||
);
|
||||
const pyzContainer = samePath.find(
|
||||
({ entry_kind }) => entry_kind === "directory",
|
||||
);
|
||||
const dataMember = samePath.find(({ entry_kind }) => entry_kind === "file");
|
||||
const child = snapshot.occurrences.find(
|
||||
({ logical_path }) => logical_path === "PYZ.pyz/child.pyc",
|
||||
);
|
||||
expect(snapshot.coverage.status).toBe("complete");
|
||||
expect(pyzContainer?.format_metadata).toMatchObject({
|
||||
kind: "pyinstaller-entry",
|
||||
index: 0,
|
||||
type_code: "z",
|
||||
source_name: "PYZ.pyz",
|
||||
});
|
||||
expect(dataMember).toMatchObject({
|
||||
artifact_id: expect.any(String),
|
||||
format_metadata: {
|
||||
kind: "pyinstaller-entry",
|
||||
index: 1,
|
||||
type_code: "x",
|
||||
source_name: "PYZ.pyz",
|
||||
},
|
||||
});
|
||||
expect(child).toMatchObject({
|
||||
entry_kind: "file",
|
||||
parent_occurrence_id: pyzContainer?.occurrence_id,
|
||||
});
|
||||
expect(
|
||||
snapshot.nodes.find(
|
||||
({ artifact_id }) => artifact_id === dataMember?.artifact_id,
|
||||
)?.sha256,
|
||||
).toBe(sha256(data));
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
({ logical_path }) => logical_path === "sibling.txt",
|
||||
),
|
||||
).toMatchObject({ artifact_id: expect.any(String) });
|
||||
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-pyinstaller-colliding-extract-",
|
||||
);
|
||||
const output = join(root, "extracted");
|
||||
await expect(
|
||||
materializeArtifactInventory(
|
||||
{
|
||||
inputPath: path,
|
||||
inputFormat: "elf",
|
||||
outputRoot: output,
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
},
|
||||
path,
|
||||
snapshot,
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "path" });
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("closes each spooled PYZ image after listing and reopens it for a requested module", async () => {
|
||||
|
||||
@@ -198,7 +198,7 @@ it.each([true, false])(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["duplicate", "size", "offset", "overlap"])(
|
||||
it.each(["size", "offset", "overlap"])(
|
||||
"bounds %s diagnostics for large nondeclaration names",
|
||||
async (mode) => {
|
||||
const name = "member".repeat(200_000);
|
||||
@@ -213,12 +213,7 @@ it.each(["duplicate", "size", "offset", "overlap"])(
|
||||
layout: "2.1",
|
||||
pythonVersion: 309,
|
||||
members:
|
||||
mode === "duplicate" || mode === "overlap"
|
||||
? [
|
||||
first,
|
||||
{ ...first, name: mode === "duplicate" ? name : name + "-b" },
|
||||
]
|
||||
: [first],
|
||||
mode === "overlap" ? [first, { ...first, name: name + "-b" }] : [first],
|
||||
});
|
||||
const toc = 64 + bytes.readUInt32BE(bytes.length - 88 + 12);
|
||||
if (mode === "offset")
|
||||
@@ -239,7 +234,7 @@ it.each(["duplicate", "size", "offset", "overlap"])(
|
||||
expect(failure.reason).toBe(mode === "offset" ? "format" : "integrity");
|
||||
expect(failure.message.length).toBeLessThan(128);
|
||||
expect(failure.message).toContain(
|
||||
`table index ${mode === "duplicate" || mode === "overlap" ? 1 : 0}`,
|
||||
`table index ${mode === "overlap" ? 1 : 0}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user