fix(ghidra): pre-create the launch-support config directory (#2470)

* fix(ghidra): pre-create the launch-support config directory

Ghidra's launch support resolves its JDK before the headless run starts and
persists that choice under `<config>/ghidra/<release directory>`. Inside REA's
private runtime a child JVM cannot create those directories, so the first
Windows Ghidra query aborted at ghidra.launch.AppConfig.saveJavaHome with:

    java.io.IOException: Failed to create directory:
    ...\config\ghidra\ghidra_12.1.4_PUBLIC
        at ghidra.launch.AppConfig.saveJavaHome(AppConfig.java:145)
        at LaunchSupport.findJavaHome(LaunchSupport.java:210)

LaunchSupport then fell back to its interactive prompt, read EOF, and reported
"JDK 21+ (64-bit) could not be found and must be manually chosen!", so the
provider failed with "Ghidra exited before bridge startup" even though
`rea doctor` reported a valid Ghidra installation and JDK.

Create the release directory while this process still owns the runtime, leaving
Ghidra only a file write inside it. The release coordinate comes from the
installation's Ghidra/application.properties (application.version plus
application.release.name) with the extraction directory name as a fallback. On
Windows each level goes through the native private-runtime owner, which opens
every existing component and creates only the final one.

Verified on Windows x64 with Ghidra 12.1.4, a 64-bit Temurin JDK 21, and REA's
packaged native addon: `rea inspect <x64 PE DLL> --provider ghidra` and the
equivalent MCP `binary_overview` call both complete and report 21 segments,
1436 procedures, and 2761 strings. Windows Ghidra remains read-only P0; this
change does not extend that boundary.

* style(ghidra): format launch configuration regressions

* fix(ghidra): reuse Java Properties parsing for launch release identity

---------

Co-authored-by: AbdulAzizShanShi <441650742@QQ.COM>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 20:51:18 +08:00
1 parent f63674cac0
commit 0ca70e121c
2 files changed
+114 -3

No files matched your search

+48 -2
View File
@@ -1,3 +1,4 @@
import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js";
import {
GHIDRA_SEED_SCRIPT,
snapshotGhidraAnalysisSeeds,
@@ -5,7 +6,7 @@ import {
} from "./GhidraAnalysisSeeds.js";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { mkdir, realpath } from "node:fs/promises";
import { mkdir, readFile, realpath } from "node:fs/promises";
import { basename, dirname, join, posix, win32 } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -219,7 +220,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
"Installed Allegrex content changed since profile resolution; reopen the target to resolve a fresh profile.",
);
}
await createGhidraRuntimeDirectories(paths, platform);
await createGhidraRuntimeDirectories(
paths,
platform,
await ghidraConfigReleaseDirectory(this.options.analyzeHeadlessPath),
);
const seedPath =
this.options.analysisSeeds === undefined
? undefined
@@ -721,9 +726,44 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({
scriptLogPath: join(runtimeRoot, "script.log"),
});
/**
* Ghidra's launch support resolves its JDK before the headless run and then
* persists that choice under `<config>/ghidra/<release directory>`. Inside
* REA's Windows private runtime a child JVM cannot create those directories
* itself, so startup aborted with "Failed to create directory" and reported
* that no supported JDK was found. Create the release directory while this
* process still owns the runtime, leaving Ghidra only a file write inside it.
*/
const ghidraConfigReleaseDirectory = async (
analyzeHeadlessPath: string,
): Promise<string> => {
const installRoot = dirname(dirname(analyzeHeadlessPath));
try {
const properties = await readFile(
join(installRoot, "Ghidra", "application.properties"),
"latin1",
);
const parsed = parseGhidraJavaProperties(properties);
if (!parsed.ok) return basename(installRoot);
const version = parsed.value.get("application.version");
const release = parsed.value.get("application.release.name");
return version === undefined ||
release === undefined ||
release.length === 0
? basename(installRoot)
: `ghidra_${version}_${release}`;
} catch (cause: unknown) {
// An unreadable properties file leaves the release coordinate unknown; the
// conventional extraction directory carries the same name.
void cause;
return basename(installRoot);
}
};
const createGhidraRuntimeDirectories = async (
paths: ReturnType<typeof ghidraRuntimePaths>,
platform: NodeJS.Platform,
configReleaseDirectory: string,
): Promise<void> => {
if (platform === "win32") {
const runtime = windowsPrivateRuntime(dirname(paths.projectRoot));
@@ -736,6 +776,11 @@ const createGhidraRuntimeDirectories = async (
paths.dataRoot,
])
runtime.mkdir(basename(path));
// Each level needs its parent first: the native owner opens every existing
// component and creates only the final one.
const configRoot = basename(paths.configRoot);
runtime.mkdir(win32.join(configRoot, "ghidra"));
runtime.mkdir(win32.join(configRoot, "ghidra", configReleaseDirectory));
return;
}
await Promise.all(
@@ -746,6 +791,7 @@ const createGhidraRuntimeDirectories = async (
paths.cacheRoot,
paths.configRoot,
paths.dataRoot,
join(paths.configRoot, "ghidra", configReleaseDirectory),
].map((path) => mkdir(path, { recursive: true, mode: 0o700 })),
);
};
@@ -1,4 +1,11 @@
import { access, chmod, readFile, stat } from "node:fs/promises";
import {
access,
chmod,
mkdir,
readFile,
stat,
writeFile,
} from "node:fs/promises";
import { join, posix, win32 } from "node:path";
import { fileURLToPath } from "node:url";
@@ -480,6 +487,64 @@ describe("Ghidra Windows launcher", () => {
).toThrow(/metacharacters/u);
});
it.skipIf(process.platform !== "win32").each([
{
properties:
"application.version=12.1.4\napplication.release.name=PUBLIC\n",
release: "ghidra_12.1.4_PUBLIC",
},
{ properties: undefined, release: "renamed-install" },
{ properties: "application.version=12.1.4\n", release: "renamed-install" },
])(
"prepares the release directory through the Windows runtime ($release)",
async ({ properties, release }) => {
const parent = await createTestTempDirectory("rea-launch-config-");
const installRoot = join(parent, "renamed-install");
if (properties !== undefined) {
await mkdir(join(installRoot, "Ghidra"), { recursive: true });
await writeFile(
join(installRoot, "Ghidra", "application.properties"),
properties,
);
}
const runtime = await createGhidraTestRuntime(parent);
runtimes.push(runtime);
const spawnProcess = vi.fn(async () => {
throw new Error("fixture stops before process creation");
});
const launcher = new GhidraHeadlessLauncher({
environment: process.env,
platform: "win32",
analyzeHeadlessPath: join(
installRoot,
"support",
"analyzeHeadless.bat",
),
javaHome: "C:\\Java\\jdk-21",
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"),
spawnProcess,
});
const launched = await launcher.launch({
runtimeRoot: runtime.path,
transport: "unix-socket",
endpointPath: join(runtime.path, "bridge.sock"),
token: "fixture-token",
runId: "d6fcbb66-e829-4ff6-a535-0035aec63139",
targetPath: join(parent, "fixture.exe"),
targetSha256: "b".repeat(64),
providerVersion: "12.1.4",
profileDigest: "a".repeat(64),
});
expect(launched.ok).toBe(false);
expect(spawnProcess).toHaveBeenCalledOnce();
expect(
(
await stat(join(runtime.path, "config", "ghidra", release))
).isDirectory(),
).toBe(true);
},
);
it.skipIf(process.platform !== "win32")(
"keeps authority in a private descriptor and isolates Ghidra state through the batch launcher",
async () => {