diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 5a1b36cb71..1c3a3adf74 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -495,6 +495,37 @@ describe("doctor runtime check", () => { ); }); +it.each([undefined, "", "not-a-version", "14garbage"])( + "retains unknown host support for macOS probe value %s", + async (version) => { + const result = await runDoctor( + undefined, + host({ macosVersion: () => Promise.resolve(version) }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: false, + classification: "unknown", + }), + ); + }, +); + +it.skipIf(process.platform !== "darwin")( + "reads the macOS version without PATH utilities", + async () => { + const system = systemDoctorHost({ + environment: { PATH: "/nonexistent" }, + }); + try { + expect(await system.macosVersion()).toMatch(/^\d+(?:\.\d+)*$/u); + } finally { + await system.close?.(); + } + }, +); + describe("doctor", () => { it("returns exact recovery for every failed diagnostic", async () => { const result = await runDoctor( @@ -518,22 +549,19 @@ describe("doctor", () => { }); }); - it.each(["11.7", undefined])( - "rejects unsupported macOS version %s", - async (version) => { - const result = await runDoctor( - undefined, - host({ macosVersion: () => Promise.resolve(version) }), - ); - expect(result.checks).toContainEqual( - expect.objectContaining({ - name: "host", - ok: false, - classification: "unsupported_host", - }), - ); - }, - ); + it.each(["11.7"])("rejects unsupported macOS version %s", async (version) => { + const result = await runDoctor( + undefined, + host({ macosVersion: () => Promise.resolve(version) }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: false, + classification: "unsupported_host", + }), + ); + }); it("admits only the experimental Windows x64 host boundary", async () => { const windows = await runDoctor( diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 26e0e66fd2..9b56c8190e 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -764,7 +764,9 @@ const readMacosVersion = async ( options: { readonly env: NodeJS.ProcessEnv }, ): Promise => { try { - return (await run("sw_vers", ["-productVersion"], options)).stdout.trim(); + return ( + await run("/usr/bin/sw_vers", ["-productVersion"], options) + ).stdout.trim(); } catch (cause: unknown) { // best-effort cleanup: optional OS version probing; failure means unknown. void cause; diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 0713c6b602..b927c3f2a4 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -129,8 +129,25 @@ const hostCheck = async (host: DoctorHost): Promise => { host.platform === "darwin" ? await host.macosVersion() : undefined; const linuxDistribution = host.platform === "linux" ? await host.linuxDistribution() : undefined; + const macosMajor = + macosVersion !== undefined && /^\d+(?:\.\d+)*$/u.test(macosVersion) + ? Number(macosVersion.split(".")[0]) + : undefined; + if (host.platform === "darwin" && macosMajor === undefined) + return { + name: "host", + ok: false, + classification: "unknown", + detail: + macosVersion === undefined + ? "The macOS version could not be determined." + : `The macOS version probe returned an unrecognized value: ${JSON.stringify(macosVersion)}.`, + remediation: "Check /usr/bin/sw_vers -productVersion and rerun doctor.", + }; const supported = - (host.platform === "darwin" && parseMajor(macosVersion) >= 12) || + (host.platform === "darwin" && + macosMajor !== undefined && + macosMajor >= 12) || (host.platform === "linux" && supportsReaHost(linuxDistribution)) || (host.platform === "win32" && host.architecture === "x64"); const detail = @@ -359,8 +376,5 @@ const unsupportedHopperRemediation = (configured: boolean): string => ? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use." : "Run rea setup to install and retain an authenticated official Hopper package for offline use."; -const parseMajor = (version: string | undefined): number => - Number.parseInt(version?.split(".")[0] ?? "0", 10); - const providerCheckName = (providerId: string, name: string): boolean => name === providerId || name.startsWith(`${providerId}-`); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 20da7d3fb7..45ee8169c2 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1714,7 +1714,7 @@ it("explains the macOS keg-only JDK case without probing an unselected installat }, }), ); - expect(probed).toEqual(["java"]); + expect(probed).toEqual(["/usr/bin/java"]); expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({ status: "failed", code: "runtime_missing", @@ -1813,8 +1813,8 @@ it.each([ scenario === "explicit home" ? ["/selected-jdk/bin/java"] : scenario === "PATH succeeds" - ? ["java"] - : ["java", keg], + ? ["/usr/bin/java"] + : ["/usr/bin/java", keg], ); expect(inspection.status).toBe( scenario === "PATH succeeds" || scenario === "keg succeeds" @@ -1825,19 +1825,21 @@ it.each([ expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({ java_discovery_failures: expect.arrayContaining([ { - command: "java", + command: "/usr/bin/java", code: "runtime_missing", detail: expect.any(String), }, ]), }); } - if (scenario === "both fail") expect(inspection.javaCommand).toBe("java"); + if (scenario === "both fail") + expect(inspection.javaCommand).toBe("/usr/bin/java"); }, ); it.each([ - ["darwin", undefined, "/selected-bin/java", "java"], + ["darwin", undefined, "/selected-bin/java", "/selected-bin/java"], + ["darwin", undefined, "/linked/../jdk/java", "/linked/../jdk/java"], ["darwin", "/selected-jdk", undefined, "/selected-jdk/bin/java"], ["linux", undefined, undefined, "java"], ["win32", undefined, undefined, "java.exe"], @@ -1849,7 +1851,9 @@ it.each([ const inspection = await inspectGhidraInstallation( { resourceOwner: inspectionResources.owner, - environment: { PATH: "/selected-bin" }, + environment: { + PATH: `:relative-bin:${pathJava === undefined ? "/selected-bin" : pathJava.slice(0, -"/java".length)}`, + }, installDir: INSTALL, platform, architecture: "arm64", diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 3b7bde3d51..1866beec1a 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -11,6 +11,7 @@ import { ok, type Result } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { readGhidraExecutableHeader } from "./GhidraExecutableHeader.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, @@ -524,24 +525,37 @@ const installationCoordinates = async ( ), ) .filter((candidate) => host.executable(candidate)); - let javaCommand = (() => { + const selectedJava: { + command: string; + source: "configured" | "path" | "homebrew" | "default"; + } = (() => { if (options.javaHome !== undefined) - return path.join( - options.javaHome, - "bin", - platform === "win32" ? "java.exe" : "java", - ); + return { + source: "configured", + command: path.join( + options.javaHome, + "bin", + platform === "win32" ? "java.exe" : "java", + ), + }; if (platform !== "darwin") - return platform === "win32" ? "java.exe" : "java"; + return { + source: "default", + command: platform === "win32" ? "java.exe" : "java", + }; // Check the caller's PATH, never a file named java in the working directory. - const javaOnPath = options.environment.PATH?.split(":").some( - (directory) => - path.isAbsolute(directory) && - host.executable(path.join(directory, "java")), - ); - if (javaOnPath) return "java"; - return homebrewJavaCandidates()[0] ?? "java"; + const javaOnPath = options.environment.PATH?.split(":") + .filter((directory) => path.isAbsolute(directory)) + .map((directory) => absoluteCallerPath(`${directory}/java`, { platform })) + .find((candidate) => host.executable(candidate)); + if (javaOnPath !== undefined) + return { source: "path", command: javaOnPath }; + const homebrew = homebrewJavaCandidates()[0]; + return homebrew === undefined + ? { source: "default", command: "/usr/bin/java" } + : { source: "homebrew", command: homebrew }; })(); + let javaCommand = selectedJava.command; // 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. @@ -574,7 +588,7 @@ const installationCoordinates = async ( if ( platform === "darwin" && options.javaHome === undefined && - javaCommand === "java" && + (selectedJava.source === "path" || selectedJava.source === "default") && javaProbe !== undefined && !javaProbe.ok && !(javaProbe.error instanceof AnalysisError) &&