Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
739cf68e1a | ||
|
|
20e4f83097 | ||
|
|
97aff28201 | ||
|
|
641a8395ee |
+15
-1
@@ -493,7 +493,13 @@ copying license secrets into logs, or killing unrelated Hopper processes.
|
||||
REA connects to an existing Ghidra installation on Linux x64/arm64, macOS x64/arm64,
|
||||
or experimental Windows x64 P0.
|
||||
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's
|
||||
`application.java.min` and `application.java.max`. Current 12.1 releases require
|
||||
`application.java.min` and `application.java.max`. The JDK's reported platform must also
|
||||
map to the same native decompiler directory as the host: a JDK running under
|
||||
emulation (for example x86_64 under Rosetta on Apple silicon) or reporting a
|
||||
non-host `os.name` fails the doctor
|
||||
even when it is an otherwise accepted 64-bit full JDK. Select a matching JDK
|
||||
through `JAVA_HOME`; adding another decompiler in the JVM-selected directory
|
||||
does not satisfy this host-validated admission rule. Current 12.1 releases require
|
||||
JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4
|
||||
and JDK 21. Each installation must include the native decompiler for the host
|
||||
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding
|
||||
@@ -556,6 +562,14 @@ registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagno
|
||||
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
|
||||
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
|
||||
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.
|
||||
It also checks that the reported JVM `os.arch` and `os.name` map to the same native decompiler
|
||||
directory validated for the host; unrecognized reported architectures or a
|
||||
non-host operating system are refused. LaunchSupport overrides of either
|
||||
property, including quoted helper-environment JVM options, are rejected at
|
||||
launch. Indirect JVM `@argument-file` options are also refused because their
|
||||
effective platform properties cannot be established before launch; provide
|
||||
explicit options instead. OS-name matching follows Ghidra's operating-system
|
||||
identifiers: macOS requires `Mac OS X`, not `Darwin` or a generic `mac*` alias.
|
||||
When Java is found through `PATH`, setup records its observed JDK home so GUI
|
||||
MCP clients do not depend on an incidental shell path. Setup shows every exact
|
||||
environment entry in its plan, writes only after approval, and never downloads,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dirname } from "node:path";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type {
|
||||
@@ -43,11 +44,35 @@ const resolveInputPath = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve the executable and require a launchable regular file before any spawn. */
|
||||
const resolveExecutablePath = async (
|
||||
selected: string,
|
||||
): ReturnType<typeof resolveInputPath> => {
|
||||
const resolved = await resolveInputPath("executable_path", selected);
|
||||
if (!resolved.ok) return resolved;
|
||||
try {
|
||||
if (!(await stat(resolved.value)).isFile())
|
||||
throw new Error("not a regular file");
|
||||
if (process.platform !== "win32")
|
||||
await access(resolved.value, constants.X_OK);
|
||||
return resolved;
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
const [executable, application, root] = await Promise.all([
|
||||
resolveInputPath("executable_path", input.executable_path),
|
||||
resolveExecutablePath(input.executable_path),
|
||||
resolveInputPath("application_path", input.application_path),
|
||||
input.application_root === undefined
|
||||
? Promise.resolve(ok(undefined))
|
||||
|
||||
@@ -96,13 +96,16 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
|
||||
if (options.signal?.aborted)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
application = await this.launch({
|
||||
const launchOptions: Parameters<typeof electron.launch>[0] = {
|
||||
executablePath: paths.executable,
|
||||
cwd: paths.root,
|
||||
env: safeElectronEnvironment(this.environment, runId),
|
||||
args: ["-r", hookPath, paths.application, ...input.args],
|
||||
timeout: Math.max(1, startupDeadline - performance.now()),
|
||||
});
|
||||
};
|
||||
application = await containLaunchRejections(() =>
|
||||
this.launch(launchOptions),
|
||||
);
|
||||
const leaderPid = application.process().pid;
|
||||
if (
|
||||
typeof leaderPid !== "number" ||
|
||||
@@ -377,6 +380,88 @@ const safeElectronEnvironment = (
|
||||
["REA_PROCESS_RUN_ID", runId] as const,
|
||||
]);
|
||||
|
||||
const PLAYWRIGHT_INTERNAL_FRAME = /playwright-core[\\/]lib[\\/]/;
|
||||
interface LaunchRejectionScope {
|
||||
failure?: unknown;
|
||||
}
|
||||
const activeLaunches = new Set<LaunchRejectionScope>();
|
||||
const heldLaunchRejections = new Map<
|
||||
Promise<unknown>,
|
||||
{
|
||||
readonly reason: Error;
|
||||
readonly candidates: Set<LaunchRejectionScope>;
|
||||
readonly hadExternalListener: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const holdLaunchRejection = (
|
||||
reason: unknown,
|
||||
promise: Promise<unknown>,
|
||||
): void => {
|
||||
if (
|
||||
!(reason instanceof Error) ||
|
||||
!PLAYWRIGHT_INTERNAL_FRAME.test(reason.stack ?? "")
|
||||
)
|
||||
throw reason;
|
||||
if (!heldLaunchRejections.has(promise))
|
||||
heldLaunchRejections.set(promise, {
|
||||
reason,
|
||||
candidates: new Set(activeLaunches),
|
||||
hadExternalListener: process
|
||||
.listeners("unhandledRejection")
|
||||
.some((listener) => listener !== holdLaunchRejection),
|
||||
});
|
||||
};
|
||||
|
||||
const forwardLaunchRejection = (
|
||||
reason: Error,
|
||||
hadExternalListener: boolean,
|
||||
): void => {
|
||||
// Listeners present at the original event already observed it. When the
|
||||
// holding listener was the only listener, restore Node's fatal behavior.
|
||||
if (!hadExternalListener)
|
||||
process.nextTick(() => {
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
|
||||
/** Playwright leaves rejected launch promises unhandled; absorb only those that repeat this launch's failure. */
|
||||
const containLaunchRejections = async <T>(
|
||||
launch: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const scope: LaunchRejectionScope = {};
|
||||
if (activeLaunches.size === 0)
|
||||
process.on("unhandledRejection", holdLaunchRejection);
|
||||
activeLaunches.add(scope);
|
||||
try {
|
||||
return await launch();
|
||||
} catch (cause: unknown) {
|
||||
scope.failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
activeLaunches.delete(scope);
|
||||
if (activeLaunches.size === 0)
|
||||
process.off("unhandledRejection", holdLaunchRejection);
|
||||
for (const [
|
||||
promise,
|
||||
{ reason, candidates, hadExternalListener },
|
||||
] of heldLaunchRejections) {
|
||||
if (!candidates.delete(scope)) continue;
|
||||
if (
|
||||
scope.failure instanceof Error &&
|
||||
reason.message !== "" &&
|
||||
scope.failure.message.includes(reason.message)
|
||||
)
|
||||
heldLaunchRejections.delete(promise);
|
||||
else if (candidates.size === 0) {
|
||||
heldLaunchRejections.delete(promise);
|
||||
forwardLaunchRejection(reason, hadExternalListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (cause: unknown): AnalysisError =>
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
|
||||
@@ -45,6 +45,24 @@ const registerOverviewCommands = (
|
||||
.describe("Load and update a local analysis snapshot"),
|
||||
...nativeOpenOptionFields,
|
||||
});
|
||||
// `analyze` declares the JavaScript integrity policy for its JavaScript
|
||||
// lane. Preserve whether the caller supplied it until the target lane is
|
||||
// known, then apply the JavaScript default only in that lane. The owner
|
||||
// field fills an omission with that default, and an `.optional()` wrapper
|
||||
// keeps reporting it, so every binary target would look like an explicit
|
||||
// policy. Remove the owner's default before making the CLI option optional;
|
||||
// reapply its metadata so help and `--schema` keep describing the option.
|
||||
const ownerIntegrityPolicy =
|
||||
javascriptApplicationOptions.shape.integrityPolicy;
|
||||
const defaultIntegrityPolicy = ownerIntegrityPolicy.parse(undefined);
|
||||
const policyMetadata = ownerIntegrityPolicy.meta();
|
||||
const omittableIntegrityPolicy = ownerIntegrityPolicy
|
||||
.removeDefault()
|
||||
.optional();
|
||||
const laneIntegrityPolicy =
|
||||
policyMetadata === undefined
|
||||
? omittableIntegrityPolicy
|
||||
: omittableIntegrityPolicy.meta(policyMetadata);
|
||||
cli.command(CLI_COMMANDS.analyze, {
|
||||
description: "Get an overview of an app",
|
||||
args: z.object({
|
||||
@@ -54,7 +72,7 @@ const registerOverviewCommands = (
|
||||
.describe("App, program, or analysis database path"),
|
||||
}),
|
||||
options: overviewOptions.extend({
|
||||
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
|
||||
integrityPolicy: laneIntegrityPolicy,
|
||||
sourcePath: javascriptApplicationOptions.shape.sourcePath,
|
||||
}),
|
||||
run: async ({ args, options }) => {
|
||||
@@ -67,7 +85,8 @@ const registerOverviewCommands = (
|
||||
runCliJavaScriptApplicationAnalysis(
|
||||
{
|
||||
input_path: resolve(args.path),
|
||||
integrity_policy: options.integrityPolicy,
|
||||
integrity_policy:
|
||||
options.integrityPolicy ?? defaultIntegrityPolicy,
|
||||
...(options.sourcePath === undefined
|
||||
? {}
|
||||
: { source_paths: options.sourcePath }),
|
||||
@@ -90,6 +109,19 @@ const registerOverviewCommands = (
|
||||
},
|
||||
],
|
||||
);
|
||||
if (options.integrityPolicy !== undefined)
|
||||
throw new AnalysisInputError(
|
||||
"analyze_javascript_application",
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: ["integrity_policy"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
|
||||
},
|
||||
],
|
||||
);
|
||||
return runDirectAnalysis(
|
||||
args.path,
|
||||
"binary_overview",
|
||||
|
||||
@@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (
|
||||
overrides: Partial<GhidraInstallationHost> = {},
|
||||
platform: NodeJS.Platform = "linux",
|
||||
): GhidraInstallationHost => ({
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -45,7 +48,7 @@ const inspection = (
|
||||
? {}
|
||||
: { installDir: options.installDir }),
|
||||
},
|
||||
installationHost(overrides),
|
||||
installationHost(overrides, options.platform ?? "linux"),
|
||||
);
|
||||
|
||||
const doctorHost = (
|
||||
@@ -129,6 +132,8 @@ describe("Ghidra doctor integration", () => {
|
||||
{ installDir: INSTALL },
|
||||
{
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "17.0.12",
|
||||
major: 17,
|
||||
home: "/usr/lib/jvm/jdk-17",
|
||||
|
||||
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
|
||||
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
import {
|
||||
ghidraInstallationDiagnostics,
|
||||
ghidraJavaEnvironment,
|
||||
inspectGhidraInstallation,
|
||||
type GhidraExecutableHeaderProbe,
|
||||
@@ -16,6 +18,14 @@ import {
|
||||
ghidraJavaRequirement,
|
||||
} from "./GhidraInstallationPolicy.js";
|
||||
|
||||
// The real Java probe shells out via spawnSync; stub it so one test can run
|
||||
// the real probeJava against captured `-XshowSettings:properties` output.
|
||||
// Every other test overrides probeJava, so the stub never fires for them.
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...original, spawnSync: vi.fn() };
|
||||
});
|
||||
|
||||
const INSTALL = "/opt/ghidra";
|
||||
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
|
||||
const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
|
||||
@@ -32,6 +42,8 @@ const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({
|
||||
});
|
||||
|
||||
const JAVA: GhidraJavaObservation = {
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: SUPPORTED_GHIDRA_JAVA_MAJOR,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -174,6 +186,11 @@ describe("Ghidra installation inspection", () => {
|
||||
{ environment: {}, installDir: INSTALL, platform, architecture },
|
||||
host({
|
||||
executable: (path) => path === HEADLESS || path === decompiler,
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
@@ -202,6 +219,7 @@ describe("Ghidra installation inspection", () => {
|
||||
return {
|
||||
...JAVA,
|
||||
home: "C:\\Java\\jdk-21",
|
||||
osName: "Windows 11",
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -721,7 +739,10 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ executableHeader: () => headerProbe(bytes) }),
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(status);
|
||||
expect(
|
||||
@@ -739,7 +760,10 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ executableHeader: () => headerProbe(bytes) }),
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
expect(
|
||||
@@ -788,7 +812,14 @@ describe("Ghidra native decompiler admission", () => {
|
||||
])("distinguishes $name", ({ options, executableHeader }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
options,
|
||||
host({ executableHeader }),
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
executableHeader,
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
@@ -849,6 +880,7 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
|
||||
}),
|
||||
@@ -872,6 +904,7 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined,
|
||||
}),
|
||||
@@ -889,3 +922,235 @@ describe("Ghidra native decompiler admission", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra JVM native directory admission", () => {
|
||||
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
code: "executable_missing",
|
||||
detail: expect.stringContaining(
|
||||
"JVM reports os.arch=x86_64, so Ghidra selects its native decompiler from os/mac_x86_64",
|
||||
),
|
||||
remediation: expect.stringContaining("JAVA_HOME"),
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
});
|
||||
|
||||
it("rejects a successful Java probe that omits os.arch", () => {
|
||||
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ probeJava: () => withoutArchitecture }),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("did not report os.arch"),
|
||||
});
|
||||
expect(projectGhidraDoctorInspection(result).available).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["riscv64", "sparcv9", ""])(
|
||||
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
|
||||
(osArch) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("unrecognized os.arch="),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a JDK whose os.name selects a different host OS", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Windows 11",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
code: "executable_missing",
|
||||
detail: expect.stringContaining("JVM reports os.name=Windows 11"),
|
||||
remediation: expect.stringContaining("JAVA_HOME"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra JVM OS admission", () => {
|
||||
it.each([
|
||||
["Darwin", false],
|
||||
["macOS", false],
|
||||
["Macintosh", false],
|
||||
["Mac OS X", true],
|
||||
["MAC OS X 26", true],
|
||||
["JVM on Mac OS X", true],
|
||||
["Mac OS X / Windows", false],
|
||||
["Mac OS X / Linux", false],
|
||||
] as const)(
|
||||
"matches Ghidra's ordered OS-name predicate for %s (available=%s)",
|
||||
(osName, available) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ probeJava: () => ({ ...JAVA, osArch: "aarch64", osName }) }),
|
||||
);
|
||||
expect(result.status).toBe(available ? "available" : "unavailable");
|
||||
expect(projectGhidraDoctorInspection(result).available).toBe(available);
|
||||
expect(result.javaOsName).toBe(osName);
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({ status: available ? "passed" : "failed" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a successful Java probe that omits os.name", () => {
|
||||
const { osName: _observedOsName, ...withoutOsName } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host({ probeJava: () => withoutOsName }),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("did not report os.name"),
|
||||
});
|
||||
expect(projectGhidraDoctorInspection(result).available).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["SunOS", "FreeBSD", ""])(
|
||||
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
|
||||
(osName) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osName }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("os.name="),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra Java probe os.arch extraction", () => {
|
||||
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
stderr: [
|
||||
"Property settings:",
|
||||
" file.encoding = UTF-8",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n"),
|
||||
} as never);
|
||||
try {
|
||||
// No host override: the real system host runs the real probeJava
|
||||
// against the stubbed spawnSync.
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
javaHome: "/usr/lib/jvm/jdk-21",
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
});
|
||||
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
|
||||
java_os_arch: "x86_64",
|
||||
java_os_name: "Linux",
|
||||
});
|
||||
} finally {
|
||||
vi.mocked(spawnSync).mockReset();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,17 @@ interface GhidraInstallationObservation {
|
||||
readonly javaCommand: string;
|
||||
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
|
||||
readonly javaVmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch; omitted when the probe cannot establish it. Ghidra
|
||||
* selects its native decompiler directory from this value.
|
||||
*/
|
||||
readonly javaOsArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name; omitted when the probe cannot establish it. Ghidra
|
||||
* selects its native decompiler OS directory from the JVM OS/architecture
|
||||
* pair, so a non-host value selects an unvalidated directory.
|
||||
*/
|
||||
readonly javaOsName?: string;
|
||||
readonly checks: readonly GhidraInstallationCheck[];
|
||||
}
|
||||
|
||||
@@ -244,6 +255,17 @@ export interface GhidraJavaObservation {
|
||||
readonly bits: number;
|
||||
readonly runtime: "jdk" | "jre";
|
||||
readonly vmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch property, which Ghidra uses to select its native
|
||||
* decompiler directory. Omitted when the probe cannot establish it.
|
||||
*/
|
||||
readonly osArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name property, which Ghidra uses with os.arch to select
|
||||
* its native decompiler OS directory. Omitted when the probe cannot
|
||||
* establish it.
|
||||
*/
|
||||
readonly osName?: string;
|
||||
}
|
||||
|
||||
/** A bounded executable-header probe: a small prefix plus the whole file size. */
|
||||
@@ -324,6 +346,12 @@ export const inspectGhidraInstallation = (
|
||||
...(coordinates.java?.vmName === undefined
|
||||
? {}
|
||||
: { javaVmName: coordinates.java.vmName }),
|
||||
...(coordinates.java?.osArch === undefined
|
||||
? {}
|
||||
: { javaOsArch: coordinates.java.osArch }),
|
||||
...(coordinates.java?.osName === undefined
|
||||
? {}
|
||||
: { javaOsName: coordinates.java.osName }),
|
||||
checks,
|
||||
};
|
||||
if (failed !== undefined)
|
||||
@@ -555,6 +583,8 @@ export const ghidraInstallationDiagnostics = (
|
||||
java_home: inspection.javaHome,
|
||||
java_version: inspection.javaVersion,
|
||||
java_vm_name: inspection.javaVmName ?? null,
|
||||
java_os_arch: inspection.javaOsArch ?? null,
|
||||
java_os_name: inspection.javaOsName ?? null,
|
||||
platform: inspection.platform,
|
||||
architecture: inspection.architecture,
|
||||
checks: inspection.checks.map((check) => ({
|
||||
@@ -632,6 +662,61 @@ const nativeDecompilerCheck = ({
|
||||
architecture,
|
||||
host.executableHeader?.(path),
|
||||
);
|
||||
// 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
|
||||
// was never validated, so every analysis fails after a passing doctor.
|
||||
// Ghidra resolves the OS directory from the JVM os.name/os.arch pair, so a
|
||||
// JVM reporting a non-host os.name (for example through -Dos.name=Windows)
|
||||
// selects a different OS directory even when os.arch matches.
|
||||
const osArch = coordinates.java?.osArch;
|
||||
const osName = coordinates.java?.osName;
|
||||
const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch);
|
||||
const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
|
||||
// Without a JVM observation the java check reports the missing runtime;
|
||||
// the decompiler directory cannot be selected, so this check defers to it.
|
||||
if (coordinates.java !== undefined) {
|
||||
if (selectedDirectory === undefined)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail:
|
||||
osArch === undefined
|
||||
? "The Java probe did not report os.arch; the JVM-selected native decompiler directory is unknown and cannot be matched to the validated host directory"
|
||||
: `JVM reports unrecognized os.arch=${osArch}; its selected native decompiler directory is unknown and cannot be matched to the validated host directory`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported architecture maps to the validated Ghidra decompiler (${validatedDirectory ?? "unsupported host"}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (
|
||||
validatedDirectory !== undefined &&
|
||||
selectedDirectory !== validatedDirectory &&
|
||||
path !== null
|
||||
)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail: `JVM reports os.arch=${osArch}, so Ghidra selects its native decompiler from os/${selectedDirectory}, but the validated decompiler targets ${validatedDirectory} (host ${platform}/${architecture})`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose architecture matches the built decompiler (${validatedDirectory}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (osName === undefined)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail:
|
||||
"The Java probe did not report os.name; the JVM-selected native decompiler OS directory is unknown and cannot be matched to the validated host platform",
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (!jvmOsNameMatchesPlatform(platform, osName) && path !== null)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail: `JVM reports os.name=${osName}, which does not match the validated host platform ${platform} (host ${platform}/${architecture})`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
|
||||
});
|
||||
}
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: path !== null && compatibility?.status !== "incompatible",
|
||||
@@ -646,6 +731,49 @@ const nativeDecompilerCheck = ({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a JVM os.name value selects the host platform's native OS directory.
|
||||
* Unknown values never align with the validated host directory; admission
|
||||
* must not treat them as matching.
|
||||
*/
|
||||
const jvmOsNameMatchesPlatform = (
|
||||
platform: NodeJS.Platform,
|
||||
osName: string,
|
||||
): boolean => {
|
||||
const normalized = osName.toLowerCase();
|
||||
// Ghidra 12.1.4 OperatingSystem.findCurrentOperatingSystem uses ordered,
|
||||
// case-insensitive substring matching, not Node's platform names or broad
|
||||
// mac*/Darwin aliases. Preserve that order for names containing several tags.
|
||||
const selected = normalized.includes("windows")
|
||||
? "win32"
|
||||
: normalized.includes("linux")
|
||||
? "linux"
|
||||
: normalized.includes("mac os x")
|
||||
? "darwin"
|
||||
: undefined;
|
||||
return selected === platform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a JVM os.arch value to the Ghidra native directory that JVM selects.
|
||||
* Unknown values stay unknown; admission must not treat them as aligned with
|
||||
* the validated host directory.
|
||||
*/
|
||||
const jvmOsArchNativeDirectory = (
|
||||
platform: NodeJS.Platform,
|
||||
osArch: string | undefined,
|
||||
): string | undefined => {
|
||||
if (osArch === undefined) return undefined;
|
||||
const normalized = osArch.toLowerCase();
|
||||
const node: NodeJS.Architecture | undefined =
|
||||
normalized === "x86_64" || normalized === "amd64" || normalized === "x64"
|
||||
? "x64"
|
||||
: normalized === "aarch64" || normalized === "arm64"
|
||||
? "arm64"
|
||||
: undefined;
|
||||
return node === undefined ? undefined : NATIVE_PLATFORMS[platform]?.[node];
|
||||
};
|
||||
|
||||
const versionCheck = (
|
||||
properties: GhidraApplicationProperties | undefined,
|
||||
): GhidraInstallationCheck => {
|
||||
@@ -781,6 +909,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
const version = javaProperty(output, "java.version");
|
||||
const home = javaProperty(output, "java.home");
|
||||
const vmName = javaProperty(output, "java.vm.name");
|
||||
const osArch = javaProperty(output, "os.arch");
|
||||
const osName = javaProperty(output, "os.name");
|
||||
const bits = Number.parseInt(
|
||||
javaProperty(output, "sun.arch.data.model") ?? "0",
|
||||
10,
|
||||
@@ -791,6 +921,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
major: javaMajor(version),
|
||||
home,
|
||||
...(vmName === null ? {} : { vmName }),
|
||||
...(osArch === null ? {} : { osArch }),
|
||||
...(osName === null ? {} : { osName }),
|
||||
bits,
|
||||
runtime: executablePath(
|
||||
(process.platform === "win32" ? win32 : posix).join(
|
||||
|
||||
@@ -2,11 +2,50 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
|
||||
/** JVM option environments remove quotes, including quotes inside an option. */
|
||||
const jvmEnvironmentArguments = (value: string): readonly string[] => {
|
||||
const arguments_: string[] = [];
|
||||
let argument = "";
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (const character of value) {
|
||||
if (quote !== undefined) {
|
||||
if (character === quote) quote = undefined;
|
||||
else argument += character;
|
||||
} else if (character === "'" || character === '"') quote = character;
|
||||
else if (/\s/u.test(character)) {
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
argument = "";
|
||||
} else argument += character;
|
||||
}
|
||||
if (quote !== undefined)
|
||||
throw new Error(
|
||||
"Invalid quoted Ghidra LaunchSupport JVM environment option",
|
||||
);
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
return arguments_;
|
||||
};
|
||||
|
||||
const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => {
|
||||
// Indirect options can change the JVM platform after this admission check.
|
||||
// Do not read arbitrary, mutable argument files and pretend their contents
|
||||
// establish the later launch; require explicit inspectable options instead.
|
||||
if (arguments_.some((argument) => argument.startsWith("@")))
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport may not supply JVM argument files; their effective platform properties cannot be validated before launch.",
|
||||
);
|
||||
if (
|
||||
arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Launch coordinates obtained from Ghidra's own release configuration helper. */
|
||||
export const ghidraJavaLaunch = async (options: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly analyzeHeadlessPath: string;
|
||||
readonly javaHome: string;
|
||||
readonly javaHome?: string;
|
||||
readonly javaVmName?: string;
|
||||
readonly homeRoot: string;
|
||||
readonly tempRoot: string;
|
||||
@@ -21,7 +60,17 @@ export const ghidraJavaLaunch = async (options: {
|
||||
}> => {
|
||||
const supportRoot = dirname(options.analyzeHeadlessPath);
|
||||
const installRoot = dirname(supportRoot);
|
||||
const command = join(options.javaHome, "bin", "java");
|
||||
const selectedJavaHome = options.javaHome ?? options.environment.JAVA_HOME;
|
||||
const command =
|
||||
selectedJavaHome === undefined
|
||||
? options.platform === "win32"
|
||||
? "java.exe"
|
||||
: "java"
|
||||
: join(
|
||||
selectedJavaHome,
|
||||
"bin",
|
||||
options.platform === "win32" ? "java.exe" : "java",
|
||||
);
|
||||
const isolatedProperties = [
|
||||
`-Duser.home=${options.homeRoot}`,
|
||||
`-Djava.io.tmpdir=${options.tempRoot}`,
|
||||
@@ -48,6 +97,9 @@ export const ghidraJavaLaunch = async (options: {
|
||||
[...supportArguments, "-envvars"],
|
||||
{
|
||||
env: environment,
|
||||
// Wait for the helper's exit on abort so a cancelled or timed-out
|
||||
// startup never leaves the probe running without an owning process.
|
||||
stopSignal: "SIGTERM",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
);
|
||||
@@ -65,11 +117,26 @@ export const ghidraJavaLaunch = async (options: {
|
||||
if ((environment[key] ?? "") === "")
|
||||
environment[key] = line.slice(separator + 1);
|
||||
}
|
||||
// The merged environment becomes the analysis JVM's environment, and the
|
||||
// JVM applies these options variables automatically. An injected
|
||||
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
|
||||
// appearing in vmArguments, so reject the effective values as well. Argument
|
||||
// files are refused because their expanded options are not established here.
|
||||
for (const key of [
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
] as const) {
|
||||
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
|
||||
}
|
||||
const configuredArguments = await execFileOutput(
|
||||
command,
|
||||
[...supportArguments, "-vmargs"],
|
||||
{
|
||||
env: environment,
|
||||
// Wait for the helper's exit on abort so a cancelled or timed-out
|
||||
// startup never leaves the probe running without an owning process.
|
||||
stopSignal: "SIGTERM",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
);
|
||||
@@ -83,6 +150,7 @@ export const ghidraJavaLaunch = async (options: {
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid Ghidra LaunchSupport JVM argument");
|
||||
rejectJvmPlatformOverrides(vmArguments);
|
||||
return {
|
||||
command,
|
||||
environment,
|
||||
|
||||
@@ -172,6 +172,8 @@ export interface GhidraHeadlessLauncherOptions {
|
||||
readonly mipsSoftFloat?: true;
|
||||
/** Spawn seam for provider-boundary lifecycle tests. */
|
||||
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
|
||||
/** LaunchSupport probe seam for Java-free launcher fixtures. */
|
||||
readonly javaLaunch?: typeof ghidraJavaLaunch;
|
||||
}
|
||||
|
||||
/** Launch Ghidra without copying scripts into or modifying its installation. */
|
||||
@@ -303,43 +305,46 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
throw new GhidraLaunchError(
|
||||
"POSIX Ghidra requires its inspected JDK home",
|
||||
);
|
||||
const inspectedJavaLaunch = await (
|
||||
this.options.javaLaunch ?? ghidraJavaLaunch
|
||||
)({
|
||||
platform,
|
||||
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
|
||||
...(this.options.javaHome === undefined
|
||||
? {}
|
||||
: { javaHome: this.options.javaHome }),
|
||||
...(this.options.javaVmName === undefined
|
||||
? {}
|
||||
: { javaVmName: this.options.javaVmName }),
|
||||
homeRoot: paths.homeRoot,
|
||||
tempRoot: paths.tempRoot,
|
||||
arguments: headlessArguments,
|
||||
environment,
|
||||
onStderr: (mode, stderr) => {
|
||||
if (stderr === "") return;
|
||||
const bytes = Buffer.byteLength(stderr);
|
||||
const redacted = redactCapturedTransportCredential(
|
||||
{ text: stderr, bytes, observedBytes: bytes },
|
||||
session.token,
|
||||
"[REDACTED]",
|
||||
);
|
||||
const bounded = boundedUtf8Text(
|
||||
redacted,
|
||||
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
launchSupport.push({
|
||||
mode,
|
||||
stderr: bounded.text,
|
||||
stderr_bytes: bytes,
|
||||
stderr_truncated: bounded.truncated,
|
||||
});
|
||||
},
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
const command =
|
||||
platform !== "win32" && this.options.javaHome !== undefined
|
||||
? await ghidraJavaLaunch({
|
||||
platform,
|
||||
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
|
||||
javaHome: this.options.javaHome,
|
||||
...(this.options.javaVmName === undefined
|
||||
? {}
|
||||
: { javaVmName: this.options.javaVmName }),
|
||||
homeRoot: paths.homeRoot,
|
||||
tempRoot: paths.tempRoot,
|
||||
arguments: headlessArguments,
|
||||
environment,
|
||||
onStderr: (mode, stderr) => {
|
||||
if (stderr === "") return;
|
||||
const bytes = Buffer.byteLength(stderr);
|
||||
const redacted = redactCapturedTransportCredential(
|
||||
{ text: stderr, bytes, observedBytes: bytes },
|
||||
session.token,
|
||||
"[REDACTED]",
|
||||
);
|
||||
const bounded = boundedUtf8Text(
|
||||
redacted,
|
||||
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
launchSupport.push({
|
||||
mode,
|
||||
stderr: bounded.text,
|
||||
stderr_bytes: bytes,
|
||||
stderr_truncated: bounded.truncated,
|
||||
});
|
||||
},
|
||||
...(options.signal === undefined
|
||||
? {}
|
||||
: { signal: options.signal }),
|
||||
})
|
||||
: { ...scriptCommand, environment };
|
||||
platform === "win32"
|
||||
? { ...scriptCommand, environment }
|
||||
: inspectedJavaLaunch;
|
||||
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
|
||||
command: command.command,
|
||||
arguments: command.arguments,
|
||||
|
||||
@@ -37,12 +37,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (): GhidraInstallationHost => ({
|
||||
platform: "linux",
|
||||
const installationHost = (
|
||||
platform: NodeJS.Platform = "linux",
|
||||
): GhidraInstallationHost => ({
|
||||
platform,
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -102,7 +106,7 @@ describe("Ghidra provider", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = provider({ ...installationHost(), platform: "win32" });
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
it("discovers the exact installation once without launching Ghidra", () => {
|
||||
@@ -112,6 +116,8 @@ describe("Ghidra provider", () => {
|
||||
probeJava: () => {
|
||||
probeCount += 1;
|
||||
return {
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -193,7 +199,7 @@ describe("Ghidra provider", () => {
|
||||
|
||||
describe("Ghidra raw-binary profile", () => {
|
||||
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const target: BinaryTarget = {
|
||||
path: "/tmp/6502.bin",
|
||||
sha256: "a".repeat(64),
|
||||
@@ -260,7 +266,7 @@ describe("Ghidra raw-binary profile", () => {
|
||||
expect(shifted.value.profile?.digest).not.toBe(
|
||||
resolved.value.profile.digest,
|
||||
);
|
||||
const windows = provider({ ...installationHost(), platform: "win32" });
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
});
|
||||
@@ -302,7 +308,7 @@ describe("Ghidra Mach-O slice support", () => {
|
||||
|
||||
describe("Ghidra platform support", () => {
|
||||
it("commits the PE role and managed classification to profile identity", async () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const application = peTarget("x86_64");
|
||||
const library = {
|
||||
...application,
|
||||
@@ -343,7 +349,7 @@ describe("Ghidra platform support", () => {
|
||||
});
|
||||
|
||||
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
expect(
|
||||
ghidra
|
||||
.capabilities()
|
||||
@@ -363,7 +369,7 @@ describe("Ghidra platform support", () => {
|
||||
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
|
||||
});
|
||||
onTestFinished(() => native.mockRestore());
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const nativeApplication = peTarget("x86_64");
|
||||
|
||||
for (const architecture of ["x86", "x86_64"] as const)
|
||||
@@ -454,7 +460,7 @@ describe("Ghidra Windows PE fixture admission", () => {
|
||||
await promisify(execFile)(process.execPath, [
|
||||
join(root, "scripts/create-ghidra-windows-fixture.mjs"),
|
||||
]);
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
for (const architecture of ["x86", "x86_64"] as const) {
|
||||
for (const dll of [false, true]) {
|
||||
const path = join(
|
||||
@@ -1818,6 +1824,8 @@ describe("compatible Ghidra builds", () => {
|
||||
readText: () =>
|
||||
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "27",
|
||||
major: 27,
|
||||
home: "/usr/lib/jvm/java-27-openjdk",
|
||||
|
||||
@@ -43,6 +43,8 @@ const fixture = (version = "12.1.4") => {
|
||||
`application.version=${version}\napplication.java.min=21\n`,
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
@@ -68,6 +68,130 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const errorBody = (result: {
|
||||
readonly content: readonly { readonly type: string }[];
|
||||
}) => {
|
||||
const content = result.content.find((item) => item.type === "text");
|
||||
if (content?.type !== "text" || !("text" in content))
|
||||
throw new Error("Missing error response");
|
||||
return JSON.parse(String(content.text));
|
||||
};
|
||||
|
||||
const createElectronApplication = async (root: string) => {
|
||||
const application = join(root, "app");
|
||||
await mkdir(application);
|
||||
await writeFile(
|
||||
join(application, "package.json"),
|
||||
JSON.stringify({ name: "fixture", main: "main.js" }),
|
||||
);
|
||||
await writeFile(join(application, "main.js"), "");
|
||||
return application;
|
||||
};
|
||||
|
||||
cliTest.skipIf(isWindows)(
|
||||
"rejects a non-executable Electron executable_path before launch through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-not-executable-");
|
||||
const application = await createElectronApplication(root);
|
||||
const executable = join(root, "not-an-executable.txt");
|
||||
await writeFile(executable, "plain text");
|
||||
await chmod(executable, 0o644);
|
||||
const request = {
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
actions: [],
|
||||
};
|
||||
const requestPath = join(root, "input.json");
|
||||
await writeFile(requestPath, JSON.stringify(request));
|
||||
|
||||
const response = await cli.run({
|
||||
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
expect(response.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const result = await call("capture_electron_scenario", request);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorBody(result)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await call("binary_session", {});
|
||||
expect(session.isError, JSON.stringify(session)).not.toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.skipIf(isWindows)(
|
||||
"returns a typed failure when an executable exits before Electron starts and keeps the MCP server and its Evidence",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-early-exit-");
|
||||
const application = await createElectronApplication(root);
|
||||
const executable = join(root, "exits-immediately");
|
||||
await writeFile(executable, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(executable, 0o755);
|
||||
const request = {
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
actions: [],
|
||||
};
|
||||
const requestPath = join(root, "input.json");
|
||||
await writeFile(requestPath, JSON.stringify(request));
|
||||
|
||||
const response = await cli.run({
|
||||
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
expect(response.json).toMatchObject({
|
||||
code: "execution_failure",
|
||||
details: { operation: "capture_electron_scenario" },
|
||||
});
|
||||
expect(response.stdout).toContain("Process failed to launch");
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const analysis = await call("analyze_javascript_application", {
|
||||
input_path: application,
|
||||
});
|
||||
expect(analysis.isError, JSON.stringify(analysis)).not.toBe(true);
|
||||
const retained = async () => {
|
||||
const bundle = await call("get_evidence_bundle", {});
|
||||
expect(bundle.isError, JSON.stringify(bundle)).not.toBe(true);
|
||||
const structured = bundle.structuredContent as {
|
||||
result: { records: { evidence_id: string }[] };
|
||||
};
|
||||
return structured.result.records.map((record) => record.evidence_id);
|
||||
};
|
||||
const before = await retained();
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
const result = await call("capture_electron_scenario", request);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorBody(result)).toMatchObject({
|
||||
error: {
|
||||
code: "execution_failure",
|
||||
details: { operation: "capture_electron_scenario" },
|
||||
},
|
||||
});
|
||||
const session = await call("binary_session", {});
|
||||
expect(session.isError, JSON.stringify(session)).not.toBe(true);
|
||||
expect(await retained()).toEqual(before);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects a file URL path field without resolving an invented local path",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -15,6 +15,19 @@ const run = async (arguments_: readonly string[]): Promise<unknown> => {
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const runFailure = async (arguments_: readonly string[]): Promise<unknown> => {
|
||||
try {
|
||||
await run(arguments_);
|
||||
} catch (cause: unknown) {
|
||||
if (typeof cause !== "object" || cause === null || !("stdout" in cause))
|
||||
throw cause;
|
||||
const stdout = cause.stdout;
|
||||
if (typeof stdout !== "string") throw cause;
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
throw new Error("Expected the CLI command to fail");
|
||||
};
|
||||
|
||||
it.each(["analyze-javascript-application", "analyze"])(
|
||||
"%s carries the integrity policy into shared JavaScript analysis",
|
||||
async (command) => {
|
||||
@@ -46,3 +59,48 @@ it("rejects the JavaScript integrity policy on binary-only inspection", async ()
|
||||
stdout: expect.stringContaining("Unknown flag: --integrityPolicy"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["fail", "record-and-continue"] as const)(
|
||||
"rejects the explicitly selected %s integrity policy on a binary analyze target",
|
||||
async (policy) => {
|
||||
const failure = await runFailure([
|
||||
"analyze",
|
||||
"unused.bin",
|
||||
"--integrity-policy",
|
||||
policy,
|
||||
]);
|
||||
expect(failure).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["integrity_policy"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("still routes a binary analyze target without an integrity policy", async () => {
|
||||
await expect(run(["analyze", "unused.bin"])).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("target does not exist"),
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises the optional integrity policy without an undefined union", async () => {
|
||||
const schema = (await run(["analyze", "--schema"])) as {
|
||||
options?: { properties?: { integrityPolicy?: unknown } };
|
||||
};
|
||||
const policy = schema.options?.properties?.integrityPolicy;
|
||||
expect(policy).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["fail", "record-and-continue"],
|
||||
description: "Behavior when declared application integrity does not match",
|
||||
});
|
||||
expect(JSON.stringify(policy)).not.toContain("undefined");
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ const host: GhidraInstallationHost = {
|
||||
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
|
||||
@@ -106,6 +106,8 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
|
||||
@@ -102,6 +102,8 @@ const installationHost: GhidraInstallationHost = {
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/fixture/jdk-21",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
processTest,
|
||||
type TestProcesses,
|
||||
} from "../../support/process/processFixture.js";
|
||||
|
||||
const script = `
|
||||
import { PlaywrightElectronActiveProvider } from "./dist/browser/PlaywrightElectronActiveProvider.js";
|
||||
import { electronActiveObservationInputSchema } from "./dist/domain/javascript/electronActiveObservation.js";
|
||||
|
||||
const [scenario, executable, root] = JSON.parse(process.argv[1]);
|
||||
let observedRejections = 0;
|
||||
if (scenario === "oneFailureWithManySuccesses" || scenario === "externalListenerSeesUnrelatedOnce")
|
||||
process.on("unhandledRejection", () => { observedRejections += 1; });
|
||||
const orphanStack = (message, file) =>
|
||||
message + "\\n at waitForLine (/app/node_modules/playwright-core/lib/server/" + file + ":247:21)";
|
||||
const playwrightError = (message, file = "electron/electron.js") => {
|
||||
const error = new Error(message);
|
||||
error.stack = "Error: " + orphanStack(message, file);
|
||||
return error;
|
||||
};
|
||||
const rejectLater = (error, ms) => setTimeout(() => void Promise.reject(error), ms);
|
||||
const fakeApplication = { process: () => ({ pid: undefined }), close: async () => undefined };
|
||||
const launchFailure = (message) =>
|
||||
new Error("electron.launch: " + message + "\\nCall log:\\n - <launching>");
|
||||
const after = (ms, action) => new Promise((resolve, reject) => setTimeout(() => {
|
||||
try { resolve(action()); } catch (error) { reject(error); }
|
||||
}, ms));
|
||||
const orphans = (message) => {
|
||||
for (let index = 0; index < 3; index += 1) void Promise.reject(playwrightError(message));
|
||||
};
|
||||
|
||||
let during = -1;
|
||||
const launches = {
|
||||
realLaunch: undefined,
|
||||
realWithUnrelatedPlaywrightRejection: async (options) => {
|
||||
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
|
||||
return (await import("playwright-core"))._electron.launch(options);
|
||||
},
|
||||
failureWithOrphans: async () => {
|
||||
during = process.listenerCount("unhandledRejection");
|
||||
orphans("Process failed to launch!");
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
failureInOneTick: async () => {
|
||||
orphans("Process failed to launch!");
|
||||
throw launchFailure("Process failed to launch!");
|
||||
},
|
||||
failureWithUnrelatedPlaywrightRejection: async () => {
|
||||
orphans("Process failed to launch!");
|
||||
void Promise.reject(playwrightError("Target page, context or browser has been closed", "page.js"));
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
externalListenerSeesUnrelatedOnce: async () => {
|
||||
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
pendingWithUnrelatedRejection: () => {
|
||||
setInterval(() => {}, 1000);
|
||||
rejectLater(new Error("unrelated application failure"), 20);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
success: async () => {
|
||||
during = process.listenerCount("unhandledRejection");
|
||||
return fakeApplication;
|
||||
},
|
||||
successWithPlaywrightRejection: async () => {
|
||||
void Promise.reject(playwrightError("stray playwright failure", "page.js"));
|
||||
await after(10, () => fakeApplication);
|
||||
return fakeApplication;
|
||||
},
|
||||
};
|
||||
|
||||
const input = (executablePath) => ({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: executablePath,
|
||||
application_path: root,
|
||||
}),
|
||||
application_root: root,
|
||||
});
|
||||
const capture = (launch, executablePath = executable) =>
|
||||
new PlaywrightElectronActiveProvider({}, launch).capture(input(executablePath));
|
||||
|
||||
const before = process.listenerCount("unhandledRejection");
|
||||
let results;
|
||||
if (scenario === "concurrentFailures" || scenario === "failureThenSuccess" || scenario === "successThenFailure" || scenario === "oneFailureWithManySuccesses") {
|
||||
const failing = (message, orphanAt, settleAt) => async () => {
|
||||
during = Math.max(during, process.listenerCount("unhandledRejection"));
|
||||
await after(orphanAt, () => orphans(message));
|
||||
await after(settleAt - orphanAt, () => { throw launchFailure(message); });
|
||||
};
|
||||
const succeeding = (settleAt) => () => {
|
||||
during = Math.max(during, process.listenerCount("unhandledRejection"));
|
||||
return after(settleAt, () => fakeApplication);
|
||||
};
|
||||
const pair = {
|
||||
concurrentFailures: [failing("alpha failed", 10, 30), failing("beta failed", 20, 50)],
|
||||
failureThenSuccess: [failing("alpha failed", 10, 20), succeeding(40)],
|
||||
successThenFailure: [failing("alpha failed", 20, 40), succeeding(30)],
|
||||
oneFailureWithManySuccesses: [failing("alpha failed", 10, 80), ...[20, 30, 40, 50].map(succeeding)],
|
||||
}[scenario];
|
||||
results = await Promise.all(pair.map((launch) => capture(launch)));
|
||||
} else {
|
||||
results = [await capture(launches[scenario] ?? undefined)];
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
console.log(JSON.stringify({
|
||||
before,
|
||||
during,
|
||||
after: process.listenerCount("unhandledRejection"),
|
||||
observedRejections,
|
||||
reasons: results.map((result) => result.ok ? null : result.error.reason ?? null),
|
||||
ok: results.map((result) => result.ok),
|
||||
causeReasons: results.map((result) => result.ok ? null : result.error.cause?.reason ?? null),
|
||||
cleanupResources: results.map((result) => result.ok ? null : result.error.cleanupResources ?? null),
|
||||
causes: results.map((result) => result.ok ? null : String(result.error.cause?.message).split("\\n")[0]),
|
||||
}));
|
||||
`;
|
||||
|
||||
// A cold macOS Swift toolchain may spend up to 120 seconds building the
|
||||
// ownership reader before any child launch reaches its assertion.
|
||||
vi.setConfig({ testTimeout: 180_000 });
|
||||
|
||||
type Outcome = {
|
||||
readonly before: number;
|
||||
readonly during: number;
|
||||
readonly after: number;
|
||||
readonly ok: readonly boolean[];
|
||||
readonly causes: readonly (string | null)[];
|
||||
readonly observedRejections: number;
|
||||
readonly reasons: readonly (string | null)[];
|
||||
readonly causeReasons: readonly (string | null)[];
|
||||
readonly cleanupResources: readonly (readonly string[] | null)[];
|
||||
};
|
||||
|
||||
const run = async (
|
||||
processes: TestProcesses,
|
||||
scenario: string,
|
||||
executable = "/not/launched",
|
||||
root = "/",
|
||||
) =>
|
||||
processes.run(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
script,
|
||||
JSON.stringify([scenario, executable, root]),
|
||||
],
|
||||
{ timeoutMs: 150_000 },
|
||||
);
|
||||
|
||||
const outcome = (result: { readonly stdout: string }): Outcome =>
|
||||
JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "") as Outcome;
|
||||
|
||||
const earlyExit = async () => {
|
||||
const root = await createTestTempDirectory("rea-launch-rejections-");
|
||||
const executable = join(root, "exits-immediately");
|
||||
await mkdir(join(root, "app"));
|
||||
await writeFile(executable, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(executable, 0o755);
|
||||
return { executable, root: join(root, "app") };
|
||||
};
|
||||
|
||||
processTest(
|
||||
"absorbs the orphaned rejections of a failed launch and removes its listener",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "failureWithOrphans");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
during: outcome(result).before + 1,
|
||||
after: outcome(result).before,
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"absorbs orphans raised in the same tick as the launch failure",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "failureInOneTick");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({ ok: [false] });
|
||||
},
|
||||
);
|
||||
|
||||
processTest.skipIf(process.platform === "win32")(
|
||||
"absorbs the orphans of a real Playwright launch that exits at once",
|
||||
async ({ processes }) => {
|
||||
const { executable, root } = await earlyExit();
|
||||
const result = await run(processes, "realLaunch", executable, root);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
expect(outcome(result).after).toBe(outcome(result).before);
|
||||
},
|
||||
);
|
||||
|
||||
processTest.skipIf(process.platform === "win32")(
|
||||
"still fails on an unrelated Playwright rejection during a real failing launch",
|
||||
async ({ processes }) => {
|
||||
const { executable, root } = await earlyExit();
|
||||
const result = await run(
|
||||
processes,
|
||||
"realWithUnrelatedPlaywrightRejection",
|
||||
executable,
|
||||
root,
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("unrelated playwright failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"does not replay an unrelated orphan to a listener that already received it",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "externalListenerSeesUnrelatedOnce");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
observedRejections: 1,
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"still fails on an unrelated Playwright rejection beside a failing launch's own orphans",
|
||||
async ({ processes }) => {
|
||||
const result = await run(
|
||||
processes,
|
||||
"failureWithUnrelatedPlaywrightRejection",
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"Target page, context or browser has been closed",
|
||||
);
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"still fails at once on an unrelated rejection while a launch is pending",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "pendingWithUnrelatedRejection");
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("unrelated application failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"removes its listener after a successful launch",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "success");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
during: outcome(result).before + 1,
|
||||
after: outcome(result).before,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"re-raises a Playwright rejection when the launch succeeds",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "successWithPlaywrightRejection");
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("stray playwright failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest.for([
|
||||
"concurrentFailures",
|
||||
"failureThenSuccess",
|
||||
"successThenFailure",
|
||||
])(
|
||||
"keeps overlapping launches from re-raising each other's orphans (%s)",
|
||||
async (scenario, { processes }) => {
|
||||
const result = await run(processes, scenario);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result).after).toBe(outcome(result).before);
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"holds each orphan once across many overlapping successful launches",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "oneFailureWithManySuccesses");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const observed = outcome(result);
|
||||
expect(observed.during).toBe(observed.before + 1);
|
||||
expect(observed.after).toBe(observed.before);
|
||||
expect(observed.observedRejections).toBe(3);
|
||||
expect(observed.ok).toEqual([false, false, false, false, false]);
|
||||
expect(observed.causes[0]).toBe("electron.launch: alpha failed");
|
||||
expect(observed.reasons).toEqual([
|
||||
null,
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
]);
|
||||
expect(observed.causeReasons).toEqual([
|
||||
null,
|
||||
...Array.from({ length: 4 }, () => "process_ownership_unavailable"),
|
||||
]);
|
||||
expect(observed.cleanupResources).toEqual([
|
||||
[],
|
||||
...Array.from({ length: 4 }, () => ["owned_process_group"]),
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -50,9 +50,16 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
process.stderr.write("failed " + probe + "\\n");
|
||||
process.exitCode = 3;
|
||||
} else if (probe === "-envvars") {
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
} else {
|
||||
process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n");
|
||||
const vmArguments = mode === "vm-malformed"
|
||||
? "invalid JVM argument\\n"
|
||||
: mode === "os-arch-override"
|
||||
? "-Dos.arch=x86_64\\n"
|
||||
: mode === "os-name-override"
|
||||
? "-Dos.name=Windows\\n"
|
||||
: "-Dfile.encoding=UTF8\\n";
|
||||
process.stdout.write(vmArguments);
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
@@ -215,6 +222,73 @@ describe.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["os-arch-override", "os-name-override"])(
|
||||
"rejects LaunchSupport %s before starting analysis",
|
||||
async (mode) => {
|
||||
const { session, launcher, analysisArguments } = await fixture(mode);
|
||||
const launched = await launcher.launch(session);
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["JAVA_TOOL_OPTIONS", "-Dos.arch=x86_64"],
|
||||
["JAVA_TOOL_OPTIONS", '"-Dos.arch=x86_64"'],
|
||||
["_JAVA_OPTIONS", "'-Dos.name=Windows'"],
|
||||
["JDK_JAVA_OPTIONS", '-D"os.name"=Windows'],
|
||||
["JDK_JAVA_OPTIONS", "-D'os.arch'=x86_64"],
|
||||
])(
|
||||
"rejects helper-environment platform injection from %s=%s before analysis",
|
||||
async (key, value) => {
|
||||
const { session, launcher, analysisArguments } = await fixture(
|
||||
`env-injection:${key}=${value}`,
|
||||
);
|
||||
const launched = await launcher.launch(session);
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("admits a quoted non-platform option without mistaking its value for a platform argument", async () => {
|
||||
const { session, launcher, analysisArguments } = await fixture(
|
||||
'env-injection:JAVA_TOOL_OPTIONS=-Ddescription="text -Dos.arch=x86_64"',
|
||||
);
|
||||
const launched = await launcher.launch(session);
|
||||
if (!launched.ok) throw launched.error;
|
||||
expect(analysisArguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["@options", '"@options with spaces"'])(
|
||||
"refuses indirect JVM options (%s) instead of assuming platform alignment",
|
||||
async (value) => {
|
||||
const { session, launcher, analysisArguments } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=${value}`,
|
||||
);
|
||||
const launched = await launcher.launch(session);
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok) throw new Error("Expected an argument-file refusal");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("may not supply JVM argument files"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("bounds retained UTF-8 warnings and reports original byte counts", async () => {
|
||||
const { session, launcher } = await fixture("large");
|
||||
const launched = await launcher.launch(session);
|
||||
@@ -290,6 +364,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
"application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
|
||||
osName:
|
||||
process.platform === "darwin"
|
||||
? "Mac OS X"
|
||||
: process.platform === "win32"
|
||||
? "Windows 11"
|
||||
: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/fixture/jdk",
|
||||
|
||||
@@ -524,6 +524,13 @@ describe("Ghidra Windows launcher runtime", () => {
|
||||
),
|
||||
javaHome: "C:\\Java\\jdk-21",
|
||||
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"),
|
||||
// The preflight validates the inspected JVM's LaunchSupport output;
|
||||
// these fixtures assert batch-spawn behavior without a working JDK.
|
||||
javaLaunch: async () => ({
|
||||
command: "C:\\Java\\jdk-21\\bin\\java.exe",
|
||||
arguments: [],
|
||||
environment: {},
|
||||
}),
|
||||
spawnProcess,
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
@@ -569,6 +576,13 @@ describe("Ghidra Windows launcher runtime", () => {
|
||||
platform: "win32",
|
||||
javaHome,
|
||||
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
|
||||
// The preflight validates the inspected JVM's LaunchSupport output;
|
||||
// this fixture captures the batch launcher without a working JDK.
|
||||
javaLaunch: async () => ({
|
||||
command: `${javaHome}\\bin\\java.exe`,
|
||||
arguments: [],
|
||||
environment: {},
|
||||
}),
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot,
|
||||
|
||||
Reference in New Issue
Block a user