fix(ghidra): preserve stable JDK registration paths

This commit is contained in:
morluto
2026-10-24 02:19:40 +08:00
parent 243790adc9
commit c062788b21
3 changed files with 149 additions and 5 deletions
+5 -1
View File
@@ -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.
+90 -1
View File
@@ -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 () => {
+54 -3
View File
@@ -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);