Files
rea/tests/boundary/filesystem/ghidraPspExtension.test.ts
T
morluto 150e996a86 refactor(providers)!: await owned readiness observations
Carry asynchronous readiness through provider selection, session status, CLI and MCP. Join observations during cancellation and shutdown, keep status readable during finalization, and retain mixed provider errors and exact cleanup dependencies through the shared inspection failure owner.

Validation: 255 focused tests across 18 files; 21 registry tests rerun with primary restoration assertion; check:fast; docs:check and 5 README conformance tests. Registered MCP and SDK schema checks use controlled providers; no real Ghidra/JVM verification.

This is a prerequisite for #4561. The synchronous Ghidra Java producer and its doctor/client ownership migration remain unfinished.

BREAKING CHANGE: binary_session candidate availability can be unknown during owner finalization. Consumers must handle this state; the internal session status API is now asynchronous.

Refs: #4561
2026-10-17 09:57:43 +08:00

1055 lines
38 KiB
TypeScript

import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { createHash } from "node:crypto";
import type { Dir } from "node:fs";
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { ok as javaProbeOk } from "../../../src/domain/result.js";
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
import {
mkdir,
readFile,
rename,
symlink,
truncate,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { silentLogger } from "../../../src/logger.js";
import type {
BinaryTarget,
MipsAbiFlags,
} from "../../../src/domain/binaryTargetTypes.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { ghidraProcessorUnsupportedReason } from "../../../src/ghidra/GhidraPspProfile.js";
import { inspectGhidraPspExtension } from "../../../src/ghidra/GhidraPspExtension.js";
import {
GhidraHeadlessLauncher,
ghidraHeadlessArguments,
} from "../../../src/ghidra/GhidraLauncher.js";
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
const controls = vi.hoisted(() => ({
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
directory: undefined as ((handle: Dir) => 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;
},
opendir: async (...args: Parameters<typeof actual.opendir>) => {
const handle = await actual.opendir(...args);
controls.directory?.(handle);
return handle;
},
};
});
afterEach(() => {
controls.file = undefined;
controls.directory = undefined;
vi.restoreAllMocks();
});
// Synthetic installation files exercise the filesystem/profile boundary, not
// Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior.
// Independently inspected with PSPDEV v20261001 / GNU readelf 2.44.
const pspAbiFlags: MipsAbiFlags = {
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
};
const target = {
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
path: "/caller/psp.elf",
sha256: "a".repeat(64),
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
gnuAttributes: null,
abiFlags: pspAbiFlags,
},
} satisfies BinaryTarget;
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
executable: () => true,
probeJava: () =>
javaProbeOk<GhidraJavaObservation>({
osArch: "amd64",
osName: "Linux",
version: "21.0.12",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
async function installation() {
const directory = await createTestTempDirectory("rea-psp-profile-");
const root = join(directory, "Ghidra", "Extensions", "ghidra-allegrex");
const contents = {
"Module.manifest": "",
"extension.properties": "name=ghidra-allegrex\nversion=12.1.3\n",
"data/languages/allegrex.ldefs": "synthetic language definition",
"data/languages/allegrex.sla": "synthetic compiled processor",
"data/languages/allegrex.pspec": "synthetic processor specification",
"data/languages/allegrex.cspec": "synthetic compiler specification",
"lib/extension.jar": "synthetic loader artifact",
};
for (const [path, text] of Object.entries(contents)) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), text);
}
await mkdir(join(directory, "support"));
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: directory });
if (!parsed.ok) throw parsed.error;
return {
directory,
root,
config: parsed.value,
provider: new GhidraProvider(parsed.value, silentLogger, {}, host),
};
}
describe.skipIf(process.platform === "win32")(
"PSP installed-extension and profile boundary",
() => {
it("binds source interpretation and installed content, not mtimes or discovery order", async () => {
const { provider, root } = await installation();
expect(provider.inspectTargetSupport(target).status).toBe("supported");
const first = await provider.resolveAnalysisProfile(target);
if (!first.ok) throw first.error;
const again = await provider.resolveAnalysisProfile(target);
if (!again.ok) throw again.error;
expect(again.value.profile?.digest).toBe(first.value.profile?.digest);
expect(first.value.profile?.parameters).toMatchObject({
architecture: "mips",
language_id: "Allegrex:LE:32:default",
compiler_spec_id: "default",
loader: "PspElfLoader",
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
mips_elf: {
elf_class: 32,
byte_order: "little",
type: 2,
flags: 0x10a23001,
abi_flags: {
version: 0,
isa_level: 2,
isa_revision: 0,
gpr_size: 1,
cpr1_size: 1,
cpr2_size: 0,
fp_abi: 2,
isa_extension: 0,
ases: 0,
flags1: 1,
flags2: 0,
},
},
psp_extension: {
id: "ghidra-allegrex",
root,
ghidra_version: "12.1.3",
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
},
});
await writeFile(
join(root, "data/languages/allegrex.sla"),
"changed processor semantics",
);
const changed = await provider.resolveAnalysisProfile(target);
if (!changed.ok) throw changed.error;
expect(changed.value.profile?.digest).not.toBe(
first.value.profile?.digest,
);
});
it("fails before starting a provider after resolved extension bytes change", async () => {
const { directory, root } = await installation();
const extension = await inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
);
await writeFile(join(root, "lib/extension.jar"), "changed loader");
const spawn = vi.fn();
const launcher = new GhidraHeadlessLauncher({
environment: {},
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
configurationDirectoryName: "ghidra_12.1.3_PUBLIC",
javaHome: "/jdk-21",
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
pspExtension: extension,
spawnProcess: spawn,
});
const launched = await launcher.launch(
{
runtimeRoot: join(directory, "runtime"),
transport: "unix-socket",
endpointPath: join(directory, "socket"),
token: "test-only-token",
runId: "00000000-0000-4000-8000-000000000001",
targetPath: target.path,
targetSha256: target.sha256,
providerVersion: "12.1.3",
profileDigest: "b".repeat(64),
},
{ resourceScope: new ArtifactResourceScope() },
);
expect(launched.ok).toBe(false);
if (launched.ok)
throw new Error("Changed extension reached provider startup");
expect(launched.error.cause).toBeInstanceOf(Error);
expect(String(launched.error.cause)).toContain(
"changed since profile resolution",
);
expect(spawn).not.toHaveBeenCalled();
});
it("requires a fresh extension when a saved profile's installation has disappeared", async () => {
const { provider, root } = await installation();
const before = await provider.resolveAnalysisProfile(target);
if (!before.ok) throw before.error;
await rename(root, `${root}-removed`);
const missing = await provider.resolveAnalysisProfile(target);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Missing installation was accepted");
expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE");
});
it.each([
["name=other\nversion=12.1.3\n", "does not match"],
["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"],
["name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n", "Unicode escape"],
])(
"rejects mismatched or malformed extension metadata %s",
async (properties, reason) => {
const { root } = await installation();
await writeFile(join(root, "extension.properties"), properties);
await expect(
inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
),
).rejects.toThrow(reason);
},
);
it("does not treat an uninstall marker or missing compiled language as installed", async () => {
const { root } = await installation();
await rename(
join(root, "Module.manifest"),
join(root, "Module.manifest.uninstalled"),
);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("Module.manifest");
});
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
const { root, directory } = await installation();
const path = join(root, "lib/extension.jar");
await rename(path, join(directory, "external.jar"));
await symlink(join(directory, "external.jar"), path);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("regular files");
});
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
const { root } = await installation();
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("inspection limit");
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP extension inventory bounds",
() => {
it.each([1, 2])(
"bounds the total inventory across %i directories",
async (directories) => {
const { root } = await installation();
for (let directory = 0; directory < directories; directory++) {
const path = join(root, `inventory-${directory}`);
await mkdir(path);
await Promise.all(
Array.from({ length: 1024 / directories }, (_, index) =>
writeFile(join(path, `entry-${index}`), ""),
),
);
}
await expect(
inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
),
).rejects.toThrow("inventory exceeds the inspection limit");
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP admission failures and explicit loader",
() => {
it("preserves already-aborted cancellation rather than reporting missing prerequisites", async () => {
const { provider } = await installation();
const result = await provider.resolveAnalysisProfile(target, {
signal: AbortSignal.abort(),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation was ignored");
expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("does not broaden the separately verified host platforms", async () => {
const { config } = await installation();
for (const platform of ["darwin", "win32"] as const) {
const provider = new GhidraProvider(
config,
silentLogger,
{},
{ ...host, platform },
);
expect(provider.inspectTargetSupport(target).status).toBe(
"unsupported",
);
}
});
it.each([
[{ byteOrder: "big" }, "little-endian"],
[{ elfClass: 64 }, "ELF32"],
[{ type: 0xffa0 }, "ET_EXEC"],
[{ flags: 0x12a23001 }, "0x10a23001"],
[{ flags: 0x10a23021 }, "0x10a23001"],
])(
"refuses unverified PSP declarations %j before extension reads",
async (change, reason) => {
const { provider } = await installation();
const altered = {
...target,
mips: { ...target.mips, ...change },
} as BinaryTarget;
expect(ghidraProcessorUnsupportedReason(altered)).toContain(reason);
expect(provider.inspectTargetSupport(altered).status).toBe(
"unsupported",
);
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Invalid target was admitted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
},
);
it("refuses a separately declared unsupported MSA argument ABI", async () => {
const { provider } = await installation();
const altered = {
...target,
mips: { ...target.mips, gnuAttributes: { fpAbi: 2, msa: 1 } },
} as BinaryTarget;
expect(ghidraProcessorUnsupportedReason(altered)).toMatch(/MSA ABI/u);
expect(provider.inspectTargetSupport(altered).status).toBe("unsupported");
});
},
);
/**
* A PSPSDK-shaped ELF32 whose PT_MIPS_ABIFLAGS record matches the PSP profile.
* Optionally declare a section-header table beyond the captured file.
*/
const pspElfBytes = ({ sectionHeadersBeyondEof = false } = {}): Buffer => {
const abiOffset = 8192;
const bytes = Buffer.alloc(abiOffset + 24);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]);
bytes.writeUInt16LE(2, 16);
bytes.writeUInt16LE(8, 18);
bytes.writeUInt32LE(1, 20);
bytes.writeUInt32LE(52, 28);
bytes.writeUInt32LE(0x10a23001, 36);
bytes.writeUInt16LE(52, 40);
bytes.writeUInt16LE(32, 42);
bytes.writeUInt16LE(1, 44);
if (sectionHeadersBeyondEof) {
bytes.writeUInt32LE(bytes.length + 64, 32); // e_shoff
bytes.writeUInt16LE(40, 46); // e_shentsize
bytes.writeUInt16LE(2, 48); // e_shnum
}
bytes.writeUInt32LE(0x70000003, 52);
bytes.writeUInt32LE(abiOffset, 56);
bytes.writeUInt32LE(24, 68);
bytes.writeUInt32LE(24, 72);
// GNU readelf -x .MIPS.abiflags, not bytes generated by REA.
bytes.set(
Buffer.from("000002000101000200000000000000000100000000000000", "hex"),
abiOffset,
);
return bytes;
};
describe.skipIf(process.platform === "win32")(
"PSP inspected ABI declaration boundary",
() => {
it("refuses a PSP image whose section headers lie beyond the captured file", async () => {
const { directory, provider } = await installation();
const path = join(directory, "psp-truncated-sections.elf");
await writeFile(path, pspElfBytes({ sectionHeadersBeyondEof: true }));
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
if (
parsed.value.kind !== "executable" ||
parsed.value.architecture !== "mips"
)
throw new Error("PSP ELF was not recognized as MIPS");
expect(parsed.value.mips.abiFlags).toEqual(pspAbiFlags);
expect(parsed.value.mips.limitations).toContainEqual(
expect.stringContaining("ABI declarations in unavailable headers"),
);
expect(provider.inspectTargetSupport(parsed.value).status).toBe(
"unsupported",
);
const result = await provider.resolveAnalysisProfile(parsed.value);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Incomplete ABI inspection was admitted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"MIPS ABI declaration inspection is incomplete",
);
});
it("retains independently inspected PSP ABI bytes through the real target reader", async () => {
const { directory, provider } = await installation();
const bytes = pspElfBytes();
const path = join(directory, "psp-with-abi.elf");
await writeFile(path, bytes);
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
if (
parsed.value.kind !== "executable" ||
parsed.value.architecture !== "mips"
)
throw new Error("PSP ELF was not recognized as MIPS");
expect(parsed.value.mips.abiFlags).toEqual(pspAbiFlags);
expect(provider.inspectTargetSupport(parsed.value).status).toBe(
"supported",
);
const profile = await provider.resolveAnalysisProfile(parsed.value);
if (!profile.ok) throw profile.error;
expect(profile.value.profile?.parameters.mips_elf).toMatchObject({
flags: 0x10a23001,
abi_flags: { isa_level: 2, fp_abi: 2, flags1: 1 },
});
});
it.each([
["version", 1],
["isaLevel", 32],
["isaRevision", 1],
["gprSize", 2],
["cpr1Size", 2],
["cpr2Size", 1],
["fpAbi", 1],
["fpAbi", 3],
["fpAbi", 5],
["isaExtension", 1],
["ases", 1],
["flags1", 0],
["flags1", 3],
["flags2", 1],
] satisfies [keyof MipsAbiFlags, number][])(
"rejects unverified PSP ABI field %s=%i before extension discovery",
async (field, value) => {
const { provider, root } = await installation();
await rename(root, `${root}-unavailable`);
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
...target,
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
gnuAttributes: null,
abiFlags: { ...pspAbiFlags, [field]: value },
},
};
expect(provider.inspectTargetSupport(altered).status).toBe(
"unsupported",
);
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Invalid ABI was admitted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"separate MIPS ABI flags record",
);
},
);
it.each([undefined, null])(
"does not guess an uninspected or absent PSP ABI record: %s",
async (abiFlags) => {
const { provider, root } = await installation();
await rename(root, `${root}-unavailable`);
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
...target,
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
gnuAttributes: null,
...(abiFlags === undefined ? {} : { abiFlags }),
},
};
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Missing ABI was inferred");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"inspected separate MIPS ABI flags record",
);
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP explicit loader boundary",
() => {
it("rejects mixing the PSP loader with a configured generic processor", async () => {
const { directory } = await installation();
const config = parseConfig({
GHIDRA_INSTALL_DIR: directory,
REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default",
});
if (!config.ok) throw config.error;
const provider = new GhidraProvider(config.value, silentLogger, {}, host);
const result = await provider.resolveAnalysisProfile(target);
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Conflicting language override was accepted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"does not accept REA_GHIDRA_LANGUAGE_ID overrides",
);
});
it("keeps PSP and generic language-override launch paths mutually exclusive", () => {
const paths = {
projectRoot: "/project",
targetPath: "/target.elf",
bridgeScriptPath: "/bridge/Rea.java",
descriptorPath: "/session",
ghidraLogPath: "/log",
scriptLogPath: "/scriptlog",
};
const languageOverride = { languageId: "x86:LE:64:default" };
expect(() =>
ghidraHeadlessArguments({ ...paths, psp: true, languageOverride }),
).toThrow("PSP ELF import cannot be combined");
expect(ghidraHeadlessArguments({ ...paths, languageOverride })).toEqual(
expect.arrayContaining(["-processor", "x86:LE:64:default"]),
);
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
expect.arrayContaining([
"-loader",
"PspElfLoader",
"-processor",
"Allegrex:LE:32:default",
]),
);
});
it("forces the PSP loader/language only for the selected profile", () => {
const paths = {
projectRoot: "/project",
targetPath: "/target.elf",
bridgeScriptPath: "/bridge/Rea.java",
descriptorPath: "/session",
ghidraLogPath: "/log",
scriptLogPath: "/scriptlog",
};
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
expect.arrayContaining([
"-loader",
"PspElfLoader",
"-processor",
"Allegrex:LE:32:default",
"-cspec",
"default",
]),
);
expect(ghidraHeadlessArguments(paths)).not.toContain(
"Allegrex:LE:32:default",
);
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP extension property syntax",
() => {
it.each([
{
form: "escaped key and value with colon separator",
properties: "n\\u0061me : ghidra-allegrex\nver\\u0073ion=12.1.3\n",
},
{
form: "continued key and value lines",
properties: "name=ghidra-\\\n allegrex\nversion=12.1.\\\n 3\n",
},
{
form: "duplicate keys use the last value",
properties:
"name=wrong\nname=ghidra-allegrex\nversion=12.1.4\nversion=12.1.3\n",
},
])(
"accepts Java Properties $form and hashes raw bytes",
async ({ properties }) => {
const { root } = await installation();
const canonical = await inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
);
const latin1Properties = Buffer.from(
`${properties}author=caf\u00e9\n`,
"latin1",
);
await writeFile(join(root, "extension.properties"), latin1Properties);
const extension = await inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
);
expect(extension.id).toBe("ghidra-allegrex");
expect(extension.sha256).not.toBe(canonical.sha256);
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP inspection completion ownership",
() => {
it.each([true, false])(
"preserves physical read failure when close aborts the caller (close rejection: %s)",
async (rejectClose) => {
const { provider, root } = await installation();
const controller = new AbortController();
const readFailure = new Error("controlled PSP read failure");
let closes = 0;
controls.file = (handle, path) => {
if (path !== join(root, "Module.manifest")) return;
controls.file = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
if (closes === 1) {
controller.abort({ caller: "late close" });
if (rejectClose) throw new Error("controlled PSP close failure");
}
await close();
});
};
try {
const result = await provider.resolveAnalysisProfile(target, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed read admitted");
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.message).toContain(readFailure.message);
expect(result.error.cleanupIncomplete).toBe(rejectClose);
expect(provider.hasPendingOwnedResources()).toBe(rejectClose);
expect((await provider.closeOwnedResources()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
expect(provider.hasPendingOwnedResources()).toBe(false);
expect(closes).toBe(rejectClose ? 2 : 1);
} finally {
await provider.closeOwnedResources();
}
},
);
it.each([true, false])(
"preserves exact cancellation during successful read finalization (close rejection: %s)",
async (rejectClose) => {
const { provider, root } = await installation();
const controller = new AbortController();
const reason = { caller: "finalization" };
let closes = 0;
controls.file = (handle, path) => {
if (path !== join(root, "Module.manifest")) return;
controls.file = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
if (closes === 1) {
controller.abort(reason);
if (rejectClose) throw new Error("controlled PSP close failure");
}
await close();
});
};
try {
const result = await provider.resolveAnalysisProfile(target, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancelled inspection admitted");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(rejectClose);
expect((await provider.closeOwnedResources()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
expect(closes).toBe(rejectClose ? 2 : 1);
} finally {
await provider.closeOwnedResources();
}
},
);
it("retains directory close uncertainty without retrying an invalidated native handle", async () => {
const { provider } = await installation();
const controller = new AbortController();
const readFailure = new Error("controlled directory read failure");
let closes = 0;
controls.directory = (handle) => {
controls.directory = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
await close();
controller.abort({ caller: "directory finalization" });
throw new Error("controlled native directory close rejection");
});
};
const result = await provider.resolveAnalysisProfile(target, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed directory admitted");
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.message).toContain(readFailure.message);
expect(result.error.cleanupIncomplete).toBe(true);
expect(provider.hasPendingOwnedResources()).toBe(true);
expect((await provider.closeOwnedResources()).ok).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
expect(closes).toBe(1);
const retried = await provider.resolveAnalysisProfile(target);
expect(retried.ok).toBe(false);
expect(closes).toBe(1);
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP launch inspection ownership",
() => {
it("keeps the launch inspection failure through client finalization and retries its exact descriptor", async () => {
const { directory, root } = await installation();
const extension = await inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
);
const controller = new AbortController();
const readFailure = new Error(
"controlled launch inspection read failure",
);
let closes = 0;
let allowClose = false;
controls.file = (handle, path) => {
if (path !== join(root, "Module.manifest")) return;
controls.file = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
controller.abort({ caller: "launch finalization" });
if (!allowClose)
throw new Error("controlled launch inspection close failure");
await close();
});
};
const spawn = vi.fn();
const launcher = new GhidraHeadlessLauncher({
environment: {},
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
configurationDirectoryName: "ghidra_12.1.3_PUBLIC",
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
pspExtension: extension,
spawnProcess: spawn,
});
const targetPath = join(directory, "target.bin");
await writeFile(targetPath, "synthetic target");
const client = new GhidraClient({
launcher,
targetPath,
targetSha256: createHash("sha256")
.update("synthetic target")
.digest("hex"),
providerVersion: "12.1.3",
profileDigest: "b".repeat(64),
});
try {
const result = await client.start(controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed PSP inspection started");
expect(result.error.kind).toBe("start");
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect(String(result.error.cause)).toContain(
"Ghidra headless launch failed",
);
expect(spawn).not.toHaveBeenCalled();
expect(closes).toBe(2); // Inspection close, then client rollback.
allowClose = true;
await client.close();
expect(closes).toBe(3);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await client.close();
}
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP inspection admission and identity",
() => {
it("retains the existing sorted recursive hash framing", async () => {
const { root } = await installation();
await mkdir(join(root, "a"));
await writeFile(join(root, "a/z"), "nested before sibling");
await writeFile(join(root, "a-foo"), "sibling after directory");
const paths = [
"Module.manifest",
"a/z",
"a-foo",
"data/languages/allegrex.cspec",
"data/languages/allegrex.ldefs",
"data/languages/allegrex.pspec",
"data/languages/allegrex.sla",
"extension.properties",
"lib/extension.jar",
];
const hash = createHash("sha256");
for (const path of paths) {
const bytes = await readFile(join(root, path));
hash.update(JSON.stringify([path, bytes.length]));
hash.update(bytes);
}
const extension = await inspectGhidraPspExtension(
root,
"12.1.3",
new ArtifactResourceScope(),
);
expect(extension.sha256).toBe(hash.digest("hex"));
});
it.each(["growth", "truncation"] as const)(
"rejects file %s during the bounded read and confirms close",
async (change) => {
const { root, provider } = await installation();
const path = join(
root,
change === "growth" ? "Module.manifest" : "extension.properties",
);
let closes = 0;
controls.file = (handle, openedPath) => {
if (openedPath !== path) return;
controls.file = undefined;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
await writeFile(path, change === "growth" ? "extra byte" : "");
return read(...args);
});
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
await close();
});
};
const result = await provider.resolveAnalysisProfile(target);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Changed file admitted");
expect(result.error.message).toContain(
change === "growth"
? "changed during inspection"
: "Truncated Allegrex component",
);
expect(closes).toBe(1);
expect(result.error.cleanupIncomplete).toBe(false);
expect(provider.hasPendingOwnedResources()).toBe(false);
expect((await provider.closeOwnedResources()).ok).toBe(true);
},
);
it("joins active final close before provider cleanup retries the same descriptor", async () => {
const { root, provider } = await installation();
let markClosing: (() => void) | undefined;
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
markClosing = resolve;
});
const finishClose = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let closes = 0;
controls.file = (handle, path) => {
if (path !== join(root, "Module.manifest")) return;
controls.file = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
if (closes === 1) {
markClosing?.();
await finishClose;
throw new Error("controlled active close rejection");
}
await close();
});
};
const inspecting = provider.resolveAnalysisProfile(target);
await closing;
let cleanupSettled = false;
const cleaning = provider.closeOwnedResources().then((result) => {
cleanupSettled = true;
return result;
});
try {
await Promise.resolve();
expect(cleanupSettled).toBe(false);
expect(closes).toBe(1);
releaseClose?.();
const result = await inspecting;
expect(result.ok).toBe(false);
expect((await cleaning).ok).toBe(true);
if (result.ok) throw new Error("Failed close admitted");
expect(result.error.cleanupIncomplete).toBe(false);
expect(closes).toBe(2);
expect(provider.hasPendingOwnedResources()).toBe(false);
} finally {
releaseClose?.();
await cleaning;
await provider.closeOwnedResources();
}
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP profile selection completion through composed sessions",
() => {
it.each(["preview", "open"] as const)(
"preserves physical failure and live cleanup after late cancellation during %s",
async (operation) => {
const { directory, root, provider } = await installation();
const path = join(directory, "controlled-psp.elf");
await writeFile(path, pspElfBytes());
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
const controller = new AbortController();
const readFailure = new Error(
"controlled session profile read failure",
);
let allowClose = false;
let closes = 0;
controls.file = (handle, openedPath) => {
if (openedPath !== join(root, "Module.manifest")) return;
controls.file = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
controller.abort({ caller: "profile inspection finalization" });
if (!allowClose)
throw new Error("controlled session profile close failure");
await close();
});
};
const createClient = vi
.spyOn(provider, "createClient")
.mockImplementation(() => {
throw new Error("Failed profile must not start an engine");
});
const binary = composeBinarySession(
new AnalysisProviderRegistry([provider]),
);
try {
const result =
operation === "preview"
? await binary.previewTarget(parsed.value, {
providerId: "ghidra",
signal: controller.signal,
})
: await binary.open(path, {
providerId: "ghidra",
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed profile admitted");
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.message).toContain(readFailure.message);
expect(result.error.cleanupIncomplete).toBe(true);
expect(await binary.status()).toMatchObject({
cleanup_pending: true,
});
expect(createClient).not.toHaveBeenCalled();
const failedClose = await binary.close();
expect(failedClose.ok).toBe(false);
expect(closes).toBe(2);
expect(result.error.cleanupIncomplete).toBe(true);
allowClose = true;
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(closes).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
if (failedClose.ok) throw new Error("Close unexpectedly succeeded");
expect(failedClose.error.cleanupIncomplete).toBe(false);
expect(await binary.status()).toMatchObject({
cleanup_pending: false,
});
} finally {
allowClose = true;
await binary.close();
}
},
);
},
);