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
This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-11-04 16:10:46 +08:00
1 parent 650a459346
commit 1998e65705
3 files changed
+256 -13

No files matched your search

+5 -1
View File
@@ -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@<major>` 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.
+207
View File
@@ -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(
+44 -12
View File
@@ -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<string | undefined>;
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 {