From d4e8ef74999666cdb735b49eb8fea367f8f86489 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Mon, 12 Oct 2026 11:59:18 +0500 Subject: [PATCH] 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. --- src/ghidra/GhidraLaunchFailure.test.ts | 166 +++++++++++++++++++++++++ src/ghidra/GhidraLauncher.ts | 45 ++++++- src/ghidra/GhidraSessionError.ts | 39 ++++++ 3 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 src/ghidra/GhidraLaunchFailure.test.ts diff --git a/src/ghidra/GhidraLaunchFailure.test.ts b/src/ghidra/GhidraLaunchFailure.test.ts new file mode 100644 index 0000000000..246ec82d0f --- /dev/null +++ b/src/ghidra/GhidraLaunchFailure.test.ts @@ -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 => { + 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 | 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; + 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 }); + } + }); +}); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 83f9f24059..8348ee28a2 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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; @@ -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 }), }, ), ); diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index 4b8bead7af..cc76ab3383 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -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 => { + 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,