diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index ff55627049..43f39f6279 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,3 +1,5 @@ +import type { JsonValue } from "./jsonValue.js"; + /** Stable tags exposed by safe analysis-error projections. */ const ANALYSIS_ERROR_TAGS = [ "AnalysisProtocolError", @@ -51,6 +53,8 @@ export interface AnalysisCleanupObservation { /** Optional provider-neutral context that must survive typed error projection. */ export interface AnalysisErrorOptions extends ErrorOptions { + /** Producer-owned, caller-safe facts retained through every typed projection. */ + readonly diagnostics?: Readonly> | undefined; readonly capturedOutput?: AnalysisCapturedOutput | undefined; readonly cleanup?: AnalysisCleanupObservation | undefined; readonly partialObservation?: AnalysisPartialObservation | undefined; @@ -58,12 +62,17 @@ export interface AnalysisErrorOptions extends ErrorOptions { /** Base class for expected analysis, provider, and session failures. */ export abstract class AnalysisError extends Error { + readonly diagnostics: Readonly> | undefined; readonly capturedOutput: AnalysisCapturedOutput | undefined; readonly cleanup: AnalysisCleanupObservation | undefined; readonly cleanupIncomplete: boolean; readonly cleanupResources: readonly string[]; constructor(message: string, options?: AnalysisErrorOptions) { super(message, options); + this.diagnostics = + options?.diagnostics === undefined + ? undefined + : structuredClone(options.diagnostics); this.capturedOutput = options?.capturedOutput === undefined ? undefined diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index b166385a08..467704b980 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -403,25 +403,33 @@ describe("analysis error projection: provider failures", () => { }); }); - it("preserves detached, actionable provider diagnostics", () => { - const diagnostics = { - runtime_root: "/tmp/rea-ghidra-fixture", - profile_digest: "a".repeat(64), - exit_code: 1, - }; - const error = new ProviderAdapterError("ghidra", "health", { - diagnostics, - }); + it.each(["adapter", "cancelled", "timeout"])( + "preserves detached, actionable diagnostics for %s errors", + (kind) => { + const diagnostics = { + runtime_root: "/tmp/rea-ghidra-fixture", + profile_digest: "a".repeat(64), + exit_code: 1, + }; + const error = + kind === "adapter" + ? new ProviderAdapterError("ghidra", "health", { diagnostics }) + : kind === "cancelled" + ? new AnalysisCancelledError("health", { diagnostics }) + : new AnalysisTimeoutError("health", 250, { diagnostics }); - expect(error.diagnostics).not.toBe(diagnostics); - expect(projectAnalysisError(error)).toMatchObject({ - details: { - provider_id: "ghidra", - operation: "health", - diagnostics, - }, - }); - }); + expect(error.diagnostics).not.toBe(diagnostics); + expect(projectAnalysisError(error)).toMatchObject({ + details: { + ...(kind === "adapter" ? { provider_id: "ghidra" } : {}), + operation: "health", + diagnostics, + }, + }); + diagnostics.exit_code = 2; + expect(error.diagnostics?.exit_code).toBe(1); + }, + ); it("maps the Linux startup compatibility exit codes without host internals", () => { const expected = [ diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index cd0c00987f..723200e181 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -52,6 +52,9 @@ export const projectAnalysisError = ( const primaryDetails = errorDetails(error); const details = { ...primaryDetails, + ...(error.diagnostics === undefined + ? {} + : { diagnostics: error.diagnostics }), ...(error.cleanupIncomplete ? { cleanup: "incomplete", @@ -358,9 +361,6 @@ const providerErrorDetails = ( resources: [...error.cleanupResources], } : {}), - ...(error.diagnostics === undefined - ? {} - : { diagnostics: error.diagnostics }), }; if (error instanceof BrowserObservationError) { const primaryReason = primaryBrowserFailureReason(error); diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index 16dbea9ef6..68aaf9f8f1 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -3,14 +3,74 @@ import { expect, it } from "vitest"; import { AnalysisInputError, AnalysisCapabilityUnavailableError, + AnalysisCancelledError, + AnalysisTimeoutError, + AnalysisArtifactChangedError, + AnalysisOutputError, + AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { projectAnalysisError } from "./analysisErrorProjection.js"; import { ProviderCleanupError } from "./providerCleanupError.js"; +import { ProviderAdapterError } from "./providerAdapterError.js"; import { redactExplicitFailure, redactExplicitPointer, } from "./explicitSensitiveFailure.js"; +const context = { + diagnostics: { + note: "explicit-secret warning", + nested: { "explicit-secret-key": 5, observed: 7 }, + caller_path: "/tmp/password-fixture?token=public-value", + }, + capturedOutput: { + stdout: "explicit-secret output", + stderr: "public stderr", + stdout_bytes: 22, + truncated: false, + }, +}; + +it.each([ + new AnalysisCancelledError("inspect", context), + new AnalysisTimeoutError("inspect", 250, context), + new AnalysisInputError("inspect", context), + new AnalysisAccessDeniedError("inspect", "/public", "EACCES", context), + new AnalysisArtifactChangedError("inspect", "/public", "changed", context), + new AnalysisOutputError("inspect", "invalid", context), + new AnalysisCapabilityUnavailableError( + "provider", + "inspect", + "unsupported", + context, + ), + new AnalysisResourceConstraintError( + "inspect", + "memory", + "capacity", + null, + context, + ), + new ProviderAdapterError("provider", "inspect", context), +])("preserves sanitized common context for $._tag", (error) => { + const sanitized = redactExplicitFailure(error, ["explicit-secret"]); + expect(sanitized._tag).toBe(error._tag); + const projected = projectAnalysisError(sanitized); + expect(projected.details).toMatchObject({ + diagnostics: { + note: "[REDACTED] warning", + nested: { observed: 7 }, + caller_path: context.diagnostics.caller_path, + }, + captured_output: { + stdout: "[REDACTED] output", + stdout_bytes: 22, + stderr: "public stderr", + }, + }); + expect(JSON.stringify(projected)).not.toContain("explicit-secret"); +}); + it.each([ ["/metadata/private~1~0/numeric", ["private/~"], "/metadata"], ["/metadata/private/child", ["private"], "/metadata"], diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index dd7a14366a..05a4f2f888 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -6,6 +6,8 @@ import { AnalysisOutputError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + AnalysisCancelledError, + AnalysisTimeoutError, } from "./analysisErrorCore.js"; import { redactExplicitText } from "./explicitSensitiveValues.js"; import type { JsonValue } from "./jsonValue.js"; @@ -72,24 +74,57 @@ export const redactExplicitFailure = ( if (typeof value === "object" && value !== null) return diagnostics(value); return value; }; + const context = { + cause: error, + ...(error.diagnostics === undefined + ? {} + : { diagnostics: diagnostics(error.diagnostics) }), + ...(error.capturedOutput === undefined + ? {} + : { + capturedOutput: { + ...error.capturedOutput, + stdout: text(error.capturedOutput.stdout), + stderr: text(error.capturedOutput.stderr), + }, + }), + ...(error.cleanup === undefined + ? {} + : { + cleanup: { + reason: text(error.cleanup.reason), + resources: error.cleanup.resources.map(text), + }, + }), + }; + if (error instanceof AnalysisCancelledError) + return new AnalysisCancelledError(error.operation, { + ...context, + partialObservation: error.partialObservation, + }); + if (error instanceof AnalysisTimeoutError) + return new AnalysisTimeoutError(error.operation, error.timeoutMs, { + ...context, + partialObservation: error.partialObservation, + }); if (error instanceof AnalysisAccessDeniedError) return new AnalysisAccessDeniedError( error.operation, text(error.path), error.systemCode, - { cause: error }, + context, ); if (error instanceof AnalysisArtifactChangedError) return new AnalysisArtifactChangedError( error.operation, text(error.path), text(error.reason), - { cause: error }, + context, ); if (error instanceof AnalysisInputError) return new AnalysisInputError( error.operation, - { cause: error }, + context, error.issues.map((issue) => ({ ...issue, // Unknown-argument paths contain literal field names, including names @@ -104,16 +139,18 @@ export const redactExplicitFailure = ( })), ); if (error instanceof AnalysisOutputError) - return new AnalysisOutputError(error.operation, text(error.reason), { - cause: error, - }); + return new AnalysisOutputError( + error.operation, + text(error.reason), + context, + ); if (error instanceof AnalysisCapabilityUnavailableError) return new AnalysisCapabilityUnavailableError( error.providerId, error.operation, text(error.reason), { - cause: error, + ...context, ...(error.userMessage === undefined ? {} : { userMessage: text(error.userMessage) }), @@ -126,19 +163,10 @@ export const redactExplicitFailure = ( text(error.reason), error.reportedLimits === null ? null : diagnostics(error.reportedLimits), { - cause: error, + ...context, ...(error.remediationAction === undefined ? {} : { remediationAction: text(error.remediationAction) }), - ...(error.capturedOutput === undefined - ? {} - : { - capturedOutput: { - stdout: text(error.capturedOutput.stdout), - stderr: text(error.capturedOutput.stderr), - truncated: error.capturedOutput.truncated, - }, - }), }, ); if (error instanceof ProviderCleanupError) @@ -149,11 +177,6 @@ export const redactExplicitFailure = ( { operation: error.operation, cause: error }, ); if (error instanceof ProviderAdapterError) - return new ProviderAdapterError(error.providerId, error.operation, { - cause: error, - ...(error.diagnostics === undefined - ? {} - : { diagnostics: diagnostics(error.diagnostics) }), - }); + return new ProviderAdapterError(error.providerId, error.operation, context); return error; }; diff --git a/src/domain/providerAdapterError.ts b/src/domain/providerAdapterError.ts index f434803c5b..02bb178a8e 100644 --- a/src/domain/providerAdapterError.ts +++ b/src/domain/providerAdapterError.ts @@ -1,4 +1,3 @@ -import type { JsonValue } from "./jsonValue.js"; import { AnalysisError, type AnalysisErrorOptions, @@ -7,14 +6,12 @@ import { /** A provider adapter failed outside its more precise typed variants. */ export interface ProviderAdapterErrorOptions extends AnalysisErrorOptions { readonly userMessage?: string; - readonly diagnostics?: Readonly>; } /** A provider adapter failed outside its more precise typed variants. */ export class ProviderAdapterError extends AnalysisError { readonly _tag = "ProviderAdapterError"; override readonly userMessage: string | undefined; - readonly diagnostics: Readonly> | undefined; constructor( readonly providerId: string, @@ -23,9 +20,5 @@ export class ProviderAdapterError extends AnalysisError { ) { super(`Provider ${providerId} adapter failed during ${operation}`, options); this.userMessage = options.userMessage; - this.diagnostics = - options.diagnostics === undefined - ? undefined - : structuredClone(options.diagnostics); } } diff --git a/src/ghidra/GhidraDiagnostics.ts b/src/ghidra/GhidraDiagnostics.ts index 2530751fcd..8ec3de36d7 100644 --- a/src/ghidra/GhidraDiagnostics.ts +++ b/src/ghidra/GhidraDiagnostics.ts @@ -61,6 +61,13 @@ export const createGhidraDiagnostics = ( ghidra_log_path: options.launch.ghidraLogPath, script_log_path: options.launch.scriptLogPath, process_id: options.launch.process.pid ?? null, + ...(options.launch.launchSupport === undefined + ? {} + : { + launch_support: options.launch.launchSupport.map((output) => ({ + ...output, + })), + }), }), ...(options.snapshot === undefined ? {} diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index da762cb797..713fe820ff 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -192,6 +192,8 @@ interface GhidraInstallationObservation { readonly applicationPropertiesPath: string | null; readonly nativeDecompilerPath: string | null; readonly javaCommand: string; + /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ + readonly javaVmName?: string; readonly checks: readonly GhidraInstallationCheck[]; } @@ -232,6 +234,7 @@ export interface GhidraJavaObservation { readonly home: string; readonly bits: number; readonly runtime: "jdk" | "jre"; + readonly vmName?: string; } /** A bounded executable-header probe: a small prefix plus the whole file size. */ @@ -309,6 +312,9 @@ export const inspectGhidraInstallation = ( applicationPropertiesPath: coordinates.applicationPropertiesPath, nativeDecompilerPath: coordinates.nativeDecompilerPath, javaCommand: coordinates.javaCommand, + ...(coordinates.java?.vmName === undefined + ? {} + : { javaVmName: coordinates.java.vmName }), checks, }; if (failed !== undefined) @@ -539,6 +545,7 @@ export const ghidraInstallationDiagnostics = ( java_command: inspection.javaCommand, java_home: inspection.javaHome, java_version: inspection.javaVersion, + java_vm_name: inspection.javaVmName ?? null, platform: inspection.platform, architecture: inspection.architecture, checks: inspection.checks.map((check) => ({ @@ -756,6 +763,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ 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 bits = Number.parseInt( javaProperty(output, "sun.arch.data.model") ?? "0", 10, @@ -765,6 +773,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ version, major: javaMajor(version), home, + ...(vmName === null ? {} : { vmName }), bits, runtime: executablePath( (process.platform === "win32" ? win32 : posix).join( diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index a38fc7052f..1e34eeb082 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -7,11 +7,13 @@ export const ghidraJavaLaunch = async (options: { readonly platform: NodeJS.Platform; readonly analyzeHeadlessPath: string; readonly javaHome: string; + readonly javaVmName?: string; readonly homeRoot: string; readonly tempRoot: string; readonly arguments: readonly string[]; readonly environment: NodeJS.ProcessEnv; readonly signal?: AbortSignal; + readonly onStderr: (mode: "-envvars" | "-vmargs", stderr: string) => void; }): Promise<{ readonly command: string; readonly arguments: readonly string[]; @@ -26,6 +28,13 @@ export const ghidraJavaLaunch = async (options: { ]; const supportArguments = [ ...isolatedProperties, + // Only identified HotSpot VMs have these unified-logging semantics. OpenJ9 + // uses different -Xlog behavior; missing VM identity must stay unknown. + ...(/^(?:OpenJDK|Java HotSpot\(TM\)) (?:64-Bit )?(?:Server|Client|Zero) VM$/u.test( + options.javaVmName ?? "", + ) + ? ["-Xlog:disable", "-Xlog:all=warning:stderr"] + : []), "-cp", join(supportRoot, "LaunchSupport.jar"), "LaunchSupport", @@ -42,6 +51,7 @@ export const ghidraJavaLaunch = async (options: { ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); + options.onStderr("-envvars", configuredEnvironment.stderr); for (const line of configuredEnvironment.stdout.split(/\r?\n/u)) { if (line === "") continue; const separator = line.indexOf("="); @@ -63,6 +73,7 @@ export const ghidraJavaLaunch = async (options: { ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); + options.onStderr("-vmargs", configuredArguments.stderr); const vmArguments = configuredArguments.stdout .split(/\r?\n/u) .filter((line) => line !== ""); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 8348ee28a2..59c5556060 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -21,6 +21,8 @@ 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 { boundedUtf8Text } from "../process/boundedUtf8Text.js"; +import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js"; import { cleanupOwnedProcessGroup, cleanupWindowsProcessTree, @@ -62,6 +64,18 @@ export type GhidraLaunch = ProviderProcessLaunch & { readonly projectRoot: string; readonly ghidraLogPath: string; readonly scriptLogPath: string; + readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; +}; + +/** Same per-stream projection budget for helper warnings and failed commands. */ +export const GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES = 8 * 1024; + +/** Successful helper stderr, retained independently of the analysis JVM output. */ +export type GhidraLaunchSupportDiagnostic = { + readonly mode: "-envvars" | "-vmargs"; + readonly stderr: string; + readonly stderr_bytes: number; + readonly stderr_truncated: boolean; }; /** Provider-owned capability that starts one isolated headless analysis. */ @@ -76,20 +90,24 @@ export interface GhidraLauncher { export class GhidraLaunchError extends Error { readonly #partialLaunch: GhidraLaunch | undefined; readonly #capturedOutput: AnalysisCapturedOutput | undefined; + readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[]; constructor( message: string, options?: ErrorOptions & { readonly partialLaunch?: GhidraLaunch; readonly capturedOutput?: AnalysisCapturedOutput; + readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; }, ) { - const { partialLaunch, capturedOutput, ...errorOptions } = options ?? {}; + const { partialLaunch, capturedOutput, launchSupport, ...errorOptions } = + options ?? {}; super(message, errorOptions); this.name = "GhidraLaunchError"; this.#partialLaunch = partialLaunch; this.#capturedOutput = capturedOutput === undefined ? undefined : { ...capturedOutput }; + this.launchSupport = (launchSupport ?? []).map((output) => ({ ...output })); } /** Process ownership retained for the client to retry an incomplete rollback. */ @@ -129,6 +147,7 @@ export interface GhidraHeadlessLauncherOptions { readonly environment: Readonly; readonly analyzeHeadlessPath: string; readonly javaHome?: string; + readonly javaVmName?: string; readonly bridgeScriptPath: string; readonly platform?: NodeJS.Platform; readonly comSpec?: string; @@ -173,6 +192,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { const paths = ghidraRuntimePaths(session.runtimeRoot); const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; + const launchSupport: GhidraLaunchSupportDiagnostic[] = []; try { const psp = this.options.pspExtension; if (psp !== undefined) { @@ -284,10 +304,32 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, analyzeHeadlessPath: this.options.analyzeHeadlessPath, javaHome: this.options.javaHome, + ...(this.options.javaVmName === undefined + ? {} + : { javaVmName: this.options.javaVmName }), homeRoot: paths.homeRoot, tempRoot: paths.tempRoot, arguments: headlessArguments, environment, + onStderr: (mode, stderr) => { + if (stderr === "") return; + const bytes = Buffer.byteLength(stderr); + const redacted = redactCapturedTransportCredential( + { text: stderr, bytes, observedBytes: bytes }, + session.token, + "[REDACTED]", + ); + const bounded = boundedUtf8Text( + redacted, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + launchSupport.push({ + mode, + stderr: bounded.text, + stderr_bytes: bytes, + stderr_truncated: bounded.truncated, + }); + }, ...(options.signal === undefined ? {} : { signal: options.signal }), @@ -306,7 +348,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { hostEnvironment: {}, ...(options.signal === undefined ? {} : { signal: options.signal }), }); - const partialLaunch = ownedGhidraLaunch(started, paths, platform); + const partialLaunch = ownedGhidraLaunch( + started, + paths, + platform, + launchSupport, + ); await writeGhidraRuntimeFile( paths.ownershipPath, `${JSON.stringify({ @@ -330,12 +377,17 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { return ok(partialLaunch); } catch (cause: unknown) { const capturedOutput = ghidraLaunchCommandOutput(cause); + const launchFailure = new GhidraLaunchError( + "Ghidra headless launch failed", + { + cause, + launchSupport, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }, + ); const primary = isAborted(options.signal) - ? new AnalysisCancelledError("open_binary", { cause }) - : new GhidraLaunchError("Ghidra headless launch failed", { - cause, - ...(capturedOutput === undefined ? {} : { capturedOutput }), - }); + ? new AnalysisCancelledError("open_binary", { cause: launchFailure }) + : launchFailure; if (started === undefined) return err(primary); let cleanupFailure: unknown; @@ -349,7 +401,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { cleanupFailure = failure; } if (cleanupFailure !== undefined) { - const partialLaunch = ownedGhidraLaunch(started, paths, platform); + const partialLaunch = ownedGhidraLaunch( + started, + paths, + platform, + launchSupport, + ); const primaryMessage = isAborted(options.signal) ? primary.message : `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`; @@ -363,6 +420,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { { cause: primary }, ), partialLaunch, + launchSupport, ...(capturedOutput === undefined ? {} : { capturedOutput }), }, ), @@ -377,11 +435,13 @@ const ownedGhidraLaunch = ( spawned: SpawnedOwnedProviderProcess, paths: ReturnType, platform: NodeJS.Platform, + launchSupport: readonly GhidraLaunchSupportDiagnostic[], ): GhidraLaunch => ({ ...ownedGhidraProcess(spawned, platform), projectRoot: paths.projectRoot, ghidraLogPath: paths.ghidraLogPath, scriptLogPath: paths.scriptLogPath, + ...(launchSupport.length === 0 ? {} : { launchSupport: [...launchSupport] }), }); const ownedGhidraProcess = ( diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index a3b2ccf2ae..8b9c254618 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -46,6 +46,7 @@ const installationHost = (): GhidraInstallationHost => ({ home: "/usr/lib/jvm/jdk-21", bits: 64, runtime: "jdk", + vmName: "OpenJDK 64-Bit Server VM", }), }); @@ -599,6 +600,11 @@ describe("Ghidra client projection", () => { expect.objectContaining({ runId: "11111111-1111-4111-8111-111111111111", platform: "linux", + launcher: expect.objectContaining({ + options: expect.objectContaining({ + javaVmName: "OpenJDK 64-Bit Server VM", + }), + }), }), ]); expect(toolCalls).toEqual([ @@ -756,6 +762,11 @@ describe("Ghidra result projection", () => { ok: false, error: { _tag: tag }, }); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { diagnostics: { remote_code: code } }, + }); }); it("returns cancellation before profile work", async () => { @@ -1691,7 +1702,10 @@ it("projects Ghidra startup failure and its incomplete cleanup together", async diagnostics: { primary_error: { code: "provider_timeout", - details: { timeout_ms: 250 }, + details: { + timeout_ms: 250, + diagnostics: { failure_kind: "timeout" }, + }, }, cleanup_error: { code: "cleanup_incomplete" }, }, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 39c89c46ae..5a4f4eafd2 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -541,6 +541,9 @@ export const createGhidraProviderClient = (input: { environment: input.environment, analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, javaHome: prerequisites.value.javaHome, + ...(installation.javaVmName === undefined + ? {} + : { javaVmName: installation.javaVmName }), bridgeScriptPath: fileURLToPath( new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), @@ -980,37 +983,40 @@ const projectPrimarySessionError = ( failure: GhidraSessionError, startupTimeoutMs: number, ): AnalysisError => { + const context = { cause: failure, diagnostics: failure.diagnostics }; if (failure.cause instanceof AnalysisAccessDeniedError) return new AnalysisAccessDeniedError( operation, failure.cause.path, failure.cause.systemCode, - { cause: failure }, + context, ); if (failure.cause instanceof AnalysisArtifactChangedError) return new AnalysisArtifactChangedError( operation, failure.cause.path, failure.cause.reason, - { cause: failure }, + context, ); if ( operation === "annotate_native_function" && failure.kind === "remote" && failure.remoteCode === "invalid_function_name" ) - return new AnalysisInputError(operation, { cause: failure }, [ + return new AnalysisInputError(operation, context, [ { path: ["name"], reason: "invalid_value", message: failure.message }, ]); - if (failure.kind === "cancelled") - return new AnalysisCancelledError(operation); + if ( + failure.kind === "cancelled" || + (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") + ) + return new AnalysisCancelledError(operation, context); if (failure.kind === "timeout" || failure.kind === "analysis_timeout") return new AnalysisTimeoutError( operation, failure.timeoutMs ?? startupTimeoutMs, + context, ); - if (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") - return new AnalysisCancelledError(operation); if ( failure.kind === "remote" && failure.remoteCode === "regex_stack_exhausted" @@ -1021,7 +1027,7 @@ const projectPrimarySessionError = ( failure.message, null, { - cause: failure, + ...context, remediationAction: "Retry this search in literal mode or simplify the regex. The active analysis session and annotations remain available.", }, @@ -1032,7 +1038,7 @@ const projectPrimarySessionError = ( failure.remoteCode ?? "", ) ) - return new AnalysisInputError(operation, { cause: failure }, [ + return new AnalysisInputError(operation, context, [ { path: [], reason: "invalid_value", message: failure.message }, ]); if (failure.kind === "remote" && failure.remoteCode === "method_unavailable") @@ -1040,9 +1046,7 @@ const projectPrimarySessionError = ( "ghidra", operation, failure.message, + context, ); - return new ProviderAdapterError("ghidra", operation, { - cause: failure, - diagnostics: failure.diagnostics, - }); + return new ProviderAdapterError("ghidra", operation, context); }; diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index cc76ab3383..ca3b4f6aca 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -2,13 +2,15 @@ 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"; +import { + GhidraLaunchError, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, +} 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 = @@ -229,18 +231,28 @@ const causeLaunchOutcome = ( redact: (value: string) => string, ): Record => { if (!(cause instanceof GhidraLaunchError)) return {}; + const launchSupport = + cause.launchSupport.length === 0 + ? {} + : { + launch_support: cause.launchSupport.map((output) => ({ + ...output, + stderr: redact(output.stderr), + })), + }; const output = cause.capturedOutput; - if (output === undefined) return {}; + if (output === undefined) return launchSupport; const stdout = boundedUtf8Text( redact(output.stdout), - MAX_LAUNCH_COMMAND_OUTPUT_BYTES, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, ); const stderr = boundedUtf8Text( redact(output.stderr), - MAX_LAUNCH_COMMAND_OUTPUT_BYTES, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, ); const truncated = output.truncated || stdout.truncated || stderr.truncated; return { + ...launchSupport, launch_outcome: { stdout: stdout.text, stderr: stderr.text, diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts index 82ed00ef76..c0a6974d5e 100644 --- a/tests/boundary/filesystem/ghidraJavaProbe.test.ts +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -2,7 +2,10 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; -import { inspectGhidraInstallation } from "../../../src/ghidra/GhidraInstallation.js"; +import { + inspectGhidraInstallation, + ghidraInstallationDiagnostics, +} from "../../../src/ghidra/GhidraInstallation.js"; import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraInstallationPolicy.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -16,12 +19,12 @@ vi.mock("node:child_process", async (importOriginal) => ({ afterEach(() => mockSpawnSync.mockReset()); it.each([ - { major: 17, fullJdk: true }, - { major: 21, fullJdk: true }, - { major: 21, fullJdk: false }, + { major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" }, + { major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" }, + { major: 21, fullJdk: false, vmName: undefined }, ])( "preserves the reported Unicode Java home and classifies JDK $major (full JDK: $fullJdk)", - async ({ major, fullJdk }) => { + async ({ major, fullJdk, vmName }) => { const root = await createTestTempDirectory("rea-java-probe-"); const configuredHome = join(root, "selected-jdk"); const reportedHome = join(root, "Réa逆向", `JDK ${String(major)}`); @@ -46,7 +49,7 @@ 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`, + stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`, status: 0, }); @@ -58,6 +61,10 @@ it.each([ expect(inspection.javaHome).toBe(reportedHome); expect(inspection.javaVersion).toBe(version); + expect(inspection.javaVmName).toBe(vmName); + expect(ghidraInstallationDiagnostics(inspection).java_vm_name).toBe( + vmName ?? null, + ); expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject( { status: fullJdk ? "passed" : "failed", diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts new file mode 100644 index 0000000000..dc4be5ef20 --- /dev/null +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -0,0 +1,345 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { describe, expect, it, onTestFinished } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { createGhidraDiagnostics } from "../../../../src/ghidra/GhidraDiagnostics.js"; +import { + GhidraHeadlessLauncher, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + type GhidraLaunchSession, +} from "../../../../src/ghidra/GhidraLauncher.js"; +import { bindGhidraSessionFailure } from "../../../../src/ghidra/GhidraSessionError.js"; +import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { waitForAbortableDelay } from "../../../../src/process/ProviderDeadline.js"; +import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js"; +import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { parseConfig } from "../../../../src/config/parseConfig.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { analysisErrorProjectionSchema } from "../../../../src/contracts/errorSchemas.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; +import { elf } from "../../../../src/domain/binaryTarget.fixture.js"; +import { silentLogger } from "../../../../src/logger.js"; + +const warning = + "[0.004s][warning][os,container] Cgroup limits may be inaccurate: /tmp/password-fixture?token=public-value"; + +const fixture = async ( + mode: string, + javaVmName: string | null = "OpenJDK 64-Bit Server VM", +) => { + const root = await createTestTempDirectory("rea-ghidra-java-launch-"); + const javaHome = join(root, "jdk"); + await mkdir(join(javaHome, "bin"), { recursive: true }); + await writeFile( + join(javaHome, "bin", "java"), + `#!/usr/bin/env node +const args = process.argv.slice(2); +const probe = args.at(-1); +if (probe !== "-envvars" && probe !== "-vmargs") process.exit(64); +const mode = ${JSON.stringify(mode)}; +if (mode === "non-hotspot" && args.some(arg => arg.startsWith("-Xlog:"))) process.exit(65); +const warning = ${JSON.stringify(warning)}; +const stream = args.includes("-Xlog:disable") && args.includes("-Xlog:all=warning:stderr") ? process.stderr : process.stdout; +if (mode !== "quiet" && mode !== "non-hotspot") stream.write(probe + ": " + warning + "\\n"); +if (mode === "credential") stream.write("explicit credential: private-transport-token\\n"); +if (mode === "large") stream.write("é".repeat(${GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES}) + "UNRETAINED-TAIL\\n"); +if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { + process.stderr.write("failed " + probe + "\\n"); + process.exitCode = 3; +} else if (probe === "-envvars") { + process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n"); +} else { + process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n"); +} +`, + { mode: 0o755 }, + ); + const session: GhidraLaunchSession = { + runtimeRoot: join(root, "runtime"), + transport: "unix-socket", + endpointPath: join(root, "endpoint.sock"), + token: "private-transport-token", + runId: randomUUID(), + targetPath: join(root, "target.bin"), + targetSha256: "0".repeat(64), + providerVersion: "test", + profileDigest: "test", + }; + const analysisArguments: (readonly string[])[] = []; + const controller = new AbortController(); + const launcher = new GhidraHeadlessLauncher({ + environment: { PATH: process.env.PATH ?? "" }, + platform: process.platform, + javaHome, + ...(javaVmName === null ? {} : { javaVmName }), + analyzeHeadlessPath: join(root, "ghidra", "support", "analyzeHeadless"), + bridgeScriptPath: join(root, "ReaGhidraBridge.java"), + spawnProcess: async (options) => { + analysisArguments.push(options.arguments); + const spawned = await spawnOwnedProviderProcess({ + ...options, + command: process.execPath, + arguments: ["-e", "setInterval(() => {}, 1000)"], + expectedCommand: process.execPath, + }); + onTestFinished(async () => { + const cleanup = + spawned.cleanup === undefined + ? await cleanupOwnedProcessGroup(spawned.ownership) + : await spawned.cleanup(); + expect(cleanup.cleaned).toBe(true); + }); + if (mode === "cancel-after-spawn") controller.abort(); + if (mode === "timeout-after-spawn") + await waitForAbortableDelay(10_000, options.signal); + return spawned; + }, + }); + return { session, launcher, analysisArguments, controller }; +}; + +describe.skipIf(process.platform === "win32")( + "Ghidra LaunchSupport JVM compatibility", + () => { + it.each(["Eclipse OpenJ9 VM", "unrecognized VM", ""])( + "keeps HotSpot-only flags off a VM identified as %s", + async (javaVmName) => { + const { session, launcher, analysisArguments } = await fixture( + "non-hotspot", + javaVmName === "" ? null : javaVmName, + ); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(analysisArguments).toHaveLength(1); + expect(launched.value.launchSupport).toBeUndefined(); + }, + ); + + it.each([ + "OpenJDK 64-Bit Server VM", + "Java HotSpot(TM) 64-Bit Server VM", + "OpenJDK 64-Bit Zero VM", + ])( + "preserves redirected warnings for %s while keeping the analysis JVM arguments clean", + async (javaVmName) => { + const { session, launcher, analysisArguments } = await fixture( + "success", + javaVmName, + ); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(analysisArguments[0]).toContain("-Dfile.encoding=UTF8"); + expect(analysisArguments[0]).toContain("-Xmx1G"); + expect( + analysisArguments[0]?.some( + (argument) => + argument.startsWith("-Xlog:") || argument.includes("[warning]"), + ), + ).toBe(false); + const diagnostics = createGhidraDiagnostics({ + targetPath: session.targetPath, + targetSha256: session.targetSha256, + transport: session.transport, + providerVersion: session.providerVersion, + profileDigest: session.profileDigest, + launch: launched.value, + token: session.token, + previous: {}, + }); + expect(diagnostics.launch_support).toEqual( + ["-envvars", "-vmargs"].map((mode) => ({ + mode, + stderr: `${mode}: ${warning}\n`, + stderr_bytes: Buffer.byteLength(`${mode}: ${warning}\n`), + stderr_truncated: false, + })), + ); + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra LaunchSupport diagnostics", + () => { + it.each(["env-failure", "vm-failure", "env-malformed", "vm-malformed"])( + "retains helper warnings through session failure diagnostics after %s", + async (mode) => { + const { session, launcher, analysisArguments } = await fixture(mode); + const launched = await launcher.launch(session); + expect(launched.ok).toBe(false); + if (launched.ok) throw new Error("Expected a helper failure"); + expect(analysisArguments).toEqual([]); + const failure = bindGhidraSessionFailure(() => ({}))( + "start", + launched.error.message, + launched.error, + ); + const cause = failure.diagnostics.failure_cause; + if (mode === "env-failure") { + expect(cause).toMatchObject({ + launch_outcome: { + exit_code: 3, + stderr: expect.stringContaining(`-envvars: ${warning}`), + }, + }); + } else { + expect(cause).toMatchObject({ + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-envvars", + stderr: `-envvars: ${warning}\n`, + }), + ]), + }); + } + if (mode === "vm-failure") + expect(cause).toMatchObject({ + launch_outcome: { + exit_code: 3, + stderr: expect.stringContaining(`-vmargs: ${warning}`), + }, + }); + if (mode === "vm-malformed") + expect(cause).toMatchObject({ + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-vmargs", + stderr: `-vmargs: ${warning}\n`, + }), + ]), + }); + }, + ); + + it("bounds retained UTF-8 warnings and reports original byte counts", async () => { + const { session, launcher } = await fixture("large"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + for (const output of launched.value.launchSupport ?? []) { + expect(output.stderr_truncated).toBe(true); + expect(output.stderr_bytes).toBeGreaterThan( + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + expect(Buffer.byteLength(output.stderr)).toBeLessThanOrEqual( + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + expect(output.stderr).not.toContain("UNRETAINED-TAIL"); + expect(output.stderr).not.toContain("�"); + } + expect(launched.value.launchSupport).toHaveLength(2); + }); + + it("omits helper diagnostics when both stderr streams are empty", async () => { + const { session, launcher } = await fixture("quiet"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(launched.value.launchSupport).toBeUndefined(); + }); + + it("redacts the explicit transport credential and preserves caller-selected paths and URL values", async () => { + const { session, launcher } = await fixture("credential"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(launched.value.launchSupport).toHaveLength(2); + for (const output of launched.value.launchSupport ?? []) { + expect(output.stderr).toContain(warning); + expect(output.stderr).toContain("explicit credential: [REDACTED]"); + expect(output.stderr).not.toContain(session.token); + expect(output.stderr_truncated).toBe(false); + } + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra startup interruption diagnostics", + () => { + it.each(["cancelled", "timeout"] as const)( + "preserves helper warnings through the provider-neutral %s projection", + async (kind) => { + const { session, launcher, controller } = await fixture( + kind === "cancelled" ? "cancel-after-spawn" : "timeout-after-spawn", + ); + const bytes = elf(2, 1, 62); + await writeFile(session.targetPath, bytes); + const target: BinaryTarget = { + path: session.targetPath, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + const config = parseConfig({ + GHIDRA_INSTALL_DIR: "/fixture/ghidra", + REA_GHIDRA_STARTUP_TIMEOUT_MS: "3000", + }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + { + platform: process.platform, + architecture: process.arch, + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.12", + major: 21, + home: "/fixture/jdk", + bits: 64, + runtime: "jdk", + }), + }, + (options) => new GhidraClient({ ...options, launcher }), + ); + const profile = await provider.resolveAnalysisProfile(target); + if (!profile.ok) throw profile.error; + if (profile.value.profile === null) + throw new Error("Expected Ghidra profile"); + const client = provider.createClient(target, profile.value.profile); + onTestFinished(async () => { + expect((await client.close()).ok).toBe(true); + }); + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected interrupted startup"); + const projected = analysisErrorProjectionSchema.parse( + JSON.parse(JSON.stringify(projectAnalysisError(result.error))), + ); + expect(projected.code).toBe( + kind === "cancelled" ? "cancelled" : "provider_timeout", + ); + expect(projected).toMatchObject({ + details: { + diagnostics: { + failure_cause: { + cause: { + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-envvars", + stderr: `-envvars: ${warning}\n`, + }), + expect.objectContaining({ + mode: "-vmargs", + stderr: `-vmargs: ${warning}\n`, + }), + ]), + }, + }, + }, + }, + }); + }, + ); + }, +);