mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
import type { Readable } from "node:stream";
|
|
import { finished } from "node:stream/promises";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { normalizeArtifactPath } from "./ArtifactPaths.js";
|
|
import { lstat, type FileHandle } from "node:fs/promises";
|
|
|
|
import type { PyInstallerMetadata } from "../domain/artifactFormatMetadata.js";
|
|
import {
|
|
cookieMetadata,
|
|
scriptPythonMagic,
|
|
} from "./pyinstaller/PyInstallerMetadata.js";
|
|
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import {
|
|
RegularFileAdmissionFailure,
|
|
openRegularFile,
|
|
sameRegularFileState,
|
|
type StableRegularFileDescriptor,
|
|
} from "../filesystem/RegularFile.js";
|
|
import {
|
|
ArtifactReaderFailure,
|
|
sameArtifactEntry,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
type ArtifactReaderTraversal,
|
|
type ArtifactTraversalOptions,
|
|
} from "./ArtifactReader.js";
|
|
import { abortIfNeeded, hashReadable } from "./ArtifactHash.js";
|
|
import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
|
|
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
|
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
|
|
import { PyInstallerPyzStore } from "./pyinstaller/PyInstallerPyzStore.js";
|
|
import { pyInstallerMemberStream } from "./pyinstaller/PyInstallerMemberStream.js";
|
|
import {
|
|
findPyInstallerCookie,
|
|
readCArchiveToc,
|
|
type CArchiveEntry,
|
|
type PyInstallerCookie,
|
|
} from "./pyinstaller/CArchiveLayout.js";
|
|
import {
|
|
hasEncryptionMarker,
|
|
projectPyzContainer,
|
|
projectPyzMember,
|
|
projectStoredMember,
|
|
sealMember,
|
|
type PyInstallerMember,
|
|
type PyzImage,
|
|
} from "./pyinstaller/CArchiveMembers.js";
|
|
import { readPyzArchive } from "./pyinstaller/PyzArchive.js";
|
|
import type { PyInstallerFailure } from "./pyinstaller/PyInstallerFailure.js";
|
|
import { pyInstallerFileAdmissionFailure } from "./pyinstaller/PyInstallerFileAdmission.js";
|
|
|
|
const MAX_PARSED_FACT_BYTES = 32 * 1024 * 1024;
|
|
const MAX_RETAINED_ENTRY_BYTES = 64 * 1024 * 1024;
|
|
const RECORD_OWNERSHIP_RESERVATION = 256;
|
|
// Conservative allowance for registry/case trie nodes, maps and traversal state.
|
|
// Repeated prefixes are charged again rather than underestimating retained work.
|
|
const PATH_COMPONENT_RESERVATION = 1024;
|
|
|
|
interface LoadedArchive {
|
|
readonly members: readonly PyInstallerMember[];
|
|
readonly traversal: ArtifactReaderTraversal;
|
|
}
|
|
|
|
/**
|
|
* Read-only inventory of a PyInstaller CArchive appended to a native executable.
|
|
* Encrypted PYZ entries are reported and not decoded. Code members are not decompiled.
|
|
*/
|
|
export class PyInstallerArtifactReader implements ArtifactReader {
|
|
readonly format = "pyinstaller" as const;
|
|
#source: StableRegularFileDescriptor | undefined;
|
|
#opening: Promise<FileHandle> | undefined;
|
|
#closed = false;
|
|
readonly #scope = new ArtifactResourceScope();
|
|
readonly #cleanup = new ArtifactResourceScope();
|
|
readonly #lifecycle = new AbortController();
|
|
#boundSha256: string | undefined;
|
|
#loaded: Promise<LoadedArchive> | undefined;
|
|
#budget = 0;
|
|
#charged = 0;
|
|
#parsedFactBytes = 0;
|
|
#retainedEntryBytes = 0;
|
|
#metadata: PyInstallerMetadata | undefined;
|
|
readonly #pyzStore = new PyInstallerPyzStore();
|
|
readonly #members = new Map<string, PyInstallerMember>();
|
|
readonly #openedPyzSizes = new Map<string, number>();
|
|
|
|
constructor(
|
|
private readonly path: string,
|
|
source?: StableRegularFileDescriptor,
|
|
) {
|
|
this.#source = source;
|
|
this.#cleanup.retain({
|
|
kind: "lease",
|
|
resource: "PyInstaller private PYZ images",
|
|
lease: { release: () => this.#pyzStore.close() },
|
|
});
|
|
}
|
|
|
|
formatMetadata(): PyInstallerMetadata | undefined {
|
|
return this.#metadata === undefined ? undefined : { ...this.#metadata };
|
|
}
|
|
|
|
async prepareContainer(
|
|
expectedSha256: string,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
const active = this.#signal(signal);
|
|
await this.#scope.run(async () => {
|
|
const handle = await this.#openHandle(active);
|
|
await this.#verifySource();
|
|
const observed =
|
|
this.#boundSha256 ??
|
|
(
|
|
await hashReadable(
|
|
readArtifactFileChunks(handle, { signal: active }),
|
|
active,
|
|
)
|
|
).sha256;
|
|
await this.#verifySource();
|
|
abortIfNeeded(active);
|
|
if (observed !== expectedSha256)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"PyInstaller carrier changed after inventory",
|
|
undefined,
|
|
artifactIntegrityDetails(
|
|
".",
|
|
{ algorithm: "sha256", digest: expectedSha256 },
|
|
observed,
|
|
false,
|
|
),
|
|
);
|
|
this.#boundSha256 = observed;
|
|
});
|
|
}
|
|
|
|
async *entries(
|
|
signal?: AbortSignal,
|
|
options?: ArtifactTraversalOptions,
|
|
): AsyncIterable<ArtifactEntry> {
|
|
const active = this.#signal(signal);
|
|
const loaded = await this.#scope.run(async () => {
|
|
const value = await this.#load(active);
|
|
await this.#verifySource();
|
|
this.#members.clear();
|
|
return value;
|
|
});
|
|
options?.onCoverage?.(loaded.traversal);
|
|
for (const member of loaded.members) {
|
|
abortIfNeeded(active);
|
|
const sealed = sealMember(member);
|
|
this.#members.set(sealed.entry.adapterKey, sealed);
|
|
yield sealed.entry;
|
|
}
|
|
}
|
|
|
|
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
|
const active = this.#signal(signal);
|
|
return this.#scope.run(async () => {
|
|
const stored = this.#members.get(entry.adapterKey);
|
|
if (stored === undefined || !sameArtifactEntry(entry, stored.entry))
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`PyInstaller entry was not produced by this reader: ${entry.path}`,
|
|
);
|
|
if (entry.kind !== "file")
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`PyInstaller entry is not a regular file: ${entry.path}`,
|
|
);
|
|
if (entry.encrypted)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`PYZ entry is encrypted and was not decoded: ${entry.path}`,
|
|
);
|
|
const read = stored.read;
|
|
if (read === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`PyInstaller entry has no readable bytes: ${entry.path}`,
|
|
);
|
|
await this.#verifySource();
|
|
const knownSize =
|
|
read.kind === "pyz"
|
|
? (read.uncompressedSize ??
|
|
this.#openedPyzSizes.get(entry.adapterKey))
|
|
: undefined;
|
|
if (read.kind === "pyz" && knownSize === undefined)
|
|
this.#charge(read.header?.length ?? 0);
|
|
const parts = await this.#pyzStore.memberSource(
|
|
read,
|
|
this.#source?.handle,
|
|
active,
|
|
);
|
|
if (active.aborted) {
|
|
parts.source.destroy();
|
|
await finished(parts.source, { cleanup: true }).catch(() => undefined);
|
|
abortIfNeeded(active);
|
|
}
|
|
// Keep one close attempt in this read. Failed owners stay in the store
|
|
// for reader.close() to retry, without losing the original failure.
|
|
let closeAttempt: Promise<void> | undefined;
|
|
const closeSource = (primary?: unknown) =>
|
|
(closeAttempt ??= parts.close?.(primary));
|
|
const stream = pyInstallerMemberStream(parts.source, {
|
|
sourceBytes:
|
|
read.kind === "carchive" ? read.source.compressedSize : read.length,
|
|
compressed:
|
|
read.kind === "pyz"
|
|
? read.compressed
|
|
: read.source.compressionFlag === 1,
|
|
header: read.header,
|
|
expectedBytes:
|
|
read.kind === "carchive" ? read.source.uncompressedSize : knownSize,
|
|
maxBytes:
|
|
read.kind === "carchive"
|
|
? read.source.uncompressedSize
|
|
: (knownSize ?? this.#budget - this.#charged),
|
|
signal: active,
|
|
...(read.kind === "pyz" && knownSize === undefined
|
|
? { charge: (bytes: number) => this.#charge(bytes) }
|
|
: {}),
|
|
complete: async (bytes) => {
|
|
try {
|
|
await parts.verify?.();
|
|
await this.#verifySource();
|
|
abortIfNeeded(active);
|
|
} catch (cause: unknown) {
|
|
await closeSource(cause);
|
|
throw cause;
|
|
}
|
|
await closeSource();
|
|
abortIfNeeded(active);
|
|
if (read.kind === "pyz" && knownSize === undefined)
|
|
this.#openedPyzSizes.set(entry.adapterKey, bytes);
|
|
},
|
|
});
|
|
void this.#scope
|
|
.run(async () => {
|
|
let primary: unknown;
|
|
try {
|
|
await stream.completion;
|
|
} catch (cause: unknown) {
|
|
primary = cause;
|
|
}
|
|
await closeSource(primary);
|
|
if (primary !== undefined) throw primary;
|
|
})
|
|
.catch((cause: unknown) =>
|
|
stream.output.destroy(
|
|
cause instanceof Error
|
|
? cause
|
|
: new Error("PyInstaller stream failed", { cause }),
|
|
),
|
|
);
|
|
return stream.output;
|
|
});
|
|
}
|
|
|
|
provenance(): readonly [] {
|
|
return [];
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.#closed = true;
|
|
const closing = new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"PyInstaller reader closed",
|
|
);
|
|
this.#lifecycle.abort(closing);
|
|
await this.#scope.close();
|
|
await this.#cleanup.close();
|
|
this.#source = undefined;
|
|
this.#closed = true;
|
|
this.#loaded = undefined;
|
|
this.#members.clear();
|
|
this.#openedPyzSizes.clear();
|
|
}
|
|
|
|
#signal(signal?: AbortSignal): AbortSignal {
|
|
abortIfNeeded(signal);
|
|
if (this.#closed)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"PyInstaller archive is closed",
|
|
);
|
|
return signal === undefined
|
|
? this.#lifecycle.signal
|
|
: AbortSignal.any([signal, this.#lifecycle.signal]);
|
|
}
|
|
|
|
async #load(signal?: AbortSignal): Promise<LoadedArchive> {
|
|
this.#loaded ??= this.#parse(signal);
|
|
return this.#loaded;
|
|
}
|
|
|
|
async #parse(signal?: AbortSignal): Promise<LoadedArchive> {
|
|
const handle = await this.#openHandle(signal);
|
|
await this.#verifySource();
|
|
const size = (await handle.stat()).size;
|
|
const cookie = await this.#cookie(handle, size, signal);
|
|
this.#budget = artifactExpansionBudgetBytes(cookie.packageLength);
|
|
const toc = await this.#toc(handle, cookie, signal);
|
|
for (const entry of toc) {
|
|
this.#charge(entry.uncompressedSize);
|
|
this.#reserveMetadata({ ...entry }, false);
|
|
}
|
|
const images = await this.#pyzImages(handle, cookie, toc, signal);
|
|
const magic = scriptPythonMagic(
|
|
cookie,
|
|
[...images.values()].map((image) => image.archive),
|
|
);
|
|
const encrypted = hasEncryptionMarker(toc);
|
|
const members = await this.#project(
|
|
toc,
|
|
cookie,
|
|
images,
|
|
magic,
|
|
encrypted,
|
|
signal,
|
|
);
|
|
for (const member of members)
|
|
if (member.read?.kind === "carchive")
|
|
this.#charge(member.read.header?.length ?? 0);
|
|
await this.#verifySource();
|
|
this.#metadata = cookieMetadata(cookie, toc, encrypted);
|
|
let opaqueImages = 0;
|
|
for (const image of images.values())
|
|
if (image.archive.kind === "opaque") opaqueImages++;
|
|
return {
|
|
members,
|
|
traversal: {
|
|
status: opaqueImages === 0 ? "complete" : "unknown",
|
|
limitations:
|
|
opaqueImages === 0
|
|
? []
|
|
: [
|
|
`${String(opaqueImages)} PYZ archives remain opaque; module traversal is unknown. See opaque PYZ occurrences for source magic and per-archive limitations.`,
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
async #openHandle(signal?: AbortSignal): Promise<FileHandle> {
|
|
abortIfNeeded(signal);
|
|
if (this.#closed)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"PyInstaller archive is closed",
|
|
);
|
|
if (this.#source !== undefined) return this.#source.handle;
|
|
// Admission belongs to the reader. A departing caller cannot cancel a peer
|
|
// or replace a late descriptor owner; every caller joins the same admission.
|
|
this.#opening ??= this.#admit();
|
|
let handle: FileHandle;
|
|
try {
|
|
handle = await this.#opening;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof ArtifactReaderFailure &&
|
|
cause.reason === "cancelled" &&
|
|
signal?.aborted
|
|
)
|
|
throw new ArtifactReaderFailure("cancelled", cause.message, {
|
|
cause: signal.reason,
|
|
cleanup: cause.cleanup,
|
|
cleanupLeases: cause.cleanupLeases,
|
|
});
|
|
throw cause;
|
|
}
|
|
abortIfNeeded(signal);
|
|
return handle;
|
|
}
|
|
|
|
async #admit(): Promise<FileHandle> {
|
|
try {
|
|
const handle = await openRegularFile(this.path, {
|
|
symlinks: "reject",
|
|
signal: this.#lifecycle.signal,
|
|
});
|
|
this.#retainCarrier(new OwnedFileHandle(handle));
|
|
this.#source = { handle, initial: await handle.stat() };
|
|
return handle;
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileAdmissionFailure)
|
|
this.#retainCarrier(cause.owner);
|
|
throw pyInstallerFileAdmissionFailure(this.path, cause);
|
|
}
|
|
}
|
|
|
|
#retainCarrier(handle: OwnedFileHandle): void {
|
|
this.#cleanup.retain({ kind: "file-handle", resource: this.path, handle });
|
|
}
|
|
|
|
async #cookie(
|
|
handle: FileHandle,
|
|
size: number,
|
|
signal?: AbortSignal,
|
|
): Promise<PyInstallerCookie> {
|
|
const found = await findPyInstallerCookie(handle, size, signal);
|
|
if (found === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"PyInstaller CArchive cookie was not found",
|
|
);
|
|
if (!found.ok) throw failure(found.error);
|
|
return found.value;
|
|
}
|
|
|
|
async #toc(
|
|
handle: FileHandle,
|
|
cookie: PyInstallerCookie,
|
|
signal?: AbortSignal,
|
|
): Promise<readonly CArchiveEntry[]> {
|
|
const toc = await readCArchiveToc(handle, cookie, signal);
|
|
if (!toc.ok) throw failure(toc.error);
|
|
return toc.value;
|
|
}
|
|
|
|
async #pyzImages(
|
|
handle: FileHandle,
|
|
cookie: PyInstallerCookie,
|
|
toc: readonly CArchiveEntry[],
|
|
signal: AbortSignal | undefined,
|
|
): Promise<ReadonlyMap<CArchiveEntry, PyzImage>> {
|
|
const images = new Map<CArchiveEntry, PyzImage>();
|
|
// Admit the complete filesystem footprint before the first private write.
|
|
this.#pyzStore.reserveImages(
|
|
toc.reduce(
|
|
(count, entry) =>
|
|
count +
|
|
(entry.typeCode === "z" && entry.compressionFlag === 1 ? 1 : 0),
|
|
0,
|
|
),
|
|
);
|
|
for (const entry of toc) {
|
|
if (entry.typeCode !== "z") continue;
|
|
abortIfNeeded(signal);
|
|
const spooled =
|
|
entry.compressionFlag === 0
|
|
? undefined
|
|
: await this.#pyzStore.spool(
|
|
handle,
|
|
cookie.archiveStart,
|
|
entry,
|
|
signal,
|
|
);
|
|
const source = spooled ?? {
|
|
handle,
|
|
start: cookie.archiveStart + entry.offset,
|
|
};
|
|
const parsed = await readPyzArchive(
|
|
source.handle,
|
|
source.start,
|
|
entry.uncompressedSize,
|
|
signal,
|
|
);
|
|
if (!parsed.ok) throw failure(parsed.error);
|
|
if (parsed.value.kind === "entries")
|
|
for (const item of parsed.value.entries)
|
|
this.#reserveMetadata({ ...item, container: entry.name }, false);
|
|
if (spooled !== undefined) await this.#pyzStore.release(spooled.handle);
|
|
images.set(entry, {
|
|
archive: parsed.value,
|
|
handle: source.handle,
|
|
start: source.start,
|
|
});
|
|
}
|
|
return images;
|
|
}
|
|
|
|
async #project(
|
|
toc: readonly CArchiveEntry[],
|
|
cookie: PyInstallerCookie,
|
|
images: ReadonlyMap<CArchiveEntry, PyzImage>,
|
|
magic: Buffer | undefined,
|
|
encrypted: boolean,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<PyInstallerMember[]> {
|
|
const members: PyInstallerMember[] = [];
|
|
const append = (member: PyInstallerMember): void => {
|
|
this.#reservePathComponents(member.entry.path);
|
|
const entry = {
|
|
...member.entry,
|
|
path: normalizeArtifactPath(member.entry.path),
|
|
};
|
|
this.#reserveMetadata(
|
|
{
|
|
path: entry.path,
|
|
adapter_key: entry.adapterKey,
|
|
format_metadata: entry.formatMetadata ?? null,
|
|
limitations: [...entry.limitations],
|
|
},
|
|
true,
|
|
);
|
|
members.push({ ...member, entry });
|
|
};
|
|
for (const entry of toc) {
|
|
if (members.length % 256 === 0) await setImmediate();
|
|
abortIfNeeded(signal);
|
|
const image = entry.typeCode === "z" ? images.get(entry) : undefined;
|
|
if (image === undefined) {
|
|
append(projectStoredMember(entry, cookie.archiveStart, magic));
|
|
continue;
|
|
}
|
|
append(projectPyzContainer(entry, cookie.archiveStart, image, encrypted));
|
|
if (image.archive.kind !== "entries") continue;
|
|
for (const item of image.archive.entries) {
|
|
if (members.length % 256 === 0) await setImmediate();
|
|
abortIfNeeded(signal);
|
|
append(
|
|
projectPyzMember(
|
|
entry,
|
|
item,
|
|
encrypted,
|
|
image.archive.pythonMagic,
|
|
item.namespace || encrypted
|
|
? undefined
|
|
: {
|
|
handle: image.handle,
|
|
offset: image.start + item.offset,
|
|
length: item.length,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return members;
|
|
}
|
|
|
|
async #verifySource(): Promise<void> {
|
|
const source = this.#source;
|
|
if (source === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"PyInstaller archive is closed",
|
|
);
|
|
const [opened, currentPath] = await Promise.all([
|
|
source.handle.stat(),
|
|
lstat(this.path),
|
|
]);
|
|
if (
|
|
!sameRegularFileState(source.initial, opened) ||
|
|
!sameRegularFileState(source.initial, currentPath)
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`PyInstaller source changed during reading: ${this.path}`,
|
|
);
|
|
}
|
|
|
|
#reservePathComponents(path: string): void {
|
|
const remaining = MAX_RETAINED_ENTRY_BYTES - this.#retainedEntryBytes;
|
|
// Case annotations can carry the path and two prefixes at every component.
|
|
// Reserve escaped text growth as well as registry/case trie nodes and maps.
|
|
const componentCost =
|
|
PATH_COMPONENT_RESERVATION + 6 * jsonValueByteLength(path);
|
|
let reservation = componentCost;
|
|
for (
|
|
let index = 0;
|
|
index < path.length && reservation <= remaining;
|
|
index++
|
|
)
|
|
if (path.charCodeAt(index) === 47) reservation += componentCost;
|
|
if (reservation > remaining)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"PyInstaller paths exceed their retained namespace budget",
|
|
{ boundary: "memory" },
|
|
);
|
|
this.#retainedEntryBytes += reservation;
|
|
}
|
|
|
|
#reserveMetadata(value: JsonValue, projected: boolean): void {
|
|
const used = projected ? this.#retainedEntryBytes : this.#parsedFactBytes;
|
|
const limit = projected ? MAX_RETAINED_ENTRY_BYTES : MAX_PARSED_FACT_BYTES;
|
|
const remaining = limit - used - RECORD_OWNERSHIP_RESERVATION;
|
|
const bytes = remaining < 0 ? 0 : jsonValueByteLength(value, remaining);
|
|
if (remaining < 0 || bytes > remaining)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"PyInstaller directory facts exceed their retained metadata budget",
|
|
{ boundary: "memory" },
|
|
);
|
|
if (projected)
|
|
this.#retainedEntryBytes += bytes + RECORD_OWNERSHIP_RESERVATION;
|
|
else this.#parsedFactBytes += bytes + RECORD_OWNERSHIP_RESERVATION;
|
|
}
|
|
|
|
#charge(bytes: number): void {
|
|
if (
|
|
!Number.isSafeInteger(bytes) ||
|
|
bytes < 0 ||
|
|
this.#charged + bytes > this.#budget
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`PyInstaller members declare ${String(this.#charged + Math.max(0, bytes))} uncompressed bytes, exceeding the decompression budget`,
|
|
);
|
|
this.#charged += bytes;
|
|
}
|
|
}
|
|
|
|
const failure = (error: PyInstallerFailure): ArtifactReaderFailure =>
|
|
new ArtifactReaderFailure(error.reason, error.message);
|