diff --git a/docs/installation.md b/docs/installation.md index 879b118cfb..52d63a8e14 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1027,7 +1027,11 @@ cached across launches. `JAVA_TOOL_OPTIONS` and `_JAVA_OPTIONS` do not expand `@file` tokens, so they remain literal-token checks. When Java is found through `PATH`, setup records its observed JDK home so GUI -MCP clients do not depend on an incidental shell path. Setup shows every exact +MCP clients do not depend on an incidental shell path. A caller-selected +`JAVA_HOME` spelling is preserved when it resolves to that observed JDK. On +macOS, a discovered Homebrew Cellar home uses the formula's stable `opt` alias +only when both resolve to the same JDK. The Java check retains the JVM-reported +home as evidence. Setup shows every exact environment entry in its plan, writes only after approval, and never downloads, installs, upgrades, or modifies Ghidra or Java. diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index d737447058..d508f845ee 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -9,7 +9,15 @@ import { parseGhidraJavaOutput, type GhidraJavaObservation, } from "./GhidraJavaProbe.js"; -import { chmod, mkdir, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + realpath, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -153,6 +161,87 @@ it.skipIf(process.platform === "win32")( }, ); +it + .skipIf(process.platform === "win32") + .each([ + "selected alias", + "selected foreign alias", + "selected missing alias", + "discovered matching opt", + "discovered foreign opt", + "discovered missing opt", + ] as const)("keeps JDK registration identity for %s", async (scenario) => { + const temporaryRoot = await createTestTempDirectory("rea-java-home-"); + const prefix = await realpath(temporaryRoot); + const suffix = "libexec/openjdk.jdk/Contents/Home"; + const firstKeg = join(prefix, "Cellar", "openjdk@21", "21.0.12"); + const secondKeg = join(prefix, "Cellar", "openjdk@21", "21.0.13"); + const observedHome = join(firstKeg, suffix); + const optAlias = join(prefix, "opt", "openjdk@21"); + const selectedAlias = join(prefix, "selected-jdk"); + await mkdir(observedHome, { recursive: true }); + await mkdir(join(secondKeg, suffix), { recursive: true }); + await mkdir(join(prefix, "opt")); + const selected = scenario.startsWith("selected"); + const missing = scenario.includes("missing"); + const foreign = scenario.includes("foreign"); + if (!missing) + await symlink( + selected + ? foreign + ? join(secondKeg, suffix) + : observedHome + : foreign + ? secondKeg + : firstKeg, + selected ? selectedAlias : optAlias, + ); + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: {}, + installDir: INSTALL, + platform: process.platform, + architecture: "x64", + ...(selected ? { javaHome: selectedAlias } : {}), + }, + host({ + probeJava: () => + javaProbeOk({ + ...JAVA, + home: observedHome, + osName: process.platform === "darwin" ? "Mac OS X" : "Linux", + }), + executable: (path) => + path === HEADLESS || + path === LINUX_DECOMPILER || + path === + `${INSTALL}/Ghidra/Features/Decompiler/os/mac_x86_64/decompile`, + }), + ); + expect(inspection.status).toBe("available"); + const stable = + !missing && !foreign && (selected || process.platform === "darwin"); + const expected = stable + ? selected + ? selectedAlias + : join(optAlias, suffix) + : observedHome; + expect(inspection.javaHome).toBe(expected); + expect( + projectGhidraDoctorInspection(inspection).registrationEnvironment.JAVA_HOME, + ).toBe(expected); + expect( + inspection.checks.find(({ name }) => name === "java")?.detail, + ).toContain(observedHome); + if (stable && !selected) { + await unlink(optAlias); + await symlink(secondKeg, optAlias); + await rm(firstKeg, { recursive: true }); + expect(await realpath(expected)).toBe(join(secondKeg, suffix)); + } +}); + it.skipIf(process.platform !== "darwin")( "uses a same-installation Homebrew opt alias for doctor registration and survives keg rotation", async () => { diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 2249af4d3c..aeb576a672 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -391,15 +391,16 @@ export const inspectGhidraInstallation = async ( throw new AnalysisCancelledError("ghidra:installation", { cause: options.signal.reason, diagnostics: ghidraInstallationDiagnostics( - availableInstallation(coordinates, observation), + availableInstallation(coordinates, observation, options.javaHome), ), }); - return availableInstallation(coordinates, observation); + return availableInstallation(coordinates, observation, options.javaHome); }; const availableInstallation = ( coordinates: GhidraInstallationCoordinates, observation: GhidraInstallationObservation, + selectedJavaHome: string | undefined, ): AvailableGhidraInstallation => { const providerVersion = coordinates.properties?.providerVersion; if ( @@ -420,7 +421,11 @@ const availableInstallation = ( analyzeHeadlessPath: coordinates.analyzeHeadlessPath, applicationPropertiesPath: coordinates.applicationPropertiesPath, providerVersion, - javaHome: coordinates.java.home, + javaHome: stableJavaHome( + coordinates.java.home, + selectedJavaHome, + observation.platform, + ), javaVersion: coordinates.java.version, }; }; @@ -1081,6 +1086,52 @@ const stableGhidraInstallationRoot = ( } }; +/** Preserve a proven caller alias, or an active Homebrew keg alias. */ +const stableJavaHome = ( + observedHome: string, + selectedHome: string | undefined, + platform: NodeJS.Platform, +): string => { + // Compare paths only with the matching host filesystem semantics. + if (platform !== process.platform) return observedHome; + const sameHome = (candidate: string): boolean => { + try { + return realpathSync(candidate) === realpathSync(observedHome); + } catch (cause: unknown) { + // Missing or unresolved aliases do not prove identity. + void cause; + return false; + } + }; + if (selectedHome !== undefined) + return sameHome(selectedHome) ? selectedHome : observedHome; + if (platform !== "darwin") return observedHome; + const parsed = posix.parse(observedHome); + const segments = observedHome.slice(parsed.root.length).split(posix.sep); + const cellarIndex = segments.length - 7; + const formula = segments[cellarIndex + 1]; + const version = segments[cellarIndex + 2]; + if ( + cellarIndex < 0 || + segments[cellarIndex] !== "Cellar" || + formula === undefined || + formula.length === 0 || + version === undefined || + version.length === 0 || + segments.slice(cellarIndex + 3).join("/") !== + "libexec/openjdk.jdk/Contents/Home" + ) + return observedHome; + const prefix = posix.join(parsed.root, ...segments.slice(0, cellarIndex)); + const candidate = posix.join( + prefix, + "opt", + formula, + "libexec/openjdk.jdk/Contents/Home", + ); + return sameHome(candidate) ? candidate : observedHome; +}; + const executablePath = (path: string): boolean => { try { accessSync(path, constants.X_OK);