diff --git a/docs/installation.md b/docs/installation.md index 1152dd848..c47062a6f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -472,8 +472,11 @@ and JDK 21. Each installation must include the native decompiler for the host architecture in `Ghidra/Features/Decompiler/os//` or the corresponding `build/os//` directory. Linux ARM64 uses `linux_arm_64`; official release archives may require you to build that native component separately. -REA checks the executable prerequisite and does not build or install native -tools, or change Gatekeeper quarantine settings. +REA checks the executable prerequisite by inspecting the file's own executable +header, so a native component built for another platform or architecture is +reported as incompatible instead of being admitted from its directory name, and +a header that cannot be read or recognized is reported as unknown. REA does not +build or install native tools, or change Gatekeeper quarantine settings. The adapter exposes 25 read-only operations: thirteen inventory/name/search operations and twelve function-analysis operations. These cover metadata, diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index d81447c8d..4f5dfce63 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js"; import { projectGhidraDoctorInspection } from "./GhidraDoctor.js"; import { ghidraJavaEnvironment, @@ -510,3 +511,171 @@ describe("Ghidra configuration paths", () => { ); }); }); + +describe("Ghidra FAT native decompiler admission", () => { + it.each([ + { + name: "foreign-only FAT", + bytes: fat([0x01000007]), + status: "unavailable", + detail: "requires mach-o arm64", + }, + { + name: "matching universal FAT", + bytes: fat([0x01000007, 0x0100000c]), + status: "available", + detail: "matches darwin/arm64", + }, + { + name: "truncated FAT table", + bytes: fat([0x01000007], 2), + status: "available", + detail: "could not be established", + }, + ])("distinguishes $name", ({ bytes, status, detail }) => { + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + }, + host({ executableHeader: () => ({ bytes, size: bytes.length }) }), + ); + expect(result.status).toBe(status); + expect( + result.checks.find(({ name }) => name === "native_decompiler")?.detail, + ).toContain(detail); + }); + + it("keeps a truncated host slice unknown despite a readable foreign slice", () => { + const bytes = fat([0x01000007, 0x0100000c]); + bytes.writeUInt32BE(bytes.length + 1, 8 + 20 + 12); + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + }, + host({ executableHeader: () => ({ bytes, size: bytes.length }) }), + ); + expect(result.status).toBe("available"); + expect( + result.checks.find(({ name }) => name === "native_decompiler")?.detail, + ).toContain("could not be established"); + }); +}); + +describe("Ghidra native decompiler admission", () => { + it.each([ + { + name: "a Linux x86-64 decompiler at the macOS arm64 path", + options: { + environment: {}, + installDir: INSTALL, + platform: "darwin" as const, + architecture: "arm64" as const, + }, + executableHeader: (path: string) => + path === MAC_DECOMPILER + ? { bytes: elf(2, 1, 62), size: 52 } + : undefined, + }, + { + name: "a macOS arm64 decompiler at the Linux x64 path", + options: { + environment: {}, + installDir: INSTALL, + platform: "linux" as const, + architecture: "x64" as const, + }, + executableHeader: (path: string) => + path === LINUX_DECOMPILER + ? { bytes: thinMach(0xcffaedfe, 0x0100000c), size: 32 } + : undefined, + }, + ])("distinguishes $name", ({ options, executableHeader }) => { + const result = inspectGhidraInstallation( + options, + host({ executableHeader }), + ); + expect(result.status).toBe("unavailable"); + expect( + result.checks.find(({ status }) => status === "failed"), + ).toMatchObject({ name: "native_decompiler", code: "executable_missing" }); + }); + + it("explains an accepted decompiler by its header, not only its path", () => { + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ + executableHeader: (path: string) => + path === LINUX_DECOMPILER + ? { bytes: elf(2, 1, 62), size: 52 } + : undefined, + }), + ); + expect(result.status).toBe("available"); + if (result.status !== "available") return; + expect( + result.checks.find(({ name }) => name === "native_decompiler"), + ).toMatchObject({ + status: "passed", + detail: `${LINUX_DECOMPILER} reports elf x86_64, which matches linux/x64`, + }); + }); + + it("keeps an unreadable header unknown instead of failing the install", () => { + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ executableHeader: () => undefined }), + ); + expect(result.status).toBe("available"); + if (result.status !== "available") return; + expect( + result.checks.find(({ name }) => name === "native_decompiler"), + ).toMatchObject({ + status: "passed", + detail: expect.stringContaining("could not be established"), + }); + }); + + it("names the observed and the required executable in the failure", () => { + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + }, + host({ + executableHeader: (path: string) => + path === MAC_DECOMPILER + ? { bytes: elf(2, 1, 62), size: 52 } + : undefined, + }), + ); + expect(result.status).toBe("unavailable"); + if (result.status !== "unavailable") return; + expect( + result.checks.find(({ name }) => name === "native_decompiler"), + ).toMatchObject({ + status: "failed", + detail: expect.stringMatching( + /reports elf x86_64, but darwin\/arm64 requires mach-o arm64/u, + ), + remediation: expect.stringContaining("os/mac_arm_64/decompile"), + }); + }); +}); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index c01d8d475..d60d447ed 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -1,10 +1,23 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { spawnSync } from "node:child_process"; import { constants } from "node:fs"; -import { accessSync, readFileSync } from "node:fs"; +import { + accessSync, + closeSync, + fstatSync, + openSync, + readFileSync, + readSync, + statSync, +} from "node:fs"; import { posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; +import { + parseExecutableHeader, + type ExecutableMetadata, +} from "../domain/binaryTarget.js"; +import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { ghidraApplicationProperties, @@ -34,6 +47,103 @@ const NATIVE_PLATFORMS: Readonly< win32: { x64: "win_x86_64" }, }; +/** Executable format each supported host runs its own Ghidra native tools as. */ +const NATIVE_TOOL_FORMATS: Readonly< + Partial> +> = { + linux: "elf", + darwin: "mach-o", + win32: "pe", +}; + +/** Node architecture identifiers as the executable-header parser reports them. */ +const NATIVE_ARCHITECTURES: readonly { + readonly node: NodeJS.Architecture; + readonly binary: BinaryArchitecture; +}[] = [ + { node: "x64", binary: "x86_64" }, + { node: "ia32", binary: "x86" }, + { node: "arm", binary: "arm" }, + { node: "arm64", binary: "arm64" }, +]; + +/** Bound installation discovery reads; larger headers remain unknown. */ +const EXECUTABLE_HEADER_PROBE_BYTES = 4096; + +/** Compatibility of one present native decompiler with the inspected host. */ +type NativeDecompilerCompatibility = + | { + readonly status: "incompatible"; + readonly detail: string; + readonly remediation: string; + } + | { readonly status: "compatible"; readonly detail: string } + | { readonly status: "unknown"; readonly detail: string }; + +/** + * Judge a native decompiler by its own executable header instead of the + * directory name that admits it. + * + * Only a header that positively contradicts the host is `incompatible`. An + * absent, unreadable, truncated, or unrecognized header stays `unknown`, so a + * file the other checks already accept is never refused on missing evidence. + */ +const assessNativeDecompiler = ( + path: string, + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + probe: GhidraExecutableHeaderProbe | undefined, +): NativeDecompilerCompatibility => { + const expectedFormat = NATIVE_TOOL_FORMATS[platform]; + const expectedArchitecture = NATIVE_ARCHITECTURES.find( + ({ node }) => node === architecture, + )?.binary; + if (expectedFormat === undefined || expectedArchitecture === undefined) + return { + status: "unknown", + detail: `${path} is executable, but no native tool format is defined for ${platform}/${architecture}`, + }; + let parsed = + probe === undefined + ? undefined + : parseExecutableHeader(probe.bytes, architecture, probe.size); + if (probe !== undefined && parsed !== undefined && !parsed.ok) { + // FAT parsing selects a host slice. A validated foreign slice can still + // prove that its complete architecture table excludes this host. If the + // host is listed but its slice is malformed, preserve the unknown outcome. + for (const { node } of NATIVE_ARCHITECTURES) { + if (node === architecture) continue; + const alternative = parseExecutableHeader(probe.bytes, node, probe.size); + if ( + alternative.ok && + !alternative.value.availableArchitectures.includes(expectedArchitecture) + ) { + parsed = alternative; + break; + } + } + } + if (parsed === undefined || !parsed.ok) + return { + status: "unknown", + detail: `${path} is executable, but its executable format for ${platform}/${architecture} could not be established`, + }; + const observed = `${parsed.value.format} ${parsed.value.architecture}`; + const expected = `${expectedFormat} ${expectedArchitecture}`; + if (observed === expected) + return { + status: "compatible", + detail: `${path} reports ${observed}, which matches ${platform}/${architecture}`, + }; + const nativeDirectory = NATIVE_PLATFORMS[platform]?.[architecture]; + const executable = platform === "win32" ? "decompile.exe" : "decompile"; + return { + status: "incompatible", + detail: `${path} reports ${observed}, but ${platform}/${architecture} requires ${expected}`, + remediation: `Provide an executable Ghidra/Features/Decompiler/os/${nativeDirectory ?? ""}/${executable} built for ${expected}, or its build/os equivalent. REA does not build or install native tools.`, + }; +}; + /** Caller-owned paths and host coordinates used for one installation probe. */ export interface GhidraInstallationOptions { readonly environment: Readonly; @@ -117,12 +227,27 @@ export interface GhidraJavaObservation { readonly runtime: "jdk" | "jre"; } +/** A bounded executable-header probe: a small prefix plus the whole file size. */ +export interface GhidraExecutableHeaderProbe { + /** A bounded prefix; a larger or unreadable header remains unknown. */ + readonly bytes: Buffer; + /** Whole-file size, since header commitments are checked against it, not the prefix. */ + readonly size: number; +} + /** Narrow synchronous seam used by provider discovery without launching Ghidra. */ export interface GhidraInstallationHost { readonly platform?: NodeJS.Platform; readonly architecture?: NodeJS.Architecture; readText(path: string): string | undefined; executable(path: string): boolean; + /** + * 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 + * unreadable file, reports the header as unknown and never rejects an + * installation that the other checks already accept. + */ + executableHeader?(path: string): GhidraExecutableHeaderProbe | undefined; probeJava( command: string, environment: NodeJS.ProcessEnv, @@ -352,15 +477,7 @@ const installationChecks = ({ remediation: "Restore support/analyzeHeadless or support/analyzeHeadless.bat from the official Ghidra release.", }), - installationCheck({ - name: "native_decompiler", - passed: coordinates.nativeDecompilerPath !== null, - code: "executable_missing", - detail: - coordinates.nativeDecompilerPath ?? - `Matching native decompiler for ${platform}/${architecture} was not found`, - remediation: `Provide an executable Ghidra/Features/Decompiler/os/${NATIVE_PLATFORMS[platform]?.[architecture] ?? ""}/${platform === "win32" ? "decompile.exe" : "decompile"}, or its build/os equivalent. REA does not build or install native tools.`, - }), + nativeDecompilerCheck({ coordinates, platform, architecture, host }), javaCheck( coordinates.java, coordinates.javaCommand, @@ -472,6 +589,40 @@ const installationCheck = (options: { remediation: options.remediation, }; +/** Require a present native decompiler without positively incompatible bytes. */ +const nativeDecompilerCheck = ({ + coordinates, + platform, + architecture, + host, +}: Pick< + GhidraInstallationCheckContext, + "coordinates" | "platform" | "architecture" | "host" +>): GhidraInstallationCheck => { + const path = coordinates.nativeDecompilerPath; + const compatibility = + path === null + ? undefined + : assessNativeDecompiler( + path, + platform, + architecture, + host.executableHeader?.(path), + ); + return installationCheck({ + name: "native_decompiler", + passed: path !== null && compatibility?.status !== "incompatible", + code: "executable_missing", + detail: + compatibility?.detail ?? + `Matching native decompiler for ${platform}/${architecture} was not found`, + remediation: + compatibility?.status === "incompatible" + ? compatibility.remediation + : `Provide an executable Ghidra/Features/Decompiler/os/${NATIVE_PLATFORMS[platform]?.[architecture] ?? ""}/${platform === "win32" ? "decompile.exe" : "decompile"}, or its build/os equivalent. REA does not build or install native tools.`, + }); +}; + const versionCheck = ( properties: GhidraApplicationProperties | undefined, ): GhidraInstallationCheck => { @@ -543,10 +694,30 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ return undefined; } }, + executableHeader(path) { + let handle: number | undefined; + try { + handle = openSync( + path, + constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY, + ); + const metadata = fstatSync(handle); + if (!metadata.isFile()) return undefined; + const bytes = Buffer.alloc(EXECUTABLE_HEADER_PROBE_BYTES); + const read = readSync(handle, bytes, 0, bytes.length, 0); + return { bytes: bytes.subarray(0, read), size: metadata.size }; + } catch (cause: unknown) { + // best-effort cleanup: a header that cannot be read stays unknown. + void cause; + return undefined; + } finally { + if (handle !== undefined) closeSync(handle); + } + }, executable(path) { try { accessSync(path, constants.X_OK); - return true; + return statSync(path).isFile(); } catch (cause: unknown) { // best-effort cleanup: optional executable probing; failure means missing. void cause;