diff --git a/src/artifacts/GodotPckArtifactReader.test.ts b/src/artifacts/GodotPckArtifactReader.test.ts index 8522682f39..92c56943e4 100644 --- a/src/artifacts/GodotPckArtifactReader.test.ts +++ b/src/artifacts/GodotPckArtifactReader.test.ts @@ -17,6 +17,7 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { openRegularFile } from "../filesystem/RegularFile.js"; import type { ArtifactEntry } from "./ArtifactReader.js"; +import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js"; import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js"; const writePck = async (bytes: Buffer): Promise => { @@ -262,32 +263,83 @@ describe("GodotPckArtifactReader member streams", () => { ); }); - it("reads members from the admitted descriptor after the path is replaced", async () => { - const root = await createTestTempDirectory("rea-godot-borrowed-source-"); - const selected = join(root, "selected"); - await mkdir(selected); - const path = join(selected, "game"); - await writeFile(path, buildGodotPck(3, members)); + it("rejects stale located facts after the admitted carrier changes", async () => { + const path = await writePck(buildGodotPck(3, members)); const descriptor = await openRegularFile(path, { symlinks: "reject" }); - const { handle } = descriptor; try { - // Replace the selected directory without changing the admitted inode's - // metadata. Moving the file itself would change its validated ctime. - await rename(selected, join(root, "original")); - await mkdir(selected); - await writeFile(path, buildGodotPck(3, [{ path: "other.gd", data: "" }])); - const reader = new GodotPckArtifactReader(path, descriptor); - const entries = await list(reader); - expect(entries.map(({ path: member }) => member)).toEqual([ - "scenes/main.gd", - "project.binary", - "data/é.json", - ]); - await reader.close(); + const location = await new ArtifactEmbeddedFormatProbes( + path, + descriptor, + ).godotPck(); + if (location === undefined) throw new Error("expected a pack location"); + const reader = new GodotPckArtifactReader(path, { + kind: "godot-pck", + source: descriptor, + location, + }); + try { + await writeFile(path, Buffer.from("replaced carrier")); + await expect(list(reader)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } } finally { - await handle.close(); + await descriptor.handle.close(); } }); + + it.each([false, true])( + "reads located pack facts from the admitted descriptor after path replacement (embedded: %s)", + async (embedded) => { + const root = await createTestTempDirectory("rea-godot-borrowed-source-"); + const selected = join(root, "selected"); + await mkdir(selected); + const path = join(selected, "game"); + const bytes = buildGodotPck(3, members); + await writeFile(path, embedded ? embedGodotPck(bytes) : bytes); + const descriptor = await openRegularFile(path, { symlinks: "reject" }); + const { handle } = descriptor; + try { + const location = await new ArtifactEmbeddedFormatProbes( + path, + descriptor, + ).godotPck(); + if (location === undefined) throw new Error("expected a pack location"); + // Replace the selected directory without changing the admitted inode's + // metadata. Moving the file itself would change its validated ctime. + await rename(selected, join(root, "original")); + await mkdir(selected); + await writeFile( + path, + buildGodotPck(3, [{ path: "other.gd", data: "" }]), + ); + const reader = new GodotPckArtifactReader(path, { + kind: "godot-pck", + source: descriptor, + location, + }); + const cancelled = new AbortController(); + cancelled.abort(); + await expect( + reader.entries(cancelled.signal)[Symbol.asyncIterator]().next(), + ).rejects.toMatchObject({ + reason: "cancelled", + }); + const entries = await list(reader); + expect(reader.formatMetadata()?.embedded).toBe(embedded); + expect(entries.map(({ path: member }) => member)).toEqual([ + "scenes/main.gd", + "project.binary", + "data/é.json", + ]); + await reader.close(); + } finally { + await handle.close(); + } + }, + ); }); describe("Godot PCK logical-byte admission", () => { diff --git a/src/artifacts/GodotPckArtifactReader.ts b/src/artifacts/GodotPckArtifactReader.ts index a807cf556a..2901c4e837 100644 --- a/src/artifacts/GodotPckArtifactReader.ts +++ b/src/artifacts/GodotPckArtifactReader.ts @@ -13,7 +13,12 @@ import { } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; -import { locateGodotPck, readPckBytes } from "./GodotPckLocation.js"; +import { + locateGodotPck, + readPckBytes, + type GodotPckAdmission, + type GodotPckLocation, +} from "./GodotPckLocation.js"; import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; @@ -37,12 +42,23 @@ export class GodotPckArtifactReader implements ArtifactReader { readonly #entries = new Map(); readonly #streams = new Set(); readonly #source: ArtifactFileSource; + readonly #location: GodotPckLocation | undefined; #prepared: Promise | undefined; #header: GodotPckMetadata | undefined; #closing = false; - constructor(path: string, admitted?: StableRegularFileDescriptor) { - this.#source = new ArtifactFileSource(path, "Godot PCK", admitted); + constructor( + path: string, + admitted?: StableRegularFileDescriptor | GodotPckAdmission, + ) { + const admission = + admitted !== undefined && "kind" in admitted ? admitted : undefined; + this.#source = new ArtifactFileSource( + path, + "Godot PCK", + admitted !== undefined && "kind" in admitted ? admitted.source : admitted, + ); + this.#location = admission?.location; } formatMetadata(): GodotPckMetadata | undefined { @@ -174,7 +190,12 @@ export class GodotPckArtifactReader implements ArtifactReader { async #prepare(signal?: AbortSignal): Promise { const { handle, initial } = await this.#source.read(signal); - const header = await readGodotPckHeader(handle, initial.size, signal); + const header = await readGodotPckHeader( + handle, + initial.size, + signal, + this.#location, + ); this.#header = header; if (header.directory_status !== "available") return; const cursor = new PckCursor( @@ -298,8 +319,10 @@ const readGodotPckHeader = async ( handle: FileHandle, size: number, signal?: AbortSignal, + admittedLocation?: GodotPckLocation, ): Promise => { - const location = await locateGodotPck(handle, size, signal); + const location = + admittedLocation ?? (await locateGodotPck(handle, size, signal)); if (location === undefined) throw formatFailure("no standalone header or executable trailer"); const { start, end, embedded } = location; diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts index d1e3e4e7a2..2588fb4f0a 100644 --- a/src/artifacts/GodotPckLocation.ts +++ b/src/artifacts/GodotPckLocation.ts @@ -1,3 +1,4 @@ +import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; import type { FileHandle } from "node:fs/promises"; import { hasGodotPckHeader, @@ -13,6 +14,13 @@ export interface GodotPckLocation { readonly embedded: boolean; } +/** Located pack facts borrowed with the descriptor that produced them. */ +export interface GodotPckAdmission { + readonly kind: "godot-pck"; + readonly source: StableRegularFileDescriptor; + readonly location: GodotPckLocation; +} + type DeclaredGodotPckTrailer = Extract< GodotPckTrailerFacts, { readonly state: "declared" } diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 2645156864..9f5f302804 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "./GodotPckLocation.js"; import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; @@ -35,6 +36,7 @@ interface ArtifactReaderConstruction { | StableRegularFileDescriptor | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission | undefined; readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; /** Caller-selected root spelling; only ASAR companion lookup consumes this. */ @@ -90,12 +92,19 @@ export function createArtifactReader( path, admission !== undefined && "kind" in admission && - admission.kind === "pyinstaller" - ? rootSource - : admission, + admission.kind === "node-sea" + ? admission + : rootSource, ); case "godot-pck": - return new GodotPckArtifactReader(path, rootSource); + return new GodotPckArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "godot-pck" + ? admission + : rootSource, + ); case "mach-o-universal": return new MachOSliceArtifactReader( path, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 5b7c8ca33a..6d7cdf89fd 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "../GodotPckLocation.js"; import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; @@ -53,6 +54,7 @@ type RootInventoryClassification = RootClassification & { | StableRegularFileDescriptor | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission | undefined; }; @@ -208,6 +210,7 @@ const classifyRootFormat = async ( | ArtifactOccurrence["artifact_format"] | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission > => { const { handle } = source; const probe = createArtifactClassificationProbe(asarHeaderAdmission); @@ -261,7 +264,8 @@ const classifyRootFormat = async ( (await hasDiskImageSignature(archiveBytes, read)) ) return "dmg"; - if (await embeddedProbes.godotPck(evidence.prefix)) return "godot-pck"; + const pck = await embeddedProbes.godotPck(evidence.prefix); + if (pck !== undefined) return { kind: "godot-pck", source, location: pck }; const fatEvidence = await readRootFatSliceHeaders(handle, evidence, signal); const mzWindowsHeaderSignature = await readRootMzWindowsHeaderSignature( handle, diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index a0f15578c2..23eed04bb2 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "../GodotPckLocation.js"; import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; @@ -23,7 +24,8 @@ export const createReader = ( readerAdmission?: | StableRegularFileDescriptor | NodeSeaAdmission - | PyInstallerAdmission, + | PyInstallerAdmission + | GodotPckAdmission, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, ): ArtifactReader | undefined => {