mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ghidra): retain launcher command outcome in launch failure diagnostics (#2011)
* fix(ghidra): retain launcher command outcome in launch failure diagnostics Ghidra headless launch failures surfaced only the GhidraLaunchError name and a generic message, dropping the launcher command's exit code/signal and JVM stderr. Retain the failed command outcome on GhidraLaunchError in the shared captured-output envelope and project it as launch_outcome in the session failure_cause, mirroring the compiler_outcome diagnostics. Fixes #1992 * test(ghidra): skip POSIX JVM fixture tests on Windows The bin/java fixture is an extensionless POSIX shell script handed to execFile, which Windows cannot execute. Follow the repo pattern (test.skipIf) so the suite is a named Windows skip.
This commit is contained in:
1 parent
9b780c47bc
commit
d4e8ef7499
3 files changed
+247
-3
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
GhidraHeadlessLauncher,
|
||||
GhidraLaunchError,
|
||||
type GhidraLaunchSession,
|
||||
} from "./GhidraLauncher.js";
|
||||
import { bindGhidraSessionFailure } from "./GhidraSessionError.js";
|
||||
|
||||
// The POSIX `bin/java` fixture cannot execute on Windows (extensionless sh script
|
||||
// handed to execFile), so these tests only run on POSIX hosts.
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
/** Projection bound per launcher-command stream asserted by the tests. */
|
||||
const EXPECTED_OUTPUT_BOUND_BYTES = 8 * 1024;
|
||||
|
||||
const launchSession = (runtimeRoot: string): GhidraLaunchSession => ({
|
||||
runtimeRoot,
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(runtimeRoot, "endpoint.sock"),
|
||||
token: "test-token",
|
||||
runId: "test-run",
|
||||
targetPath: join(runtimeRoot, "target.bin"),
|
||||
targetSha256: "0".repeat(64),
|
||||
providerVersion: "test",
|
||||
profileDigest: "test",
|
||||
});
|
||||
|
||||
/** Stand-in "java" whose LaunchSupport probe fails the way a broken JVM does. */
|
||||
const failingJavaHome = async (script: string): Promise<string> => {
|
||||
const javaHome = await mkdtemp(join(tmpdir(), "rea-fake-java-"));
|
||||
await mkdir(join(javaHome, "bin"), { recursive: true });
|
||||
await writeFile(join(javaHome, "bin", "java"), script, { mode: 0o755 });
|
||||
return javaHome;
|
||||
};
|
||||
|
||||
const probeLauncher = (javaHome: string, runtimeRoot: string) =>
|
||||
new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
platform: "darwin",
|
||||
analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"),
|
||||
javaHome,
|
||||
bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"),
|
||||
});
|
||||
|
||||
const launchOutcomeOf = (error: Error): Record<string, unknown> | undefined => {
|
||||
const sessionError = bindGhidraSessionFailure(() => ({}))(
|
||||
"start",
|
||||
error.message,
|
||||
error,
|
||||
);
|
||||
const cause = sessionError.diagnostics["failure_cause"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outcome = cause["launch_outcome"] as Record<string, unknown>;
|
||||
return outcome === undefined ? undefined : outcome;
|
||||
};
|
||||
|
||||
describe("Ghidra launch failure diagnostics", () => {
|
||||
it("retains the failed launcher command outcome on the launch error", async () => {
|
||||
const javaHome = await failingJavaHome(
|
||||
"#!/bin/sh\necho 'jvm probe exploded' >&2\nexit 3\n",
|
||||
);
|
||||
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
|
||||
try {
|
||||
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
|
||||
launchSession(runtimeRoot),
|
||||
);
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok) throw new Error("expected the launch to fail");
|
||||
expect(launched.error).toBeInstanceOf(GhidraLaunchError);
|
||||
expect(launched.error.message).toBe("Ghidra headless launch failed");
|
||||
const captured =
|
||||
launched.error instanceof GhidraLaunchError
|
||||
? launched.error.capturedOutput
|
||||
: undefined;
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.exit_code).toBe(3);
|
||||
expect(captured?.signal).toBeNull();
|
||||
expect(captured?.stdout).toBe("");
|
||||
expect(captured?.stderr).toContain("jvm probe exploded");
|
||||
expect(captured?.truncated).toBe(false);
|
||||
} finally {
|
||||
await rm(javaHome, { recursive: true, force: true });
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the retained launcher outcome in the session failure_cause", async () => {
|
||||
const javaHome = await failingJavaHome(
|
||||
"#!/bin/sh\necho 'jvm probe exploded' >&2\nexit 3\n",
|
||||
);
|
||||
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
|
||||
try {
|
||||
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
|
||||
launchSession(runtimeRoot),
|
||||
);
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok) throw new Error("expected the launch to fail");
|
||||
const outcome = launchOutcomeOf(launched.error);
|
||||
expect(outcome).toBeDefined();
|
||||
expect(outcome?.["exit_code"]).toBe(3);
|
||||
expect(outcome?.["signal"]).toBeNull();
|
||||
expect(outcome?.["stderr"]).toContain("jvm probe exploded");
|
||||
expect(outcome?.["truncated"]).toBe(false);
|
||||
} finally {
|
||||
await rm(javaHome, { recursive: true, force: true });
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds projected launcher output and marks truncation explicitly", async () => {
|
||||
const oversized = `${"x".repeat(EXPECTED_OUTPUT_BOUND_BYTES)}UNRETAINED-TAIL`;
|
||||
const javaHome = await failingJavaHome(
|
||||
`#!/bin/sh\nprintf '%s' '${oversized}' >&2\nexit 1\n`,
|
||||
);
|
||||
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
|
||||
try {
|
||||
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
|
||||
launchSession(runtimeRoot),
|
||||
);
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok) throw new Error("expected the launch to fail");
|
||||
const outcome = launchOutcomeOf(launched.error);
|
||||
expect(outcome?.["truncated"]).toBe(true);
|
||||
expect(outcome?.["exit_code"]).toBe(1);
|
||||
expect(outcome?.["stderr_bytes"]).toBe(oversized.length);
|
||||
expect((outcome?.["stderr"] as string).length).toBeLessThanOrEqual(
|
||||
EXPECTED_OUTPUT_BOUND_BYTES,
|
||||
);
|
||||
expect(outcome?.["stderr"] as string).not.toContain("UNRETAINED-TAIL");
|
||||
} finally {
|
||||
await rm(javaHome, { recursive: true, force: true });
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves non-command launch failures without retained output", async () => {
|
||||
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
|
||||
try {
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
platform: "darwin",
|
||||
analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"),
|
||||
bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"),
|
||||
});
|
||||
const launched = await launcher.launch(launchSession(runtimeRoot));
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok) throw new Error("expected the launch to fail");
|
||||
expect(launched.error).toBeInstanceOf(GhidraLaunchError);
|
||||
expect(launched.error.message).toBe("Ghidra headless launch failed");
|
||||
expect(
|
||||
launched.error instanceof GhidraLaunchError
|
||||
? launched.error.capturedOutput
|
||||
: undefined,
|
||||
).toBeUndefined();
|
||||
expect(launchOutcomeOf(launched.error)).toBeUndefined();
|
||||
} finally {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
||||
import { execFileOutputFailure } from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
cleanupWindowsProcessTree,
|
||||
@@ -73,23 +75,55 @@ export interface GhidraLauncher {
|
||||
/** Local launch failure retained as the cause of a provider-neutral error. */
|
||||
export class GhidraLaunchError extends Error {
|
||||
readonly #partialLaunch: GhidraLaunch | undefined;
|
||||
readonly #capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: ErrorOptions & { readonly partialLaunch?: GhidraLaunch },
|
||||
options?: ErrorOptions & {
|
||||
readonly partialLaunch?: GhidraLaunch;
|
||||
readonly capturedOutput?: AnalysisCapturedOutput;
|
||||
},
|
||||
) {
|
||||
const { partialLaunch, ...errorOptions } = options ?? {};
|
||||
const { partialLaunch, capturedOutput, ...errorOptions } = options ?? {};
|
||||
super(message, errorOptions);
|
||||
this.name = "GhidraLaunchError";
|
||||
this.#partialLaunch = partialLaunch;
|
||||
this.#capturedOutput =
|
||||
capturedOutput === undefined ? undefined : { ...capturedOutput };
|
||||
}
|
||||
|
||||
/** Process ownership retained for the client to retry an incomplete rollback. */
|
||||
get partialLaunch(): GhidraLaunch | undefined {
|
||||
return this.#partialLaunch;
|
||||
}
|
||||
|
||||
/** Launcher command outcome retained when a launch command reported one. */
|
||||
get capturedOutput(): AnalysisCapturedOutput | undefined {
|
||||
return this.#capturedOutput === undefined
|
||||
? undefined
|
||||
: { ...this.#capturedOutput };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain a failed launcher command's exit/signal and output in the shared
|
||||
* captured-output envelope. Bounds apply at projection; truncation there stays
|
||||
* explicit, matching the compiler-outcome diagnostics.
|
||||
*/
|
||||
const ghidraLaunchCommandOutput = (
|
||||
cause: unknown,
|
||||
): AnalysisCapturedOutput | undefined => {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (failure === undefined) return undefined;
|
||||
return {
|
||||
stdout: failure.stdout,
|
||||
stderr: failure.stderr,
|
||||
truncated: failure.outputTruncated,
|
||||
exit_code: typeof failure.code === "number" ? failure.code : null,
|
||||
signal: failure.signal,
|
||||
};
|
||||
};
|
||||
|
||||
/** Static coordinates for an extracted Ghidra release and packaged script. */
|
||||
export interface GhidraHeadlessLauncherOptions {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
@@ -295,9 +329,13 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
}
|
||||
return ok(partialLaunch);
|
||||
} catch (cause: unknown) {
|
||||
const capturedOutput = ghidraLaunchCommandOutput(cause);
|
||||
const primary = isAborted(options.signal)
|
||||
? new AnalysisCancelledError("open_binary", { cause })
|
||||
: new GhidraLaunchError("Ghidra headless launch failed", { cause });
|
||||
: new GhidraLaunchError("Ghidra headless launch failed", {
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
});
|
||||
if (started === undefined) return err(primary);
|
||||
|
||||
let cleanupFailure: unknown;
|
||||
@@ -325,6 +363,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
{ cause: primary },
|
||||
),
|
||||
partialLaunch,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import { GhidraLaunchError } from "./GhidraLauncher.js";
|
||||
|
||||
const MAX_CAUSE_DEPTH = 3;
|
||||
const MAX_CAUSE_MESSAGE_BYTES = 2 * 1024;
|
||||
const MAX_CAUSE_FIELD_BYTES = 1024;
|
||||
const MAX_COMPILER_STDERR_BYTES = 8 * 1024;
|
||||
const MAX_LAUNCH_COMMAND_OUTPUT_BYTES = 8 * 1024;
|
||||
|
||||
/** Stable failure phase for one private Ghidra headless session. */
|
||||
export type GhidraSessionFailureKind =
|
||||
@@ -149,6 +151,7 @@ const describeErrorCause = (
|
||||
...causeCode(cause, redact),
|
||||
...causeSignal(cause, redact),
|
||||
...causeCompilerOutcome(cause, redact),
|
||||
...causeLaunchOutcome(cause, redact),
|
||||
...nestedErrorCause(cause, redact, depth),
|
||||
};
|
||||
};
|
||||
@@ -220,6 +223,42 @@ const boundedCompilerField = (
|
||||
return { value: bounded.text, truncated: bounded.truncated };
|
||||
};
|
||||
|
||||
/** Retain a launch failure's launcher command outcome in its cause envelope. */
|
||||
const causeLaunchOutcome = (
|
||||
cause: Error,
|
||||
redact: (value: string) => string,
|
||||
): Record<string, JsonValue> => {
|
||||
if (!(cause instanceof GhidraLaunchError)) return {};
|
||||
const output = cause.capturedOutput;
|
||||
if (output === undefined) return {};
|
||||
const stdout = boundedUtf8Text(
|
||||
redact(output.stdout),
|
||||
MAX_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
const stderr = boundedUtf8Text(
|
||||
redact(output.stderr),
|
||||
MAX_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
const truncated = output.truncated || stdout.truncated || stderr.truncated;
|
||||
return {
|
||||
launch_outcome: {
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
truncated,
|
||||
...(truncated
|
||||
? {
|
||||
stdout_bytes: Buffer.byteLength(output.stdout),
|
||||
stderr_bytes: Buffer.byteLength(output.stderr),
|
||||
}
|
||||
: {}),
|
||||
...(output.exit_code === undefined
|
||||
? {}
|
||||
: { exit_code: output.exit_code }),
|
||||
...(output.signal === undefined ? {} : { signal: output.signal }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const nestedErrorCause = (
|
||||
cause: Error,
|
||||
redact: (value: string) => string,
|
||||
|
||||
Reference in new issue
Block a user