Compare commits

...
Author SHA1 Message Date
morluto 739cf68e1a fix(ghidra): match upstream OS selection and refuse indirect JVM options 2026-10-14 07:43:08 +08:00
20e4f83097 fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory (#2792)
* fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory

probeJava now also reads the JVM os.arch property. When it maps to a
different native decompiler directory than the one validated from the
host architecture (for example an x86_64 JDK under Rosetta on Apple
silicon selecting os/mac_x86_64 while only mac_arm_64 was built), the
native_decompiler check fails with a remediation pointing at a
matching-arch JDK instead of reporting a healthy installation that
fails every analysis. Unknown os.arch values keep the previous
behavior.

* fix(ghidra): tighten JVM arch remediation and cover os.arch extraction

Address review findings on the JVM os.arch decompiler-directory check:
- The remediation no longer suggests building the decompiler for the
  JVM-selected directory, which could never clear the check; it only
  advises pointing JAVA_HOME at a matching-architecture JDK.
- Add a test running the real Java probe against captured
  -XshowSettings:properties output, asserting os.arch reaches the
  installation diagnostics.
- Document the JDK-architecture prerequisite in the Ghidra setup guide.

* fix(ghidra): refuse unrecognized JVM native architectures

* docs(ghidra): align doctor prerequisites with JVM architecture admission

* fix(ghidra): reject unreported JVM architecture

* test(ghidra): separate JVM and executable admission cases

* fix(ghidra): defer decompiler arch validation when Java is absent

* fix(ghidra): reject JVM native architecture override

* fix(ghidra): validate JVM platform overrides

* fix(ghidra): validate JVM os.name and helper-environment platform overrides

* test(ci): isolate report fixtures and declare PowerPC Java bounds

* test(ghidra): provide observed JVM platform in PowerPC fixture

* fix(ghidra): parse quoted JVM environment options before admission

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:31:11 +08:00
97aff28201 fix(electron): reject non-executable paths and contain launch rejections (#2705)
* fix(electron): reject non-executable paths and contain launch rejections

An executable_path that runs but never prints the inspector line made
Playwright's electron.launch() leave three waits unawaited. They rejected
unhandled and Node ended the CLI or the MCP server (#2269).

Check the resolved executable_path is a regular file, and on non-Windows
hosts that it passes X_OK, before any launch. Wrap only the launch call
in a scoped unhandledRejection listener that absorbs rejections whose
stack is in playwright-core/lib and re-throws anything else.

Tested on Linux with Node 22.23.1, 24.18.0 and 26.11.1.

Refs #2269

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* fix(electron): absorb only this launch's orphaned rejections

The listener around the launch dropped every rejection with a
playwright-core stack, so an unrelated Playwright failure during the
launch window would vanish. Hold those rejections instead. When the
launch settles, drop only the ones whose message is part of the launch's
own error (the orphans of a failed launch). Raise every other one again
through the unhandledRejection path, so the process still fails. A launch
that succeeds raises all of them. Rejections without a Playwright stack
still fail at once.

One setImmediate turn before the decision lets rejections queued by the
settling tick arrive first. It is a single turn, not a timer. A shared
set of absorbed errors stops overlapping launches from raising each
other's orphans. The listener is removed on every path.

Add child-process tests that drive the built provider through its launch
seam, since Vitest's worker has its own unhandledRejection listener.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(electron): distinguish launch success from capture ownership

* test(electron): assert cleanup ownership failures around overlapping launches

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(android): make DEX descriptor pattern portable

* fix(ci): isolate Linux Hopper process fixture inventory

* fix(browser): avoid replaying observed launch rejections

* style: format comparison and IDA sources

* test(electron): allow cold macOS ownership setup

* test(electron): allow cold macOS setup in the early-exit path test

The test that runs an executable which exits at once builds the Swift
ownership helper twice on macOS: in the CLI process, and again in the
MCP server process on its first tool call that prepares. A cold Swift
cache can spend up to 120 seconds on a build. The test had only the
30 second default.

Give the test 180 seconds, as e1579ec31 did for
electronLaunchRejections.test.ts and 65a809a0d did for
webSourceLocationCli.test.ts. cli.run has no deadline of its own, so
it needs no timeoutMs.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: Claude Opus 5.5 <opus@fox.invalid>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
2026-10-14 07:30:14 +08:00
641a8395ee fix(cli): refuse a JavaScript integrity policy the binary analyze lane cannot honor (#2808)
* fix(cli): refuse an integrity policy the binary analyze lane cannot honor

`analyze` declares --integrity-policy for its JavaScript lane and forwards it
only there. On a binary target the flag was accepted and then dropped:
inspect_artifact-style integrity handling does not exist on this path, and
binary_overview is a strictObject with no input at all, so the request could
never take effect and the run proceeded as if the caller had not asked.

The same branch already refuses source_paths on a binary target with a typed
AnalysisInputError, and the sibling `inspect` command refuses the flag outright,
so the refusal is the established handling here. The policy option carries a
default, so the check keys off a value that differs from the owner schema's own
default rather than the flag's presence; otherwise every binary analyze would be
refused. The default is read from the schema instead of being restated.

Verified with 5 integrity policy CLI cases, check:fast and
verify:test-discovery (1023 files) on macOS arm64, and by running the built CLI
for the no-flag, explicit-default and record-and-continue cases. The regression
case failed with the command reaching the binary lane before the change.

* fix(cli): reject explicit default integrity policies for binaries

* fix(cli): keep an omitted integrity policy absent on the binary lane

`javascriptApplicationOptions.shape.integrityPolicy` fills an omission with its
default, and `.optional()` keeps reporting that parsed value, so
`options.integrityPolicy !== undefined` was true for every `analyze <target>`
call: ordinary binary analysis was rejected before the target was even read.

List `z.undefined()` first in a union so an omitted option stays absent while
the owner still validates a supplied value, and reapply the owner's metadata so
help and `--schema` keep describing the option.

Validation: tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts reports
1 failed | 5 passed without this change (the case "still routes a binary
analyze target without an integrity policy" returned invalid_request for
integrity_policy instead of reaching the target) and 6 passed with it;
`npm run check:fast` passes on macOS 26.5.1 arm64 with Node.js 24.18.0.

* fix(cli): omit integrity defaults from binary option schema

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:28:00 +08:00
20 changed files with 1310 additions and 61 deletions
+15 -1
View File
@@ -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
+34 -2
View File
@@ -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",
+6 -1
View File
@@ -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",
+2
View File
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
home: "/jdk-21",
bits: 64,
runtime: "jdk",
osArch: "amd64",
osName: "Linux",
}),
};
+268 -3
View File
@@ -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();
}
});
});
+132
View File
@@ -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(
+70 -2
View File
@@ -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,
+41 -36
View File
@@ -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,
+17 -9
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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,