diff --git a/src/artifacts/ArtifactEmbeddedFormatProbes.ts b/src/artifacts/ArtifactEmbeddedFormatProbes.ts index c638b2d9c0..173de825df 100644 --- a/src/artifacts/ArtifactEmbeddedFormatProbes.ts +++ b/src/artifacts/ArtifactEmbeddedFormatProbes.ts @@ -13,14 +13,17 @@ import { locateNodeSea, type NodeSeaLocation, } from "./nodeSea/NodeSeaLocation.js"; -import { containsPyInstallerArchive } from "./pyinstaller/CArchiveLayout.js"; +import { + findPyInstallerCookie, + type PyInstallerCookie, +} from "./pyinstaller/CArchiveLayout.js"; /** Completed observations only; null means examined absence, omission means unexamined. */ export interface ArtifactEmbeddedFormatProbeFacts { readonly sourceState: RegularFileState; readonly godotPck?: GodotPckLocation | null; readonly nodeSea?: NodeSeaLocation | null; - readonly pyInstaller?: boolean; + readonly pyInstaller?: PyInstallerCookie | null; } /** Share format observations only across admissions of the same unchanged regular file. */ @@ -72,17 +75,19 @@ export class ArtifactEmbeddedFormatProbes { return location; } - async pyInstaller(): Promise { + async pyInstaller(): Promise { abortIfNeeded(this.signal); - if (this.#facts.pyInstaller !== undefined) return this.#facts.pyInstaller; - const found = await containsPyInstallerArchive( + if (this.#facts.pyInstaller !== undefined) + return this.#facts.pyInstaller ?? undefined; + const found = await findPyInstallerCookie( this.source.handle, this.source.initial.size, this.signal, ); abortIfNeeded(this.signal); - this.#facts = { ...this.#facts, pyInstaller: found }; - return found; + const cookie = found?.ok === true ? found.value : undefined; + this.#facts = { ...this.#facts, pyInstaller: cookie ?? null }; + return cookie; } completedFacts(): ArtifactEmbeddedFormatProbeFacts { diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts index c2c15e1f05..aaf74fc3af 100644 --- a/src/artifacts/PyInstallerArtifactReader.ts +++ b/src/artifacts/PyInstallerArtifactReader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { Readable } from "node:stream"; import { finished } from "node:stream/promises"; import { setImmediate } from "node:timers/promises"; @@ -110,6 +111,7 @@ interface LoadedArchive { export class PyInstallerArtifactReader implements ArtifactReader { readonly format = "pyinstaller" as const; readonly #source: ArtifactFileSource; + #admission: PyInstallerAdmission | undefined; #closed = false; readonly #scope = new ArtifactResourceScope(); readonly #cleanup = new ArtifactResourceScope(); @@ -124,8 +126,16 @@ export class PyInstallerArtifactReader implements ArtifactReader { readonly #members = new Map(); readonly #openedPyzSizes = new Map(); - constructor(path: string, source?: StableRegularFileDescriptor) { - this.#source = new ArtifactFileSource(path, "PyInstaller", source); + constructor( + path: string, + source?: StableRegularFileDescriptor | PyInstallerAdmission, + ) { + if (source !== undefined && "kind" in source) { + this.#admission = source; + this.#source = new ArtifactFileSource(path, "PyInstaller", source.source); + } else { + this.#source = new ArtifactFileSource(path, "PyInstaller", source); + } this.#cleanup.retain({ kind: "lease", resource: "PyInstaller private PYZ images", @@ -287,6 +297,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { await this.#cleanup.close(); this.#closed = true; this.#loaded = undefined; + this.#admission = undefined; this.#members.clear(); this.#openedPyzSizes.clear(); } @@ -313,7 +324,8 @@ export class PyInstallerArtifactReader implements ArtifactReader { const { handle, initial } = await this.#source.readSelectedPath(); const size = initial.size; const pathDialect = await carrierPathDialect(handle, size, signal); - const cookie = await this.#cookie(handle, size, signal); + const cookie = + this.#admission?.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) { @@ -339,6 +351,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { if (member.read?.kind === "carchive") this.#charge(member.read.header?.length ?? 0); await this.#source.readSelectedPath(); + this.#admission = undefined; this.#metadata = cookieMetadata(cookie, toc, encrypted); let opaqueImages = 0; for (const image of images.values()) diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 96bf9888f7..2645156864 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { AsarArtifactReader } from "./AsarArtifactReader.js"; @@ -33,6 +34,7 @@ interface ArtifactReaderConstruction { readonly admission?: | StableRegularFileDescriptor | NodeSeaAdmission + | PyInstallerAdmission | undefined; readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; /** Caller-selected root spelling; only ASAR companion lookup consumes this. */ @@ -75,9 +77,23 @@ export function createArtifactReader( : { selectedPath: options.selectedPath }), }); case "pyinstaller": - return new PyInstallerArtifactReader(path, rootSource); + return new PyInstallerArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "pyinstaller" + ? admission + : rootSource, + ); case "node-sea": - return new NodeSeaArtifactReader(path, admission); + return new NodeSeaArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "pyinstaller" + ? rootSource + : admission, + ); case "godot-pck": return new GodotPckArtifactReader(path, rootSource); case "mach-o-universal": diff --git a/src/artifacts/extraction/ArtifactExtraction.test.ts b/src/artifacts/extraction/ArtifactExtraction.test.ts index 51c39ce3e3..c4250d3b1a 100644 --- a/src/artifacts/extraction/ArtifactExtraction.test.ts +++ b/src/artifacts/extraction/ArtifactExtraction.test.ts @@ -375,7 +375,7 @@ it.each([false, true] as const)( vi.mock("../pyinstaller/CArchiveLayout.js", async (importOriginal) => ({ ...(await importOriginal()), - containsPyInstallerArchive: () => + findPyInstallerCookie: () => Promise.reject(new ArtifactReaderFailure("format", "detection failed")), })); diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 047dd9956b..eea3bfbc2a 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -603,7 +603,7 @@ const requireExtractableFormat = async ( (await probes.godotPck()) !== undefined || ((format === "elf" || format === "pe" || format === "mach-o") && ((await probes.nodeSea()) !== undefined || - (await probes.pyInstaller()))); + (await probes.pyInstaller()) !== undefined)); if (pack && input.expectedRootSha256 !== undefined) { const digest = await hashStableRootArtifactHandle( path, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 55ab3a3171..5b7c8ca33a 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; @@ -51,6 +52,7 @@ type RootInventoryClassification = RootClassification & { readonly readerAdmission: | StableRegularFileDescriptor | NodeSeaAdmission + | PyInstallerAdmission | undefined; }; @@ -202,7 +204,11 @@ const classifyRootFormat = async ( signal?: AbortSignal, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, -): Promise => { +): Promise< + | ArtifactOccurrence["artifact_format"] + | NodeSeaAdmission + | PyInstallerAdmission +> => { const { handle } = source; const probe = createArtifactClassificationProbe(asarHeaderAdmission); const chunk = Buffer.alloc(64 * 1024); @@ -292,7 +298,8 @@ const classifyRootFormat = async ( } const packaged = await embeddedProbes.pyInstaller(); abortIfNeeded(signal); - if (packaged) return "pyinstaller"; + if (packaged !== undefined) + return { kind: "pyinstaller", source, cookie: packaged }; } return format; }; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 9cf3cfe18e..a0f15578c2 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import type { @@ -19,7 +20,10 @@ export const createReader = ( path: string, format: ArtifactOccurrence["artifact_format"], environment: Readonly, - readerAdmission?: StableRegularFileDescriptor | NodeSeaAdmission, + readerAdmission?: + | StableRegularFileDescriptor + | NodeSeaAdmission + | PyInstallerAdmission, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, ): ArtifactReader | undefined => { diff --git a/src/artifacts/pyinstaller/CArchiveLayout.ts b/src/artifacts/pyinstaller/CArchiveLayout.ts index b1549d44c9..539e27b7cd 100644 --- a/src/artifacts/pyinstaller/CArchiveLayout.ts +++ b/src/artifacts/pyinstaller/CArchiveLayout.ts @@ -83,16 +83,6 @@ export const isCArchiveDeclaration = (entry: CArchiveEntry): boolean => export const isCArchivePythonModule = (entry: CArchiveEntry): boolean => entry.role === "module" || entry.role === "package"; -/** True when a native executable has a structurally valid CArchive cookie. */ -export const containsPyInstallerArchive = async ( - handle: FileHandle, - fileSize: number, - signal?: AbortSignal, -): Promise => { - const found = await findPyInstallerCookie(handle, fileSize, signal); - return found?.ok === true; -}; - /** Locate a structurally valid cookie. Invalid magic copies are skipped. */ export const findPyInstallerCookie = async ( handle: FileHandle, diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts new file mode 100644 index 0000000000..04d7ec7c10 --- /dev/null +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts @@ -0,0 +1,118 @@ +import { appendFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { withOwnedRegularFile } from "../../filesystem/OwnedRegularFile.js"; +import { ArtifactEmbeddedFormatProbes } from "../ArtifactEmbeddedFormatProbes.js"; +import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; +import { inventoryArtifact } from "../inventory/ArtifactInventory.js"; +import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js"; +import { cArchiveExecutable } from "./CArchive.fixture.js"; +import * as layout from "./CArchiveLayout.js"; + +const fixture = () => + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 314, + pythonLibrary: "libpython3.14.so", + members: [ + { + name: "payload", + typeCode: "x", + payload: Buffer.from("fixture"), + compress: false, + }, + ], + }); + +it("inventories members using the cookie discovered by root classification", async () => { + const root = await createTestTempDirectory( + "rea-pyinstaller-cookie-inventory-", + ); + const path = join(root, "app"); + await writeFile(path, fixture()); + const locate = vi.spyOn(layout, "findPyInstallerCookie"); + const resourceScope = new ArtifactResourceScope(); + try { + const result = await inventoryArtifact(path, { + environment: {}, + resourceScope, + }); + expect(result.manifest.root_format).toBe("pyinstaller"); + expect( + result.occurrences.some( + (entry) => + entry.logical_path === "payload" && entry.hash_status === "verified", + ), + ).toBe(true); + expect(locate).toHaveBeenCalledTimes(1); + } finally { + await resourceScope.close(); + locate.mockRestore(); + } +}); + +it("rejects changed source before consuming an admitted cookie and leaves the borrowed descriptor open", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-cookie-change-"); + const path = join(root, "app"); + await writeFile(path, fixture()); + await withOwnedRegularFile(path, {}, async (source) => { + const cookie = await new ArtifactEmbeddedFormatProbes( + path, + source, + ).pyInstaller(); + if (cookie === undefined) throw new Error("Fixture cookie was not found"); + const reader = new PyInstallerArtifactReader(path, { + kind: "pyinstaller", + source, + cookie, + }); + await appendFile(path, "changed"); + try { + await expect( + (async () => { + for await (const entry of reader.entries()) + throw new Error(`Changed source exposed member ${entry.path}`); + })(), + ).rejects.toMatchObject({ reason: "integrity" }); + } finally { + await reader.close(); + } + await expect(source.handle.stat()).resolves.toMatchObject({ + size: fixture().length + 7, + }); + }); +}); + +it("retains completed cookie facts across unchanged admissions and honors cached cancellation", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-cookie-cache-"); + const path = join(root, "app"); + await writeFile(path, fixture()); + const locate = vi.spyOn(layout, "findPyInstallerCookie"); + try { + await withOwnedRegularFile(path, {}, async (source) => { + const controller = new AbortController(); + const probes = new ArtifactEmbeddedFormatProbes( + path, + source, + controller.signal, + ); + const cookie = await probes.pyInstaller(); + expect(cookie).toMatchObject({ layout: "2.1", pythonVersion: 314 }); + const resumed = new ArtifactEmbeddedFormatProbes( + path, + source, + controller.signal, + probes.completedFacts(), + ); + await expect(resumed.pyInstaller()).resolves.toBe(cookie); + controller.abort(new Error("cancel cached cookie")); + await expect(resumed.pyInstaller()).rejects.toMatchObject({ + reason: "cancelled", + }); + expect(locate).toHaveBeenCalledTimes(1); + }); + } finally { + locate.mockRestore(); + } +}); diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.ts new file mode 100644 index 0000000000..b4f8b4d783 --- /dev/null +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.ts @@ -0,0 +1,9 @@ +import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; +import type { PyInstallerCookie } from "./CArchiveLayout.js"; + +/** Validated cookie facts borrowed with the descriptor that produced them. */ +export interface PyInstallerAdmission { + readonly kind: "pyinstaller"; + readonly source: StableRegularFileDescriptor; + readonly cookie: PyInstallerCookie; +} diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 45b3636190..6ceda198fb 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -266,10 +266,7 @@ it.each([ await writeFile(source, bytes()); const pck = vi.spyOn(pckLocation, "locateGodotPck"); const sea = vi.spyOn(seaLocation, "locateNodeSea"); - const pyInstaller = vi.spyOn( - pyInstallerLocation, - "containsPyInstallerArchive", - ); + const pyInstaller = vi.spyOn(pyInstallerLocation, "findPyInstallerCookie"); const hashRoot = rootHash.hashStableRootArtifactHandle; let probesBeforeHash: number[] | undefined; const hash = vi @@ -310,6 +307,10 @@ it.each([ expect(extracted.manifest.root_format).toBe(format); expect(extracted.artifacts.length).toBeGreaterThan(0); expect(probesBeforeHash).toEqual(probes); + // Preflight's cookie also supplies inventory. Extraction opens its + // own reader and establishes that reader's cookie independently. + if (format === "pyinstaller") + expect(pyInstaller).toHaveBeenCalledTimes(2); }, ); } finally {