diff --git a/docs/installation.md b/docs/installation.md index f8a996120d..2893dc75ec 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -833,6 +833,16 @@ rea doctor --json rea setup ``` +On macOS, [Homebrew OpenJDK is keg-only](https://formulae.brew.sh/formula/openjdk) +and may be unavailable to the system `java` wrapper. Set `JAVA_HOME` to the +selected formula's `libexec/openjdk.jdk/Contents/Home`, for example +`/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home` or +`/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home`. Use a JDK inside the +Java version bounds declared by your Ghidra installation; REA does not select +another installed JDK automatically. Doctor distinguishes an absent Java +command from a command that runs and fails, and retains bounded probe output +and any parsed properties when the selected runtime cannot be admitted. + On Windows, configure the existing installation in PowerShell: ```powershell diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 275faf872b..43d9386c96 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -1,3 +1,4 @@ +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { AnalysisProfileResolution, ProviderIdentity, @@ -63,12 +64,15 @@ export const resolveGhidraAnalysisProfile = async ( target: BinaryTarget, identity: ProviderIdentity, installation: GhidraInstallationInspection, + resources: ArtifactResourceScope, signal?: AbortSignal, languageOverride?: GhidraLanguageOverride, disabledAnalyzers?: readonly string[], ): Promise> => { if (signal?.aborted === true) - return Promise.resolve(err(new AnalysisCancelledError("open_binary"))); + return Promise.resolve( + err(new AnalysisCancelledError("open_binary", { cause: signal.reason })), + ); if (target.kind !== "executable") return Promise.resolve(ok({ profile: null })); if (installation.status === "unavailable") @@ -96,7 +100,7 @@ export const resolveGhidraAnalysisProfile = async ( ), ); const pspExtension = isGhidraPspTarget(target) - ? await resolveGhidraPspExtension(installation, signal) + ? await resolveGhidraPspExtension(installation, resources, signal) : null; if (pspExtension !== null && !pspExtension.ok) return pspExtension; const provider = { ...identity, version: installation.providerVersion }; diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 6e65230fd2..4609185997 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises"; import type { Socket } from "node:net"; import type { ProgressReporter } from "../application/ProgressReporter.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -760,7 +761,7 @@ export class GhidraClient { providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, }, - { signal: deadline.signal }, + { signal: deadline.signal, resourceScope: this.#fileResources }, ) .catch((cause: unknown) => err(this.#failure("start", "Ghidra launcher failed", cause)), @@ -778,7 +779,15 @@ export class GhidraClient { }); this.#startupOutputCheckpoint = this.#process.checkpointOutput(); } - const failure = deadline.signal.aborted + const producerFailure = + launched.error instanceof GhidraLaunchError + ? launched.error.primaryFailure + : undefined; + const interrupted = + launched.error instanceof AnalysisCancelledError || + producerFailure instanceof AnalysisCancelledError || + (producerFailure === undefined && deadline.signal.aborted); + const failure = interrupted ? this.#interruptionFailure(deadline, launched.error) : launched.error instanceof GhidraSessionError ? launched.error diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 0a98bcb759..5f741989e1 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -1,3 +1,6 @@ +import { missingJavaProbe } from "./GhidraInstallation.fixture.js"; +import { ok as javaProbeOk } from "../domain/result.js"; +import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runDoctor, type DoctorHost } from "../application/Doctor.js"; @@ -19,15 +22,16 @@ const installationHost = ( ): 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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: platform === "win32" ? "Windows 11" : "Linux", + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + runtime: "jdk", + }), ...overrides, }); @@ -130,15 +134,16 @@ describe("Ghidra doctor integration", () => { inspection( { installDir: INSTALL }, { - probeJava: () => ({ - osArch: "amd64", - osName: "Linux", - version: "17.0.12", - major: 17, - home: "/usr/lib/jvm/jdk-17", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "17.0.12", + major: 17, + home: "/usr/lib/jvm/jdk-17", + bits: 64, + runtime: "jdk", + }), }, ), "ghidra-java", @@ -364,7 +369,7 @@ describe("Windows native doctor failure diagnostics", () => { installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC", platform: "win32", }, - { probeJava: () => undefined }, + { probeJava: () => missingJavaProbe }, ), ); expect(projected.available).toBe(false); diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts index 1590ea14b8..ac24e53175 100644 --- a/src/ghidra/GhidraDoctor.ts +++ b/src/ghidra/GhidraDoctor.ts @@ -33,7 +33,11 @@ export const projectGhidraDoctorInspection = ( : {}, checks: [ ...selectedChecks(inspection).map((check) => - projectCheck(check, inspection.installDir !== null), + projectCheck( + check, + inspection.installDir !== null, + inspection.javaProbeFailure, + ), ), ...(readiness !== undefined ? [ @@ -107,11 +111,25 @@ const selectedChecks = ( const projectCheck = ( check: GhidraInstallationCheck, configured: boolean, + javaFailure: GhidraInstallationInspection["javaProbeFailure"], ): DoctorProviderCheck => ({ name: check.name, ok: check.status === "passed", code: check.status === "failed" ? check.code : null, detail: check.detail, + ...(check.name === "java" && javaFailure?.output !== undefined + ? { + details: { + output: { ...javaFailure.output }, + ...(javaFailure.processErrorCode === undefined + ? {} + : { process_error_code: javaFailure.processErrorCode }), + ...(javaFailure.properties === undefined + ? {} + : { properties: { ...javaFailure.properties } }), + }, + } + : {}), remediation: check.status === "failed" ? check.remediation : null, classification: check.name === "platform" || check.name === "architecture" @@ -120,7 +138,11 @@ const projectCheck = ( check.status === "failed" && check.code === "runtime_missing" ? "missing_dependency" - : configured - ? "config_drift" - : "missing_analysis_engine", + : check.name === "java" && + check.status === "failed" && + check.code === "runtime_unavailable" + ? "runtime_failure" + : configured + ? "config_drift" + : "missing_analysis_engine", }); diff --git a/src/ghidra/GhidraInstallation.fixture.ts b/src/ghidra/GhidraInstallation.fixture.ts index 1becec6c8b..f3c16d245b 100644 --- a/src/ghidra/GhidraInstallation.fixture.ts +++ b/src/ghidra/GhidraInstallation.fixture.ts @@ -1,3 +1,7 @@ +import { ok as javaProbeOk } from "../domain/result.js"; +import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; +import { err } from "../domain/result.js"; +import type { GhidraJavaProbeFailure } from "./GhidraJavaProbe.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; /** Simulated valid installation for profile tests; never starts Ghidra or Java. */ @@ -6,13 +10,21 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = { architecture: "x64", readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, - probeJava: () => ({ - version: "21.0.11", - major: 21, - home: "/jdk-21", - bits: 64, - runtime: "jdk", - osArch: "amd64", - osName: "Linux", - }), + probeJava: () => + javaProbeOk({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + osArch: "amd64", + osName: "Linux", + }), }; + +/** A confirmed absent Java command for installation admission fixtures. */ +export const missingJavaProbe = err({ + _tag: "GhidraJavaProbeFailure", + code: "runtime_missing", + detail: "Java command was not found", +}); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 0a558371ac..fffa5d6e56 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1,3 +1,6 @@ +import { missingJavaProbe } from "./GhidraInstallation.fixture.js"; +import { ok as javaProbeOk } from "../domain/result.js"; +import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,7 +15,6 @@ import { inspectGhidraInstallation, type GhidraExecutableHeaderProbe, type GhidraInstallationHost, - type GhidraJavaObservation, } from "./GhidraInstallation.js"; import { ghidraApplicationProperties, @@ -66,7 +68,7 @@ const host = ( : undefined, executable: (path) => path === HEADLESS || path === MAC_DECOMPILER || path === LINUX_DECOMPILER, - probeJava: () => JAVA, + probeJava: () => javaProbeOk(JAVA), ...overrides, }); @@ -183,8 +185,8 @@ it("probes the selected Java environment without reading conflicting ambient set environment.REA_GHIDRA_CONTEXT === "selected" && environment.REA_GHIDRA_AMBIENT === undefined && environment.PATH === "/selected-bin" - ? JAVA - : undefined, + ? javaProbeOk(JAVA) + : missingJavaProbe, }), ); expect(inspection.status).toBe("available"); @@ -251,11 +253,12 @@ describe("Ghidra installation inspection", () => { host({ readText: () => `application.version=${providerVersion}\napplication.java.min=21\napplication.java.max=\n`, - probeJava: () => ({ - ...JAVA, - version: javaVersion, - major, - }), + probeJava: () => + javaProbeOk({ + ...JAVA, + version: javaVersion, + major, + }), }), ); expect(result.status).toBe("available"); @@ -286,11 +289,12 @@ 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", - }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: architecture === "arm64" ? "aarch64" : "amd64", + osName: platform === "darwin" ? "Mac OS X" : "Linux", + }), }), ), ).toMatchObject({ @@ -302,7 +306,9 @@ describe("Ghidra installation inspection", () => { } }, ); +}); +describe("Ghidra Windows installation commitment", () => { it("accepts the exact Windows x64 batch launcher and JDK commitment", () => { const windowsHost: GhidraInstallationHost = { platform: "win32", @@ -316,11 +322,11 @@ describe("Ghidra installation inspection", () => { probeJava: (command, environment) => { expect(command).toBe("C:\\Java\\jdk-21\\bin\\java.exe"); expect(environment.PATH).toMatch(/^C:\\Java\\jdk-21\\bin;/u); - return { + return javaProbeOk({ ...JAVA, home: "C:\\Java\\jdk-21", osName: "Windows 11", - }; + }); }, }; @@ -430,7 +436,7 @@ describe("Ghidra installation rejection diagnostics", () => { platform: "linux" as const, architecture: "x64" as const, }, - override: { probeJava: () => undefined }, + override: { probeJava: () => missingJavaProbe }, failed: "java", code: "runtime_missing", }, @@ -441,7 +447,14 @@ describe("Ghidra installation rejection diagnostics", () => { platform: "linux" as const, architecture: "x64" as const, }, - override: { probeJava: () => ({ ...JAVA, major: 17, version: "17" }) }, + override: { + probeJava: () => + javaProbeOk({ + ...JAVA, + major: 17, + version: "17", + }), + }, failed: "java", code: "unsupported_version", }, @@ -452,7 +465,13 @@ describe("Ghidra installation rejection diagnostics", () => { platform: "linux" as const, architecture: "x64" as const, }, - override: { probeJava: () => ({ ...JAVA, runtime: "jre" as const }) }, + override: { + probeJava: () => + javaProbeOk({ + ...JAVA, + runtime: "jre" as const, + }), + }, failed: "java", code: "unsupported_version", }, @@ -718,7 +737,12 @@ describe("Ghidra installation version admission", () => { name: "JDK above the installation maximum", readText: () => "application.version=12.1.4\napplication.java.min=21\napplication.java.max=25\n", - probeJava: () => ({ ...JAVA, major: 27, version: "27" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + major: 27, + version: "27", + }), failed: "java", code: "unsupported_version", }, @@ -726,28 +750,28 @@ describe("Ghidra installation version admission", () => { name: "unreadable Java minimum", readText: () => "application.version=12.1.4\napplication.java.min=latest\n", - probeJava: () => JAVA, + probeJava: () => javaProbeOk(JAVA), failed: "java", code: "version_unresolved", }, { name: "missing Java minimum", readText: () => "application.version=12.1.4\n", - probeJava: () => JAVA, + probeJava: () => javaProbeOk(JAVA), failed: "java", code: "version_unresolved", }, { name: "unparseable Ghidra version", readText: () => "application.version=latest\napplication.java.min=21\n", - probeJava: () => JAVA, + probeJava: () => javaProbeOk(JAVA), failed: "version", code: "version_unresolved", }, { name: "different Ghidra line", readText: () => "application.version=12.2.0\napplication.java.min=21\n", - probeJava: () => JAVA, + probeJava: () => javaProbeOk(JAVA), failed: "version", code: "unsupported_version", }, @@ -779,7 +803,12 @@ describe("Ghidra installation version admission", () => { host({ readText: () => "application.version=12.0.4\napplication.java.min=21\napplication.java.max=\n", - probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + major: 17, + version: "17.0.20.1", + }), }), ); expect(result.status).toBe("unavailable"); @@ -804,7 +833,12 @@ describe("Ghidra installation version admission", () => { host({ readText: () => `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, - probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + major: 17, + version: "17.0.20.1", + }), }), ), ).toMatchObject({ status: "available", javaVersion: "17.0.20.1" }); @@ -863,7 +897,7 @@ describe("Ghidra configuration paths", () => { host({ probeJava: (command) => { probed.push(command); - return JAVA; + return javaProbeOk(JAVA); }, }), ); @@ -920,7 +954,12 @@ describe("Ghidra FAT native decompiler admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Mac OS X", + }), executableHeader: () => headerProbe(bytes), }), ); @@ -941,7 +980,12 @@ describe("Ghidra FAT native decompiler admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Mac OS X", + }), executableHeader: () => headerProbe(bytes), }), ); @@ -993,11 +1037,12 @@ describe("Ghidra native decompiler admission", () => { const result = inspectGhidraInstallation( options, host({ - probeJava: () => ({ - ...JAVA, - osArch: options.architecture === "arm64" ? "aarch64" : "amd64", - osName: options.platform === "darwin" ? "Mac OS X" : "Linux", - }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: options.architecture === "arm64" ? "aarch64" : "amd64", + osName: options.platform === "darwin" ? "Mac OS X" : "Linux", + }), executableHeader, }), ); @@ -1029,7 +1074,9 @@ describe("Ghidra native decompiler admission", () => { detail: `${LINUX_DECOMPILER} reports elf x86_64, which matches linux/x64`, }); }); +}); +describe("Ghidra native decompiler compatibility", () => { it("keeps an unreadable header unknown instead of failing the install", () => { const result = inspectGhidraInstallation( { @@ -1060,7 +1107,12 @@ describe("Ghidra native decompiler admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Mac OS X", + }), executableHeader: (path: string) => path === MAC_DECOMPILER ? headerProbe(bytes) : undefined, }), @@ -1084,7 +1136,12 @@ describe("Ghidra native decompiler admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Mac OS X", + }), executableHeader: (path: string) => path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined, }), @@ -1113,7 +1170,12 @@ describe("Ghidra JVM native directory admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "x86_64", + osName: "Mac OS X", + }), }), ); expect(result.status).toBe("unavailable"); @@ -1139,7 +1201,12 @@ describe("Ghidra JVM native directory admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Mac OS X", + }), }), ); expect(result.status).toBe("available"); @@ -1154,7 +1221,10 @@ describe("Ghidra JVM native directory admission", () => { platform: "darwin", architecture: "arm64", }, - host({ probeJava: () => withoutArchitecture }), + host({ + probeJava: () => + javaProbeOk(withoutArchitecture), + }), ); expect(result.status).toBe("unavailable"); expect( @@ -1177,7 +1247,8 @@ describe("Ghidra JVM native directory admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ ...JAVA, osArch }), + probeJava: () => + javaProbeOk({ ...JAVA, osArch }), }), ); expect(result.status).toBe("unavailable"); @@ -1200,11 +1271,12 @@ describe("Ghidra JVM native directory admission", () => { architecture: "arm64", }, host({ - probeJava: () => ({ - ...JAVA, - osArch: "aarch64", - osName: "Windows 11", - }), + probeJava: () => + javaProbeOk({ + ...JAVA, + osArch: "aarch64", + osName: "Windows 11", + }), }), ); expect(result.status).toBe("unavailable"); @@ -1230,7 +1302,9 @@ describe("Ghidra JVM OS admission", () => { platform: "linux", architecture: "x64", }, - host({ probeJava: () => withoutOsName }), + host({ + probeJava: () => javaProbeOk(withoutOsName), + }), ); expect(result.status).toBe("unavailable"); expect( @@ -1253,7 +1327,8 @@ describe("Ghidra JVM OS admission", () => { architecture: "x64", }, host({ - probeJava: () => ({ ...JAVA, osName }), + probeJava: () => + javaProbeOk({ ...JAVA, osName }), }), ); expect(result.status).toBe("unavailable"); @@ -1272,18 +1347,21 @@ 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"), + stdout: Buffer.alloc(0), + signal: null, + stderr: Buffer.from( + [ + "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 @@ -1304,3 +1382,31 @@ describe("Ghidra Java probe os.arch extraction", () => { } }); }); + +it("explains the macOS keg-only JDK case without probing an unselected installation", () => { + const probed: string[] = []; + const inspection = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + }, + host({ + probeJava: (command) => { + probed.push(command); + return missingJavaProbe; + }, + }), + ); + expect(probed).toEqual(["java"]); + expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({ + status: "failed", + code: "runtime_missing", + remediation: expect.stringContaining("Homebrew OpenJDK can be keg-only"), + }); + const java = inspection.checks.find(({ name }) => name === "java"); + expect(java?.status === "failed" ? java.remediation : "").toContain( + "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home", + ); +}); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index c98f5f7724..4437dbe2a1 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -1,5 +1,10 @@ +import { + probeGhidraJava, + type GhidraJavaObservation, + type GhidraJavaProbeFailure, +} from "./GhidraJavaProbe.js"; +import type { Result } from "../domain/result.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { spawnSync } from "node:child_process"; import { constants } from "node:fs"; import { accessSync, @@ -75,15 +80,6 @@ const NATIVE_ARCHITECTURES: readonly { /** Bound installation discovery reads; larger headers remain unknown. */ const EXECUTABLE_HEADER_PROBE_BYTES = 4096; -/** - * Bound the Java version probe so a chatty or hostile JVM cannot force an - * unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for - * `-XshowSettings:properties`; the same 1 MiB budget used for other Java - * listings leaves ample headroom. A probe that exceeds the budget yields no - * observation and is reported through the existing failed-probe path. - */ -const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024; - /** Compatibility of one present native decompiler with the inspected host. */ type NativeDecompilerCompatibility = | { @@ -206,6 +202,7 @@ interface GhidraInstallationObservation { readonly configurationDirectoryName: string | null; readonly nativeDecompilerPath: string | null; readonly javaCommand: string; + readonly javaProbeFailure?: GhidraJavaProbeFailure; /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ readonly javaVmName?: string; /** @@ -252,29 +249,6 @@ export type GhidraInstallationInspection = | AvailableGhidraInstallation | UnavailableGhidraInstallation; -/** Parsed identity of the JDK selected for Ghidra. */ -export interface GhidraJavaObservation { - readonly version: string; - readonly major: number; - readonly home: string; - readonly bits: number | null; - /** Present when Java reported a bitness value that could not be parsed exactly. */ - readonly unparsedBits?: string; - 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. */ export interface GhidraExecutableHeaderProbe { /** A bounded prefix; a larger or unreadable header remains unknown. */ @@ -299,7 +273,7 @@ export interface GhidraInstallationHost { probeJava( command: string, environment: NodeJS.ProcessEnv, - ): GhidraJavaObservation | undefined; + ): Result; /** Resolve a PATH launcher to a validated installation without executing it. */ discoverInstallDirOnPath?( environment: Readonly, @@ -315,6 +289,7 @@ interface GhidraInstallationCoordinates { readonly properties: GhidraApplicationProperties | undefined; readonly javaCommand: string; readonly java: GhidraJavaObservation | undefined; + readonly javaProbeFailure: GhidraJavaProbeFailure | undefined; } interface GhidraInstallationCheckContext { @@ -349,6 +324,8 @@ export const inspectGhidraInstallation = ( (check): check is Extract => check.status === "failed", ); + const javaObservation = + coordinates.java ?? coordinates.javaProbeFailure?.properties; const observation = { platform, architecture, @@ -357,15 +334,18 @@ export const inspectGhidraInstallation = ( coordinates.properties?.configurationDirectoryName ?? null, nativeDecompilerPath: coordinates.nativeDecompilerPath, javaCommand: coordinates.javaCommand, - ...(coordinates.java?.vmName === undefined + ...(coordinates.javaProbeFailure === undefined ? {} - : { javaVmName: coordinates.java.vmName }), - ...(coordinates.java?.osArch === undefined + : { javaProbeFailure: coordinates.javaProbeFailure }), + ...(javaObservation?.vmName == null ? {} - : { javaOsArch: coordinates.java.osArch }), - ...(coordinates.java?.osName === undefined + : { javaVmName: javaObservation.vmName }), + ...(javaObservation?.osArch == null ? {} - : { javaOsName: coordinates.java.osName }), + : { javaOsArch: javaObservation.osArch }), + ...(javaObservation?.osName == null + ? {} + : { javaOsName: javaObservation.osName }), checks, }; if (failed !== undefined) @@ -375,8 +355,8 @@ export const inspectGhidraInstallation = ( installDir: coordinates.installDir, analyzeHeadlessPath: coordinates.analyzeHeadlessPath, providerVersion: coordinates.properties?.providerVersion ?? null, - javaHome: coordinates.java?.home ?? options.javaHome ?? null, - javaVersion: coordinates.java?.version ?? null, + javaHome: javaObservation?.home ?? options.javaHome ?? null, + javaVersion: javaObservation?.version ?? null, rejection: { code: failed.code, reason: failed.remediation }, }; return availableInstallation(coordinates, observation); @@ -476,6 +456,18 @@ const installationCoordinates = ( "bin", platform === "win32" ? "java.exe" : "java", ); + // Relative JAVA_HOME is refused by configuration validation without execution. + const javaProbe = + options.javaHome === undefined || path.isAbsolute(options.javaHome) + ? host.probeJava( + javaCommand, + ghidraJavaEnvironment( + options.javaHome, + options.environment, + platform, + ), + ) + : undefined; return { installDir, applicationPropertiesPath, @@ -486,19 +478,9 @@ const installationCoordinates = ( ? undefined : ghidraApplicationProperties(properties), javaCommand, - // The configuration check rejects a relative JAVA_HOME. Probing it would - // run whatever `bin/java` the current directory holds. - java: - options.javaHome === undefined || path.isAbsolute(options.javaHome) - ? host.probeJava( - javaCommand, - ghidraJavaEnvironment( - options.javaHome, - options.environment, - platform, - ), - ) - : undefined, + java: javaProbe?.ok === true ? javaProbe.value : undefined, + javaProbeFailure: + javaProbe !== undefined && !javaProbe.ok ? javaProbe.error : undefined, }; }; @@ -552,6 +534,8 @@ const installationChecks = ({ coordinates.javaCommand, javaHome, coordinates.properties, + platform, + coordinates.javaProbeFailure, ), ]; @@ -589,7 +573,7 @@ const configurationCheck = ( }); }; -/** Project an installation probe into caller-visible, secret-free diagnostics. */ +/** Project an installation probe into caller-visible, bounded diagnostics. */ export const ghidraInstallationDiagnostics = ( inspection: GhidraInstallationInspection, ): Readonly> => ({ @@ -599,6 +583,26 @@ export const ghidraInstallationDiagnostics = ( application_properties_path: inspection.applicationPropertiesPath, provider_version: inspection.providerVersion, java_command: inspection.javaCommand, + ...(inspection.javaProbeFailure === undefined + ? {} + : { + java_probe_failure: { + code: inspection.javaProbeFailure.code, + ...(inspection.javaProbeFailure.processErrorCode === undefined + ? {} + : { + process_error_code: + inspection.javaProbeFailure.processErrorCode, + }), + detail: inspection.javaProbeFailure.detail, + ...(inspection.javaProbeFailure.properties === undefined + ? {} + : { properties: { ...inspection.javaProbeFailure.properties } }), + ...(inspection.javaProbeFailure.output === undefined + ? {} + : { output: { ...inspection.javaProbeFailure.output } }), + }, + }), java_home: inspection.javaHome, java_version: inspection.javaVersion, java_vm_name: inspection.javaVmName ?? null, @@ -688,8 +692,14 @@ const nativeDecompilerCheck = ({ // 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 osArch = + coordinates.java?.osArch ?? + coordinates.javaProbeFailure?.properties?.osArch ?? + undefined; + const osName = + coordinates.java?.osName ?? + coordinates.javaProbeFailure?.properties?.osName ?? + undefined; const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch); const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture]; // Without a JVM observation the java check reports the missing runtime; @@ -814,6 +824,8 @@ const javaCheck = ( command: string, configuredHome: string | undefined, properties: GhidraApplicationProperties | undefined, + platform: NodeJS.Platform, + failure: GhidraJavaProbeFailure | undefined, ): GhidraInstallationCheck => { const requirement = ghidraJavaRequirement(properties); const range = ghidraJavaRangeLabel(requirement); @@ -836,8 +848,8 @@ const javaCheck = ( const detail = observation === undefined ? requirement.unresolved === null - ? command - : `${command}; ${requirement.unresolved}` + ? (failure?.detail ?? `${command}: Java probe was not attempted`) + : `${failure?.detail ?? command}; ${requirement.unresolved}` : `${observation.version}; ${bitness}; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}; accepted ${range}`; return installationCheck({ name: "java", @@ -846,12 +858,12 @@ const javaCheck = ( requirement.unresolved !== null ? "version_unresolved" : observation === undefined - ? "runtime_missing" + ? (failure?.code ?? "version_unresolved") : knownMismatch ? "unsupported_version" : "version_unresolved", detail, - remediation: javaRemediation(requirement, configuredHome, range), + remediation: javaRemediation(requirement, configuredHome, range, platform), }); }; @@ -859,11 +871,12 @@ const javaRemediation = ( requirement: GhidraJavaRequirement, configuredHome: string | undefined, range: string, + platform: NodeJS.Platform, ): string => { if (requirement.unresolved !== null) return `The Ghidra installation's Java bounds could not be read (${requirement.unresolved}). Install a 64-bit full JDK inside the declared application.java.min and application.java.max.`; return configuredHome === undefined - ? `Install ${range}, or set JAVA_HOME before starting REA.` + ? `Select ${range} on PATH, or set JAVA_HOME to its installation root before starting REA.${platform === "darwin" ? " Homebrew OpenJDK can be keg-only: set JAVA_HOME to the selected formula's libexec/openjdk.jdk/Contents/Home (for example /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home or /usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home). The installed JDK must satisfy the declared Ghidra Java bounds." : ""}` : `Update JAVA_HOME to the root of ${range} installation.`; }; @@ -909,72 +922,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ return false; } }, - probeJava(command, environment) { - const observed = spawnSync( - command, - [ - "-Dstdout.encoding=UTF-8", - "-Dstderr.encoding=UTF-8", - // JDK 17/18 use the legacy names; JDK 19+ use the names above. - "-Dsun.stdout.encoding=UTF-8", - "-Dsun.stderr.encoding=UTF-8", - "-XX:-UsePerfData", - "-XshowSettings:properties", - "-version", - ], - { - encoding: "utf8", - env: environment, - timeout: 5_000, - maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES, - windowsHide: true, - }, - ); - if (observed.error !== undefined || observed.status !== 0) return undefined; - const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`; - 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 reportedBits = javaProperty(output, "sun.arch.data.model"); - const parsedBits = - reportedBits !== null && /^\d+$/.test(reportedBits) - ? Number(reportedBits) - : null; - const bits = - parsedBits !== null && Number.isSafeInteger(parsedBits) - ? parsedBits - : null; - if ( - version === null || - version.length === 0 || - home === null || - home.length === 0 - ) - return undefined; - return { - version, - major: javaMajor(version), - home, - ...(vmName === null ? {} : { vmName }), - ...(osArch === null ? {} : { osArch }), - ...(osName === null ? {} : { osName }), - bits, - ...(reportedBits === null || bits !== null - ? {} - : { unparsedBits: reportedBits }), - runtime: executablePath( - (process.platform === "win32" ? win32 : posix).join( - home, - "bin", - process.platform === "win32" ? "javac.exe" : "javac", - ), - ) - ? "jdk" - : "jre", - }; - }, + probeJava: probeGhidraJava, discoverInstallDirOnPath: discoverGhidraInstallationOnPath, }); @@ -1031,15 +979,3 @@ const executablePath = (path: string): boolean => { return false; } }; - -const javaProperty = (output: string, name: string): string | null => - new RegExp( - `^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`, - "mu", - ).exec(output)?.[1] ?? null; - -const javaMajor = (version: string): number => { - const first = Number.parseInt(version.split(".")[0] ?? "0", 10); - if (first !== 1) return first; - return Number.parseInt(version.split(".")[1] ?? "0", 10); -}; diff --git a/src/ghidra/GhidraJavaProbe.test.ts b/src/ghidra/GhidraJavaProbe.test.ts new file mode 100644 index 0000000000..85a8965362 --- /dev/null +++ b/src/ghidra/GhidraJavaProbe.test.ts @@ -0,0 +1,146 @@ +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { probeGhidraJava } from "./GhidraJavaProbe.js"; + +vi.mock("node:child_process", async (original) => ({ + ...(await original()), + spawnSync: vi.fn(), +})); +afterEach(() => vi.mocked(spawnSync).mockReset()); + +const probe = (values: Partial>) => { + vi.mocked(spawnSync).mockReturnValue({ + pid: 1, + status: 0, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + output: [], + ...values, + } as never); + return probeGhidraJava("/selected/bin/java", {}); +}; + +describe("Ghidra Java prerequisite process facts", () => { + it.each([ + ["ENOENT", "runtime_missing"], + ["EACCES", "runtime_unavailable"], + ["ETIMEDOUT", "runtime_unavailable"], + ["ENOBUFS", "runtime_unavailable"], + ])("keeps %s distinct from other runtime failures", (code, expected) => { + const error = Object.assign(new Error(`controlled ${code}`), { code }); + expect(probe({ error, status: null })).toMatchObject({ + ok: false, + error: { + _tag: "GhidraJavaProbeFailure", + code: expected, + processErrorCode: code, + detail: expect.stringContaining(code), + output: { exit_code: null, truncated: code === "ENOBUFS" }, + }, + }); + }); + + it("keeps raw byte counts and both streams on a nonzero exit", () => { + const result = probe({ + status: 7, + stdout: Buffer.from([0xff]), + stderr: Buffer.from("controlled runtime failure\n"), + }); + expect(result).toMatchObject({ + ok: false, + error: { + code: "runtime_unavailable", + detail: expect.stringContaining("Java exited 7"), + output: { + stdout: "\ufffd", + stdout_bytes: 1, + stderr: "controlled runtime failure\n", + stderr_bytes: 27, + exit_code: 7, + signal: null, + truncated: false, + }, + }, + }); + }); + + it("retains partial parsed properties when a successful probe cannot establish its version", () => { + expect( + probe({ + stdout: Buffer.from( + "java.home = /selected/jdk\nos.arch = aarch64\nsun.arch.data.model = 64\n", + ), + }), + ).toMatchObject({ + ok: false, + error: { + code: "version_unresolved", + properties: { + home: "/selected/jdk", + version: null, + osArch: "aarch64", + bits: 64, + }, + output: { exit_code: 0 }, + }, + }); + }); + + it("bounds diagnostic text and preserves examined stream sizes", () => { + expect(probe({ status: 7, stdout: Buffer.alloc(12000, 65) })).toMatchObject( + { + ok: false, + error: { + output: { + stdout: "A".repeat(8192), + stdout_bytes: 12000, + stderr_bytes: 0, + truncated: true, + }, + }, + }, + ); + }); +}); + +it.each([ + "21garbage", + "garbage", + "21.foo", + "9007199254740993", + "21.9999999999999999", + "21+9999999999999999", + "1.21.0", + "21.0", + "021", +])("keeps malformed java.version %s unresolved", (version) => { + expect( + probe({ + stdout: Buffer.from( + `java.home = /selected/jdk\njava.version = ${version}\nsun.arch.data.model = 64\n`, + ), + }), + ).toMatchObject({ + ok: false, + error: { + code: "version_unresolved", + properties: { version, home: "/selected/jdk" }, + }, + }); +}); + +it.each([ + ["21-ea", 21], + ["21.0.11+9-LTS", 21], + ["27-internal+0-adhoc.user.build", 27], + ["1.8.0_452-b09", 8], +] as const)("recognizes the Java major in %s", (version, major) => { + expect( + probe({ + stdout: Buffer.from( + `java.home = /selected/jdk\njava.version = ${version}\nsun.arch.data.model = 64\n`, + ), + }), + ).toMatchObject({ ok: true, value: { version, major } }); +}); diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts new file mode 100644 index 0000000000..1637eb2a8d --- /dev/null +++ b/src/ghidra/GhidraJavaProbe.ts @@ -0,0 +1,198 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { constants, accessSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { err, ok, type Result } from "../domain/result.js"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; + +/** + * Bound the Java version probe so a chatty or hostile JVM cannot force an + * unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for + * `-XshowSettings:properties`; the same 1 MiB budget used for other Java + * listings leaves ample headroom. A probe that exceeds the budget yields no + * observation and is reported through the existing failed-probe path. + */ +const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024; + +/** Parsed identity of the JDK selected for Ghidra. */ +export interface GhidraJavaObservation { + readonly version: string; + readonly major: number; + readonly home: string; + readonly bits: number | null; + /** Present when Java reported a bitness value that could not be parsed exactly. */ + readonly unparsedBits?: string; + 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; +} + +/** Expected probe failures retain their producing process facts. */ +export interface GhidraJavaProbeFailure { + readonly _tag: "GhidraJavaProbeFailure"; + readonly code: + | "runtime_missing" + | "runtime_unavailable" + | "version_unresolved"; + readonly detail: string; + readonly processErrorCode?: string; + readonly output?: AnalysisCapturedOutput; + readonly properties?: { + readonly version: string | null; + readonly home: string | null; + readonly vmName: string | null; + readonly osArch: string | null; + readonly osName: string | null; + readonly bits: number | null; + }; +} + +const JAVA_DIAGNOSTIC_STREAM_BYTES = 8192; +const capturedProbeOutput = ( + observed: SpawnSyncReturns, +): AnalysisCapturedOutput => { + const stdout = observed.stdout ?? Buffer.alloc(0); + const stderr = observed.stderr ?? Buffer.alloc(0); + return { + stdout: stdout.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"), + stderr: stderr.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"), + truncated: + stdout.length > JAVA_DIAGNOSTIC_STREAM_BYTES || + stderr.length > JAVA_DIAGNOSTIC_STREAM_BYTES || + (observed.error !== undefined && + "code" in observed.error && + observed.error.code === "ENOBUFS"), + stdout_bytes: stdout.length, + stderr_bytes: stderr.length, + exit_code: observed.status, + signal: observed.signal, + }; +}; + +const executablePath = (path: string): boolean => { + try { + accessSync(path, constants.X_OK); + return statSync(path).isFile(); + } catch { + return false; + } +}; +export const probeGhidraJava = ( + command: string, + environment: NodeJS.ProcessEnv, +): Result => { + const observed = spawnSync( + command, + [ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + // JDK 17/18 use the legacy names; JDK 19+ use the names above. + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + "-XX:-UsePerfData", + "-XshowSettings:properties", + "-version", + ], + { + encoding: "buffer", + env: environment, + timeout: 5_000, + maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES, + windowsHide: true, + }, + ); + if (observed.error !== undefined || observed.status !== 0) { + const code = + observed.error !== undefined && "code" in observed.error + ? observed.error.code + : undefined; + return err({ + _tag: "GhidraJavaProbeFailure", + code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable", + ...(typeof code === "string" ? { processErrorCode: code } : {}), + detail: `${command}: ${observed.error?.message ?? `Java exited ${String(observed.status)}${observed.signal === null ? "" : ` (${observed.signal})`}`}`, + output: capturedProbeOutput(observed), + }); + } + const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`; + const version = javaProperty(output, "java.version") || null; + const home = javaProperty(output, "java.home") || null; + const vmName = javaProperty(output, "java.vm.name"); + const osArch = javaProperty(output, "os.arch"); + const osName = javaProperty(output, "os.name"); + const reportedBits = javaProperty(output, "sun.arch.data.model"); + const parsedBits = + reportedBits !== null && /^\d+$/.test(reportedBits) + ? Number(reportedBits) + : null; + const bits = + parsedBits !== null && Number.isSafeInteger(parsedBits) ? parsedBits : null; + const major = version === null ? undefined : javaMajor(version); + if (version === null || home === null || major === undefined) + return err({ + _tag: "GhidraJavaProbeFailure", + code: "version_unresolved", + detail: `${command}: successful Java probe did not establish a valid java.version and nonempty java.home`, + output: capturedProbeOutput(observed), + properties: { version, home, vmName, osArch, osName, bits }, + }); + return ok({ + version, + major, + home, + ...(vmName === null ? {} : { vmName }), + ...(osArch === null ? {} : { osArch }), + ...(osName === null ? {} : { osName }), + bits, + ...(reportedBits === null || bits !== null + ? {} + : { unparsedBits: reportedBits }), + runtime: executablePath( + (process.platform === "win32" ? win32 : posix).join( + home, + "bin", + process.platform === "win32" ? "javac.exe" : "javac", + ), + ) + ? "jdk" + : "jre", + }); +}; +const javaProperty = (output: string, name: string): string | null => + new RegExp( + `^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`, + "mu", + ).exec(output)?.[1] ?? null; + +// Runtime.Version's numeric/pre-release/build grammar; Java 8 used 1.8.0_... . +// https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/Runtime.Version.html +const javaMajor = (version: string): number | undefined => { + const legacy = + /^1\.([1-8])(?:\.[0-9]+)*(?:_[0-9]+)?(?:-[-a-zA-Z0-9.]+)?$/u.exec(version); + if (legacy?.[1] !== undefined) return Number(legacy[1]); + const modern = + /^(?[1-9][0-9]*(?:(?:\.0)*\.[1-9][0-9]*)*)(?:(?:-[a-zA-Z0-9]+)?\+(?0|[1-9][0-9]*)(?:-[-a-zA-Z0-9.]+)?|-[a-zA-Z0-9]+(?:-[-a-zA-Z0-9.]+)?|\+-[-a-zA-Z0-9.]+)?$/u.exec( + version, + ); + const number = modern?.groups?.number; + if (number === undefined) return undefined; + const parts = number.split(".").map(Number); + const build = modern?.groups?.build; + // Runtime.Version parses numeric components and the build as Java integers. + if ( + [...parts, ...(build === undefined ? [] : [Number(build)])].some( + (value) => !Number.isSafeInteger(value) || value > 0x7fffffff, + ) + ) + return undefined; + return parts[0]; +}; diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ac093c7161..7167f3bd80 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,3 +1,4 @@ +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; import { snapshotGhidraAnalysisSeeds, @@ -88,7 +89,10 @@ export type GhidraLaunchSupportDiagnostic = { export interface GhidraLauncher { launch( session: GhidraLaunchSession, - options?: { readonly signal?: AbortSignal }, + options?: { + readonly signal?: AbortSignal; + readonly resourceScope?: ArtifactResourceScope; + }, ): Promise>; } @@ -104,6 +108,8 @@ export class GhidraLaunchError extends Error { readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[]; readonly rollbackFailure: GhidraLaunchRollbackFailure | undefined; readonly cleanupFailure: AnalysisError | undefined; + /** Known producer outcome, independent of a signal that changes during finalization. */ + readonly primaryFailure: AnalysisError | undefined; constructor( message: string, @@ -127,6 +133,12 @@ export class GhidraLaunchError extends Error { this.name = "GhidraLaunchError"; this.#partialLaunch = partialLaunch; this.rollbackFailure = rollbackFailure; + this.primaryFailure = + errorOptions.cause instanceof AnalysisError + ? errorOptions.cause + : errorOptions.cause instanceof GhidraLaunchError + ? errorOptions.cause.primaryFailure + : undefined; const inherited = errorOptions.cause instanceof DarwinProcessOwnershipInspectionError || errorOptions.cause instanceof GhidraLaunchError @@ -241,10 +253,17 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { async launch( session: GhidraLaunchSession, - options: { readonly signal?: AbortSignal } = {}, + options: { + readonly signal?: AbortSignal; + readonly resourceScope?: ArtifactResourceScope; + } = {}, ): Promise> { if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const paths = ghidraRuntimePaths(session.runtimeRoot); const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; @@ -270,9 +289,14 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { throw new GhidraLaunchError( "Allegrex profile does not belong to the selected Ghidra installation.", ); + if (options.resourceScope === undefined) + throw new GhidraLaunchError( + "PSP inspection requires a caller-owned resource scope.", + ); const inspected = await inspectGhidraPspExtension( psp.root, psp.ghidra_version, + options.resourceScope, options.signal, ); if (inspected.sha256 !== psp.sha256) @@ -450,6 +474,8 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { } return ok(partialLaunch); } catch (cause: unknown) { + if (cause instanceof AnalysisCancelledError && started === undefined) + return err(cause); const capturedOutput = ghidraLaunchCommandOutput(cause); const launchFailure = new GhidraLaunchError( "Ghidra headless launch failed", @@ -459,23 +485,25 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ...(capturedOutput === undefined ? {} : { capturedOutput }), }, ); - const primary = isAborted(options.signal) - ? new AnalysisCancelledError("open_binary", { - cause: launchFailure, - ...(launchFailure.cleanupFailure === undefined - ? {} - : { - cleanup: { - reason: launchFailure.cleanupFailure.message, - resources: launchFailure.cleanupFailure.cleanupResources, - }, - }), - }).bindCleanupDependencies( - launchFailure.cleanupFailure === undefined - ? [] - : [launchFailure.cleanupFailure], - ) - : launchFailure; + const primary = + cause instanceof AnalysisCancelledError || + (!(cause instanceof AnalysisError) && isAborted(options.signal)) + ? new AnalysisCancelledError("open_binary", { + cause: launchFailure, + ...(launchFailure.cleanupFailure === undefined + ? {} + : { + cleanup: { + reason: launchFailure.cleanupFailure.message, + resources: launchFailure.cleanupFailure.cleanupResources, + }, + }), + }).bindCleanupDependencies( + launchFailure.cleanupFailure === undefined + ? [] + : [launchFailure.cleanupFailure], + ) + : launchFailure; if (started === undefined) return err(primary); const cleanupOwner = started; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 0f12f86fbf..740bc16358 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1,3 +1,5 @@ +import { ok as javaProbeOk } from "../domain/result.js"; +import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; @@ -50,16 +52,17 @@ const installationHost = ( 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", - bits: 64, - runtime: "jdk", - vmName: "OpenJDK 64-Bit Server VM", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: platform === "win32" ? "Windows 11" : "Linux", + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + runtime: "jdk", + vmName: "OpenJDK 64-Bit Server VM", + }), }); const provider = ( @@ -208,7 +211,7 @@ describe("Ghidra provider", () => { ...installationHost(), probeJava: () => { probeCount += 1; - return { + return javaProbeOk({ osArch: "amd64", osName: "Linux", version: "21.0.11", @@ -216,7 +219,7 @@ describe("Ghidra provider", () => { home: "/usr/lib/jvm/jdk-21", bits: 64, runtime: "jdk", - }; + }); }, }; const ghidra = provider(host); @@ -2292,15 +2295,16 @@ describe("compatible Ghidra builds", () => { ...installationHost(), 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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "27", + major: 27, + home: "/usr/lib/jvm/java-27-openjdk", + bits: 64, + runtime: "jdk", + }), }, () => ({ start: () => diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index d07cd9df61..f8dd4f9001 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -1,3 +1,6 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { AnalysisClient, @@ -78,6 +81,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ /** Ghidra candidate backed by an isolated ephemeral headless import. */ export class GhidraProvider implements AnalysisProviderCandidate { + readonly #inspectionResources = new ArtifactResourceScope(); #installation: GhidraInstallationInspection | undefined; private readonly environment: NodeJS.ProcessEnv; @@ -223,6 +227,28 @@ export class GhidraProvider implements AnalysisProviderCandidate { }; } + hasPendingOwnedResources(): boolean { + return this.#inspectionResources.hasPending; + } + + async closeOwnedResources() { + try { + await this.#inspectionResources.retryCleanup(); + return ok(null); + } catch (cause: unknown) { + const error = new ProviderCleanupError( + "ghidra", + cause instanceof ArtifactReaderFailure + ? (cause.cleanup?.resources ?? []) + : [], + { reason: providerFailureText(cause) }, + { cause }, + ); + bindArtifactFileCleanup(cause, error); + return err(error); + } + } + async resolveAnalysisProfile( target: BinaryTarget, options?: AnalysisProfileResolutionOptions, @@ -232,6 +258,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { target, GHIDRA_PROVIDER_IDENTITY, installation, + this.#inspectionResources, options?.signal, this.config.ghidraLanguageOverride, this.config.ghidraDisabledAnalyzers, diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index 9c017f367f..bdd759fda8 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -2,19 +2,28 @@ import { createHash } from "node:crypto"; import { lstat, opendir, realpath } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; -import { - openRegularFile, - sameRegularFileState, -} from "../filesystem/RegularFile.js"; +import { sameRegularFileState } from "../filesystem/RegularFile.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, } from "../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; +import { + assertArtifactReadActive, + artifactFileCancellationCause, + artifactFilePrimaryFailure, + bindArtifactFileCleanup, + withArtifactRegularFile, +} from "../artifacts/ArtifactFileOperation.js"; + /** Installed processor code and data, distinct from REA's analysis-extension ABI. */ export const ghidraPspExtensionSchema = z.strictObject({ id: z.literal("ghidra-allegrex"), @@ -41,9 +50,25 @@ const REQUIRED = [ export const inspectGhidraPspExtension = async ( root: string, ghidraVersion: string, + resources: ArtifactResourceScope, signal?: AbortSignal, ): Promise => { - signal?.throwIfAborted(); + try { + return await resources.run(() => + fingerprint(root, ghidraVersion, resources, signal), + ); + } catch (cause: unknown) { + throw ghidraPspInspectionError(cause); + } +}; + +const fingerprint = async ( + root: string, + ghidraVersion: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); if (!isAbsolute(root) || (await realpath(root)) !== root) throw new Error( "The installed Allegrex directory must be a canonical non-symlink path.", @@ -51,7 +76,7 @@ export const inspectGhidraPspExtension = async ( const files: string[] = []; let entries = 1; // The root itself consumes one inventory entry. const visit = async (relative: string, depth: number): Promise => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); if (depth > MAX_DEPTH) throw new Error( "Allegrex extension directory nesting exceeds the inspection limit.", @@ -59,15 +84,13 @@ export const inspectGhidraPspExtension = async ( const path = join(root, relative); const metadata = await lstat(path); if (metadata.isDirectory()) { - const children: string[] = []; - for await (const child of await opendir(path)) { - signal?.throwIfAborted(); - if (++entries > MAX_ENTRIES) - throw new Error( - "Allegrex extension inventory exceeds the inspection limit.", - ); - children.push(child.name); - } + const children = await readChildren( + path, + MAX_ENTRIES - entries, + resources, + signal, + ); + entries += children.length; children.sort(); for (const child of children) await visit( @@ -91,10 +114,9 @@ export const inspectGhidraPspExtension = async ( let bytesRead = 0; let properties = Buffer.alloc(0); for (const relative of files) { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const path = join(root, relative); - const handle = await openRegularFile(path, { symlinks: "reject", signal }); - try { + await withArtifactRegularFile(path, resources, signal, async (handle) => { const before = await handle.stat(); if ( !Number.isSafeInteger(before.size) || @@ -111,22 +133,30 @@ export const inspectGhidraPspExtension = async ( const buffer = Buffer.alloc(Math.min(65536, before.size + 1)); let offset = 0; const chunks: Buffer[] = []; - while (offset < before.size) { - signal?.throwIfAborted(); - const read = await handle.read( + // Serial reads keep final close behind every native read. One extra byte + // detects growth without a stream's read-ahead or unbounded allocation. + while (offset <= before.size) { + assertArtifactReadActive(signal); + const { bytesRead } = await handle.read( buffer, 0, - Math.min(buffer.length, before.size - offset), + Math.min(buffer.length, before.size - offset + 1), offset, ); - if (read.bytesRead === 0) - throw new Error(`Truncated Allegrex component: ${relative}`); - const chunk = buffer.subarray(0, read.bytesRead); + assertArtifactReadActive(signal); + if (bytesRead === 0) break; + offset += bytesRead; + if (offset > before.size) + throw new Error( + `Allegrex component changed during inspection: ${relative}`, + ); + const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); if (relative === "extension.properties") chunks.push(Buffer.from(chunk)); - offset += read.bytesRead; } + if (offset !== before.size) + throw new Error(`Truncated Allegrex component: ${relative}`); if ( !sameRegularFileState(before, await handle.stat()) || !sameRegularFileState(before, await lstat(path)) @@ -137,9 +167,7 @@ export const inspectGhidraPspExtension = async ( bytesRead += before.size; if (relative === "extension.properties") properties = Buffer.concat(chunks); - } finally { - await handle.close(); - } + }); } // Ghidra's extension.properties version is the compatible Ghidra version, // not the extension release (e.g. v21.4); content identity supplies the latter. @@ -159,7 +187,7 @@ export const inspectGhidraPspExtension = async ( throw new Error( `Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`, ); - signal?.throwIfAborted(); + assertArtifactReadActive(signal); return { id: "ghidra-allegrex", root, @@ -171,11 +199,11 @@ export const inspectGhidraPspExtension = async ( /** Resolve prerequisites before opening or admitting cached PSP analysis. */ export const resolveGhidraPspExtension = async ( installation: GhidraInstallationInspection, + resources: ArtifactResourceScope, signal?: AbortSignal, ): Promise> => { - if (signal?.aborted) - return err(new AnalysisCancelledError("resolve_analysis_profile")); try { + assertArtifactReadActive(signal); if ( installation.status !== "available" || installation.platform !== "linux" || @@ -194,20 +222,98 @@ export const resolveGhidraPspExtension = async ( await inspectGhidraPspExtension( root, installation.providerVersion, + resources, signal, ), ); } catch (cause: unknown) { - if (signal?.aborted) - return err(new AnalysisCancelledError("resolve_analysis_profile")); - const reason = `PSP_EXTENSION_UNAVAILABLE: ${cause instanceof Error ? cause.message : String(cause)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`; - return err( - new AnalysisCapabilityUnavailableError( - "ghidra", - "resolve_analysis_profile", - reason, - { cause, userMessage: reason }, - ), - ); + return err(ghidraPspInspectionError(cause)); } }; + +/** Preserve the producer's primary failure and its live cleanup dependencies. */ +const ghidraPspInspectionError = (cause: unknown): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; + const primary = artifactFilePrimaryFailure(cause); + const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`; + const error = + cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" + ? new AnalysisCancelledError("resolve_analysis_profile", { + cause: artifactFileCancellationCause(cause), + cleanup, + }) + : new AnalysisCapabilityUnavailableError( + "ghidra", + "resolve_analysis_profile", + reason, + { + cause, + userMessage: reason, + cleanup, + }, + ); + bindArtifactFileCleanup(cause, error); + return error; +}; + +/** Close before descending, retaining the exact directory owner on uncertainty. */ +const readChildren = async ( + path: string, + remainingEntries: number, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const owner = new OwnedDirectoryHandle(await opendir(path)); + let outcome: + | { readonly kind: "completed"; readonly names: string[] } + | { readonly kind: "failed"; readonly cause: unknown }; + try { + const names: string[] = []; + for (;;) { + assertArtifactReadActive(signal); + const child = await owner.handle.read(); + assertArtifactReadActive(signal); + if (child === null) break; + if (names.length >= remainingEntries) + throw new Error( + "Allegrex extension inventory exceeds the inspection limit.", + ); + names.push(child.name); + } + outcome = { kind: "completed", names }; + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + } + const lease = new AnalysisCleanupLease(() => owner.close()); + const attempt = await resources.release({ + kind: "lease", + resource: path, + lease, + }); + if (attempt.kind === "failed") { + const primary = + outcome.kind === "failed" + ? outcome.cause + : signal?.aborted + ? new ArtifactReaderFailure( + "cancelled", + "PSP inspection cancelled during directory close", + { cause: signal.reason }, + ) + : attempt.cause; + const error = ghidraPspInspectionError( + ArtifactReaderFailure.withCleanup( + primary, + ArtifactReaderFailure.cleanupObservation(attempt.cause, path), + ), + ); + lease.bindCleanupError(error); + throw error; + } + if (outcome.kind === "failed") throw outcome.cause; + assertArtifactReadActive(signal); + return outcome.names; +}; diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 5ed8c319f9..08957b4d6b 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -1,3 +1,5 @@ +import { ok as javaProbeOk } from "../domain/result.js"; +import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import { describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../tests/fixtures/binarySession.js"; @@ -43,15 +45,16 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { readText: () => `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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + runtime: "jdk", + }), }, (options) => ({ start: async () => diff --git a/tests/boundary/cli/ghidraJavaDoctor.test.ts b/tests/boundary/cli/ghidraJavaDoctor.test.ts new file mode 100644 index 0000000000..c29bcb3773 --- /dev/null +++ b/tests/boundary/cli/ghidraJavaDoctor.test.ts @@ -0,0 +1,117 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([ + ["missing", "missing_dependency", "runtime_missing"], + ["nonzero", "runtime_failure", "runtime_unavailable"], + ["properties", "config_drift", "version_unresolved"], +] as const)( + "preserves the %s Java prerequisite result through doctor", + async ([failure, classification, code], { cli }) => { + const root = await createTestTempDirectory("rea-java-doctor-"); + const jdk = join(root, "jdk"); + const install = join(root, "ghidra"); + const platform = process.platform === "darwin" ? "mac" : "linux"; + const architecture = process.arch === "arm64" ? "arm_64" : "x86_64"; + const native = join( + install, + "Ghidra/Features/Decompiler/os", + `${platform}_${architecture}`, + "decompile", + ); + for (const path of [ + join(jdk, "bin"), + join(install, "support"), + join(install, "Ghidra"), + join(native, ".."), + ]) + await mkdir(path, { recursive: true }); + await writeFile( + join(install, "Ghidra/application.properties"), + "application.version=12.1.4\napplication.java.min=21\n", + ); + await writeFile( + join(install, "support/analyzeHeadless"), + "#!/bin/sh\nexit 99\n", + ); + await chmod(join(install, "support/analyzeHeadless"), 0o700); + await writeFile( + native, + process.platform === "darwin" + ? thinMach( + 0xfeedfacf, + process.arch === "arm64" ? 0x0100000c : 0x01000007, + ) + : elf(2, 1, process.arch === "arm64" ? 183 : 62), + ); + await chmod(native, 0o700); + if (failure !== "missing") { + const body = + failure === "nonzero" + ? 'process.stderr.write("controlled runtime failure\\n"); process.exit(7);' + : `process.stdout.write(${JSON.stringify(`java.home = ${jdk}\nos.arch = aarch64\n`)});`; + await writeFile( + join(jdk, "bin/java"), + `#!${process.execPath}\n${body}\n`, + ); + await chmod(join(jdk, "bin/java"), 0o700); + } + const result = await cli.run({ + arguments: ["doctor", "--provider", "ghidra", "--json"], + environment: { + JAVA_HOME: jdk, + GHIDRA_INSTALL_DIR: install, + REA_LOG_LEVEL: "silent", + }, + }); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1); + expect(result.json).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "ghidra-java", + classification, + ok: false, + }), + ]), + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "ghidra", + checks: expect.arrayContaining([ + expect.objectContaining({ name: "java", code }), + ]), + }), + ]), + }); + if (failure === "nonzero") + expect(result.json).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "ghidra-java", + detail: expect.stringContaining("Java exited 7"), + details: expect.objectContaining({ + output: expect.objectContaining({ + stderr: "controlled runtime failure\n", + exit_code: 7, + stderr_bytes: 27, + }), + }), + }), + ]), + }); + if (failure === "properties") + expect(result.json).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "ghidra-java", + details: expect.objectContaining({ + properties: expect.objectContaining({ home: jdk, version: null }), + }), + }), + ]), + }); + }, +); diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts index 1bdd4a162a..821598f565 100644 --- a/tests/boundary/filesystem/ghidraJavaProbe.test.ts +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -1,3 +1,4 @@ +import type { SpawnSyncReturns } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; @@ -18,6 +19,24 @@ vi.mock("node:child_process", async (importOriginal) => ({ afterEach(() => mockSpawnSync.mockReset()); +/** Match the production probe's raw-byte spawn contract at this fixture boundary. */ +const probeOutput = ( + stderrText: string, + error?: Error, +): SpawnSyncReturns => { + const stdout = Buffer.alloc(0); + const stderr = Buffer.from(stderrText, "utf8"); + return { + pid: 1, + output: [null, stdout, stderr], + stdout, + stderr, + status: error === undefined ? 0 : null, + signal: null, + ...(error === undefined ? {} : { error }), + }; +}; + it.each([ { major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" }, { major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" }, @@ -47,11 +66,11 @@ it.each([ ); } const version = `${String(major)}.0.11`; - mockSpawnSync.mockReturnValue({ - stdout: "", - stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`, - status: 0, - }); + mockSpawnSync.mockReturnValue( + probeOutput( + ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`, + ), + ); const inspection = inspectGhidraInstallation({ environment: {}, @@ -83,7 +102,7 @@ it.each([ "-Dsun.stdout.encoding=UTF-8", "-Dsun.stderr.encoding=UTF-8", ]), - expect.objectContaining({ encoding: "utf8" }), + expect.objectContaining({ encoding: "buffer" }), ); }, ); @@ -127,11 +146,11 @@ it.each([ join(installDir, "Ghidra", "application.properties"), `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, ); - mockSpawnSync.mockReturnValue({ - stdout: "", - stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`, - status: 0, - }); + mockSpawnSync.mockReturnValue( + probeOutput( + ` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`, + ), + ); const inspection = inspectGhidraInstallation({ environment: {}, @@ -172,11 +191,11 @@ it("keeps a known 32-bit Java report classified as unsupported", async () => { join(installDir, "Ghidra", "application.properties"), `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, ); - mockSpawnSync.mockReturnValue({ - stdout: "", - stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`, - status: 0, - }); + mockSpawnSync.mockReturnValue( + probeOutput( + ` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`, + ), + ); const inspection = inspectGhidraInstallation({ environment: {}, @@ -202,14 +221,12 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J ); // A chatty or hostile JVM that overruns the capture budget: spawnSync reports // ENOBUFS, which the probe already treats as a failed observation. - mockSpawnSync.mockReturnValue({ - stdout: "", - stderr: "java.version = 21.0.11\n", - status: null, - error: Object.assign(new Error("spawnSync /java ENOBUFS"), { - code: "ENOBUFS", - }), - }); + mockSpawnSync.mockReturnValue( + probeOutput( + "java.version = 21.0.11\n", + Object.assign(new Error("spawnSync /java ENOBUFS"), { code: "ENOBUFS" }), + ), + ); const inspection = inspectGhidraInstallation({ environment: {}, @@ -227,6 +244,17 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J expect(inspection.javaHome).toBe(configuredHome); expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({ status: "failed", - code: "runtime_missing", + code: "runtime_unavailable", + }); + expect( + ghidraInstallationDiagnostics(inspection).java_probe_failure, + ).toMatchObject({ + code: "runtime_unavailable", + process_error_code: "ENOBUFS", + output: { + stderr: "java.version = 21.0.11\n", + truncated: true, + signal: null, + }, }); }); diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 5edce1aedd..38c7b1c780 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -1,6 +1,20 @@ -import { mkdir, rename, symlink, truncate, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import type { Dir } from "node:fs"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { ok as javaProbeOk } from "../../../src/domain/result.js"; +import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js"; +import { + mkdir, + readFile, + rename, + symlink, + truncate, + writeFile, + type FileHandle, +} from "node:fs/promises"; import { dirname, join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { silentLogger } from "../../../src/logger.js"; @@ -18,6 +32,32 @@ import { } from "../../../src/ghidra/GhidraLauncher.js"; import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js"; +const controls = vi.hoisted(() => ({ + file: undefined as ((handle: FileHandle, path: string) => void) | undefined, + directory: undefined as ((handle: Dir) => void) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + controls.file?.(handle, String(args[0])); + return handle; + }, + opendir: async (...args: Parameters) => { + const handle = await actual.opendir(...args); + controls.directory?.(handle); + return handle; + }, + }; +}); +afterEach(() => { + controls.file = undefined; + controls.directory = undefined; + vi.restoreAllMocks(); +}); + // Synthetic installation files exercise the filesystem/profile boundary, not // Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior. // Independently inspected with PSPDEV v20261001 / GNU readelf 2.44. @@ -55,15 +95,16 @@ const host: GhidraInstallationHost = { architecture: "x64", 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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "21.0.12", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), }; async function installation() { const directory = await createTestTempDirectory("rea-psp-profile-"); @@ -148,7 +189,11 @@ describe.skipIf(process.platform === "win32")( it("fails before starting a provider after resolved extension bytes change", async () => { const { directory, root } = await installation(); - const extension = await inspectGhidraPspExtension(root, "12.1.3"); + const extension = await inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ); await writeFile(join(root, "lib/extension.jar"), "changed loader"); const spawn = vi.fn(); const launcher = new GhidraHeadlessLauncher({ @@ -160,17 +205,20 @@ describe.skipIf(process.platform === "win32")( pspExtension: extension, spawnProcess: spawn, }); - const launched = await launcher.launch({ - runtimeRoot: join(directory, "runtime"), - transport: "unix-socket", - endpointPath: join(directory, "socket"), - token: "test-only-token", - runId: "00000000-0000-4000-8000-000000000001", - targetPath: target.path, - targetSha256: target.sha256, - providerVersion: "12.1.3", - profileDigest: "b".repeat(64), - }); + const launched = await launcher.launch( + { + runtimeRoot: join(directory, "runtime"), + transport: "unix-socket", + endpointPath: join(directory, "socket"), + token: "test-only-token", + runId: "00000000-0000-4000-8000-000000000001", + targetPath: target.path, + targetSha256: target.sha256, + providerVersion: "12.1.3", + profileDigest: "b".repeat(64), + }, + { resourceScope: new ArtifactResourceScope() }, + ); expect(launched.ok).toBe(false); if (launched.ok) throw new Error("Changed extension reached provider startup"); @@ -201,9 +249,13 @@ describe.skipIf(process.platform === "win32")( async (properties, reason) => { const { root } = await installation(); await writeFile(join(root, "extension.properties"), properties); - await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow( - reason, - ); + await expect( + inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ), + ).rejects.toThrow(reason); }, ); @@ -213,9 +265,9 @@ describe.skipIf(process.platform === "win32")( join(root, "Module.manifest"), join(root, "Module.manifest.uninstalled"), ); - await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow( - "Module.manifest", - ); + await expect( + inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()), + ).rejects.toThrow("Module.manifest"); }); it("rejects symlinked code instead of hashing an untracked external producer", async () => { @@ -223,17 +275,17 @@ describe.skipIf(process.platform === "win32")( const path = join(root, "lib/extension.jar"); await rename(path, join(directory, "external.jar")); await symlink(join(directory, "external.jar"), path); - await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow( - "regular files", - ); + await expect( + inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()), + ).rejects.toThrow("regular files"); }); it("bounds sparse extension input before reading or allocating its declared extent", async () => { const { root } = await installation(); await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1); - await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow( - "inspection limit", - ); + await expect( + inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()), + ).rejects.toThrow("inspection limit"); }); }, ); @@ -254,9 +306,13 @@ describe.skipIf(process.platform === "win32")( ), ); } - await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow( - "inventory exceeds the inspection limit", - ); + await expect( + inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ), + ).rejects.toThrow("inventory exceeds the inspection limit"); }, ); }, @@ -580,16 +636,337 @@ describe.skipIf(process.platform === "win32")( "accepts Java Properties $form and hashes raw bytes", async ({ properties }) => { const { root } = await installation(); - const canonical = await inspectGhidraPspExtension(root, "12.1.3"); + const canonical = await inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ); const latin1Properties = Buffer.from( `${properties}author=caf\u00e9\n`, "latin1", ); await writeFile(join(root, "extension.properties"), latin1Properties); - const extension = await inspectGhidraPspExtension(root, "12.1.3"); + const extension = await inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ); expect(extension.id).toBe("ghidra-allegrex"); expect(extension.sha256).not.toBe(canonical.sha256); }, ); }, ); + +describe.skipIf(process.platform === "win32")( + "PSP inspection completion ownership", + () => { + it.each([true, false])( + "preserves physical read failure when close aborts the caller (close rejection: %s)", + async (rejectClose) => { + const { provider, root } = await installation(); + const controller = new AbortController(); + const readFailure = new Error("controlled PSP read failure"); + let closes = 0; + controls.file = (handle, path) => { + if (path !== join(root, "Module.manifest")) return; + controls.file = undefined; + vi.spyOn(handle, "read").mockRejectedValue(readFailure); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + if (closes === 1) { + controller.abort({ caller: "late close" }); + if (rejectClose) throw new Error("controlled PSP close failure"); + } + await close(); + }); + }; + try { + const result = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Failed read admitted"); + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(result.error.message).toContain(readFailure.message); + expect(result.error.cleanupIncomplete).toBe(rejectClose); + expect(provider.hasPendingOwnedResources()).toBe(rejectClose); + expect((await provider.closeOwnedResources()).ok).toBe(true); + expect(result.error.cleanupIncomplete).toBe(false); + expect(provider.hasPendingOwnedResources()).toBe(false); + expect(closes).toBe(rejectClose ? 2 : 1); + } finally { + await provider.closeOwnedResources(); + } + }, + ); + + it.each([true, false])( + "preserves exact cancellation during successful read finalization (close rejection: %s)", + async (rejectClose) => { + const { provider, root } = await installation(); + const controller = new AbortController(); + const reason = { caller: "finalization" }; + let closes = 0; + controls.file = (handle, path) => { + if (path !== join(root, "Module.manifest")) return; + controls.file = undefined; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + if (closes === 1) { + controller.abort(reason); + if (rejectClose) throw new Error("controlled PSP close failure"); + } + await close(); + }); + }; + try { + const result = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Cancelled inspection admitted"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(rejectClose); + expect((await provider.closeOwnedResources()).ok).toBe(true); + expect(result.error.cleanupIncomplete).toBe(false); + expect(closes).toBe(rejectClose ? 2 : 1); + } finally { + await provider.closeOwnedResources(); + } + }, + ); + + it("retains directory close uncertainty without retrying an invalidated native handle", async () => { + const { provider } = await installation(); + const controller = new AbortController(); + const readFailure = new Error("controlled directory read failure"); + let closes = 0; + controls.directory = (handle) => { + controls.directory = undefined; + vi.spyOn(handle, "read").mockRejectedValue(readFailure); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + await close(); + controller.abort({ caller: "directory finalization" }); + throw new Error("controlled native directory close rejection"); + }); + }; + const result = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Failed directory admitted"); + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(result.error.message).toContain(readFailure.message); + expect(result.error.cleanupIncomplete).toBe(true); + expect(provider.hasPendingOwnedResources()).toBe(true); + expect((await provider.closeOwnedResources()).ok).toBe(false); + expect(result.error.cleanupIncomplete).toBe(true); + expect(closes).toBe(1); + const retried = await provider.resolveAnalysisProfile(target); + expect(retried.ok).toBe(false); + expect(closes).toBe(1); + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "PSP launch inspection ownership", + () => { + it("keeps the launch inspection failure through client finalization and retries its exact descriptor", async () => { + const { directory, root } = await installation(); + const extension = await inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ); + const controller = new AbortController(); + const readFailure = new Error( + "controlled launch inspection read failure", + ); + let closes = 0; + let allowClose = false; + controls.file = (handle, path) => { + if (path !== join(root, "Module.manifest")) return; + controls.file = undefined; + vi.spyOn(handle, "read").mockRejectedValue(readFailure); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + controller.abort({ caller: "launch finalization" }); + if (!allowClose) + throw new Error("controlled launch inspection close failure"); + await close(); + }); + }; + const spawn = vi.fn(); + const launcher = new GhidraHeadlessLauncher({ + environment: {}, + analyzeHeadlessPath: join(directory, "support/analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.3_PUBLIC", + bridgeScriptPath: "/packaged/ReaGhidraBridge.java", + pspExtension: extension, + spawnProcess: spawn, + }); + const targetPath = join(directory, "target.bin"); + await writeFile(targetPath, "synthetic target"); + const client = new GhidraClient({ + launcher, + targetPath, + targetSha256: createHash("sha256") + .update("synthetic target") + .digest("hex"), + providerVersion: "12.1.3", + profileDigest: "b".repeat(64), + }); + try { + const result = await client.start(controller.signal); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Failed PSP inspection started"); + expect(result.error.kind).toBe("start"); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + expect(String(result.error.cause)).toContain( + "Ghidra headless launch failed", + ); + expect(spawn).not.toHaveBeenCalled(); + expect(closes).toBe(2); // Inspection close, then client rollback. + allowClose = true; + await client.close(); + expect(closes).toBe(3); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await client.close(); + } + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "PSP inspection admission and identity", + () => { + it("retains the existing sorted recursive hash framing", async () => { + const { root } = await installation(); + await mkdir(join(root, "a")); + await writeFile(join(root, "a/z"), "nested before sibling"); + await writeFile(join(root, "a-foo"), "sibling after directory"); + const paths = [ + "Module.manifest", + "a/z", + "a-foo", + "data/languages/allegrex.cspec", + "data/languages/allegrex.ldefs", + "data/languages/allegrex.pspec", + "data/languages/allegrex.sla", + "extension.properties", + "lib/extension.jar", + ]; + const hash = createHash("sha256"); + for (const path of paths) { + const bytes = await readFile(join(root, path)); + hash.update(JSON.stringify([path, bytes.length])); + hash.update(bytes); + } + const extension = await inspectGhidraPspExtension( + root, + "12.1.3", + new ArtifactResourceScope(), + ); + expect(extension.sha256).toBe(hash.digest("hex")); + }); + + it.each(["growth", "truncation"] as const)( + "rejects file %s during the bounded read and confirms close", + async (change) => { + const { root, provider } = await installation(); + const path = join( + root, + change === "growth" ? "Module.manifest" : "extension.properties", + ); + let closes = 0; + controls.file = (handle, openedPath) => { + if (openedPath !== path) return; + controls.file = undefined; + const read = handle.read.bind(handle); + vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => { + await writeFile(path, change === "growth" ? "extra byte" : ""); + return read(...args); + }); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + await close(); + }); + }; + const result = await provider.resolveAnalysisProfile(target); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Changed file admitted"); + expect(result.error.message).toContain( + change === "growth" + ? "changed during inspection" + : "Truncated Allegrex component", + ); + expect(closes).toBe(1); + expect(result.error.cleanupIncomplete).toBe(false); + expect(provider.hasPendingOwnedResources()).toBe(false); + expect((await provider.closeOwnedResources()).ok).toBe(true); + }, + ); + + it("joins active final close before provider cleanup retries the same descriptor", async () => { + const { root, provider } = await installation(); + let markClosing: (() => void) | undefined; + let releaseClose: (() => void) | undefined; + const closing = new Promise((resolve) => { + markClosing = resolve; + }); + const finishClose = new Promise((resolve) => { + releaseClose = resolve; + }); + let closes = 0; + controls.file = (handle, path) => { + if (path !== join(root, "Module.manifest")) return; + controls.file = undefined; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + if (closes === 1) { + markClosing?.(); + await finishClose; + throw new Error("controlled active close rejection"); + } + await close(); + }); + }; + const inspecting = provider.resolveAnalysisProfile(target); + await closing; + let cleanupSettled = false; + const cleaning = provider.closeOwnedResources().then((result) => { + cleanupSettled = true; + return result; + }); + try { + await Promise.resolve(); + expect(cleanupSettled).toBe(false); + expect(closes).toBe(1); + releaseClose?.(); + const result = await inspecting; + expect(result.ok).toBe(false); + expect((await cleaning).ok).toBe(true); + if (result.ok) throw new Error("Failed close admitted"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(closes).toBe(2); + expect(provider.hasPendingOwnedResources()).toBe(false); + } finally { + releaseClose?.(); + await cleaning; + await provider.closeOwnedResources(); + } + }); + }, +); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index aaca199d23..84b1a88e2a 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -1,3 +1,5 @@ +import { ok as javaProbeOk } from "../../../src/domain/result.js"; +import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js"; import { expect } from "vitest"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -105,15 +107,16 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({ architecture: "x64", 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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + runtime: "jdk", + }), }); const sessionInfo = (profileDigest: string, targetSha256: string) => ({ diff --git a/tests/boundary/mcp/ghidraReopen.test.ts b/tests/boundary/mcp/ghidraReopen.test.ts index a69b41d65a..f7c584ae0e 100644 --- a/tests/boundary/mcp/ghidraReopen.test.ts +++ b/tests/boundary/mcp/ghidraReopen.test.ts @@ -1,3 +1,5 @@ +import { ok as javaProbeOk } from "../../../src/domain/result.js"; +import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js"; import { spawn, type ChildProcess } from "node:child_process"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -101,15 +103,16 @@ const installationHost: GhidraInstallationHost = { architecture: "x64", 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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + osArch: "amd64", + osName: "Linux", + version: "21.0.11", + major: 21, + home: "/fixture/jdk-21", + bits: 64, + runtime: "jdk", + }), }; // Only installation discovery and the Java bridge are simulated. Requests use diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 1c74da203f..05bd255ed2 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -1,3 +1,5 @@ +import { ok as javaProbeOk } from "../../../../src/domain/result.js"; +import type { GhidraJavaObservation } from "../../../../src/ghidra/GhidraJavaProbe.js"; import { mkdir, stat, writeFile } from "node:fs/promises"; import { createHash, randomUUID } from "node:crypto"; import { join } from "node:path"; @@ -443,20 +445,21 @@ describe.skipIf(process.platform === "win32")( readText: () => "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", - bits: 64, - runtime: "jdk", - }), + probeJava: () => + javaProbeOk({ + 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", + bits: 64, + runtime: "jdk", + }), }, (options) => new GhidraClient({ ...options, launcher }), );