From e0b989cc0aedfabab1128fd2e20ee98fed1e67e9 Mon Sep 17 00:00:00 2001 From: Jeongwan Jeon Date: Sat, 17 Oct 2026 01:13:46 +0900 Subject: [PATCH] fix(ghidra): consume inspected configuration directory identity (#4077) * fix(ghidra): consume inspected configuration directory identity Carry the parsed release coordinate through installation inspection and provider prerequisites into the launcher. Remove duplicate property reads and basename guesses, preserve unresolved identity, and canonicalize directory versions without changing observed provider versions. Keep verifier callers, lifecycle regressions, and installation guidance aligned. Extract the Ghidra-only follow-up from #2293 without changing CLI output or unrelated documentation. * docs(ghidra): clarify startup failure side-effect boundary Distinguish the launcher's directory and Java startup guard from the client's earlier private runtime allocation and target snapshot. Keep existing failure cleanup and runtime behavior unchanged. --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/installation.md | 15 ++++ scripts/verify-real-ghidra-windows.mjs | 1 + scripts/verify-real-ghidra.mjs | 1 + src/ghidra/GhidraInstallation.test.ts | 80 +++++++++++++++++++ src/ghidra/GhidraInstallation.ts | 4 + src/ghidra/GhidraInstallationPolicy.ts | 24 ++++++ src/ghidra/GhidraLaunchFailure.test.ts | 2 + src/ghidra/GhidraLauncher.ts | 37 ++------- src/ghidra/GhidraLauncherEnvironment.test.ts | 1 + src/ghidra/GhidraProvider.behavior.test.ts | 24 ++++++ src/ghidra/GhidraProviderClient.ts | 4 + src/ghidra/GhidraReleasePolicy.ts | 18 ++++- .../filesystem/ghidraPspExtension.test.ts | 1 + .../providers/ghidra/ghidraClient.test.ts | 1 + .../providers/ghidra/ghidraJavaLaunch.test.ts | 65 ++++++++++++++- .../providers/ghidra/ghidraLauncher.test.ts | 34 ++------ 16 files changed, 255 insertions(+), 57 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 7f12b7ea63..f8a996120d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -870,6 +870,21 @@ 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, installs, upgrades, or modifies Ghidra or Java. +Installation inspection derives the private configuration directory identity from +`application.version` and `application.release.name` in +`Ghidra/application.properties`. The directory's version spelling follows Ghidra +(for example, `12.1.0` becomes +`12.1`), while the reported provider version preserves the original value. +The launcher uses that inspected coordinate; it does not reread the properties +or guess from the extracted folder's name. +If the identity is missing, malformed, or unsafe as a single path component, +the launcher fails before preparing its home/cache/config/temp directories or +starting Java. The client may already have allocated private runtime/endpoint +resources and a target snapshot; existing startup-failure cleanup still applies. +Restore the selected release's properties instead of renaming its installation folder. +This coordinate selects only REA's session-private configuration directory, not +the installation or an existing user configuration directory. + Each verified session uses an ephemeral temporary project and isolated home/cache/config/temp paths. REA passes `-readOnly`, `-deleteProject`, uses Ghidra's default analysis settings, and loads its packaged Java diff --git a/scripts/verify-real-ghidra-windows.mjs b/scripts/verify-real-ghidra-windows.mjs index c3aa8fdd13..a69cb502a7 100644 --- a/scripts/verify-real-ghidra-windows.mjs +++ b/scripts/verify-real-ghidra-windows.mjs @@ -110,6 +110,7 @@ if (profile.value.profile.parameters.executable_role !== role) const client = new GhidraClient({ platform: installation.platform, launcher: new GhidraHeadlessLauncher({ + configurationDirectoryName: installation.configurationDirectoryName, environment: process.env, analyzeHeadlessPath: installation.analyzeHeadlessPath, ...(process.env.JAVA_HOME === undefined diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index 2911851644..74c0197b2f 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -489,6 +489,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) { const client = new GhidraClient({ launcher: new GhidraHeadlessLauncher({ + configurationDirectoryName: installation.configurationDirectoryName, environment: process.env, analyzeHeadlessPath: installation.analyzeHeadlessPath, ...(process.env.JAVA_HOME === undefined diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 89d1068a78..0a558371ac 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -471,6 +471,85 @@ describe("Ghidra installation rejection diagnostics", () => { }); }); +describe("Ghidra configuration directory identity", () => { + it.each([ + [ + "application.version=12.1.4\napplication.release.name=PUBLIC\n", + "ghidra_12.1.4_PUBLIC", + ], + [ + "application.version=12.1.2-2.1\napplication.release.name=DEV\n", + "ghidra_12.1.2-2.1_DEV", + ], + [ + "application.version=12.1.4\napplication.release.name=pub\\\n lic\n", + "ghidra_12.1.4_PUBLIC", + ], + ["application.version=12.1.4\n", null], + ["application.release.name=PUBLIC\n", null], + ["application.version=12.1.4\napplication.release.name=\n", null], + ["application.version=12.1.4\napplication.release.name=../outside\n", null], + [ + "application.version=12.1.4\napplication.release.name=..\\\\outside\n", + null, + ], + ["application.version=../12.1.4\napplication.release.name=PUBLIC\n", null], + [ + "application.version=12.1.4+build\napplication.release.name=PUBLIC\n", + null, + ], + ["application.version=12.1.4\napplication.release.name=\\uNOTHEX\n", null], + ])("derives a safe release coordinate from %j", (content, expected) => { + expect(ghidraApplicationProperties(content)).toMatchObject({ + configurationDirectoryName: expected, + }); + }); + + it.each([ + ["12.1.0", "ghidra_12.1_PUBLIC"], + ["012.01.000", "ghidra_12.1_PUBLIC"], + ["12.1.0-BETA", "ghidra_12.1-BETA_PUBLIC"], + ["12.1.0-", "ghidra_12.1_PUBLIC"], + ])( + "uses Ghidra's canonical configuration version while retaining %s", + (raw, expected) => { + expect( + ghidraApplicationProperties( + `application.version=${raw}\napplication.release.name=PUBLIC\n`, + ), + ).toMatchObject({ + providerVersion: raw, + configurationDirectoryName: expected, + }); + }, + ); + + it.each([ + [ + "application.version=12.1.4\napplication.release.name=PUBLIC\napplication.java.min=21\n", + "ghidra_12.1.4_PUBLIC", + ], + ["application.version=12.1.4\napplication.java.min=21\n", null], + ])( + "preserves the inspected identity independently of the installation basename", + (content, expected) => { + const inspection = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ readText: () => content }), + ); + expect(inspection).toMatchObject({ + status: "available", + configurationDirectoryName: expected, + }); + }, + ); +}); + describe("Ghidra release line and JDK bounds", () => { it("uses Java Properties last-key, delimiter, escape, and continuation rules", () => { const properties = ghidraApplicationProperties( @@ -486,6 +565,7 @@ describe("Ghidra release line and JDK bounds", () => { ); expect(properties).toEqual({ providerVersion: "12.1.4", + configurationDirectoryName: null, javaMinimum: { status: "major", major: 21 }, javaMaximum: { status: "absent" }, parseError: null, diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index a7216a223d..c98f5f7724 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -202,6 +202,8 @@ interface GhidraInstallationObservation { readonly platform: NodeJS.Platform; readonly architecture: NodeJS.Architecture; readonly applicationPropertiesPath: string | null; + /** Parsed release identity, never inferred from the installation directory name. */ + readonly configurationDirectoryName: string | null; readonly nativeDecompilerPath: string | null; readonly javaCommand: string; /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ @@ -351,6 +353,8 @@ export const inspectGhidraInstallation = ( platform, architecture, applicationPropertiesPath: coordinates.applicationPropertiesPath, + configurationDirectoryName: + coordinates.properties?.configurationDirectoryName ?? null, nativeDecompilerPath: coordinates.nativeDecompilerPath, javaCommand: coordinates.javaCommand, ...(coordinates.java?.vmName === undefined diff --git a/src/ghidra/GhidraInstallationPolicy.ts b/src/ghidra/GhidraInstallationPolicy.ts index cf6e897e99..ddc15219ff 100644 --- a/src/ghidra/GhidraInstallationPolicy.ts +++ b/src/ghidra/GhidraInstallationPolicy.ts @@ -1,4 +1,5 @@ import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; +import { ghidraConfigurationVersion } from "./GhidraReleasePolicy.js"; /** One Java major bound declared by `application.properties`. */ type GhidraJavaBound = @@ -18,6 +19,8 @@ export type GhidraJavaRequirement = /** Version and Java bounds read from one `application.properties` file. */ export interface GhidraApplicationProperties { readonly providerVersion: string | null; + /** Release-owned private settings directory; null when its identity is unresolved. */ + readonly configurationDirectoryName: string | null; readonly javaMinimum: GhidraJavaBound; readonly javaMaximum: GhidraJavaBound; /** A malformed Java properties escape invalidates the entire file. */ @@ -32,15 +35,36 @@ export const ghidraApplicationProperties = ( if (!parsed.ok) return { providerVersion: null, + configurationDirectoryName: null, javaMinimum: { status: "unresolved", raw: parsed.error }, javaMaximum: { status: "unresolved", raw: parsed.error }, parseError: parsed.error, }; const providerVersion = parsed.value.get("application.version"); + // ApplicationIdentifier removes Java's ASCII whitespace and uppercases the + // release name. Derive only a single safe component for official Ghidra builds. + const releaseName = parsed.value + .get("application.release.name") + ?.replaceAll(/[ \t\n\r\f\v]/gu, "") + .toUpperCase(); + // providerVersion above remains the original evidence. + const configurationVersion = + providerVersion === undefined + ? undefined + : ghidraConfigurationVersion(providerVersion); + const configurationDirectoryName = + providerVersion !== undefined && + configurationVersion !== undefined && + releaseName !== undefined && + /^[A-Za-z0-9][A-Za-z0-9._+-]*$/u.test(providerVersion) && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(releaseName) + ? `ghidra_${configurationVersion}_${releaseName}` + : null; const minimum = parsed.value.get("application.java.min"); const maximum = parsed.value.get("application.java.max"); return { providerVersion: providerVersion ?? null, + configurationDirectoryName, javaMinimum: minimum === undefined ? { status: "absent" } : parseJavaBound(minimum), // Ghidra uses an explicitly empty maximum to mean no upper bound. diff --git a/src/ghidra/GhidraLaunchFailure.test.ts b/src/ghidra/GhidraLaunchFailure.test.ts index d25cd9932c..425f817f03 100644 --- a/src/ghidra/GhidraLaunchFailure.test.ts +++ b/src/ghidra/GhidraLaunchFailure.test.ts @@ -42,6 +42,7 @@ const probeLauncher = (javaHome: string, runtimeRoot: string) => environment: {}, platform: "darwin", analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"), + configurationDirectoryName: "ghidra_test_PUBLIC", javaHome, bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"), }); @@ -152,6 +153,7 @@ describe("Ghidra launch failure diagnostics", () => { environment: {}, platform: "darwin", analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"), + configurationDirectoryName: "ghidra_test_PUBLIC", bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"), }); const launched = await launcher.launch(launchSession(runtimeRoot)); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index fea00e270e..125f40df5f 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,4 +1,3 @@ -import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; import { snapshotGhidraAnalysisSeeds, @@ -6,7 +5,7 @@ import { } from "./GhidraAnalysisSeeds.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { mkdir, readFile, realpath } from "node:fs/promises"; +import { mkdir, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; import writeFileAtomic from "write-file-atomic"; @@ -149,6 +148,8 @@ const ghidraLaunchCommandOutput = ( export interface GhidraHeadlessLauncherOptions { readonly environment: Readonly; readonly analyzeHeadlessPath: string; + /** Release coordinate owned by installation inspection; null must not be guessed. */ + readonly configurationDirectoryName: string | null; readonly javaHome?: string; readonly javaVmName?: string; readonly bridgeScriptPath: string; @@ -200,6 +201,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { let configuredMaxHeap: string | undefined; const launchSupport: GhidraLaunchSupportDiagnostic[] = []; try { + if (this.options.configurationDirectoryName === null) + throw new GhidraLaunchError( + "Ghidra configuration directory identity is unresolved; restore application.version and application.release.name in the selected installation's application.properties.", + ); const psp = this.options.pspExtension; if (psp !== undefined) { const installedRoot = await realpath( @@ -228,7 +233,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { await createGhidraRuntimeDirectories( paths, platform, - await ghidraConfigReleaseDirectory(this.options.analyzeHeadlessPath), + this.options.configurationDirectoryName, ); const seedPath = this.options.analysisSeeds === undefined @@ -754,32 +759,6 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({ * 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 => { - 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, platform: NodeJS.Platform, diff --git a/src/ghidra/GhidraLauncherEnvironment.test.ts b/src/ghidra/GhidraLauncherEnvironment.test.ts index f5d84880f1..14fd2a4db6 100644 --- a/src/ghidra/GhidraLauncherEnvironment.test.ts +++ b/src/ghidra/GhidraLauncherEnvironment.test.ts @@ -46,6 +46,7 @@ it("uses selected mixed-case Windows PATH and ComSpec through direct launch boun environment, platform: "win32", analyzeHeadlessPath: "C:\\ghidra\\support\\analyzeHeadless.bat", + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", bridgeScriptPath: "C:\\rea\\bridge.py", }); environment.ComSpec = "C:\\changed\\cmd.exe"; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index d6392109e9..0f12f86fbf 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -149,6 +149,30 @@ it.each([ }, ); +it("carries the inspected release identity into the production launcher", async () => { + const launchers: GhidraHeadlessLauncher[] = []; + const ghidra = provider( + { + ...installationHost(), + readText: () => + "application.version=12.1.4\napplication.release.name=PUBLIC\napplication.java.min=21\n", + }, + (options) => { + launchers.push(options.launcher as GhidraHeadlessLauncher); + return { + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(ok(null)), + }; + }, + ); + const client = await createElfClient(ghidra); + expect(launchers[0]?.options).toMatchObject({ + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + }); + await client.close(); +}); + describe("Ghidra provider", () => { it("commits DOS loader and real-mode language while retaining the CPU family", async () => { const ghidra = provider(); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 1c0ec2d29b..327fcd17cf 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -580,6 +580,8 @@ export const createGhidraProviderClient = (input: { launcher: new GhidraHeadlessLauncher({ environment: input.environment, analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, + configurationDirectoryName: + prerequisites.value.configurationDirectoryName, javaHome: prerequisites.value.javaHome, ...(installation.javaVmName === undefined ? {} @@ -986,6 +988,7 @@ export const createGhidraProviderClient = (input: { interface GhidraClientCoordinates { readonly analyzeHeadlessPath: string; + readonly configurationDirectoryName: string | null; readonly javaHome: string; readonly providerVersion: string; readonly profile: AnalysisProfileCommitment; @@ -1026,6 +1029,7 @@ const ghidraClientPrerequisites = ( return err(new ProviderAdapterError("ghidra", "health")); return ok({ analyzeHeadlessPath: installation.analyzeHeadlessPath, + configurationDirectoryName: installation.configurationDirectoryName, javaHome: installation.javaHome, providerVersion: installation.providerVersion, profile, diff --git a/src/ghidra/GhidraReleasePolicy.ts b/src/ghidra/GhidraReleasePolicy.ts index bc52e9d591..695afaa302 100644 --- a/src/ghidra/GhidraReleasePolicy.ts +++ b/src/ghidra/GhidraReleasePolicy.ts @@ -8,6 +8,7 @@ interface GhidraReleaseVersion { readonly major: number; readonly minor: number; readonly patch: number; + readonly suffix: string; } /** How an observed Ghidra release relates to this adapter's bridge. */ @@ -108,7 +109,22 @@ const parseGhidraRelease = (raw: string): GhidraReleaseVersion | undefined => { if (match === null || major === undefined || minor === undefined) return undefined; const patch = match[3] === undefined ? 0 : integerGroup(match[3]); - return patch === undefined ? undefined : { major, minor, patch }; + return patch === undefined + ? undefined + : { major, minor, patch, suffix: match[4] ?? "" }; +}; + +/** + * The version spelling Ghidra's `ApplicationVersion.toString` uses in its + * settings directory: numeric components normalized, a zero patch and an + * empty tag omitted. A build-metadata (`+`) suffix has no such spelling. + */ +export const ghidraConfigurationVersion = (raw: string): string | undefined => { + const version = parseGhidraRelease(raw); + if (version === undefined || version.suffix.startsWith("+")) return undefined; + const patch = version.patch === 0 ? "" : `.${String(version.patch)}`; + const suffix = version.suffix === "-" ? "" : version.suffix; + return `${String(version.major)}.${String(version.minor)}${patch}${suffix}`; }; const integerGroup = (value: string | undefined): number | undefined => { diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 33261d96e2..5edce1aedd 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -154,6 +154,7 @@ describe.skipIf(process.platform === "win32")( const launcher = new GhidraHeadlessLauncher({ environment: {}, analyzeHeadlessPath: join(directory, "support/analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.3_PUBLIC", javaHome: "/jdk-21", bridgeScriptPath: "/packaged/ReaGhidraBridge.java", pspExtension: extension, diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 9348f001d8..61967a9400 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -1481,6 +1481,7 @@ describe.skipIf(process.platform === "win32")( environment: process.env, javaHome: join(root, "jdk"), analyzeHeadlessPath: "/unused/ghidra/support/analyzeHeadless", + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", bridgeScriptPath: "/unused/rea/ReaGhidraBridge.java", spawnProcess: async ({ runId }) => { const spawned = spawn( diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 4e6c8f99b1..3a5a7c7192 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, stat, writeFile } from "node:fs/promises"; import { createHash, randomUUID } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it, onTestFinished } from "vitest"; @@ -85,6 +85,7 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { javaHome, ...(javaVmName === null ? {} : { javaVmName }), analyzeHeadlessPath: join(root, "ghidra", "support", "analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", bridgeScriptPath: join(root, "ReaGhidraBridge.java"), spawnProcess: async (options) => { analysisArguments.push(options.arguments); @@ -110,6 +111,68 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { return { session, launcher, analysisArguments, controller }; }; +describe.skipIf(process.platform === "win32")( + "Ghidra inspected configuration identity", + () => { + it("prepares the inspected release directory without reinterpreting changed properties", async () => { + const { session, launcher } = await fixture("quiet"); + const propertiesRoot = join( + session.runtimeRoot, + "..", + "ghidra", + "Ghidra", + ); + await mkdir(propertiesRoot, { recursive: true }); + await writeFile( + join(propertiesRoot, "application.properties"), + "application.version=12.1.2\napplication.release.name=OTHER\n", + ); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect( + ( + await stat( + join( + session.runtimeRoot, + "config", + "ghidra", + "ghidra_12.1.4_PUBLIC", + ), + ) + ).isDirectory(), + ).toBe(true); + await expect( + stat( + join(session.runtimeRoot, "config", "ghidra", "ghidra_12.1.2_OTHER"), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects unresolved identity before creating runtime directories or starting Java", async () => { + const { session, launcher, analysisArguments } = await fixture("quiet"); + const unresolved = new GhidraHeadlessLauncher({ + ...launcher.options, + configurationDirectoryName: null, + }); + const launched = await unresolved.launch(session); + expect(launched).toMatchObject({ + ok: false, + error: { + cause: { + message: expect.stringContaining( + "configuration directory identity is unresolved", + ), + }, + }, + }); + expect(analysisArguments).toEqual([]); + await expect(stat(session.runtimeRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); + describe.skipIf(process.platform === "win32")( "Ghidra LaunchSupport JVM compatibility", () => { diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 7e2a89c8f1..514202943d 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -1,11 +1,4 @@ -import { - access, - chmod, - mkdir, - readFile, - stat, - writeFile, -} from "node:fs/promises"; +import { access, chmod, readFile, stat } from "node:fs/promises"; import { join, posix, win32 } from "node:path"; import { fileURLToPath } from "node:url"; @@ -489,26 +482,13 @@ describe("Ghidra Windows launcher", () => { }); describe("Ghidra Windows launcher runtime", () => { - 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 }) => { + it + .skipIf(process.platform !== "win32") + .each(["ghidra_12.1.4_PUBLIC", "ghidra_12.1.2_DEV"])( + "prepares the inspected release directory through the Windows runtime (%s)", + async (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 () => { @@ -517,6 +497,7 @@ describe("Ghidra Windows launcher runtime", () => { const launcher = new GhidraHeadlessLauncher({ environment: process.env, platform: "win32", + configurationDirectoryName: release, analyzeHeadlessPath: join( installRoot, "support", @@ -571,6 +552,7 @@ describe("Ghidra Windows launcher runtime", () => { // Select the fixture's Node PATH and injected JVM options explicitly. environment: process.env, analyzeHeadlessPath: fixturePath, + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", // POSIX follows the real inspected-JVM route; Windows uses the official // batch script because its launcher contract differs. platform: "win32",