From 1998e65705afd04dee078724932fa98d446248b9 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Wed, 4 Nov 2026 16:10:46 +0800 Subject: [PATCH] fix(ghidra): discover installed versioned Homebrew JDKs (#8853) * fix(ghidra): discover installed versioned Homebrew JDKs * test(ghidra): protect Homebrew Java candidate precedence * fix(ghidra): continue automatic JDK discovery after a failed keg --- docs/installation.md | 6 +- src/ghidra/GhidraInstallation.test.ts | 207 ++++++++++++++++++++++++++ src/ghidra/GhidraInstallation.ts | 56 +++++-- 3 files changed, 256 insertions(+), 13 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index a9ff7d110a..c788ae6168 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1160,7 +1160,11 @@ selected formula's `libexec/openjdk.jdk/Contents/Home`, for example Java version bounds declared by your Ghidra installation; REA does not select another JDK when `JAVA_HOME` is explicit or the PATH JVM runs. On macOS, when the PATH launcher cannot start a JVM, it tries executable Homebrew -`opt/openjdk` candidates, preferring the host architecture's prefix. Failed +`opt/openjdk` and installed `opt/openjdk@` candidates, preferring the host +architecture's prefix and the unversioned formula within each prefix. Versioned +aliases are considered in ascending major order within Ghidra's declared Java +bounds; the selected executable still undergoes the normal Java probe and +admission checks. REA does not invoke Homebrew or install a JDK. Failed discovery probes remain available in installation diagnostics. 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. diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index f830572f61..3e0daeffc0 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -27,6 +27,7 @@ import { ghidraJavaEnvironment, discoverGhidraInstallationOnPath, inspectGhidraInstallation, + systemGhidraInstallationHost, type GhidraInstallationHost, } from "./GhidraInstallation.js"; import { @@ -1732,6 +1733,212 @@ describe("Ghidra Java probe os.arch extraction", () => { }); }); +it("enumerates installed opt aliases without requiring a Homebrew executable", async () => { + const root = await createTestTempDirectory("rea-ghidra-opt-aliases-"); + try { + await mkdir(join(root, "openjdk@21")); + await mkdir(join(root, "unrelated")); + const systemHost = systemGhidraInstallationHost(); + expect(systemHost.directoryEntries?.(root).toSorted()).toEqual([ + "openjdk@21", + "unrelated", + ]); + expect(systemHost.directoryEntries?.(join(root, "absent"))).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["arm64", "x64"] as const)( + "discovers installed versioned Homebrew Java on macOS %s", + async (architecture) => { + const prefix = architecture === "arm64" ? "/opt/homebrew" : "/usr/local"; + const command = `${prefix}/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java`; + const commands: string[] = []; + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: "/usr/bin" }, + installDir: INSTALL, + platform: "darwin", + architecture, + }, + host({ + directoryEntries: (directory) => + directory === `${prefix}/opt` + ? ["openjdk@21", "unrelated", "openjdk@21/../foreign"] + : [], + executable: (candidate) => + candidate === "/usr/bin/java" || + candidate === command || + candidate.endsWith("/mac_x86_64/decompile") || + host().executable(candidate), + probeJava: (candidate) => { + commands.push(candidate); + return candidate === command + ? javaProbeOk({ + ...JAVA, + home: command.slice(0, -9), + osName: "Mac OS X", + osArch: architecture === "arm64" ? "aarch64" : "x86_64", + }) + : missingJavaProbe; + }, + }), + ); + expect(commands).toEqual(["/usr/bin/java", command]); + expect(inspection.status).toBe("available"); + expect(inspection.javaCommand).toBe(command); + }, +); + +it.each([ + ["arm64", true, "/opt/homebrew", "/usr/local"], + ["x64", true, "/usr/local", "/opt/homebrew"], + ["arm64", false, "/opt/homebrew", "/usr/local"], + ["x64", false, "/usr/local", "/opt/homebrew"], +] as const)( + "orders installed Homebrew Java candidates on macOS %s with PATH Java %s", + async (architecture, pathJava, preferred, other) => { + const suffix = "/libexec/openjdk.jdk/Contents/Home/bin/java"; + const candidates = [ + `${preferred}/opt/openjdk${suffix}`, + `${preferred}/opt/openjdk@21${suffix}`, + `${preferred}/opt/openjdk@25${suffix}`, + `${other}/opt/openjdk${suffix}`, + `${other}/opt/openjdk@21${suffix}`, + `${other}/opt/openjdk@25${suffix}`, + ]; + for (const selected of [0, 1, 2, 3, undefined]) { + const commands: string[] = []; + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: pathJava ? "/usr/bin" : "/empty-bin" }, + installDir: INSTALL, + platform: "darwin", + architecture, + }, + host({ + readText: () => + "application.version=12.1.4\napplication.java.min=21\napplication.java.max=25\n", + directoryEntries: () => ["openjdk@25", "openjdk@21"], + executable: (candidate) => + (pathJava && candidate === "/usr/bin/java") || + candidates.includes(candidate) || + candidate.endsWith("/mac_x86_64/decompile") || + host().executable(candidate), + probeJava: (command) => { + commands.push(command); + return selected !== undefined && command === candidates[selected] + ? javaProbeOk({ + ...JAVA, + home: command.slice(0, -9), + major: selected === 2 ? 25 : 21, + version: selected === 2 ? "25.0.1" : JAVA.version, + osName: "Mac OS X", + osArch: architecture === "arm64" ? "aarch64" : "x86_64", + }) + : missingJavaProbe; + }, + }), + ); + expect(commands).toEqual([ + ...(pathJava ? ["/usr/bin/java"] : []), + ...candidates.slice( + 0, + selected === undefined ? undefined : selected + 1, + ), + ]); + expect(inspection.status).toBe( + selected === undefined ? "unavailable" : "available", + ); + if (selected !== undefined) + expect(inspection.javaCommand).toBe(candidates[selected]); + } + }, +); + +it.each([ + "explicit", + "path", + "missing", + "wrong version", + "wrong architecture", + "cancelled", +] as const)( + "preserves Java selection and admission during versioned discovery: %s", + async (scenario) => { + const directoryEntries = vi.fn(() => [ + "openjdk@17", + "openjdk@21", + "openjdk@999", + "openjdk@21/../foreign", + ]); + const keg = + "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java"; + const commands: string[] = []; + const controller = new AbortController(); + const result = inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: "/usr/bin" }, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + signal: controller.signal, + ...(scenario === "explicit" ? { javaHome: "/selected-jdk" } : {}), + }, + host({ + readText: () => + "application.version=12.1.4\napplication.java.min=21\napplication.java.max=21\n", + directoryEntries, + executable: (candidate) => + candidate === "/usr/bin/java" || + candidate === keg || + host().executable(candidate), + probeJava: (command) => { + commands.push(command); + if (scenario === "cancelled" && command === keg) { + controller.abort("controlled cancellation"); + throw new AnalysisCancelledError("ghidra:installation"); + } + if ( + scenario === "explicit" || + scenario === "missing" || + (scenario !== "path" && command !== keg) + ) + return missingJavaProbe; + return javaProbeOk({ + ...JAVA, + home: "/selected-jdk", + major: scenario === "wrong version" ? 17 : 21, + version: scenario === "wrong version" ? "17.0.1" : "21.0.11", + osName: "Mac OS X", + osArch: scenario === "wrong architecture" ? "x86_64" : "aarch64", + }); + }, + }), + ); + if (scenario === "cancelled") { + await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError); + } else { + const inspection = await result; + expect(inspection.status).toBe( + scenario === "path" ? "available" : "unavailable", + ); + if (scenario === "wrong version" || scenario === "wrong architecture") + expect(inspection.javaCommand).toBe(keg); + } + if (scenario === "explicit" || scenario === "path") { + expect(directoryEntries).not.toHaveBeenCalled(); + expect(commands).toEqual([ + scenario === "explicit" ? "/selected-jdk/bin/java" : "/usr/bin/java", + ]); + } else expect(commands).toEqual(["/usr/bin/java", keg]); + }, +); + it("explains the macOS keg-only JDK case without probing an unselected installation", async () => { const probed: string[] = []; const inspection = await inspectGhidraInstallation( diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index e3b49766b4..474e0a612d 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -19,7 +19,13 @@ import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, } from "./GhidraInstallationProperties.js"; -import { constants, accessSync, realpathSync, statSync } from "node:fs"; +import { + constants, + accessSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; import { delimiter as pathDelimiter, dirname, posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; @@ -274,6 +280,8 @@ export interface GhidraInstallationHost { options: GhidraInstallationFileOptions, ): string | undefined | Promise; executable(path: string): boolean; + /** Optional best-effort enumeration of installed Homebrew opt aliases. */ + directoryEntries?(path: string): readonly string[]; /** * Read a bounded header prefix of one executable so admission can reject a * tool built for another platform or architecture. An omitted seam, or an @@ -512,17 +520,35 @@ const installationCoordinates = async ( nativeDecompilerHeader.error.cleanupIncomplete) ) throw nativeDecompilerHeader.error; + const parsedProperties = + properties === undefined + ? undefined + : ghidraApplicationProperties(properties); + const javaRequirement = ghidraJavaRequirement(parsedProperties); const homebrewJavaCandidates = () => (architecture === "arm64" ? ["/opt/homebrew", "/usr/local"] : ["/usr/local", "/opt/homebrew"] ) - .map((prefix) => - path.join( - prefix, - "opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java", - ), - ) + .flatMap((prefix) => { + const opt = path.join(prefix, "opt"); + const versioned = (host.directoryEntries?.(opt) ?? []) + .filter((name) => /^openjdk@[1-9][0-9]*$/u.test(name)) + .map((name) => ({ + name, + major: Number(name.slice("openjdk@".length)), + })) + .filter( + ({ major }) => + Number.isSafeInteger(major) && + ghidraJavaMajorAccepted(major, javaRequirement), + ) + .sort((left, right) => left.major - right.major) + .map(({ name }) => name); + return ["openjdk", ...versioned].map((formula) => + path.join(opt, formula, "libexec/openjdk.jdk/Contents/Home/bin/java"), + ); + }) .filter((candidate) => host.executable(candidate)); const selectedJava: { command: string; @@ -587,7 +613,7 @@ const installationCoordinates = async ( if ( platform === "darwin" && options.javaHome === undefined && - (selectedJava.source === "path" || selectedJava.source === "default") && + selectedJava.source !== "configured" && javaProbe !== undefined && !javaProbe.ok && !(javaProbe.error instanceof AnalysisError) && @@ -600,6 +626,7 @@ const installationCoordinates = async ( failure: javaProbe.error, }); for (const candidate of homebrewJavaCandidates()) { + if (candidate === selectedJava.command) continue; const fallback = await host.probeJava( candidate, ghidraJavaEnvironment(undefined, options.environment, platform), @@ -629,10 +656,7 @@ const installationCoordinates = async ( analyzeHeadlessPath, nativeDecompilerPath, nativeDecompilerHeader, - properties: - properties === undefined - ? undefined - : ghidraApplicationProperties(properties), + properties: parsedProperties, ...(javaDiscoveryFailures.length === 0 ? {} : { javaDiscoveryFailures }), javaCommand, java: javaProbe?.ok === true ? javaProbe.value : undefined, @@ -1074,6 +1098,14 @@ const javaRemediation = ( export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ readText: readGhidraInstallationProperties, + directoryEntries(path) { + try { + return readdirSync(path); + } catch { + // Optional discovery cannot replace explicit Java selection or admission. + return []; + } + }, executableHeader: readGhidraExecutableHeader, executable(path) { try {