mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
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:
1 parent
650a459346
commit
1998e65705
3 files changed
+256
-13
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user