mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
chore(experiment): refresh main before function-boundary exploration
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
@@ -833,6 +833,16 @@ rea doctor --json
|
||||
rea setup
|
||||
```
|
||||
|
||||
On macOS, [Homebrew OpenJDK is keg-only](https://formulae.brew.sh/formula/openjdk)
|
||||
and may be unavailable to the system `java` wrapper. Set `JAVA_HOME` to the
|
||||
selected formula's `libexec/openjdk.jdk/Contents/Home`, for example
|
||||
`/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home` or
|
||||
`/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home`. Use a JDK inside the
|
||||
Java version bounds declared by your Ghidra installation; REA does not select
|
||||
another installed JDK automatically. 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.
|
||||
|
||||
On Windows, configure the existing installation in PowerShell:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type {
|
||||
AnalysisProfileResolution,
|
||||
ProviderIdentity,
|
||||
@@ -63,12 +64,15 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
target: BinaryTarget,
|
||||
identity: ProviderIdentity,
|
||||
installation: GhidraInstallationInspection,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
languageOverride?: GhidraLanguageOverride,
|
||||
disabledAnalyzers?: readonly string[],
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return Promise.resolve(err(new AnalysisCancelledError("open_binary")));
|
||||
return Promise.resolve(
|
||||
err(new AnalysisCancelledError("open_binary", { cause: signal.reason })),
|
||||
);
|
||||
if (target.kind !== "executable")
|
||||
return Promise.resolve(ok({ profile: null }));
|
||||
if (installation.status === "unavailable")
|
||||
@@ -96,7 +100,7 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
),
|
||||
);
|
||||
const pspExtension = isGhidraPspTarget(target)
|
||||
? await resolveGhidraPspExtension(installation, signal)
|
||||
? await resolveGhidraPspExtension(installation, resources, signal)
|
||||
: null;
|
||||
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
|
||||
const provider = { ...identity, version: installation.providerVersion };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import type { Socket } from "node:net";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
@@ -760,7 +761,7 @@ export class GhidraClient {
|
||||
providerVersion: this.#options.providerVersion,
|
||||
profileDigest: this.#options.profileDigest,
|
||||
},
|
||||
{ signal: deadline.signal },
|
||||
{ signal: deadline.signal, resourceScope: this.#fileResources },
|
||||
)
|
||||
.catch((cause: unknown) =>
|
||||
err(this.#failure("start", "Ghidra launcher failed", cause)),
|
||||
@@ -778,7 +779,15 @@ export class GhidraClient {
|
||||
});
|
||||
this.#startupOutputCheckpoint = this.#process.checkpointOutput();
|
||||
}
|
||||
const failure = deadline.signal.aborted
|
||||
const producerFailure =
|
||||
launched.error instanceof GhidraLaunchError
|
||||
? launched.error.primaryFailure
|
||||
: undefined;
|
||||
const interrupted =
|
||||
launched.error instanceof AnalysisCancelledError ||
|
||||
producerFailure instanceof AnalysisCancelledError ||
|
||||
(producerFailure === undefined && deadline.signal.aborted);
|
||||
const failure = interrupted
|
||||
? this.#interruptionFailure(deadline, launched.error)
|
||||
: launched.error instanceof GhidraSessionError
|
||||
? launched.error
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runDoctor, type DoctorHost } from "../application/Doctor.js";
|
||||
@@ -19,15 +22,16 @@ const installationHost = (
|
||||
): GhidraInstallationHost => ({
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -130,15 +134,16 @@ describe("Ghidra doctor integration", () => {
|
||||
inspection(
|
||||
{ installDir: INSTALL },
|
||||
{
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "17.0.12",
|
||||
major: 17,
|
||||
home: "/usr/lib/jvm/jdk-17",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "17.0.12",
|
||||
major: 17,
|
||||
home: "/usr/lib/jvm/jdk-17",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
},
|
||||
),
|
||||
"ghidra-java",
|
||||
@@ -364,7 +369,7 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
|
||||
platform: "win32",
|
||||
},
|
||||
{ probeJava: () => undefined },
|
||||
{ probeJava: () => missingJavaProbe },
|
||||
),
|
||||
);
|
||||
expect(projected.available).toBe(false);
|
||||
|
||||
@@ -33,7 +33,11 @@ export const projectGhidraDoctorInspection = (
|
||||
: {},
|
||||
checks: [
|
||||
...selectedChecks(inspection).map((check) =>
|
||||
projectCheck(check, inspection.installDir !== null),
|
||||
projectCheck(
|
||||
check,
|
||||
inspection.installDir !== null,
|
||||
inspection.javaProbeFailure,
|
||||
),
|
||||
),
|
||||
...(readiness !== undefined
|
||||
? [
|
||||
@@ -107,11 +111,25 @@ const selectedChecks = (
|
||||
const projectCheck = (
|
||||
check: GhidraInstallationCheck,
|
||||
configured: boolean,
|
||||
javaFailure: GhidraInstallationInspection["javaProbeFailure"],
|
||||
): DoctorProviderCheck => ({
|
||||
name: check.name,
|
||||
ok: check.status === "passed",
|
||||
code: check.status === "failed" ? check.code : null,
|
||||
detail: check.detail,
|
||||
...(check.name === "java" && javaFailure?.output !== undefined
|
||||
? {
|
||||
details: {
|
||||
output: { ...javaFailure.output },
|
||||
...(javaFailure.processErrorCode === undefined
|
||||
? {}
|
||||
: { process_error_code: javaFailure.processErrorCode }),
|
||||
...(javaFailure.properties === undefined
|
||||
? {}
|
||||
: { properties: { ...javaFailure.properties } }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
remediation: check.status === "failed" ? check.remediation : null,
|
||||
classification:
|
||||
check.name === "platform" || check.name === "architecture"
|
||||
@@ -120,7 +138,11 @@ const projectCheck = (
|
||||
check.status === "failed" &&
|
||||
check.code === "runtime_missing"
|
||||
? "missing_dependency"
|
||||
: configured
|
||||
? "config_drift"
|
||||
: "missing_analysis_engine",
|
||||
: check.name === "java" &&
|
||||
check.status === "failed" &&
|
||||
check.code === "runtime_unavailable"
|
||||
? "runtime_failure"
|
||||
: configured
|
||||
? "config_drift"
|
||||
: "missing_analysis_engine",
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import type { GhidraJavaProbeFailure } from "./GhidraJavaProbe.js";
|
||||
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
|
||||
|
||||
/** Simulated valid installation for profile tests; never starts Ghidra or Java. */
|
||||
@@ -6,13 +10,21 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
}),
|
||||
};
|
||||
|
||||
/** A confirmed absent Java command for installation admission fixtures. */
|
||||
export const missingJavaProbe = err<GhidraJavaProbeFailure>({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: "runtime_missing",
|
||||
detail: "Java command was not found",
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -12,7 +15,6 @@ import {
|
||||
inspectGhidraInstallation,
|
||||
type GhidraExecutableHeaderProbe,
|
||||
type GhidraInstallationHost,
|
||||
type GhidraJavaObservation,
|
||||
} from "./GhidraInstallation.js";
|
||||
import {
|
||||
ghidraApplicationProperties,
|
||||
@@ -66,7 +68,7 @@ const host = (
|
||||
: undefined,
|
||||
executable: (path) =>
|
||||
path === HEADLESS || path === MAC_DECOMPILER || path === LINUX_DECOMPILER,
|
||||
probeJava: () => JAVA,
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(JAVA),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -183,8 +185,8 @@ it("probes the selected Java environment without reading conflicting ambient set
|
||||
environment.REA_GHIDRA_CONTEXT === "selected" &&
|
||||
environment.REA_GHIDRA_AMBIENT === undefined &&
|
||||
environment.PATH === "/selected-bin"
|
||||
? JAVA
|
||||
: undefined,
|
||||
? javaProbeOk<GhidraJavaObservation>(JAVA)
|
||||
: missingJavaProbe,
|
||||
}),
|
||||
);
|
||||
expect(inspection.status).toBe("available");
|
||||
@@ -251,11 +253,12 @@ describe("Ghidra installation inspection", () => {
|
||||
host({
|
||||
readText: () =>
|
||||
`application.version=${providerVersion}\napplication.java.min=21\napplication.java.max=\n`,
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
version: javaVersion,
|
||||
major,
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
version: javaVersion,
|
||||
major,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
@@ -286,11 +289,12 @@ describe("Ghidra installation inspection", () => {
|
||||
{ environment: {}, installDir: INSTALL, platform, architecture },
|
||||
host({
|
||||
executable: (path) => path === HEADLESS || path === decompiler,
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
@@ -302,7 +306,9 @@ describe("Ghidra installation inspection", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra Windows installation commitment", () => {
|
||||
it("accepts the exact Windows x64 batch launcher and JDK commitment", () => {
|
||||
const windowsHost: GhidraInstallationHost = {
|
||||
platform: "win32",
|
||||
@@ -316,11 +322,11 @@ describe("Ghidra installation inspection", () => {
|
||||
probeJava: (command, environment) => {
|
||||
expect(command).toBe("C:\\Java\\jdk-21\\bin\\java.exe");
|
||||
expect(environment.PATH).toMatch(/^C:\\Java\\jdk-21\\bin;/u);
|
||||
return {
|
||||
return javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
home: "C:\\Java\\jdk-21",
|
||||
osName: "Windows 11",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -430,7 +436,7 @@ describe("Ghidra installation rejection diagnostics", () => {
|
||||
platform: "linux" as const,
|
||||
architecture: "x64" as const,
|
||||
},
|
||||
override: { probeJava: () => undefined },
|
||||
override: { probeJava: () => missingJavaProbe },
|
||||
failed: "java",
|
||||
code: "runtime_missing",
|
||||
},
|
||||
@@ -441,7 +447,14 @@ describe("Ghidra installation rejection diagnostics", () => {
|
||||
platform: "linux" as const,
|
||||
architecture: "x64" as const,
|
||||
},
|
||||
override: { probeJava: () => ({ ...JAVA, major: 17, version: "17" }) },
|
||||
override: {
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
major: 17,
|
||||
version: "17",
|
||||
}),
|
||||
},
|
||||
failed: "java",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
@@ -452,7 +465,13 @@ describe("Ghidra installation rejection diagnostics", () => {
|
||||
platform: "linux" as const,
|
||||
architecture: "x64" as const,
|
||||
},
|
||||
override: { probeJava: () => ({ ...JAVA, runtime: "jre" as const }) },
|
||||
override: {
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
runtime: "jre" as const,
|
||||
}),
|
||||
},
|
||||
failed: "java",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
@@ -718,7 +737,12 @@ describe("Ghidra installation version admission", () => {
|
||||
name: "JDK above the installation maximum",
|
||||
readText: () =>
|
||||
"application.version=12.1.4\napplication.java.min=21\napplication.java.max=25\n",
|
||||
probeJava: () => ({ ...JAVA, major: 27, version: "27" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
major: 27,
|
||||
version: "27",
|
||||
}),
|
||||
failed: "java",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
@@ -726,28 +750,28 @@ describe("Ghidra installation version admission", () => {
|
||||
name: "unreadable Java minimum",
|
||||
readText: () =>
|
||||
"application.version=12.1.4\napplication.java.min=latest\n",
|
||||
probeJava: () => JAVA,
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(JAVA),
|
||||
failed: "java",
|
||||
code: "version_unresolved",
|
||||
},
|
||||
{
|
||||
name: "missing Java minimum",
|
||||
readText: () => "application.version=12.1.4\n",
|
||||
probeJava: () => JAVA,
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(JAVA),
|
||||
failed: "java",
|
||||
code: "version_unresolved",
|
||||
},
|
||||
{
|
||||
name: "unparseable Ghidra version",
|
||||
readText: () => "application.version=latest\napplication.java.min=21\n",
|
||||
probeJava: () => JAVA,
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(JAVA),
|
||||
failed: "version",
|
||||
code: "version_unresolved",
|
||||
},
|
||||
{
|
||||
name: "different Ghidra line",
|
||||
readText: () => "application.version=12.2.0\napplication.java.min=21\n",
|
||||
probeJava: () => JAVA,
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(JAVA),
|
||||
failed: "version",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
@@ -779,7 +803,12 @@ describe("Ghidra installation version admission", () => {
|
||||
host({
|
||||
readText: () =>
|
||||
"application.version=12.0.4\napplication.java.min=21\napplication.java.max=\n",
|
||||
probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
major: 17,
|
||||
version: "17.0.20.1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -804,7 +833,12 @@ describe("Ghidra installation version admission", () => {
|
||||
host({
|
||||
readText: () =>
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
major: 17,
|
||||
version: "17.0.20.1",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ status: "available", javaVersion: "17.0.20.1" });
|
||||
@@ -863,7 +897,7 @@ describe("Ghidra configuration paths", () => {
|
||||
host({
|
||||
probeJava: (command) => {
|
||||
probed.push(command);
|
||||
return JAVA;
|
||||
return javaProbeOk<GhidraJavaObservation>(JAVA);
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -920,7 +954,12 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
@@ -941,7 +980,12 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
@@ -993,11 +1037,12 @@ describe("Ghidra native decompiler admission", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
options,
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
executableHeader,
|
||||
}),
|
||||
);
|
||||
@@ -1029,7 +1074,9 @@ describe("Ghidra native decompiler admission", () => {
|
||||
detail: `${LINUX_DECOMPILER} reports elf x86_64, which matches linux/x64`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra native decompiler compatibility", () => {
|
||||
it("keeps an unreadable header unknown instead of failing the install", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
@@ -1060,7 +1107,12 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
|
||||
}),
|
||||
@@ -1084,7 +1136,12 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined,
|
||||
}),
|
||||
@@ -1113,7 +1170,12 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "x86_64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -1139,7 +1201,12 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Mac OS X",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
@@ -1154,7 +1221,10 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ probeJava: () => withoutArchitecture }),
|
||||
host({
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>(withoutArchitecture),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
@@ -1177,7 +1247,8 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({ ...JAVA, osArch }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -1200,11 +1271,12 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Windows 11",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Windows 11",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -1230,7 +1302,9 @@ describe("Ghidra JVM OS admission", () => {
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host({ probeJava: () => withoutOsName }),
|
||||
host({
|
||||
probeJava: () => javaProbeOk<GhidraJavaObservation>(withoutOsName),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
@@ -1253,7 +1327,8 @@ describe("Ghidra JVM OS admission", () => {
|
||||
architecture: "x64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osName }),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({ ...JAVA, osName }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -1272,18 +1347,21 @@ describe("Ghidra Java probe os.arch extraction", () => {
|
||||
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
stderr: [
|
||||
"Property settings:",
|
||||
" file.encoding = UTF-8",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n"),
|
||||
stdout: Buffer.alloc(0),
|
||||
signal: null,
|
||||
stderr: Buffer.from(
|
||||
[
|
||||
"Property settings:",
|
||||
" file.encoding = UTF-8",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
} as never);
|
||||
try {
|
||||
// No host override: the real system host runs the real probeJava
|
||||
@@ -1304,3 +1382,31 @@ describe("Ghidra Java probe os.arch extraction", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the macOS keg-only JDK case without probing an unselected installation", () => {
|
||||
const probed: string[] = [];
|
||||
const inspection = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: (command) => {
|
||||
probed.push(command);
|
||||
return missingJavaProbe;
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(probed).toEqual(["java"]);
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
status: "failed",
|
||||
code: "runtime_missing",
|
||||
remediation: expect.stringContaining("Homebrew OpenJDK can be keg-only"),
|
||||
});
|
||||
const java = inspection.checks.find(({ name }) => name === "java");
|
||||
expect(java?.status === "failed" ? java.remediation : "").toContain(
|
||||
"/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
probeGhidraJava,
|
||||
type GhidraJavaObservation,
|
||||
type GhidraJavaProbeFailure,
|
||||
} from "./GhidraJavaProbe.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
accessSync,
|
||||
@@ -75,15 +80,6 @@ const NATIVE_ARCHITECTURES: readonly {
|
||||
/** Bound installation discovery reads; larger headers remain unknown. */
|
||||
const EXECUTABLE_HEADER_PROBE_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Bound the Java version probe so a chatty or hostile JVM cannot force an
|
||||
* unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for
|
||||
* `-XshowSettings:properties`; the same 1 MiB budget used for other Java
|
||||
* listings leaves ample headroom. A probe that exceeds the budget yields no
|
||||
* observation and is reported through the existing failed-probe path.
|
||||
*/
|
||||
const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** Compatibility of one present native decompiler with the inspected host. */
|
||||
type NativeDecompilerCompatibility =
|
||||
| {
|
||||
@@ -206,6 +202,7 @@ interface GhidraInstallationObservation {
|
||||
readonly configurationDirectoryName: string | null;
|
||||
readonly nativeDecompilerPath: string | null;
|
||||
readonly javaCommand: string;
|
||||
readonly javaProbeFailure?: GhidraJavaProbeFailure;
|
||||
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
|
||||
readonly javaVmName?: string;
|
||||
/**
|
||||
@@ -252,29 +249,6 @@ export type GhidraInstallationInspection =
|
||||
| AvailableGhidraInstallation
|
||||
| UnavailableGhidraInstallation;
|
||||
|
||||
/** Parsed identity of the JDK selected for Ghidra. */
|
||||
export interface GhidraJavaObservation {
|
||||
readonly version: string;
|
||||
readonly major: number;
|
||||
readonly home: string;
|
||||
readonly bits: number | null;
|
||||
/** Present when Java reported a bitness value that could not be parsed exactly. */
|
||||
readonly unparsedBits?: string;
|
||||
readonly runtime: "jdk" | "jre";
|
||||
readonly vmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch property, which Ghidra uses to select its native
|
||||
* decompiler directory. Omitted when the probe cannot establish it.
|
||||
*/
|
||||
readonly osArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name property, which Ghidra uses with os.arch to select
|
||||
* its native decompiler OS directory. Omitted when the probe cannot
|
||||
* establish it.
|
||||
*/
|
||||
readonly osName?: string;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -299,7 +273,7 @@ export interface GhidraInstallationHost {
|
||||
probeJava(
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): GhidraJavaObservation | undefined;
|
||||
): Result<GhidraJavaObservation, GhidraJavaProbeFailure>;
|
||||
/** Resolve a PATH launcher to a validated installation without executing it. */
|
||||
discoverInstallDirOnPath?(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
@@ -315,6 +289,7 @@ interface GhidraInstallationCoordinates {
|
||||
readonly properties: GhidraApplicationProperties | undefined;
|
||||
readonly javaCommand: string;
|
||||
readonly java: GhidraJavaObservation | undefined;
|
||||
readonly javaProbeFailure: GhidraJavaProbeFailure | undefined;
|
||||
}
|
||||
|
||||
interface GhidraInstallationCheckContext {
|
||||
@@ -349,6 +324,8 @@ export const inspectGhidraInstallation = (
|
||||
(check): check is Extract<GhidraInstallationCheck, { status: "failed" }> =>
|
||||
check.status === "failed",
|
||||
);
|
||||
const javaObservation =
|
||||
coordinates.java ?? coordinates.javaProbeFailure?.properties;
|
||||
const observation = {
|
||||
platform,
|
||||
architecture,
|
||||
@@ -357,15 +334,18 @@ export const inspectGhidraInstallation = (
|
||||
coordinates.properties?.configurationDirectoryName ?? null,
|
||||
nativeDecompilerPath: coordinates.nativeDecompilerPath,
|
||||
javaCommand: coordinates.javaCommand,
|
||||
...(coordinates.java?.vmName === undefined
|
||||
...(coordinates.javaProbeFailure === undefined
|
||||
? {}
|
||||
: { javaVmName: coordinates.java.vmName }),
|
||||
...(coordinates.java?.osArch === undefined
|
||||
: { javaProbeFailure: coordinates.javaProbeFailure }),
|
||||
...(javaObservation?.vmName == null
|
||||
? {}
|
||||
: { javaOsArch: coordinates.java.osArch }),
|
||||
...(coordinates.java?.osName === undefined
|
||||
: { javaVmName: javaObservation.vmName }),
|
||||
...(javaObservation?.osArch == null
|
||||
? {}
|
||||
: { javaOsName: coordinates.java.osName }),
|
||||
: { javaOsArch: javaObservation.osArch }),
|
||||
...(javaObservation?.osName == null
|
||||
? {}
|
||||
: { javaOsName: javaObservation.osName }),
|
||||
checks,
|
||||
};
|
||||
if (failed !== undefined)
|
||||
@@ -375,8 +355,8 @@ export const inspectGhidraInstallation = (
|
||||
installDir: coordinates.installDir,
|
||||
analyzeHeadlessPath: coordinates.analyzeHeadlessPath,
|
||||
providerVersion: coordinates.properties?.providerVersion ?? null,
|
||||
javaHome: coordinates.java?.home ?? options.javaHome ?? null,
|
||||
javaVersion: coordinates.java?.version ?? null,
|
||||
javaHome: javaObservation?.home ?? options.javaHome ?? null,
|
||||
javaVersion: javaObservation?.version ?? null,
|
||||
rejection: { code: failed.code, reason: failed.remediation },
|
||||
};
|
||||
return availableInstallation(coordinates, observation);
|
||||
@@ -476,6 +456,18 @@ const installationCoordinates = (
|
||||
"bin",
|
||||
platform === "win32" ? "java.exe" : "java",
|
||||
);
|
||||
// Relative JAVA_HOME is refused by configuration validation without execution.
|
||||
const javaProbe =
|
||||
options.javaHome === undefined || path.isAbsolute(options.javaHome)
|
||||
? host.probeJava(
|
||||
javaCommand,
|
||||
ghidraJavaEnvironment(
|
||||
options.javaHome,
|
||||
options.environment,
|
||||
platform,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
installDir,
|
||||
applicationPropertiesPath,
|
||||
@@ -486,19 +478,9 @@ const installationCoordinates = (
|
||||
? undefined
|
||||
: ghidraApplicationProperties(properties),
|
||||
javaCommand,
|
||||
// The configuration check rejects a relative JAVA_HOME. Probing it would
|
||||
// run whatever `bin/java` the current directory holds.
|
||||
java:
|
||||
options.javaHome === undefined || path.isAbsolute(options.javaHome)
|
||||
? host.probeJava(
|
||||
javaCommand,
|
||||
ghidraJavaEnvironment(
|
||||
options.javaHome,
|
||||
options.environment,
|
||||
platform,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
java: javaProbe?.ok === true ? javaProbe.value : undefined,
|
||||
javaProbeFailure:
|
||||
javaProbe !== undefined && !javaProbe.ok ? javaProbe.error : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -552,6 +534,8 @@ const installationChecks = ({
|
||||
coordinates.javaCommand,
|
||||
javaHome,
|
||||
coordinates.properties,
|
||||
platform,
|
||||
coordinates.javaProbeFailure,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -589,7 +573,7 @@ const configurationCheck = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Project an installation probe into caller-visible, secret-free diagnostics. */
|
||||
/** Project an installation probe into caller-visible, bounded diagnostics. */
|
||||
export const ghidraInstallationDiagnostics = (
|
||||
inspection: GhidraInstallationInspection,
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
@@ -599,6 +583,26 @@ export const ghidraInstallationDiagnostics = (
|
||||
application_properties_path: inspection.applicationPropertiesPath,
|
||||
provider_version: inspection.providerVersion,
|
||||
java_command: inspection.javaCommand,
|
||||
...(inspection.javaProbeFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
java_probe_failure: {
|
||||
code: inspection.javaProbeFailure.code,
|
||||
...(inspection.javaProbeFailure.processErrorCode === undefined
|
||||
? {}
|
||||
: {
|
||||
process_error_code:
|
||||
inspection.javaProbeFailure.processErrorCode,
|
||||
}),
|
||||
detail: inspection.javaProbeFailure.detail,
|
||||
...(inspection.javaProbeFailure.properties === undefined
|
||||
? {}
|
||||
: { properties: { ...inspection.javaProbeFailure.properties } }),
|
||||
...(inspection.javaProbeFailure.output === undefined
|
||||
? {}
|
||||
: { output: { ...inspection.javaProbeFailure.output } }),
|
||||
},
|
||||
}),
|
||||
java_home: inspection.javaHome,
|
||||
java_version: inspection.javaVersion,
|
||||
java_vm_name: inspection.javaVmName ?? null,
|
||||
@@ -688,8 +692,14 @@ const nativeDecompilerCheck = ({
|
||||
// Ghidra resolves the OS directory from the JVM os.name/os.arch pair, so a
|
||||
// JVM reporting a non-host os.name (for example through -Dos.name=Windows)
|
||||
// selects a different OS directory even when os.arch matches.
|
||||
const osArch = coordinates.java?.osArch;
|
||||
const osName = coordinates.java?.osName;
|
||||
const osArch =
|
||||
coordinates.java?.osArch ??
|
||||
coordinates.javaProbeFailure?.properties?.osArch ??
|
||||
undefined;
|
||||
const osName =
|
||||
coordinates.java?.osName ??
|
||||
coordinates.javaProbeFailure?.properties?.osName ??
|
||||
undefined;
|
||||
const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch);
|
||||
const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
|
||||
// Without a JVM observation the java check reports the missing runtime;
|
||||
@@ -814,6 +824,8 @@ const javaCheck = (
|
||||
command: string,
|
||||
configuredHome: string | undefined,
|
||||
properties: GhidraApplicationProperties | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
failure: GhidraJavaProbeFailure | undefined,
|
||||
): GhidraInstallationCheck => {
|
||||
const requirement = ghidraJavaRequirement(properties);
|
||||
const range = ghidraJavaRangeLabel(requirement);
|
||||
@@ -836,8 +848,8 @@ const javaCheck = (
|
||||
const detail =
|
||||
observation === undefined
|
||||
? requirement.unresolved === null
|
||||
? command
|
||||
: `${command}; ${requirement.unresolved}`
|
||||
? (failure?.detail ?? `${command}: Java probe was not attempted`)
|
||||
: `${failure?.detail ?? command}; ${requirement.unresolved}`
|
||||
: `${observation.version}; ${bitness}; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}; accepted ${range}`;
|
||||
return installationCheck({
|
||||
name: "java",
|
||||
@@ -846,12 +858,12 @@ const javaCheck = (
|
||||
requirement.unresolved !== null
|
||||
? "version_unresolved"
|
||||
: observation === undefined
|
||||
? "runtime_missing"
|
||||
? (failure?.code ?? "version_unresolved")
|
||||
: knownMismatch
|
||||
? "unsupported_version"
|
||||
: "version_unresolved",
|
||||
detail,
|
||||
remediation: javaRemediation(requirement, configuredHome, range),
|
||||
remediation: javaRemediation(requirement, configuredHome, range, platform),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -859,11 +871,12 @@ const javaRemediation = (
|
||||
requirement: GhidraJavaRequirement,
|
||||
configuredHome: string | undefined,
|
||||
range: string,
|
||||
platform: NodeJS.Platform,
|
||||
): string => {
|
||||
if (requirement.unresolved !== null)
|
||||
return `The Ghidra installation's Java bounds could not be read (${requirement.unresolved}). Install a 64-bit full JDK inside the declared application.java.min and application.java.max.`;
|
||||
return configuredHome === undefined
|
||||
? `Install ${range}, or set JAVA_HOME before starting REA.`
|
||||
? `Select ${range} on PATH, or set JAVA_HOME to its installation root before starting REA.${platform === "darwin" ? " Homebrew OpenJDK can be keg-only: set JAVA_HOME to the selected formula's libexec/openjdk.jdk/Contents/Home (for example /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home or /usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home). The installed JDK must satisfy the declared Ghidra Java bounds." : ""}`
|
||||
: `Update JAVA_HOME to the root of ${range} installation.`;
|
||||
};
|
||||
|
||||
@@ -909,72 +922,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
probeJava(command, environment) {
|
||||
const observed = spawnSync(
|
||||
command,
|
||||
[
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
// JDK 17/18 use the legacy names; JDK 19+ use the names above.
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
"-XX:-UsePerfData",
|
||||
"-XshowSettings:properties",
|
||||
"-version",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
timeout: 5_000,
|
||||
maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (observed.error !== undefined || observed.status !== 0) return undefined;
|
||||
const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`;
|
||||
const version = javaProperty(output, "java.version");
|
||||
const home = javaProperty(output, "java.home");
|
||||
const vmName = javaProperty(output, "java.vm.name");
|
||||
const osArch = javaProperty(output, "os.arch");
|
||||
const osName = javaProperty(output, "os.name");
|
||||
const reportedBits = javaProperty(output, "sun.arch.data.model");
|
||||
const parsedBits =
|
||||
reportedBits !== null && /^\d+$/.test(reportedBits)
|
||||
? Number(reportedBits)
|
||||
: null;
|
||||
const bits =
|
||||
parsedBits !== null && Number.isSafeInteger(parsedBits)
|
||||
? parsedBits
|
||||
: null;
|
||||
if (
|
||||
version === null ||
|
||||
version.length === 0 ||
|
||||
home === null ||
|
||||
home.length === 0
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
version,
|
||||
major: javaMajor(version),
|
||||
home,
|
||||
...(vmName === null ? {} : { vmName }),
|
||||
...(osArch === null ? {} : { osArch }),
|
||||
...(osName === null ? {} : { osName }),
|
||||
bits,
|
||||
...(reportedBits === null || bits !== null
|
||||
? {}
|
||||
: { unparsedBits: reportedBits }),
|
||||
runtime: executablePath(
|
||||
(process.platform === "win32" ? win32 : posix).join(
|
||||
home,
|
||||
"bin",
|
||||
process.platform === "win32" ? "javac.exe" : "javac",
|
||||
),
|
||||
)
|
||||
? "jdk"
|
||||
: "jre",
|
||||
};
|
||||
},
|
||||
probeJava: probeGhidraJava,
|
||||
discoverInstallDirOnPath: discoverGhidraInstallationOnPath,
|
||||
});
|
||||
|
||||
@@ -1031,15 +979,3 @@ const executablePath = (path: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const javaProperty = (output: string, name: string): string | null =>
|
||||
new RegExp(
|
||||
`^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`,
|
||||
"mu",
|
||||
).exec(output)?.[1] ?? null;
|
||||
|
||||
const javaMajor = (version: string): number => {
|
||||
const first = Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
if (first !== 1) return first;
|
||||
return Number.parseInt(version.split(".")[1] ?? "0", 10);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeGhidraJava } from "./GhidraJavaProbe.js";
|
||||
|
||||
vi.mock("node:child_process", async (original) => ({
|
||||
...(await original<typeof import("node:child_process")>()),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
afterEach(() => vi.mocked(spawnSync).mockReset());
|
||||
|
||||
const probe = (values: Partial<ReturnType<typeof spawnSync>>) => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
output: [],
|
||||
...values,
|
||||
} as never);
|
||||
return probeGhidraJava("/selected/bin/java", {});
|
||||
};
|
||||
|
||||
describe("Ghidra Java prerequisite process facts", () => {
|
||||
it.each([
|
||||
["ENOENT", "runtime_missing"],
|
||||
["EACCES", "runtime_unavailable"],
|
||||
["ETIMEDOUT", "runtime_unavailable"],
|
||||
["ENOBUFS", "runtime_unavailable"],
|
||||
])("keeps %s distinct from other runtime failures", (code, expected) => {
|
||||
const error = Object.assign(new Error(`controlled ${code}`), { code });
|
||||
expect(probe({ error, status: null })).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: expected,
|
||||
processErrorCode: code,
|
||||
detail: expect.stringContaining(code),
|
||||
output: { exit_code: null, truncated: code === "ENOBUFS" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw byte counts and both streams on a nonzero exit", () => {
|
||||
const result = probe({
|
||||
status: 7,
|
||||
stdout: Buffer.from([0xff]),
|
||||
stderr: Buffer.from("controlled runtime failure\n"),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "runtime_unavailable",
|
||||
detail: expect.stringContaining("Java exited 7"),
|
||||
output: {
|
||||
stdout: "\ufffd",
|
||||
stdout_bytes: 1,
|
||||
stderr: "controlled runtime failure\n",
|
||||
stderr_bytes: 27,
|
||||
exit_code: 7,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains partial parsed properties when a successful probe cannot establish its version", () => {
|
||||
expect(
|
||||
probe({
|
||||
stdout: Buffer.from(
|
||||
"java.home = /selected/jdk\nos.arch = aarch64\nsun.arch.data.model = 64\n",
|
||||
),
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "version_unresolved",
|
||||
properties: {
|
||||
home: "/selected/jdk",
|
||||
version: null,
|
||||
osArch: "aarch64",
|
||||
bits: 64,
|
||||
},
|
||||
output: { exit_code: 0 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds diagnostic text and preserves examined stream sizes", () => {
|
||||
expect(probe({ status: 7, stdout: Buffer.alloc(12000, 65) })).toMatchObject(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
output: {
|
||||
stdout: "A".repeat(8192),
|
||||
stdout_bytes: 12000,
|
||||
stderr_bytes: 0,
|
||||
truncated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"21garbage",
|
||||
"garbage",
|
||||
"21.foo",
|
||||
"9007199254740993",
|
||||
"21.9999999999999999",
|
||||
"21+9999999999999999",
|
||||
"1.21.0",
|
||||
"21.0",
|
||||
"021",
|
||||
])("keeps malformed java.version %s unresolved", (version) => {
|
||||
expect(
|
||||
probe({
|
||||
stdout: Buffer.from(
|
||||
`java.home = /selected/jdk\njava.version = ${version}\nsun.arch.data.model = 64\n`,
|
||||
),
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "version_unresolved",
|
||||
properties: { version, home: "/selected/jdk" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["21-ea", 21],
|
||||
["21.0.11+9-LTS", 21],
|
||||
["27-internal+0-adhoc.user.build", 27],
|
||||
["1.8.0_452-b09", 8],
|
||||
] as const)("recognizes the Java major in %s", (version, major) => {
|
||||
expect(
|
||||
probe({
|
||||
stdout: Buffer.from(
|
||||
`java.home = /selected/jdk\njava.version = ${version}\nsun.arch.data.model = 64\n`,
|
||||
),
|
||||
}),
|
||||
).toMatchObject({ ok: true, value: { version, major } });
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { constants, accessSync, statSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
||||
|
||||
/**
|
||||
* Bound the Java version probe so a chatty or hostile JVM cannot force an
|
||||
* unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for
|
||||
* `-XshowSettings:properties`; the same 1 MiB budget used for other Java
|
||||
* listings leaves ample headroom. A probe that exceeds the budget yields no
|
||||
* observation and is reported through the existing failed-probe path.
|
||||
*/
|
||||
const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** Parsed identity of the JDK selected for Ghidra. */
|
||||
export interface GhidraJavaObservation {
|
||||
readonly version: string;
|
||||
readonly major: number;
|
||||
readonly home: string;
|
||||
readonly bits: number | null;
|
||||
/** Present when Java reported a bitness value that could not be parsed exactly. */
|
||||
readonly unparsedBits?: string;
|
||||
readonly runtime: "jdk" | "jre";
|
||||
readonly vmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch property, which Ghidra uses to select its native
|
||||
* decompiler directory. Omitted when the probe cannot establish it.
|
||||
*/
|
||||
readonly osArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name property, which Ghidra uses with os.arch to select
|
||||
* its native decompiler OS directory. Omitted when the probe cannot
|
||||
* establish it.
|
||||
*/
|
||||
readonly osName?: string;
|
||||
}
|
||||
|
||||
/** Expected probe failures retain their producing process facts. */
|
||||
export interface GhidraJavaProbeFailure {
|
||||
readonly _tag: "GhidraJavaProbeFailure";
|
||||
readonly code:
|
||||
| "runtime_missing"
|
||||
| "runtime_unavailable"
|
||||
| "version_unresolved";
|
||||
readonly detail: string;
|
||||
readonly processErrorCode?: string;
|
||||
readonly output?: AnalysisCapturedOutput;
|
||||
readonly properties?: {
|
||||
readonly version: string | null;
|
||||
readonly home: string | null;
|
||||
readonly vmName: string | null;
|
||||
readonly osArch: string | null;
|
||||
readonly osName: string | null;
|
||||
readonly bits: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
const JAVA_DIAGNOSTIC_STREAM_BYTES = 8192;
|
||||
const capturedProbeOutput = (
|
||||
observed: SpawnSyncReturns<Buffer>,
|
||||
): AnalysisCapturedOutput => {
|
||||
const stdout = observed.stdout ?? Buffer.alloc(0);
|
||||
const stderr = observed.stderr ?? Buffer.alloc(0);
|
||||
return {
|
||||
stdout: stdout.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
|
||||
stderr: stderr.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
|
||||
truncated:
|
||||
stdout.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
|
||||
stderr.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
|
||||
(observed.error !== undefined &&
|
||||
"code" in observed.error &&
|
||||
observed.error.code === "ENOBUFS"),
|
||||
stdout_bytes: stdout.length,
|
||||
stderr_bytes: stderr.length,
|
||||
exit_code: observed.status,
|
||||
signal: observed.signal,
|
||||
};
|
||||
};
|
||||
|
||||
const executablePath = (path: string): boolean => {
|
||||
try {
|
||||
accessSync(path, constants.X_OK);
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const probeGhidraJava = (
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
|
||||
const observed = spawnSync(
|
||||
command,
|
||||
[
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
// JDK 17/18 use the legacy names; JDK 19+ use the names above.
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
"-XX:-UsePerfData",
|
||||
"-XshowSettings:properties",
|
||||
"-version",
|
||||
],
|
||||
{
|
||||
encoding: "buffer",
|
||||
env: environment,
|
||||
timeout: 5_000,
|
||||
maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (observed.error !== undefined || observed.status !== 0) {
|
||||
const code =
|
||||
observed.error !== undefined && "code" in observed.error
|
||||
? observed.error.code
|
||||
: undefined;
|
||||
return err({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
|
||||
...(typeof code === "string" ? { processErrorCode: code } : {}),
|
||||
detail: `${command}: ${observed.error?.message ?? `Java exited ${String(observed.status)}${observed.signal === null ? "" : ` (${observed.signal})`}`}`,
|
||||
output: capturedProbeOutput(observed),
|
||||
});
|
||||
}
|
||||
const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`;
|
||||
const version = javaProperty(output, "java.version") || null;
|
||||
const home = javaProperty(output, "java.home") || null;
|
||||
const vmName = javaProperty(output, "java.vm.name");
|
||||
const osArch = javaProperty(output, "os.arch");
|
||||
const osName = javaProperty(output, "os.name");
|
||||
const reportedBits = javaProperty(output, "sun.arch.data.model");
|
||||
const parsedBits =
|
||||
reportedBits !== null && /^\d+$/.test(reportedBits)
|
||||
? Number(reportedBits)
|
||||
: null;
|
||||
const bits =
|
||||
parsedBits !== null && Number.isSafeInteger(parsedBits) ? parsedBits : null;
|
||||
const major = version === null ? undefined : javaMajor(version);
|
||||
if (version === null || home === null || major === undefined)
|
||||
return err({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: "version_unresolved",
|
||||
detail: `${command}: successful Java probe did not establish a valid java.version and nonempty java.home`,
|
||||
output: capturedProbeOutput(observed),
|
||||
properties: { version, home, vmName, osArch, osName, bits },
|
||||
});
|
||||
return ok({
|
||||
version,
|
||||
major,
|
||||
home,
|
||||
...(vmName === null ? {} : { vmName }),
|
||||
...(osArch === null ? {} : { osArch }),
|
||||
...(osName === null ? {} : { osName }),
|
||||
bits,
|
||||
...(reportedBits === null || bits !== null
|
||||
? {}
|
||||
: { unparsedBits: reportedBits }),
|
||||
runtime: executablePath(
|
||||
(process.platform === "win32" ? win32 : posix).join(
|
||||
home,
|
||||
"bin",
|
||||
process.platform === "win32" ? "javac.exe" : "javac",
|
||||
),
|
||||
)
|
||||
? "jdk"
|
||||
: "jre",
|
||||
});
|
||||
};
|
||||
const javaProperty = (output: string, name: string): string | null =>
|
||||
new RegExp(
|
||||
`^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`,
|
||||
"mu",
|
||||
).exec(output)?.[1] ?? null;
|
||||
|
||||
// Runtime.Version's numeric/pre-release/build grammar; Java 8 used 1.8.0_... .
|
||||
// https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/Runtime.Version.html
|
||||
const javaMajor = (version: string): number | undefined => {
|
||||
const legacy =
|
||||
/^1\.([1-8])(?:\.[0-9]+)*(?:_[0-9]+)?(?:-[-a-zA-Z0-9.]+)?$/u.exec(version);
|
||||
if (legacy?.[1] !== undefined) return Number(legacy[1]);
|
||||
const modern =
|
||||
/^(?<number>[1-9][0-9]*(?:(?:\.0)*\.[1-9][0-9]*)*)(?:(?:-[a-zA-Z0-9]+)?\+(?<build>0|[1-9][0-9]*)(?:-[-a-zA-Z0-9.]+)?|-[a-zA-Z0-9]+(?:-[-a-zA-Z0-9.]+)?|\+-[-a-zA-Z0-9.]+)?$/u.exec(
|
||||
version,
|
||||
);
|
||||
const number = modern?.groups?.number;
|
||||
if (number === undefined) return undefined;
|
||||
const parts = number.split(".").map(Number);
|
||||
const build = modern?.groups?.build;
|
||||
// Runtime.Version parses numeric components and the build as Java integers.
|
||||
if (
|
||||
[...parts, ...(build === undefined ? [] : [Number(build)])].some(
|
||||
(value) => !Number.isSafeInteger(value) || value > 0x7fffffff,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
return parts[0];
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||
import {
|
||||
snapshotGhidraAnalysisSeeds,
|
||||
@@ -88,7 +89,10 @@ export type GhidraLaunchSupportDiagnostic = {
|
||||
export interface GhidraLauncher {
|
||||
launch(
|
||||
session: GhidraLaunchSession,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
},
|
||||
): Promise<Result<GhidraLaunch, GhidraLaunchError | AnalysisCancelledError>>;
|
||||
}
|
||||
|
||||
@@ -104,6 +108,8 @@ export class GhidraLaunchError extends Error {
|
||||
readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[];
|
||||
readonly rollbackFailure: GhidraLaunchRollbackFailure | undefined;
|
||||
readonly cleanupFailure: AnalysisError | undefined;
|
||||
/** Known producer outcome, independent of a signal that changes during finalization. */
|
||||
readonly primaryFailure: AnalysisError | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -127,6 +133,12 @@ export class GhidraLaunchError extends Error {
|
||||
this.name = "GhidraLaunchError";
|
||||
this.#partialLaunch = partialLaunch;
|
||||
this.rollbackFailure = rollbackFailure;
|
||||
this.primaryFailure =
|
||||
errorOptions.cause instanceof AnalysisError
|
||||
? errorOptions.cause
|
||||
: errorOptions.cause instanceof GhidraLaunchError
|
||||
? errorOptions.cause.primaryFailure
|
||||
: undefined;
|
||||
const inherited =
|
||||
errorOptions.cause instanceof DarwinProcessOwnershipInspectionError ||
|
||||
errorOptions.cause instanceof GhidraLaunchError
|
||||
@@ -241,10 +253,17 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
|
||||
async launch(
|
||||
session: GhidraLaunchSession,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
} = {},
|
||||
): Promise<Result<GhidraLaunch, GhidraLaunchError | AnalysisCancelledError>> {
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", {
|
||||
cause: options.signal?.reason,
|
||||
}),
|
||||
);
|
||||
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
||||
const platform = this.options.platform ?? process.platform;
|
||||
let started: SpawnedOwnedProviderProcess | undefined;
|
||||
@@ -270,9 +289,14 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
throw new GhidraLaunchError(
|
||||
"Allegrex profile does not belong to the selected Ghidra installation.",
|
||||
);
|
||||
if (options.resourceScope === undefined)
|
||||
throw new GhidraLaunchError(
|
||||
"PSP inspection requires a caller-owned resource scope.",
|
||||
);
|
||||
const inspected = await inspectGhidraPspExtension(
|
||||
psp.root,
|
||||
psp.ghidra_version,
|
||||
options.resourceScope,
|
||||
options.signal,
|
||||
);
|
||||
if (inspected.sha256 !== psp.sha256)
|
||||
@@ -450,6 +474,8 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
}
|
||||
return ok(partialLaunch);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError && started === undefined)
|
||||
return err(cause);
|
||||
const capturedOutput = ghidraLaunchCommandOutput(cause);
|
||||
const launchFailure = new GhidraLaunchError(
|
||||
"Ghidra headless launch failed",
|
||||
@@ -459,23 +485,25 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
},
|
||||
);
|
||||
const primary = isAborted(options.signal)
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: launchFailure,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: launchFailure.cleanupFailure.message,
|
||||
resources: launchFailure.cleanupFailure.cleanupResources,
|
||||
},
|
||||
}),
|
||||
}).bindCleanupDependencies(
|
||||
launchFailure.cleanupFailure === undefined
|
||||
? []
|
||||
: [launchFailure.cleanupFailure],
|
||||
)
|
||||
: launchFailure;
|
||||
const primary =
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
(!(cause instanceof AnalysisError) && isAborted(options.signal))
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: launchFailure,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: launchFailure.cleanupFailure.message,
|
||||
resources: launchFailure.cleanupFailure.cleanupResources,
|
||||
},
|
||||
}),
|
||||
}).bindCleanupDependencies(
|
||||
launchFailure.cleanupFailure === undefined
|
||||
? []
|
||||
: [launchFailure.cleanupFailure],
|
||||
)
|
||||
: launchFailure;
|
||||
if (started === undefined) return err(primary);
|
||||
|
||||
const cleanupOwner = started;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -50,16 +52,17 @@ const installationHost = (
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
vmName: "OpenJDK 64-Bit Server VM",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
vmName: "OpenJDK 64-Bit Server VM",
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = (
|
||||
@@ -208,7 +211,7 @@ describe("Ghidra provider", () => {
|
||||
...installationHost(),
|
||||
probeJava: () => {
|
||||
probeCount += 1;
|
||||
return {
|
||||
return javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
@@ -216,7 +219,7 @@ describe("Ghidra provider", () => {
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
const ghidra = provider(host);
|
||||
@@ -2292,15 +2295,16 @@ describe("compatible Ghidra builds", () => {
|
||||
...installationHost(),
|
||||
readText: () =>
|
||||
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "27",
|
||||
major: 27,
|
||||
home: "/usr/lib/jvm/java-27-openjdk",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "27",
|
||||
major: 27,
|
||||
home: "/usr/lib/jvm/java-27-openjdk",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
},
|
||||
() => ({
|
||||
start: () =>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
@@ -78,6 +81,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
||||
|
||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
||||
export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
readonly #inspectionResources = new ArtifactResourceScope();
|
||||
#installation: GhidraInstallationInspection | undefined;
|
||||
private readonly environment: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -223,6 +227,28 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
};
|
||||
}
|
||||
|
||||
hasPendingOwnedResources(): boolean {
|
||||
return this.#inspectionResources.hasPending;
|
||||
}
|
||||
|
||||
async closeOwnedResources() {
|
||||
try {
|
||||
await this.#inspectionResources.retryCleanup();
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
const error = new ProviderCleanupError(
|
||||
"ghidra",
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? (cause.cleanup?.resources ?? [])
|
||||
: [],
|
||||
{ reason: providerFailureText(cause) },
|
||||
{ cause },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return err(error);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
@@ -232,6 +258,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
target,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
installation,
|
||||
this.#inspectionResources,
|
||||
options?.signal,
|
||||
this.config.ghidraLanguageOverride,
|
||||
this.config.ghidraDisabledAnalyzers,
|
||||
|
||||
@@ -2,19 +2,28 @@ import { createHash } from "node:crypto";
|
||||
import { lstat, opendir, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
|
||||
import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js";
|
||||
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
|
||||
import {
|
||||
assertArtifactReadActive,
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFailure,
|
||||
bindArtifactFileCleanup,
|
||||
withArtifactRegularFile,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
|
||||
/** Installed processor code and data, distinct from REA's analysis-extension ABI. */
|
||||
export const ghidraPspExtensionSchema = z.strictObject({
|
||||
id: z.literal("ghidra-allegrex"),
|
||||
@@ -41,9 +50,25 @@ const REQUIRED = [
|
||||
export const inspectGhidraPspExtension = async (
|
||||
root: string,
|
||||
ghidraVersion: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GhidraPspExtension> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
return await resources.run(() =>
|
||||
fingerprint(root, ghidraVersion, resources, signal),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw ghidraPspInspectionError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const fingerprint = async (
|
||||
root: string,
|
||||
ghidraVersion: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GhidraPspExtension> => {
|
||||
assertArtifactReadActive(signal);
|
||||
if (!isAbsolute(root) || (await realpath(root)) !== root)
|
||||
throw new Error(
|
||||
"The installed Allegrex directory must be a canonical non-symlink path.",
|
||||
@@ -51,7 +76,7 @@ export const inspectGhidraPspExtension = async (
|
||||
const files: string[] = [];
|
||||
let entries = 1; // The root itself consumes one inventory entry.
|
||||
const visit = async (relative: string, depth: number): Promise<void> => {
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error(
|
||||
"Allegrex extension directory nesting exceeds the inspection limit.",
|
||||
@@ -59,15 +84,13 @@ export const inspectGhidraPspExtension = async (
|
||||
const path = join(root, relative);
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isDirectory()) {
|
||||
const children: string[] = [];
|
||||
for await (const child of await opendir(path)) {
|
||||
signal?.throwIfAborted();
|
||||
if (++entries > MAX_ENTRIES)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
children.push(child.name);
|
||||
}
|
||||
const children = await readChildren(
|
||||
path,
|
||||
MAX_ENTRIES - entries,
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
entries += children.length;
|
||||
children.sort();
|
||||
for (const child of children)
|
||||
await visit(
|
||||
@@ -91,10 +114,9 @@ export const inspectGhidraPspExtension = async (
|
||||
let bytesRead = 0;
|
||||
let properties = Buffer.alloc(0);
|
||||
for (const relative of files) {
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
const path = join(root, relative);
|
||||
const handle = await openRegularFile(path, { symlinks: "reject", signal });
|
||||
try {
|
||||
await withArtifactRegularFile(path, resources, signal, async (handle) => {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
@@ -111,22 +133,30 @@ export const inspectGhidraPspExtension = async (
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
while (offset < before.size) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await handle.read(
|
||||
// Serial reads keep final close behind every native read. One extra byte
|
||||
// detects growth without a stream's read-ahead or unbounded allocation.
|
||||
while (offset <= before.size) {
|
||||
assertArtifactReadActive(signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, before.size - offset),
|
||||
Math.min(buffer.length, before.size - offset + 1),
|
||||
offset,
|
||||
);
|
||||
if (read.bytesRead === 0)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
const chunk = buffer.subarray(0, read.bytesRead);
|
||||
assertArtifactReadActive(signal);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
if (offset > before.size)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (offset !== before.size)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
@@ -137,9 +167,7 @@ export const inspectGhidraPspExtension = async (
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
properties = Buffer.concat(chunks);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ghidra's extension.properties version is the compatible Ghidra version,
|
||||
// not the extension release (e.g. v21.4); content identity supplies the latter.
|
||||
@@ -159,7 +187,7 @@ export const inspectGhidraPspExtension = async (
|
||||
throw new Error(
|
||||
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
return {
|
||||
id: "ghidra-allegrex",
|
||||
root,
|
||||
@@ -171,11 +199,11 @@ export const inspectGhidraPspExtension = async (
|
||||
/** Resolve prerequisites before opening or admitting cached PSP analysis. */
|
||||
export const resolveGhidraPspExtension = async (
|
||||
installation: GhidraInstallationInspection,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<GhidraPspExtension, AnalysisError>> => {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
try {
|
||||
assertArtifactReadActive(signal);
|
||||
if (
|
||||
installation.status !== "available" ||
|
||||
installation.platform !== "linux" ||
|
||||
@@ -194,20 +222,98 @@ export const resolveGhidraPspExtension = async (
|
||||
await inspectGhidraPspExtension(
|
||||
root,
|
||||
installation.providerVersion,
|
||||
resources,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${cause instanceof Error ? cause.message : String(cause)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"resolve_analysis_profile",
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
),
|
||||
);
|
||||
return err(ghidraPspInspectionError(cause));
|
||||
}
|
||||
};
|
||||
|
||||
/** Preserve the producer's primary failure and its live cleanup dependencies. */
|
||||
const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
const cleanup =
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
const primary = artifactFilePrimaryFailure(cause);
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
const error =
|
||||
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
|
||||
? new AnalysisCancelledError("resolve_analysis_profile", {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
cleanup,
|
||||
})
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"resolve_analysis_profile",
|
||||
reason,
|
||||
{
|
||||
cause,
|
||||
userMessage: reason,
|
||||
cleanup,
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
/** Close before descending, retaining the exact directory owner on uncertainty. */
|
||||
const readChildren = async (
|
||||
path: string,
|
||||
remainingEntries: number,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const owner = new OwnedDirectoryHandle(await opendir(path));
|
||||
let outcome:
|
||||
| { readonly kind: "completed"; readonly names: string[] }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const names: string[] = [];
|
||||
for (;;) {
|
||||
assertArtifactReadActive(signal);
|
||||
const child = await owner.handle.read();
|
||||
assertArtifactReadActive(signal);
|
||||
if (child === null) break;
|
||||
if (names.length >= remainingEntries)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
names.push(child.name);
|
||||
}
|
||||
outcome = { kind: "completed", names };
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const lease = new AnalysisCleanupLease(() => owner.close());
|
||||
const attempt = await resources.release({
|
||||
kind: "lease",
|
||||
resource: path,
|
||||
lease,
|
||||
});
|
||||
if (attempt.kind === "failed") {
|
||||
const primary =
|
||||
outcome.kind === "failed"
|
||||
? outcome.cause
|
||||
: signal?.aborted
|
||||
? new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"PSP inspection cancelled during directory close",
|
||||
{ cause: signal.reason },
|
||||
)
|
||||
: attempt.cause;
|
||||
const error = ghidraPspInspectionError(
|
||||
ArtifactReaderFailure.withCleanup(
|
||||
primary,
|
||||
ArtifactReaderFailure.cleanupObservation(attempt.cause, path),
|
||||
),
|
||||
);
|
||||
lease.bindCleanupError(error);
|
||||
throw error;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
assertArtifactReadActive(signal);
|
||||
return outcome.names;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestBinarySession } from "../../tests/fixtures/binarySession.js";
|
||||
@@ -43,15 +45,16 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => {
|
||||
readText: () =>
|
||||
`application.version=${version}\napplication.java.min=21\n`,
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
},
|
||||
(options) => ({
|
||||
start: async () =>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([
|
||||
["missing", "missing_dependency", "runtime_missing"],
|
||||
["nonzero", "runtime_failure", "runtime_unavailable"],
|
||||
["properties", "config_drift", "version_unresolved"],
|
||||
] as const)(
|
||||
"preserves the %s Java prerequisite result through doctor",
|
||||
async ([failure, classification, code], { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-java-doctor-");
|
||||
const jdk = join(root, "jdk");
|
||||
const install = join(root, "ghidra");
|
||||
const platform = process.platform === "darwin" ? "mac" : "linux";
|
||||
const architecture = process.arch === "arm64" ? "arm_64" : "x86_64";
|
||||
const native = join(
|
||||
install,
|
||||
"Ghidra/Features/Decompiler/os",
|
||||
`${platform}_${architecture}`,
|
||||
"decompile",
|
||||
);
|
||||
for (const path of [
|
||||
join(jdk, "bin"),
|
||||
join(install, "support"),
|
||||
join(install, "Ghidra"),
|
||||
join(native, ".."),
|
||||
])
|
||||
await mkdir(path, { recursive: true });
|
||||
await writeFile(
|
||||
join(install, "Ghidra/application.properties"),
|
||||
"application.version=12.1.4\napplication.java.min=21\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(install, "support/analyzeHeadless"),
|
||||
"#!/bin/sh\nexit 99\n",
|
||||
);
|
||||
await chmod(join(install, "support/analyzeHeadless"), 0o700);
|
||||
await writeFile(
|
||||
native,
|
||||
process.platform === "darwin"
|
||||
? thinMach(
|
||||
0xfeedfacf,
|
||||
process.arch === "arm64" ? 0x0100000c : 0x01000007,
|
||||
)
|
||||
: elf(2, 1, process.arch === "arm64" ? 183 : 62),
|
||||
);
|
||||
await chmod(native, 0o700);
|
||||
if (failure !== "missing") {
|
||||
const body =
|
||||
failure === "nonzero"
|
||||
? 'process.stderr.write("controlled runtime failure\\n"); process.exit(7);'
|
||||
: `process.stdout.write(${JSON.stringify(`java.home = ${jdk}\nos.arch = aarch64\n`)});`;
|
||||
await writeFile(
|
||||
join(jdk, "bin/java"),
|
||||
`#!${process.execPath}\n${body}\n`,
|
||||
);
|
||||
await chmod(join(jdk, "bin/java"), 0o700);
|
||||
}
|
||||
const result = await cli.run({
|
||||
arguments: ["doctor", "--provider", "ghidra", "--json"],
|
||||
environment: {
|
||||
JAVA_HOME: jdk,
|
||||
GHIDRA_INSTALL_DIR: install,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
});
|
||||
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "ghidra-java",
|
||||
classification,
|
||||
ok: false,
|
||||
}),
|
||||
]),
|
||||
providerInspections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "ghidra",
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "java", code }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
if (failure === "nonzero")
|
||||
expect(result.json).toMatchObject({
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "ghidra-java",
|
||||
detail: expect.stringContaining("Java exited 7"),
|
||||
details: expect.objectContaining({
|
||||
output: expect.objectContaining({
|
||||
stderr: "controlled runtime failure\n",
|
||||
exit_code: 7,
|
||||
stderr_bytes: 27,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
if (failure === "properties")
|
||||
expect(result.json).toMatchObject({
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "ghidra-java",
|
||||
details: expect.objectContaining({
|
||||
properties: expect.objectContaining({ home: jdk, version: null }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SpawnSyncReturns } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
@@ -18,6 +19,24 @@ vi.mock("node:child_process", async (importOriginal) => ({
|
||||
|
||||
afterEach(() => mockSpawnSync.mockReset());
|
||||
|
||||
/** Match the production probe's raw-byte spawn contract at this fixture boundary. */
|
||||
const probeOutput = (
|
||||
stderrText: string,
|
||||
error?: Error,
|
||||
): SpawnSyncReturns<Buffer> => {
|
||||
const stdout = Buffer.alloc(0);
|
||||
const stderr = Buffer.from(stderrText, "utf8");
|
||||
return {
|
||||
pid: 1,
|
||||
output: [null, stdout, stderr],
|
||||
stdout,
|
||||
stderr,
|
||||
status: error === undefined ? 0 : null,
|
||||
signal: null,
|
||||
...(error === undefined ? {} : { error }),
|
||||
};
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" },
|
||||
{ major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" },
|
||||
@@ -47,11 +66,11 @@ it.each([
|
||||
);
|
||||
}
|
||||
const version = `${String(major)}.0.11`;
|
||||
mockSpawnSync.mockReturnValue({
|
||||
stdout: "",
|
||||
stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
|
||||
status: 0,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
|
||||
),
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
@@ -83,7 +102,7 @@ it.each([
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
]),
|
||||
expect.objectContaining({ encoding: "utf8" }),
|
||||
expect.objectContaining({ encoding: "buffer" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -127,11 +146,11 @@ it.each([
|
||||
join(installDir, "Ghidra", "application.properties"),
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
);
|
||||
mockSpawnSync.mockReturnValue({
|
||||
stdout: "",
|
||||
stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
|
||||
status: 0,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
|
||||
),
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
@@ -172,11 +191,11 @@ it("keeps a known 32-bit Java report classified as unsupported", async () => {
|
||||
join(installDir, "Ghidra", "application.properties"),
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
);
|
||||
mockSpawnSync.mockReturnValue({
|
||||
stdout: "",
|
||||
stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
|
||||
status: 0,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
@@ -202,14 +221,12 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
|
||||
);
|
||||
// A chatty or hostile JVM that overruns the capture budget: spawnSync reports
|
||||
// ENOBUFS, which the probe already treats as a failed observation.
|
||||
mockSpawnSync.mockReturnValue({
|
||||
stdout: "",
|
||||
stderr: "java.version = 21.0.11\n",
|
||||
status: null,
|
||||
error: Object.assign(new Error("spawnSync /java ENOBUFS"), {
|
||||
code: "ENOBUFS",
|
||||
}),
|
||||
});
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
"java.version = 21.0.11\n",
|
||||
Object.assign(new Error("spawnSync /java ENOBUFS"), { code: "ENOBUFS" }),
|
||||
),
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
@@ -227,6 +244,17 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
|
||||
expect(inspection.javaHome).toBe(configuredHome);
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
status: "failed",
|
||||
code: "runtime_missing",
|
||||
code: "runtime_unavailable",
|
||||
});
|
||||
expect(
|
||||
ghidraInstallationDiagnostics(inspection).java_probe_failure,
|
||||
).toMatchObject({
|
||||
code: "runtime_unavailable",
|
||||
process_error_code: "ENOBUFS",
|
||||
output: {
|
||||
stderr: "java.version = 21.0.11\n",
|
||||
truncated: true,
|
||||
signal: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { mkdir, rename, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Dir } from "node:fs";
|
||||
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { ok as javaProbeOk } from "../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
@@ -18,6 +32,32 @@ import {
|
||||
} from "../../../src/ghidra/GhidraLauncher.js";
|
||||
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
|
||||
|
||||
const controls = vi.hoisted(() => ({
|
||||
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
|
||||
directory: undefined as ((handle: Dir) => void) | undefined,
|
||||
}));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args);
|
||||
controls.file?.(handle, String(args[0]));
|
||||
return handle;
|
||||
},
|
||||
opendir: async (...args: Parameters<typeof actual.opendir>) => {
|
||||
const handle = await actual.opendir(...args);
|
||||
controls.directory?.(handle);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
controls.file = undefined;
|
||||
controls.directory = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Synthetic installation files exercise the filesystem/profile boundary, not
|
||||
// Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior.
|
||||
// Independently inspected with PSPDEV v20261001 / GNU readelf 2.44.
|
||||
@@ -55,15 +95,16 @@ const host: GhidraInstallationHost = {
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
};
|
||||
async function installation() {
|
||||
const directory = await createTestTempDirectory("rea-psp-profile-");
|
||||
@@ -148,7 +189,11 @@ describe.skipIf(process.platform === "win32")(
|
||||
|
||||
it("fails before starting a provider after resolved extension bytes change", async () => {
|
||||
const { directory, root } = await installation();
|
||||
const extension = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
await writeFile(join(root, "lib/extension.jar"), "changed loader");
|
||||
const spawn = vi.fn();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
@@ -160,17 +205,20 @@ describe.skipIf(process.platform === "win32")(
|
||||
pspExtension: extension,
|
||||
spawnProcess: spawn,
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot: join(directory, "runtime"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(directory, "socket"),
|
||||
token: "test-only-token",
|
||||
runId: "00000000-0000-4000-8000-000000000001",
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
});
|
||||
const launched = await launcher.launch(
|
||||
{
|
||||
runtimeRoot: join(directory, "runtime"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(directory, "socket"),
|
||||
token: "test-only-token",
|
||||
runId: "00000000-0000-4000-8000-000000000001",
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
},
|
||||
{ resourceScope: new ArtifactResourceScope() },
|
||||
);
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok)
|
||||
throw new Error("Changed extension reached provider startup");
|
||||
@@ -201,9 +249,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
async (properties, reason) => {
|
||||
const { root } = await installation();
|
||||
await writeFile(join(root, "extension.properties"), properties);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
reason,
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow(reason);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -213,9 +265,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
join(root, "Module.manifest"),
|
||||
join(root, "Module.manifest.uninstalled"),
|
||||
);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"Module.manifest",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("Module.manifest");
|
||||
});
|
||||
|
||||
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
|
||||
@@ -223,17 +275,17 @@ describe.skipIf(process.platform === "win32")(
|
||||
const path = join(root, "lib/extension.jar");
|
||||
await rename(path, join(directory, "external.jar"));
|
||||
await symlink(join(directory, "external.jar"), path);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"regular files",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("regular files");
|
||||
});
|
||||
|
||||
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
|
||||
const { root } = await installation();
|
||||
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inspection limit",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("inspection limit");
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -254,9 +306,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
),
|
||||
);
|
||||
}
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inventory exceeds the inspection limit",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow("inventory exceeds the inspection limit");
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -580,16 +636,337 @@ describe.skipIf(process.platform === "win32")(
|
||||
"accepts Java Properties $form and hashes raw bytes",
|
||||
async ({ properties }) => {
|
||||
const { root } = await installation();
|
||||
const canonical = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const canonical = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
const latin1Properties = Buffer.from(
|
||||
`${properties}author=caf\u00e9\n`,
|
||||
"latin1",
|
||||
);
|
||||
await writeFile(join(root, "extension.properties"), latin1Properties);
|
||||
const extension = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
expect(extension.id).toBe("ghidra-allegrex");
|
||||
expect(extension.sha256).not.toBe(canonical.sha256);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP inspection completion ownership",
|
||||
() => {
|
||||
it.each([true, false])(
|
||||
"preserves physical read failure when close aborts the caller (close rejection: %s)",
|
||||
async (rejectClose) => {
|
||||
const { provider, root } = await installation();
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error("controlled PSP read failure");
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
controller.abort({ caller: "late close" });
|
||||
if (rejectClose) throw new Error("controlled PSP close failure");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed read admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
expect(result.error.message).toContain(readFailure.message);
|
||||
expect(result.error.cleanupIncomplete).toBe(rejectClose);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(rejectClose);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
expect(closes).toBe(rejectClose ? 2 : 1);
|
||||
} finally {
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"preserves exact cancellation during successful read finalization (close rejection: %s)",
|
||||
async (rejectClose) => {
|
||||
const { provider, root } = await installation();
|
||||
const controller = new AbortController();
|
||||
const reason = { caller: "finalization" };
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
controller.abort(reason);
|
||||
if (rejectClose) throw new Error("controlled PSP close failure");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Cancelled inspection admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(rejectClose);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toBe(rejectClose ? 2 : 1);
|
||||
} finally {
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains directory close uncertainty without retrying an invalidated native handle", async () => {
|
||||
const { provider } = await installation();
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error("controlled directory read failure");
|
||||
let closes = 0;
|
||||
controls.directory = (handle) => {
|
||||
controls.directory = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
await close();
|
||||
controller.abort({ caller: "directory finalization" });
|
||||
throw new Error("controlled native directory close rejection");
|
||||
});
|
||||
};
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed directory admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
expect(result.error.message).toContain(readFailure.message);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(true);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(closes).toBe(1);
|
||||
const retried = await provider.resolveAnalysisProfile(target);
|
||||
expect(retried.ok).toBe(false);
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP launch inspection ownership",
|
||||
() => {
|
||||
it("keeps the launch inspection failure through client finalization and retries its exact descriptor", async () => {
|
||||
const { directory, root } = await installation();
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error(
|
||||
"controlled launch inspection read failure",
|
||||
);
|
||||
let closes = 0;
|
||||
let allowClose = false;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
controller.abort({ caller: "launch finalization" });
|
||||
if (!allowClose)
|
||||
throw new Error("controlled launch inspection close failure");
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const spawn = vi.fn();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
|
||||
configurationDirectoryName: "ghidra_12.1.3_PUBLIC",
|
||||
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
|
||||
pspExtension: extension,
|
||||
spawnProcess: spawn,
|
||||
});
|
||||
const targetPath = join(directory, "target.bin");
|
||||
await writeFile(targetPath, "synthetic target");
|
||||
const client = new GhidraClient({
|
||||
launcher,
|
||||
targetPath,
|
||||
targetSha256: createHash("sha256")
|
||||
.update("synthetic target")
|
||||
.digest("hex"),
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
});
|
||||
try {
|
||||
const result = await client.start(controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed PSP inspection started");
|
||||
expect(result.error.kind).toBe("start");
|
||||
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
|
||||
expect(String(result.error.cause)).toContain(
|
||||
"Ghidra headless launch failed",
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(closes).toBe(2); // Inspection close, then client rollback.
|
||||
allowClose = true;
|
||||
await client.close();
|
||||
expect(closes).toBe(3);
|
||||
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
allowClose = true;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP inspection admission and identity",
|
||||
() => {
|
||||
it("retains the existing sorted recursive hash framing", async () => {
|
||||
const { root } = await installation();
|
||||
await mkdir(join(root, "a"));
|
||||
await writeFile(join(root, "a/z"), "nested before sibling");
|
||||
await writeFile(join(root, "a-foo"), "sibling after directory");
|
||||
const paths = [
|
||||
"Module.manifest",
|
||||
"a/z",
|
||||
"a-foo",
|
||||
"data/languages/allegrex.cspec",
|
||||
"data/languages/allegrex.ldefs",
|
||||
"data/languages/allegrex.pspec",
|
||||
"data/languages/allegrex.sla",
|
||||
"extension.properties",
|
||||
"lib/extension.jar",
|
||||
];
|
||||
const hash = createHash("sha256");
|
||||
for (const path of paths) {
|
||||
const bytes = await readFile(join(root, path));
|
||||
hash.update(JSON.stringify([path, bytes.length]));
|
||||
hash.update(bytes);
|
||||
}
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
expect(extension.sha256).toBe(hash.digest("hex"));
|
||||
});
|
||||
|
||||
it.each(["growth", "truncation"] as const)(
|
||||
"rejects file %s during the bounded read and confirms close",
|
||||
async (change) => {
|
||||
const { root, provider } = await installation();
|
||||
const path = join(
|
||||
root,
|
||||
change === "growth" ? "Module.manifest" : "extension.properties",
|
||||
);
|
||||
let closes = 0;
|
||||
controls.file = (handle, openedPath) => {
|
||||
if (openedPath !== path) return;
|
||||
controls.file = undefined;
|
||||
const read = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
|
||||
await writeFile(path, change === "growth" ? "extra byte" : "");
|
||||
return read(...args);
|
||||
});
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const result = await provider.resolveAnalysisProfile(target);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Changed file admitted");
|
||||
expect(result.error.message).toContain(
|
||||
change === "growth"
|
||||
? "changed during inspection"
|
||||
: "Truncated Allegrex component",
|
||||
);
|
||||
expect(closes).toBe(1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("joins active final close before provider cleanup retries the same descriptor", async () => {
|
||||
const { root, provider } = await installation();
|
||||
let markClosing: (() => void) | undefined;
|
||||
let releaseClose: (() => void) | undefined;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
markClosing = resolve;
|
||||
});
|
||||
const finishClose = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
markClosing?.();
|
||||
await finishClose;
|
||||
throw new Error("controlled active close rejection");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const inspecting = provider.resolveAnalysisProfile(target);
|
||||
await closing;
|
||||
let cleanupSettled = false;
|
||||
const cleaning = provider.closeOwnedResources().then((result) => {
|
||||
cleanupSettled = true;
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(closes).toBe(1);
|
||||
releaseClose?.();
|
||||
const result = await inspecting;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((await cleaning).ok).toBe(true);
|
||||
if (result.ok) throw new Error("Failed close admitted");
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toBe(2);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
} finally {
|
||||
releaseClose?.();
|
||||
await cleaning;
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ok as javaProbeOk } from "../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { expect } from "vitest";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
@@ -105,15 +107,16 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionInfo = (profileDigest: string, targetSha256: string) => ({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ok as javaProbeOk } from "../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -101,15 +103,16 @@ const installationHost: GhidraInstallationHost = {
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/fixture/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/fixture/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
};
|
||||
|
||||
// Only installation discovery and the Java bridge are simulated. Requests use
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ok as javaProbeOk } from "../../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
@@ -443,20 +445,21 @@ describe.skipIf(process.platform === "win32")(
|
||||
readText: () =>
|
||||
"application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
|
||||
osName:
|
||||
process.platform === "darwin"
|
||||
? "Mac OS X"
|
||||
: process.platform === "win32"
|
||||
? "Windows 11"
|
||||
: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/fixture/jdk",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
|
||||
osName:
|
||||
process.platform === "darwin"
|
||||
? "Mac OS X"
|
||||
: process.platform === "win32"
|
||||
? "Windows 11"
|
||||
: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/fixture/jdk",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
},
|
||||
(options) => new GhidraClient({ ...options, launcher }),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user