1050 lines
38 KiB
TypeScript
1050 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 } = await installation();
|
|
const path = join(directory, "psp-truncated-sections.elf");
|
|
await writeFile(path, pspElfBytes({ sectionHeadersBeyondEof: true }));
|
|
const parsed = await parseBinaryTarget(path);
|
|
expect(parsed).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "file_format_rejected",
|
|
reason: expect.stringContaining("truncated ELF section header table"),
|
|
},
|
|
});
|
|
});
|
|
|
|
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).toMatchObject({
|
|
_tag: "ConfigurationError",
|
|
settings: [
|
|
{
|
|
setting: "REA_GHIDRA_LANGUAGE_ID",
|
|
constraint:
|
|
"The bounded PSP Allegrex profile does not accept processor or compiler specification 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 directory: { close(): Promise<void> } = handle;
|
|
const close = directory.close.bind(handle);
|
|
vi.spyOn(directory, "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();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|