fix(ghidra): retain executable header read and cleanup ownership

Read bounded stable prefixes through the installation file owner, preserve unknown header diagnostics, and propagate cancellation or incomplete cleanup. Share extent and identity validation with full artifact reads.

Validated with 269 focused tests, check:fast, and docs:check. Controlled Darwin file faults reproduce the original lost owner; real Ghidra and native Windows/Linux verification remain unperformed.

Refs #4676
This commit is contained in:
morluto committed 2026-10-17 12:17:44 +08:00
1 parent 5112fe07ae
commit e31aa72c9d
7 files changed
+609 -66

No files matched your search

+6
View File
@@ -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.
+66 -10
View File
@@ -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<Buffer> => {
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<Buffer> => {
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<void> => {
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;
};
+6
View File
@@ -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"
+88
View File
@@ -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<Result<GhidraExecutableHeaderProbe, AnalysisError>> => {
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"),
},
}),
}),
);
}
};
+90 -12
View File
@@ -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);
},
);
+58 -44
View File
@@ -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<GhidraExecutableHeaderProbe | undefined, AnalysisError>
| Promise<Result<GhidraExecutableHeaderProbe | undefined, AnalysisError>>;
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);
@@ -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<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
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<typeof file.read>) => {
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<void>) | 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();
}
},
);