diff --git a/docs/installation.md b/docs/installation.md index 2f8a0444f7..1bc6ed83b3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -996,7 +996,10 @@ 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 +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 +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 d508f845ee..1735404b07 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1774,6 +1774,69 @@ it.each([ }, ); +it.each([ + "PATH succeeds", + "keg succeeds", + "both fail", + "explicit home", +] as const)( + "discovers a keg-only JDK after a failed PATH launcher: %s", + async (scenario) => { + const commands: string[] = []; + const keg = + "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java"; + const selectedHome = + scenario === "explicit home" ? "/selected-jdk" : undefined; + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: "/usr/bin" }, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + ...(selectedHome === undefined ? {} : { javaHome: selectedHome }), + }, + host({ + executable: (candidate) => + candidate === "/usr/bin/java" || + candidate === keg || + host().executable(candidate), + probeJava: (command) => { + commands.push(command); + return scenario === "PATH succeeds" || + (scenario === "keg succeeds" && command === keg) + ? javaProbeOk({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }) + : missingJavaProbe; + }, + }), + ); + expect(commands).toEqual( + scenario === "explicit home" + ? ["/selected-jdk/bin/java"] + : scenario === "PATH succeeds" + ? ["java"] + : ["java", keg], + ); + expect(inspection.status).toBe( + scenario === "PATH succeeds" || scenario === "keg succeeds" + ? "available" + : "unavailable", + ); + if (scenario === "keg succeeds" || scenario === "both fail") { + expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({ + java_discovery_failures: expect.arrayContaining([ + { + command: "java", + code: "runtime_missing", + detail: expect.any(String), + }, + ]), + }); + } + if (scenario === "both fail") expect(inspection.javaCommand).toBe("java"); + }, +); + it.each([ ["darwin", undefined, "/selected-bin/java", "java"], ["darwin", "/selected-jdk", undefined, "/selected-jdk/bin/java"], diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index aeb576a672..8aeb2172ab 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -201,6 +201,10 @@ interface GhidraInstallationObservation { readonly configurationDirectoryName: string | null; readonly nativeDecompilerPath: string | null; readonly nativeDecompilerHeaderFailure?: AnalysisError; + readonly javaDiscoveryFailures?: readonly { + readonly command: string; + readonly failure: GhidraJavaProbeFailure; + }[]; readonly javaCommand: string; readonly javaProbeFailure?: GhidraJavaProbeFailure; /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ @@ -305,6 +309,10 @@ interface GhidraInstallationCoordinates { AnalysisError >; readonly properties: GhidraApplicationProperties | undefined; + readonly javaDiscoveryFailures?: readonly { + readonly command: string; + readonly failure: GhidraJavaProbeFailure; + }[]; readonly javaCommand: string; readonly java: GhidraJavaObservation | undefined; readonly javaProbeFailure: GhidraJavaProbeFailure | undefined; @@ -361,6 +369,9 @@ export const inspectGhidraInstallation = async ( nativeDecompilerHeaderFailure: coordinates.nativeDecompilerHeader.error, }), + ...(coordinates.javaDiscoveryFailures === undefined + ? {} + : { javaDiscoveryFailures: coordinates.javaDiscoveryFailures }), javaCommand: coordinates.javaCommand, ...(coordinates.javaProbeFailure === undefined ? {} @@ -502,7 +513,19 @@ const installationCoordinates = async ( nativeDecompilerHeader.error.cleanupIncomplete) ) throw nativeDecompilerHeader.error; - const javaCommand = (() => { + 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", + ), + ) + .filter((candidate) => host.executable(candidate)); + let javaCommand = (() => { if (options.javaHome !== undefined) return path.join( options.javaHome, @@ -518,26 +541,12 @@ const installationCoordinates = async ( host.executable(path.join(directory, "java")), ); if (javaOnPath) return "java"; - // Prefer the Homebrew prefix matching the process architecture. - const prefixes = - architecture === "arm64" - ? ["/opt/homebrew", "/usr/local"] - : ["/usr/local", "/opt/homebrew"]; - return ( - prefixes - .map((prefix) => - path.join( - prefix, - "opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java", - ), - ) - .find((candidate) => host.executable(candidate)) ?? "java" - ); + return homebrewJavaCandidates()[0] ?? "java"; })(); // Java is a prerequisite of a selected installation. Without one, or with a // relative JAVA_HOME, configuration validation refuses Ghidra before Java can // matter, so callers such as setup and doctor must not execute a host JVM. - const javaProbe = + let javaProbe = installDir !== null && (options.javaHome === undefined || path.isAbsolute(options.javaHome)) ? await host.probeJava( @@ -559,6 +568,55 @@ const installationCoordinates = async ( javaProbe.error instanceof AnalysisError ) throw javaProbe.error; + const javaDiscoveryFailures: { + command: string; + failure: GhidraJavaProbeFailure; + }[] = []; + if ( + platform === "darwin" && + options.javaHome === undefined && + javaCommand === "java" && + javaProbe !== undefined && + !javaProbe.ok && + !(javaProbe.error instanceof AnalysisError) && + javaProbe.error.code !== "version_unresolved" && + javaProbe.error.cancellation === undefined && + !options.signal?.aborted + ) { + javaDiscoveryFailures.push({ + command: javaCommand, + failure: javaProbe.error, + }); + for (const candidate of homebrewJavaCandidates()) { + const fallback = await host.probeJava( + candidate, + ghidraJavaEnvironment(undefined, options.environment, platform), + { + resourceOwner: options.resourceOwner, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }, + ); + if (!fallback.ok && fallback.error instanceof AnalysisError) + throw fallback.error; + if (fallback.ok) { + javaCommand = candidate; + javaProbe = fallback; + break; + } + javaDiscoveryFailures.push({ + command: candidate, + failure: fallback.error, + }); + if ( + fallback.error.cancellation !== undefined || + options.signal?.aborted + ) { + javaCommand = candidate; + javaProbe = fallback; + break; + } + } + } return { installDir, applicationPropertiesPath, @@ -569,6 +627,7 @@ const installationCoordinates = async ( properties === undefined ? undefined : ghidraApplicationProperties(properties), + ...(javaDiscoveryFailures.length === 0 ? {} : { javaDiscoveryFailures }), javaCommand, java: javaProbe?.ok === true ? javaProbe.value : undefined, javaProbeFailure: @@ -709,6 +768,26 @@ export const ghidraInstallationDiagnostics = ( : { output: { ...inspection.javaProbeFailure.output } }), }, }), + ...(inspection.javaDiscoveryFailures === undefined + ? {} + : { + java_discovery_failures: inspection.javaDiscoveryFailures.map( + ({ command, failure }) => ({ + command, + code: failure.code, + detail: failure.detail, + ...(failure.output === undefined + ? {} + : { output: { ...failure.output } }), + ...(failure.properties === undefined + ? {} + : { properties: { ...failure.properties } }), + ...(failure.cancellation === undefined + ? {} + : { cancellation_observed: true }), + }), + ), + }), java_home: inspection.javaHome, java_version: inspection.javaVersion, java_vm_name: inspection.javaVmName ?? null,