diff --git a/docs/installation.md b/docs/installation.md index 57eba7949d..16993b4087 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -901,6 +901,12 @@ Properties format limit. Reads preserve Ghidra's Latin-1 byte mapping and check the descriptor and selected name for changes. Permission failures and resource refusals remain explicit; failed descriptor cleanup must succeed before that owner admits another inspection. +Native decompiler compatibility inspection follows the selected executable symlink +and reads at most the first 4096 bytes through the same descriptor owner. It +checks the full extent and selected name for changes without reading the remainder. +An unreadable or unrecognized header leaves compatibility unknown; diagnostics +retain read failures. Cancellation and incomplete cleanup propagate as failures, +and failed cleanup retains the exact descriptor for retry. This coordinate selects only REA's session-private configuration directory, not the installation or an existing user configuration directory. diff --git a/src/filesystem/readStableFileBytes.ts b/src/filesystem/readStableFileBytes.ts index 882a430619..2798bcd3cb 100644 --- a/src/filesystem/readStableFileBytes.ts +++ b/src/filesystem/readStableFileBytes.ts @@ -1,3 +1,4 @@ +import type { BigIntStats } from "node:fs"; import { lstat, stat, type FileHandle } from "node:fs/promises"; import { assertRegularFileActive, @@ -18,15 +19,17 @@ export class RegularFileCapacityError extends Error { } } +interface StableReadOptions { + readonly signal?: AbortSignal; + readonly symlinks: "follow" | "reject"; +} + /** Read an admitted descriptor completely, checking its extent and selected name. */ export const readStableFileBytes = async ( file: FileHandle, path: string, maximumBytes: number, - options: { - readonly signal?: AbortSignal; - readonly symlinks: "follow" | "reject"; - }, + options: StableReadOptions, ): Promise => { if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) throw new RangeError( @@ -36,17 +39,72 @@ export const readStableFileBytes = async ( const before = await file.stat({ bigint: true }); if (before.size > BigInt(maximumBytes)) throw new RegularFileCapacityError(path, maximumBytes, before.size); + const bytes = await readExtent( + file, + path, + Number(before.size), + options.signal, + ); assertRegularFileActive(options.signal); - const bytes = Buffer.alloc(Number(before.size)); + const extra = Buffer.alloc(1); + if ((await file.read(extra, 0, 1, bytes.length)).bytesRead !== 0) + throw new RegularFileChangedError(path); + await assertStableIdentity(file, path, before, options); + return bytes; +}; + +/** Read only the requested prefix, retaining the validated full file extent. */ +export const readStableFilePrefix = async ( + file: FileHandle, + path: string, + maximumBytes: number, + options: StableReadOptions, +): Promise<{ readonly bytes: Buffer; readonly size: bigint }> => { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) + throw new RangeError( + "Maximum prefix bytes must be a nonnegative safe integer", + ); + assertRegularFileActive(options.signal); + const before = await file.stat({ bigint: true }); + const length = Number( + before.size < BigInt(maximumBytes) ? before.size : BigInt(maximumBytes), + ); + const bytes = await readExtent(file, path, length, options.signal); + await assertStableIdentity(file, path, before, options); + return { bytes, size: before.size }; +}; + +const readExtent = async ( + file: FileHandle, + path: string, + length: number, + signal?: AbortSignal, +): Promise => { + assertRegularFileActive(signal); + const bytes = Buffer.alloc(length); let offset = 0; while (offset < bytes.length) { - assertRegularFileActive(options.signal); - const read = await file.read(bytes, offset, bytes.length - offset, offset); + assertRegularFileActive(signal); + const read = await file.read({ + buffer: bytes, + offset, + length: bytes.length - offset, + position: offset, + }); if (read.bytesRead === 0) throw new RegularFileChangedError(path); offset += read.bytesRead; } + assertRegularFileActive(signal); + return bytes; +}; + +const assertStableIdentity = async ( + file: FileHandle, + path: string, + before: BigIntStats, + options: StableReadOptions, +): Promise => { assertRegularFileActive(options.signal); - const extra = Buffer.alloc(1); const named = async () => { try { return await (options.symlinks === "follow" ? stat : lstat)(path, { @@ -65,11 +123,9 @@ export const readStableFileBytes = async ( } }; if ( - (await file.read(extra, 0, 1, offset)).bytesRead !== 0 || !sameRegularFileState(before, await file.stat({ bigint: true })) || !sameRegularFileState(before, await named()) ) throw new RegularFileChangedError(path); assertRegularFileActive(options.signal); - return bytes; }; diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts index 1bd8307b09..55c69d893f 100644 --- a/src/ghidra/GhidraDoctor.ts +++ b/src/ghidra/GhidraDoctor.ts @@ -1,3 +1,4 @@ +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; import type { @@ -39,6 +40,7 @@ export const projectGhidraDoctorInspection = ( check, inspection.installDir !== null, inspection.javaProbeFailure, + inspection.nativeDecompilerHeaderFailure, ), ), ...(readiness !== undefined @@ -121,6 +123,7 @@ const projectCheck = ( check: GhidraInstallationCheck, configured: boolean, javaFailure: GhidraInstallationInspection["javaProbeFailure"], + headerFailure: GhidraInstallationInspection["nativeDecompilerHeaderFailure"], ): DoctorProviderCheck => ({ name: check.name, ok: check.status === "passed", @@ -139,6 +142,9 @@ const projectCheck = ( }, } : {}), + ...(check.name === "native_decompiler" && headerFailure !== undefined + ? { details: { header_failure: projectAnalysisError(headerFailure) } } + : {}), remediation: check.status === "failed" ? check.remediation : null, classification: check.name === "platform" || check.name === "architecture" diff --git a/src/ghidra/GhidraExecutableHeader.ts b/src/ghidra/GhidraExecutableHeader.ts new file mode 100644 index 0000000000..23a8c2632d --- /dev/null +++ b/src/ghidra/GhidraExecutableHeader.ts @@ -0,0 +1,88 @@ +import { createHash } from "node:crypto"; +import type { GhidraExecutableHeaderProbe } from "./GhidraInstallation.js"; +import type { GhidraInstallationFileOptions } from "./GhidraInstallationProperties.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { withArtifactRegularFile } from "../artifacts/ArtifactFileOperation.js"; +import { readStableFilePrefix } from "../filesystem/readStableFileBytes.js"; +import { ghidraFileFailure } from "./GhidraFileArtifacts.js"; + +/** Only this prefix is examined; the remainder is never retained or read. */ +export const GHIDRA_EXECUTABLE_HEADER_BYTES = 4096; + +/** Retain complete prefix observations independently of final descriptor cleanup. */ +export const readGhidraExecutableHeader = async ( + path: string, + options: GhidraInstallationFileOptions, +): Promise> => { + let completedHeader: GhidraExecutableHeaderProbe | undefined; + try { + return ok( + await options.resourceOwner.run(() => + withArtifactRegularFile( + path, + { + retain: (resource) => + options.resourceOwner.retain( + { close: () => resource.lease.release() }, + resource.resource, + ), + }, + options.signal, + async (file) => { + const observed = await readStableFilePrefix( + file, + path, + GHIDRA_EXECUTABLE_HEADER_BYTES, + { + symlinks: "follow", + ...(options.signal === undefined + ? {} + : { signal: options.signal }), + }, + ); + if (observed.size > BigInt(Number.MAX_SAFE_INTEGER)) + throw new AnalysisResourceConstraintError( + "ghidra:installation", + "file-size", + "Executable extent cannot be represented exactly by the header parser", + { + maximum_bytes: Number.MAX_SAFE_INTEGER, + observed_bytes: observed.size.toString(), + }, + { + diagnostics: { path, stage: "native_decompiler_header" }, + retryable: false, + }, + ); + completedHeader = { + bytes: observed.bytes, + size: Number(observed.size), + }; + return completedHeader; + }, + "follow", + ), + ), + ); + } catch (cause: unknown) { + return err( + ghidraFileFailure(cause, "ghidra:installation", { + path, + stage: "native_decompiler_header", + ...(completedHeader === undefined + ? {} + : { + completed_header: { + file_bytes: completedHeader.size, + examined_bytes: completedHeader.bytes.length, + prefix_sha256: createHash("sha256") + .update(completedHeader.bytes) + .digest("hex"), + }, + }), + }), + ); + } +}; diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index cf288015fd..2118c0e30e 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1,6 +1,9 @@ import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js"; import { missingJavaProbe } from "./GhidraInstallation.fixture.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err } from "../domain/result.js"; import { ok as javaProbeOk } from "../domain/result.js"; import { parseGhidraJavaOutput, @@ -17,7 +20,6 @@ import { ghidraJavaEnvironment, discoverGhidraInstallationOnPath, inspectGhidraInstallation, - type GhidraExecutableHeaderProbe, type GhidraInstallationHost, } from "./GhidraInstallation.js"; import { @@ -46,10 +48,8 @@ const WINDOWS_PROPERTIES = `${WINDOWS_INSTALL}\\Ghidra\\application.properties`; const WINDOWS_HEADLESS = `${WINDOWS_INSTALL}\\support\\analyzeHeadless.bat`; const WINDOWS_DECOMPILER = `${WINDOWS_INSTALL}\\Ghidra\\Features\\Decompiler\\os\\win_x86_64\\decompile.exe`; -const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({ - bytes, - size: bytes.length, -}); +const headerProbe = (bytes: Buffer) => + javaProbeOk({ bytes, size: bytes.length }); const JAVA: GhidraJavaObservation = { osArch: "amd64", @@ -1035,7 +1035,9 @@ describe("Ghidra native decompiler admission", () => { architecture: "x64" as const, }, executableHeader: (path: string) => - path === LINUX_DECOMPILER ? headerProbe(elf(1, 1, 8)) : undefined, + path === LINUX_DECOMPILER + ? headerProbe(elf(1, 1, 8)) + : javaProbeOk(undefined), }, { name: "a Linux x86-64 decompiler at the macOS arm64 path", @@ -1046,7 +1048,9 @@ describe("Ghidra native decompiler admission", () => { architecture: "arm64" as const, }, executableHeader: (path: string) => - path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined, + path === MAC_DECOMPILER + ? headerProbe(elf(2, 1, 62)) + : javaProbeOk(undefined), }, { name: "a macOS arm64 decompiler at the Linux x64 path", @@ -1059,7 +1063,7 @@ describe("Ghidra native decompiler admission", () => { executableHeader: (path: string) => path === LINUX_DECOMPILER ? headerProbe(thinMach(0xcffaedfe, 0x0100000c)) - : undefined, + : javaProbeOk(undefined), }, ])("distinguishes $name", async ({ options, executableHeader }) => { const result = await inspectGhidraInstallation( @@ -1091,7 +1095,9 @@ describe("Ghidra native decompiler admission", () => { }, host({ executableHeader: (path: string) => - path === LINUX_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined, + path === LINUX_DECOMPILER + ? headerProbe(elf(2, 1, 62)) + : javaProbeOk(undefined), }), ); expect(result.status).toBe("available"); @@ -1115,7 +1121,7 @@ describe("Ghidra native decompiler compatibility", () => { platform: "linux", architecture: "x64", }, - host({ executableHeader: () => undefined }), + host({ executableHeader: () => javaProbeOk(undefined) }), ); expect(result.status).toBe("available"); if (result.status !== "available") return; @@ -1145,7 +1151,7 @@ describe("Ghidra native decompiler compatibility", () => { osName: "Mac OS X", }), executableHeader: (path: string) => - path === MAC_DECOMPILER ? headerProbe(bytes) : undefined, + path === MAC_DECOMPILER ? headerProbe(bytes) : javaProbeOk(undefined), }), ); expect(result.status).toBe("available"); @@ -1175,7 +1181,9 @@ describe("Ghidra native decompiler compatibility", () => { osName: "Mac OS X", }), executableHeader: (path: string) => - path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined, + path === MAC_DECOMPILER + ? headerProbe(elf(2, 1, 62)) + : javaProbeOk(undefined), }), ); expect(result.status).toBe("unavailable"); @@ -1536,3 +1544,73 @@ it.each([ ).toBe(false); }, ); + +it("retains a failed header observation as unknown in installation and doctor diagnostics", async () => { + const failure = new ProviderAdapterError("ghidra", "ghidra:installation", { + userMessage: "controlled header read failure", + diagnostics: { + path: LINUX_DECOMPILER, + stage: "native_decompiler_header", + system_code: "EIO", + }, + }); + const result = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ executableHeader: () => err(failure) }), + ); + expect(result.status).toBe("available"); + expect(result.nativeDecompilerHeaderFailure).toBe(failure); + expect(ghidraInstallationDiagnostics(result)).toMatchObject({ + native_decompiler_header_failure: { + details: { + operation: "ghidra:installation", + diagnostics: { system_code: "EIO" }, + }, + }, + }); + expect( + projectGhidraDoctorInspection(result).checks.find( + ({ name }) => name === "native_decompiler", + ), + ).toMatchObject({ + ok: true, + detail: expect.stringContaining("header is unknown"), + details: { + header_failure: { details: { diagnostics: { system_code: "EIO" } } }, + }, + }); +}); + +it.each(["cancelled", "cleanup"] as const)( + "propagates header %s instead of treating it as optional unknown compatibility", + async (kind) => { + const reason = { request: "header interrupted" }; + const failure = + kind === "cancelled" + ? new AnalysisCancelledError("ghidra:installation", { cause: reason }) + : new ProviderAdapterError("ghidra", "ghidra:installation", { + cleanup: { + resources: [LINUX_DECOMPILER], + reason: "controlled close refusal", + }, + }); + await expect( + inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ executableHeader: () => err(failure) }), + ), + ).rejects.toBe(failure); + }, +); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 090dfee35f..4f04d33974 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -7,22 +7,16 @@ import { type GhidraJavaProbeFailure, type GhidraJavaProbeOptions, } from "./GhidraJavaProbe.js"; -import type { Result } from "../domain/result.js"; +import { ok, type Result } from "../domain/result.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { readGhidraExecutableHeader } from "./GhidraExecutableHeader.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, } from "./GhidraInstallationProperties.js"; import { constants } from "node:fs"; -import { - accessSync, - closeSync, - fstatSync, - openSync, - readSync, - realpathSync, - statSync, -} from "node:fs"; +import { accessSync, realpathSync, statSync } from "node:fs"; import { delimiter as pathDelimiter, dirname, posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; @@ -84,9 +78,6 @@ const NATIVE_ARCHITECTURES: readonly { { node: "arm64", binary: "arm64" }, ]; -/** Bound installation discovery reads; larger headers remain unknown. */ -const EXECUTABLE_HEADER_PROBE_BYTES = 4096; - /** Compatibility of one present native decompiler with the inspected host. */ type NativeDecompilerCompatibility = | { @@ -210,6 +201,7 @@ interface GhidraInstallationObservation { /** Parsed release identity, never inferred from the installation directory name. */ readonly configurationDirectoryName: string | null; readonly nativeDecompilerPath: string | null; + readonly nativeDecompilerHeaderFailure?: AnalysisError; readonly javaCommand: string; readonly javaProbeFailure?: GhidraJavaProbeFailure; /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ @@ -279,9 +271,15 @@ export interface GhidraInstallationHost { * Read a bounded header prefix of one executable so admission can reject a * tool built for another platform or architecture. An omitted seam, or an * unreadable file, reports the header as unknown and never rejects an - * installation that the other checks already accept. + * installation that the other checks already accept. Cancellation and + * incomplete descriptor cleanup propagate as failures. */ - executableHeader?(path: string): GhidraExecutableHeaderProbe | undefined; + executableHeader?( + path: string, + options: GhidraInstallationFileOptions, + ): + | Result + | Promise>; probeJava( command: string, environment: NodeJS.ProcessEnv, @@ -303,6 +301,10 @@ interface GhidraInstallationCoordinates { readonly applicationPropertiesPath: string | null; readonly analyzeHeadlessPath: string | null; readonly nativeDecompilerPath: string | null; + readonly nativeDecompilerHeader: Result< + GhidraExecutableHeaderProbe | undefined, + AnalysisError + >; readonly properties: GhidraApplicationProperties | undefined; readonly javaCommand: string; readonly java: GhidraJavaObservation | undefined; @@ -354,6 +356,12 @@ export const inspectGhidraInstallation = async ( configurationDirectoryName: coordinates.properties?.configurationDirectoryName ?? null, nativeDecompilerPath: coordinates.nativeDecompilerPath, + ...(coordinates.nativeDecompilerHeader.ok + ? {} + : { + nativeDecompilerHeaderFailure: + coordinates.nativeDecompilerHeader.error, + }), javaCommand: coordinates.javaCommand, ...(coordinates.javaProbeFailure === undefined ? {} @@ -477,6 +485,19 @@ const installationCoordinates = async ( resourceOwner: options.resourceOwner, ...(options.signal === undefined ? {} : { signal: options.signal }), }); + const nativeDecompilerHeader = + nativeDecompilerPath === null || host.executableHeader === undefined + ? ok(undefined) + : await host.executableHeader(nativeDecompilerPath, { + resourceOwner: options.resourceOwner, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + if ( + !nativeDecompilerHeader.ok && + (nativeDecompilerHeader.error instanceof AnalysisCancelledError || + nativeDecompilerHeader.error.cleanupIncomplete) + ) + throw nativeDecompilerHeader.error; const javaCommand = (() => { if (options.javaHome !== undefined) return path.join( @@ -536,6 +557,7 @@ const installationCoordinates = async ( applicationPropertiesPath, analyzeHeadlessPath, nativeDecompilerPath, + nativeDecompilerHeader, properties: properties === undefined ? undefined @@ -595,7 +617,7 @@ const installationChecks = ({ remediation: "Restore support/analyzeHeadless or support/analyzeHeadless.bat from the official Ghidra release.", }), - nativeDecompilerCheck({ coordinates, platform, architecture, host }), + nativeDecompilerCheck({ coordinates, platform, architecture }), javaCheck( coordinates.java, coordinates.javaCommand, @@ -647,6 +669,13 @@ export const ghidraInstallationDiagnostics = ( install_dir: inspection.installDir, analyze_headless_path: inspection.analyzeHeadlessPath, native_decompiler_path: inspection.nativeDecompilerPath, + ...(inspection.nativeDecompilerHeaderFailure === undefined + ? {} + : { + native_decompiler_header_failure: projectAnalysisError( + inspection.nativeDecompilerHeaderFailure, + ), + }), application_properties_path: inspection.applicationPropertiesPath, provider_version: inspection.providerVersion, java_command: inspection.javaCommand, @@ -740,21 +769,25 @@ const nativeDecompilerCheck = ({ coordinates, platform, architecture, - host, }: Pick< GhidraInstallationCheckContext, - "coordinates" | "platform" | "architecture" | "host" + "coordinates" | "platform" | "architecture" >): GhidraInstallationCheck => { const path = coordinates.nativeDecompilerPath; const compatibility = path === null ? undefined - : assessNativeDecompiler( - path, - platform, - architecture, - host.executableHeader?.(path), - ); + : !coordinates.nativeDecompilerHeader.ok + ? { + status: "unknown" as const, + detail: `${path} header is unknown: ${coordinates.nativeDecompilerHeader.error.message}`, + } + : assessNativeDecompiler( + path, + platform, + architecture, + coordinates.nativeDecompilerHeader.value, + ); // Ghidra resolves its native decompiler directory from the JVM's os.arch, // not the host process architecture. A JDK running under emulation (for // example x86_64 under Rosetta on Apple silicon) selects a directory that @@ -952,26 +985,7 @@ const javaRemediation = ( export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ readText: readGhidraInstallationProperties, - executableHeader(path) { - let handle: number | undefined; - try { - handle = openSync( - path, - constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY, - ); - const metadata = fstatSync(handle); - if (!metadata.isFile()) return undefined; - const bytes = Buffer.alloc(EXECUTABLE_HEADER_PROBE_BYTES); - const read = readSync(handle, bytes, 0, bytes.length, 0); - return { bytes: bytes.subarray(0, read), size: metadata.size }; - } catch (cause: unknown) { - // best-effort cleanup: a header that cannot be read stays unknown. - void cause; - return undefined; - } finally { - if (handle !== undefined) closeSync(handle); - } - }, + executableHeader: readGhidraExecutableHeader, executable(path) { try { accessSync(path, constants.X_OK); diff --git a/tests/boundary/filesystem/ghidraExecutableHeader.test.ts b/tests/boundary/filesystem/ghidraExecutableHeader.test.ts new file mode 100644 index 0000000000..bf2b2f4aa4 --- /dev/null +++ b/tests/boundary/filesystem/ghidraExecutableHeader.test.ts @@ -0,0 +1,295 @@ +import { createHash } from "node:crypto"; +import { + appendFile, + rename, + symlink, + truncate, + writeFile, + type FileHandle, +} from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { + readGhidraExecutableHeader, + GHIDRA_EXECUTABLE_HEADER_BYTES, +} from "../../../src/ghidra/GhidraExecutableHeader.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { createInspectionResources } from "../../fixtures/inspectionResources.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const controls = vi.hoisted(() => ({ + file: undefined as ((handle: FileHandle, path: string) => void) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + controls.file?.(handle, String(args[0])); + return handle; + }, + }; +}); +afterEach(() => { + controls.file = undefined; + vi.restoreAllMocks(); +}); + +it.each([0, 23, 8192])( + "reads the complete bounded prefix and full extent through short native reads (%i bytes)", + async (length) => { + const root = await createTestTempDirectory("rea-header-prefix-"); + const path = join(root, "decompile"); + const bytes = Buffer.alloc(length, 0x42); + await writeFile(path, bytes); + let bytesRead = 0; + controls.file = (file) => { + const read = file.read.bind(file); + vi.spyOn(file, "read").mockImplementation(async (options) => { + const result = await read({ + ...options, + length: Math.min(options?.length ?? 0, 7), + }); + bytesRead += result.bytesRead; + return result; + }); + }; + const resources = createInspectionResources(); + try { + expect( + await readGhidraExecutableHeader(path, { + resourceOwner: resources.owner, + }), + ).toEqual({ + ok: true, + value: { + bytes: bytes.subarray(0, GHIDRA_EXECUTABLE_HEADER_BYTES), + size: length, + }, + }); + expect(bytesRead).toBe(Math.min(length, GHIDRA_EXECUTABLE_HEADER_BYTES)); + } finally { + await resources.close(); + } + }, +); +it.skipIf(process.platform === "win32")( + "follows the caller-selected executable symlink", + async () => { + const root = await createTestTempDirectory("rea-header-symlink-"); + const path = join(root, "actual"), + selected = join(root, "selected"); + await writeFile(path, "selected executable"); + await symlink(path, selected); + const resources = createInspectionResources(); + try { + expect( + await readGhidraExecutableHeader(selected, { + resourceOwner: resources.owner, + }), + ).toMatchObject({ + ok: true, + value: { bytes: Buffer.from("selected executable"), size: 19 }, + }); + } finally { + await resources.close(); + } + }, +); + +it.each(["replace", "grow", "shrink", "disappear"] as const)( + "rejects a changed executable prefix (%s)", + async (change) => { + const root = await createTestTempDirectory("rea-header-change-"); + const path = join(root, "decompile"), + replacement = join(root, "replacement"); + await writeFile(path, "original"); + await writeFile(replacement, "original"); + controls.file = (file) => { + const read = file.read.bind(file); + vi.spyOn(file, "read").mockImplementationOnce( + async (...args: Parameters) => { + const result = await read(...args); + if (change === "replace") await rename(replacement, path); + else if (change === "disappear") + await rename(path, join(root, "retired")); + else if (change === "grow") await appendFile(path, "growth"); + else await truncate(path, 1); + return result; + }, + ); + }; + const resources = createInspectionResources(); + try { + expect( + await readGhidraExecutableHeader(path, { + resourceOwner: resources.owner, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError", path }, + }); + } finally { + await resources.close(); + } + }, +); + +it.each(["completed", "physical", "close"] as const)( + "retains the exact descriptor and primary observation across final-close cancellation (%s)", + async (outcome) => { + const root = await createTestTempDirectory("rea-header-retry-"); + const path = join(root, "decompile"), + bytes = Buffer.from("executable prefix"); + await writeFile(path, bytes); + const resources = createInspectionResources(), + controller = new AbortController(); + const reason = { request: "cancel header finalization" }; + const physical = Object.assign( + new Error("controlled header read failure"), + { code: "EIO", syscall: "read" }, + ); + let denied = true, + closes = 0, + opens = 0; + let actualClose: (() => Promise) | undefined; + controls.file = (file) => { + opens++; + actualClose = file.close.bind(file); + if (outcome === "physical") + vi.spyOn(file, "read").mockRejectedValue(physical); + vi.spyOn(file, "close").mockImplementation(async () => { + closes++; + if (outcome !== "close") controller.abort(reason); + if (denied) throw new Error("controlled header close failure"); + await actualClose?.(); + }); + }; + try { + const result = await readGhidraExecutableHeader(path, { + resourceOwner: resources.owner, + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected retained failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([path]); + if (outcome === "completed") + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + diagnostics: { + completed_header: { + file_bytes: bytes.length, + examined_bytes: bytes.length, + prefix_sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }); + else if (outcome === "close") + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { + completed_header: { + file_bytes: bytes.length, + examined_bytes: bytes.length, + }, + }, + }); + else + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { + system_code: "EIO", + syscall: "read", + reason: physical.message, + }, + }); + await expect(resources.close()).rejects.toThrow(); + expect(closes).toBe(2); + await rename(path, join(root, "retired")); + await writeFile(path, "replacement"); + denied = false; + await resources.close(); + expect(closes).toBe(3); + expect(opens).toBe(1); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + denied = false; + await resources.close(); + await actualClose?.(); + } + }, +); + +it("does not open or adopt foreign resources from an opaque pre-aborted reason", async () => { + const resources = createInspectionResources(), + controller = new AbortController(); + const reason = new ProviderCleanupError("foreign", ["foreign-resource"], { + reason: "caller-owned", + }); + controller.abort(reason); + let opens = 0; + controls.file = () => { + opens++; + }; + try { + expect( + await readGhidraExecutableHeader("/not-opened", { + resourceOwner: resources.owner, + signal: controller.signal, + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + }, + }); + await resources.close(); + expect(opens).toBe(0); + expect(reason.cleanupIncomplete).toBe(true); + } finally { + await resources.close(); + } +}); + +it.each([null, "caller stopped header inspection"])( + "retains completed prefix facts after successful final close and cancellation (%j)", + async (reason) => { + const root = await createTestTempDirectory("rea-header-late-cancel-"); + const path = join(root, "decompile"); + await writeFile(path, "header"); + const controller = new AbortController(), + resources = createInspectionResources(); + controls.file = (file) => { + const close = file.close.bind(file); + vi.spyOn(file, "close").mockImplementation(async () => { + await close(); + controller.abort(reason); + }); + }; + try { + expect( + await readGhidraExecutableHeader(path, { + resourceOwner: resources.owner, + signal: controller.signal, + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + diagnostics: { + completed_header: { file_bytes: 6, examined_bytes: 6 }, + }, + }, + }); + } finally { + await resources.close(); + } + }, +);