diff --git a/docs/installation.md b/docs/installation.md index 42e179798f..720f1ae45f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -779,6 +779,32 @@ is no fixed per-operation or response-size ceiling. Unresolved computed calls remain unknown, reference-kind provenance is preserved, and provider-specific pseudocode is never treated as original source or Hopper-equivalent text. +### Missing macOS native decompiler + +If `rea doctor --json` reports `native_decompiler` as missing on macOS x64, +the extracted Ghidra archive may include the decompiler source without the +macOS executable. For Ghidra 12.1.4, you can build it manually with an existing +Xcode Command Line Tools installation, `make`, and `bison`: + +```bash +cd "$GHIDRA_INSTALL_DIR/Ghidra/Features/Decompiler/src/decompile/cpp" +make ghidra_opt +decompiler_dir="$GHIDRA_INSTALL_DIR/Ghidra/Features/Decompiler/os/mac_x86_64" +mkdir -p "$decompiler_dir" +test ! -e "$decompiler_dir/decompile" && test ! -L "$decompiler_dir/decompile" && + install -m 755 ghidra_opt "$decompiler_dir/decompile" +rea doctor --json +``` + +The copy step requires the destination to be absent and preserves an existing +decompiler. This recipe is specific to macOS x64: the +[Ghidra 12.1.4 Makefile](https://github.com/NationalSecurityAgency/ghidra/blob/Ghidra_12.1.4_build/Ghidra/Features/Decompiler/src/decompile/cpp/Makefile) +explicitly selects `-arch x86_64` on Darwin. For other architectures or a Gradle +build, follow the matching release's +[native component instructions](https://github.com/NationalSecurityAgency/ghidra/blob/Ghidra_12.1.4_build/GhidraDocs/GettingStarted.md#building-native-components). +REA accepts the matching executable under either `os//` or +`build/os//`; it does not build the component during setup or diagnosis. + ### Ghidra import language and analysis seeds Ghidra detects the language from the executable header. To set it (for example, Borland C++ PE images): @@ -882,8 +908,12 @@ verification workflows. Unexpected request failures retain their original internal cause. CLI and MCP errors expose Error names, messages, and available codes under -`details.diagnostics.failure_cause`; other rejection values retain their -primitive value or an explicit type. Shutdown warnings include the failure +`details.diagnostics.failure_cause`, including bounded nested causes and explicit +truncation markers. Darwin ownership-helper compilation failures also retain +the compiler outcome, exit code or signal, and bounded stderr; a timed-out +compiler remains distinguishable from a compiler that exited with an error. +Other rejection values retain their primitive value or an explicit type. +Shutdown warnings include the failure kind, message, and diagnostics, while process and temporary-project cleanup continues. These messages redact known bridge authentication tokens and preserve local paths and other analysis context. diff --git a/src/ghidra/GhidraLanguageOverride.test.ts b/src/ghidra/GhidraLanguageOverride.test.ts index f29294d4f8..a355b39fab 100644 --- a/src/ghidra/GhidraLanguageOverride.test.ts +++ b/src/ghidra/GhidraLanguageOverride.test.ts @@ -158,8 +158,14 @@ describe("Ghidra language catalog", () => { { languageId: "x86:LE:32:default", compilerSpecId: "removed" }, catalog, "12.1.4", - ), - ).not.toBeNull(); + )?.settings, + ).toEqual([ + { + setting: "REA_GHIDRA_COMPILER_SPEC_ID", + constraint: + "must be one of the selected language's compiler specs: borlandcpp", + }, + ]); }); it.each([ diff --git a/src/ghidra/GhidraSessionError.test.ts b/src/ghidra/GhidraSessionError.test.ts new file mode 100644 index 0000000000..874ef6a118 --- /dev/null +++ b/src/ghidra/GhidraSessionError.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from "vitest"; + +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; +import { bindGhidraSessionFailure } from "./GhidraSessionError.js"; +import { GhidraLaunchError } from "./GhidraLauncher.js"; + +it("reports truncated cyclic causes in serializable diagnostics", () => { + const cause = new Error("cyclic launcher failure"); + cause.cause = cause; + const projectFailure = bindGhidraSessionFailure(() => ({})); + + const diagnostics = projectFailure( + "start", + "Ghidra launcher failed", + cause, + ).diagnostics; + + expect(JSON.stringify(diagnostics)).toContain('"cause_truncated":true'); +}); + +it("keeps bounded compiler and nested launcher outcomes in diagnostics", () => { + const compiler = new DarwinProcessOwnershipInspectionError( + "macOS process ownership helper compilation failed via xcrun (compiler result: 7)", + { + cause: Object.assign(new Error("compiler exited"), { code: 7 }), + compilerOutcome: { + outcome: "exited", + code: 7, + signal: null, + stderr: "SDK mismatch", + stderrBytes: 12, + stderrTruncated: false, + outputTruncated: false, + killed: false, + }, + }, + ); + const launch = new GhidraLaunchError("Ghidra headless launch failed", { + cause: compiler, + }); + const projectFailure = bindGhidraSessionFailure(() => ({})); + + expect( + projectFailure("start", "Ghidra launcher failed", launch).diagnostics, + ).toMatchObject({ + failure_cause: { + name: "GhidraLaunchError", + cause: { + name: "DarwinProcessOwnershipInspectionError", + compiler_outcome: { + outcome: "exited", + code: 7, + stderr: "SDK mismatch", + }, + cause: { code: 7 }, + }, + }, + }); + + const spawnFailure = Object.assign( + new Error("spawn analyzeHeadless ENOENT"), + { code: "ENOENT" }, + ); + const launchFailure = new GhidraLaunchError("Ghidra headless launch failed", { + cause: spawnFailure, + }); + expect( + projectFailure("start", "Ghidra launcher failed", launchFailure) + .diagnostics, + ).toMatchObject({ + failure_cause: { + name: "GhidraLaunchError", + cause: { code: "ENOENT" }, + }, + }); +}); + +it("bounds compiler stderr after explicit redaction at UTF-8 boundaries", () => { + const sensitiveValue = "caller-selected-secret"; + const projectFailure = bindGhidraSessionFailure( + () => ({}), + (value) => value.replaceAll(sensitiveValue, "replacement".repeat(100)), + ); + const stderr = `${"é".repeat(3900)}${sensitiveValue}`; + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler failed", + { + compilerOutcome: { + outcome: "exited", + code: 1, + signal: null, + stderr, + stderrBytes: Buffer.byteLength(stderr, "utf8"), + stderrTruncated: false, + outputTruncated: false, + killed: false, + }, + }, + ); + const failureCause = projectFailure( + "start", + "Ghidra launcher failed", + compiler, + ).diagnostics.failure_cause as { + readonly compiler_outcome: { + readonly stderr: string; + readonly stderrTruncated: boolean; + }; + }; + + expect( + Buffer.byteLength(failureCause.compiler_outcome.stderr, "utf8"), + ).toBeLessThanOrEqual(8 * 1024); + expect(failureCause.compiler_outcome.stderrTruncated).toBe(true); + expect(failureCause.compiler_outcome.stderr).not.toContain(sensitiveValue); + expect(failureCause.compiler_outcome.stderr).not.toContain("\uFFFD"); +}); diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index feaad5c50f..4b8bead7af 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -1,5 +1,12 @@ 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"; + +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; /** Stable failure phase for one private Ghidra headless session. */ export type GhidraSessionFailureKind = @@ -104,24 +111,121 @@ export const bindGhidraSessionFailure = const describeCause = ( cause: unknown, redact: (value: string) => string, + depth = 0, ): JsonValue => { if (cause instanceof Error) { - const code = "code" in cause ? cause.code : undefined; + return describeErrorCause(cause, redact, depth); + } + if (typeof cause === "string") { + const message = boundedUtf8Text(redact(cause), MAX_CAUSE_MESSAGE_BYTES); return { - name: redact(cause.name), - message: redact(cause.message), - ...(typeof code === "string" - ? { code: redact(code) } - : typeof code === "number" && Number.isFinite(code) - ? { code } - : {}), + type: "string", + message: message.text, + ...(message.truncated ? { message_truncated: true } : {}), }; } - if (typeof cause === "string") - return { type: "string", message: redact(cause) }; if (cause === null || typeof cause === "boolean") return { type: cause === null ? "null" : "boolean", value: cause }; if (typeof cause === "number" && Number.isFinite(cause)) return { type: "number", value: cause }; return { type: typeof cause }; }; + +const describeErrorCause = ( + cause: Error, + redact: (value: string) => string, + depth: number, +): JsonValue => { + const message = boundedUtf8Text( + redact(cause.message), + MAX_CAUSE_MESSAGE_BYTES, + ); + const name = boundedUtf8Text(redact(cause.name), MAX_CAUSE_FIELD_BYTES); + return { + name: name.text, + ...(name.truncated ? { name_truncated: true } : {}), + message: message.text, + ...(message.truncated ? { message_truncated: true } : {}), + ...causeCode(cause, redact), + ...causeSignal(cause, redact), + ...causeCompilerOutcome(cause, redact), + ...nestedErrorCause(cause, redact, depth), + }; +}; + +const causeCode = ( + cause: Error, + redact: (value: string) => string, +): Record => { + const code = "code" in cause ? cause.code : undefined; + if (typeof code === "string") { + const bounded = boundedUtf8Text(redact(code), MAX_CAUSE_FIELD_BYTES); + return { + code: bounded.text, + ...(bounded.truncated ? { code_truncated: true } : {}), + }; + } + if (typeof code === "number" && Number.isFinite(code)) return { code }; + return {}; +}; + +const causeSignal = ( + cause: Error, + redact: (value: string) => string, +): Record => { + const signal = "signal" in cause ? cause.signal : undefined; + if (typeof signal === "string") { + const bounded = boundedUtf8Text(redact(signal), MAX_CAUSE_FIELD_BYTES); + return { + signal: bounded.text, + ...(bounded.truncated ? { signal_truncated: true } : {}), + }; + } + if (signal === null) return { signal: null }; + return {}; +}; + +const causeCompilerOutcome = ( + cause: Error, + redact: (value: string) => string, +): Record => { + if (!(cause instanceof DarwinProcessOwnershipInspectionError)) return {}; + const outcome = cause.compilerOutcome; + if (outcome === undefined) return {}; + const stderr = boundedUtf8Text( + redact(outcome.stderr), + MAX_COMPILER_STDERR_BYTES, + ); + const code = boundedCompilerField(outcome.code, redact); + const signal = boundedCompilerField(outcome.signal, redact); + return { + compiler_outcome: { + ...outcome, + code: code.value, + signal: signal.value, + stderr: stderr.text, + stderrTruncated: outcome.stderrTruncated || stderr.truncated, + ...(code.truncated ? { codeTruncated: true } : {}), + ...(signal.truncated ? { signalTruncated: true } : {}), + }, + }; +}; + +const boundedCompilerField = ( + value: string | number | null, + redact: (value: string) => string, +): { readonly value: string | number | null; readonly truncated: boolean } => { + if (typeof value !== "string") return { value, truncated: false }; + const bounded = boundedUtf8Text(redact(value), MAX_CAUSE_FIELD_BYTES); + return { value: bounded.text, truncated: bounded.truncated }; +}; + +const nestedErrorCause = ( + cause: Error, + redact: (value: string) => string, + depth: number, +): Record => { + if (!("cause" in cause)) return {}; + if (depth >= MAX_CAUSE_DEPTH) return { cause_truncated: true }; + return { cause: describeCause(cause.cause, redact, depth + 1) }; +}; diff --git a/src/ghidra/extensions/GhidraExtensions.test.ts b/src/ghidra/extensions/GhidraExtensions.test.ts index 72bd6cf3d9..1fbe884cfb 100644 --- a/src/ghidra/extensions/GhidraExtensions.test.ts +++ b/src/ghidra/extensions/GhidraExtensions.test.ts @@ -149,7 +149,7 @@ describe("optional Ghidra extension boundary", () => { ); await expect( snapshotGhidraExtensions([extension], runtime), - ).rejects.toThrow(); + ).rejects.toMatchObject({ code: "EEXIST" }); }); it("rejects artifact changes after the profile commitment", async () => { const { extension, path } = await artifact(); @@ -174,15 +174,21 @@ describe("optional Ghidra extension boundary", () => { diagnostics: { path, extension: "nativeaot" }, }); } - expect( - ( - await resolveGhidraExtensions( - config(join(directory, "absent.jar")), - target, - "linux", - ) - ).ok, - ).toBe(false); + const absentPath = join(directory, "absent.jar"); + const absent = await resolveGhidraExtensions( + config(absentPath), + target, + "linux", + ); + expect(absent.ok).toBe(false); + if (!absent.ok) + expect(absent.error).toMatchObject({ + diagnostics: { + path: absentPath, + extension: "nativeaot", + reason: expect.stringContaining("ENOENT"), + }, + }); }); it.skipIf(process.platform === "win32")( "rejects a FIFO without requiring another process to open its writer", @@ -250,24 +256,30 @@ describe("Ghidra extension profile and producer validation", () => { it("requires exact inventory, artifact digest and consistent producer status", async () => { const { extension } = await artifact(); const valid = result(extension.sha256); + const inventoryMismatch = + "Ghidra extension result inventory differs from its committed profile."; expect(validateGhidraExtensionResults([extension], [valid])).toBeNull(); - expect(validateGhidraExtensionResults([extension], [])).not.toBeNull(); - expect(validateGhidraExtensionResults([], [valid])).not.toBeNull(); - expect( - validateGhidraExtensionResults([extension], [valid, valid]), - ).not.toBeNull(); + expect(validateGhidraExtensionResults([extension], [])).toBe( + inventoryMismatch, + ); + expect(validateGhidraExtensionResults([], [valid])).toBe(inventoryMismatch); + expect(validateGhidraExtensionResults([extension], [valid, valid])).toBe( + inventoryMismatch, + ); expect( validateGhidraExtensionResults( [extension], [{ ...valid, sha256: "b".repeat(64) }], ), - ).not.toBeNull(); + ).toBe("Ghidra extension identity mismatch: nativeaot"); expect( validateGhidraExtensionResults( [extension], [{ ...valid, status: "complete" }], ), - ).not.toBeNull(); + ).toBe( + "NativeAOT extension returned a malformed or inconsistent producer report.", + ); }); it("requires matching explicit configuration for imported executable profiles", async () => { const { extension, path } = await artifact(); @@ -281,7 +293,9 @@ describe("Ghidra extension profile and producer validation", () => { ).toBeNull(); expect( validateGhidraExtensionProfile([extension], config(), target, "linux"), - ).not.toBeNull(); + ).toBe( + "Configured Ghidra extension inventory differs from the resolved analysis profile.", + ); expect( validateGhidraExtensionProfile( [extension], @@ -289,7 +303,9 @@ describe("Ghidra extension profile and producer validation", () => { target, "darwin", ), - ).not.toBeNull(); + ).toBe( + "NativeAOT recovery is verified on Linux only; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis.", + ); expect( validateGhidraExtensionProfile( [{ ...extension, entry_class: "arbitrary.Code" }], @@ -297,7 +313,9 @@ describe("Ghidra extension profile and producer validation", () => { target, "linux", ), - ).not.toBeNull(); + ).toBe( + "Ghidra extension profile lacks matching explicit local configuration: nativeaot", + ); expect( validateGhidraExtensionProfile( [{ ...extension, configured_path: "/another.jar" }], @@ -305,7 +323,9 @@ describe("Ghidra extension profile and producer validation", () => { target, "linux", ), - ).not.toBeNull(); + ).toBe( + "Ghidra extension profile lacks matching explicit local configuration: nativeaot", + ); }); it("does not classify PE/CLI or ReadyToRun as NativeAOT", async () => { const resolved = await resolveGhidraExtensions( @@ -389,9 +409,9 @@ describe("Ghidra extension recovery coverage", () => { }, }, }; - expect( - validateGhidraExtensionResults([extension], [incomplete]), - ).not.toBeNull(); + expect(validateGhidraExtensionResults([extension], [incomplete])).toBe( + "NativeAOT recovery coverage contradicts its reported completion status.", + ); expect( validateGhidraExtensionResults( [extension], @@ -409,7 +429,9 @@ describe("Ghidra extension recovery coverage", () => { [extension], [{ ...positive, result: { ...positive.result, discovery: null } }], ), - ).not.toBeNull(); + ).toBe( + "NativeAOT extension returned a malformed or inconsistent producer report.", + ); }); it("preserves real loader failures without requiring a successful analyzer report", async () => { const { extension } = await artifact(); @@ -439,7 +461,9 @@ describe("Ghidra extension recovery coverage", () => { [extension], [{ ...incomplete, status: "complete", reason: null }], ), - ).not.toBeNull(); + ).toBe( + "NativeAOT recovery omitted its supported format, method-table inventory or derived-memory identity.", + ); }); it.each(["not_applicable", "unsupported", "failed"] as const)( "rejects contradictory %s reports without discarding discovery context", diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 3eb2ae81be..3e1c3a90f5 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -61,6 +61,7 @@ const DYLIB_FIELDS = [ ]; // otool prints options for dylib_use_command and a timestamp for dylib_command. const DYLIB_ENCODING_FIELDS = ["time stamp", "options"]; +const EMPTY_VALUE_FIELDS = new Set(["options", "segname", "sectname"]); /** * otool emits LF, but a capture replayed from another host or toolchain can @@ -201,11 +202,12 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { if (!Number.isSafeInteger(index)) throw new TypeError("otool load-command index is unsafe"); const header = block.split(/\n\s*Section\n/u)[0] ?? block; - const fields = parseFields(header); + const parsed = parseFields(header); + const fields = parsed.values; const kind = stringField(fields, "cmd"); if (kind === null) throw new TypeError("otool load command is missing cmd"); - for (const key of scalarFields(kind)) requireUniqueField(header, key); - if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(header, fields); + for (const key of scalarFields(kind)) requireUniqueField(parsed, key); + if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(parsed); state.commands.push({ index, kind, @@ -218,23 +220,18 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { collectSegment(kind, block, fields, state); }; -const validateDylibCommandLines = ( - block: string, - fields: Readonly>, -): void => { +const validateDylibCommandLines = (parsed: ParsedFields): void => { const encodingFields = DYLIB_ENCODING_FIELDS.filter((key) => - Object.hasOwn(fields, key), + Object.hasOwn(parsed.values, key), ); if (encodingFields.length !== 1) throw new TypeError( "otool dylib command must contain timestamp or options", ); - for (const key of encodingFields) requireUniqueField(block, key); + for (const key of encodingFields) requireUniqueField(parsed, key); const allowed = new Set([...DYLIB_FIELDS, ...DYLIB_ENCODING_FIELDS]); - for (const rawLine of block.split("\n").slice(1)) { - const line = rawLine.trim(); - if (line.length === 0) continue; - const key = parseField(line)?.[0]; + for (const field of parsed.rows) { + const key = field?.[0]; if (key === undefined || !allowed.has(key)) throw new TypeError( "otool dylib command contains ambiguous multiline text", @@ -343,10 +340,18 @@ const collectSegment = ( state.segments.push(segment); }; -const parseFields = (block: string): Record => { +interface ParsedFields { + readonly values: Record; + readonly rows: readonly (readonly [string, string] | undefined)[]; +} + +const parseFields = (block: string): ParsedFields => { const fields: Record = {}; + const rows: Array = []; for (const rawLine of block.split(/\r?\n/u).slice(1)) { + if (rawLine.trim().length === 0) continue; const field = parseField(rawLine); + rows.push(field); if (field === undefined) continue; const [key, value] = field; fields[key] = @@ -354,32 +359,25 @@ const parseFields = (block: string): Record => { ? value : (numeric(value) ?? value); } - return fields; + return { values: fields, rows }; }; const parseField = (rawLine: string): readonly [string, string] | undefined => { const line = rawLine.trim(); - // A dylib-use command with no flags still has an empty options row. - // Segment and section names are fixed char[16] fields that may be all NUL; - // MH_OBJECT files put their sections in a segment with an empty segname. - if (line === "options" || line === "segname" || line === "sectname") - return [line, ""]; // Match time stamp as a whole without swallowing `name stamp plugin.dylib`. - const match = /^((?:time stamp)|\S+(?:\s+version)?)\s+(.+)$/u.exec(line); - return match?.[1] === undefined || match[2] === undefined + const match = /^((?:time stamp)|\S+(?:\s+version)?)(?:\s+(.+))?$/u.exec(line); + return match?.[1] === undefined ? undefined - : [match[1], match[2].trim()]; + : [match[1], match[2]?.trim() ?? ""]; }; -const requireUniqueField = (block: string, key: string): void => { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const pattern = new RegExp(`^\\s*${escaped}(?:\\s|$)`, "mu"); - const lines = block.split("\n").filter((line) => pattern.test(line)); - if (lines.length !== 1) +const requireUniqueField = (parsed: ParsedFields, key: string): void => { + const matches = parsed.rows.filter((field) => field?.[0] === key); + const field = matches[0]; + if (matches.length !== 1 || field === undefined) throw new TypeError(`otool field ${key} is missing or repeated`); - // parseField owns which labeled lines carry a value; a bare required label - // is truncated output, except the empty dylib-use options row it admits. - if (parseField(lines[0] ?? "")?.[0] !== key) + // Only options and fixed-width Mach-O names may legitimately be empty. + if (field[1] === "" && !EMPTY_VALUE_FIELDS.has(key)) throw new TypeError(`otool field ${key} has no value`); }; @@ -402,7 +400,8 @@ const parseSections = (block: string): ParsedSection[] => .split(/(?=\n\s*Section\n)/u) .slice(1) .map((sectionBlock) => { - const fields = parseFields(`Section${sectionBlock}`); + const parsed = parseFields(`Section${sectionBlock}`); + const fields = parsed.values; for (const key of [ "sectname", "segname", @@ -412,7 +411,7 @@ const parseSections = (block: string): ParsedSection[] => "align", "flags", ]) - requireUniqueField(sectionBlock, key); + requireUniqueField(parsed, key); return { segment: nonEmptyName(stringField(fields, "segname")), name: nonEmptyName(stringField(fields, "sectname")), diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 69304b0636..3fbb44a6ae 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -6,11 +6,8 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import { safeParseJson } from "../domain/safeJson.js"; -import { - execFileOutput, - execFileOutputFailure, - type ExecFileOutputFailure, -} from "./ExecFileOutput.js"; +import { boundedUtf8Text } from "./boundedUtf8Text.js"; +import { execFileOutput, execFileOutputFailure } from "./ExecFileOutput.js"; import type { ProcessIdentityObservation, ProcessRunTokenObservation, @@ -82,42 +79,118 @@ const COMPILER_TIMEOUT_MS = 60_000; /** Bound on compiler diagnostics embedded in the inspection failure text. */ const STDERR_EXCERPT_LIMIT = 512; -/** Name the observed compiler outcome without implying an exit code it lacks. */ -const compilerOutcome = ( - code: string | number | null, - failure: ExecFileOutputFailure | undefined, - timeoutMs: number, -): string => { - if (typeof code === "number") return `exit code ${String(code)}`; - if (code === "ETIMEDOUT") return `timed out after ${String(timeoutMs)} ms`; - if (code === null) - return typeof failure?.signal === "string" - ? `stopped by signal ${failure.signal}` - : "unknown"; - return code; -}; - -const compilerDiagnostics = ( - failure: ExecFileOutputFailure | undefined, - cause: unknown, -): string => { - if (failure === undefined) - return cause instanceof Error ? cause.message : String(cause); - const diagnostics = failure.stderr.trim(); - if (diagnostics === "") return "no compiler diagnostics were captured"; - return diagnostics.length > STDERR_EXCERPT_LIMIT || failure.outputTruncated - ? `${diagnostics.slice(0, STDERR_EXCERPT_LIMIT)}… [diagnostics truncated]` - : diagnostics; -}; - /** The native Darwin process-ownership reader could not be prepared. */ export class DarwinProcessOwnershipInspectionError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); + constructor( + message: string, + options?: ErrorOptions & { + readonly compilerOutcome?: DarwinCompilerOutcome; + }, + ) { + const { compilerOutcome, ...errorOptions } = options ?? {}; + super(message, errorOptions); this.name = "DarwinProcessOwnershipInspectionError"; + this.compilerOutcome = compilerOutcome; } + + readonly compilerOutcome: DarwinCompilerOutcome | undefined; } +/** Explicit, bounded outcome from compiling REA's Darwin ownership helper. */ +export interface DarwinCompilerOutcome { + readonly outcome: + | "exited" + | "signaled" + | "timed_out" + | "spawn_failed" + | "failed"; + readonly code: string | number | null; + readonly signal: string | null; + readonly stderr: string; + readonly stderrBytes: number; + readonly stderrTruncated: boolean; + readonly outputTruncated: boolean; + readonly killed: boolean; + readonly timeoutMs?: number; +} + +const MAX_COMPILER_STDERR_BYTES = 8 * 1024; + +const compilerOutcome = ( + cause: unknown, + timeoutMs: number, +): DarwinCompilerOutcome | undefined => { + const failure = execFileOutputFailure(cause); + if (failure === undefined) return undefined; + const stderr = boundedUtf8Text(failure.stderr, MAX_COMPILER_STDERR_BYTES); + const outcome = + failure.code === "ETIMEDOUT" + ? "timed_out" + : typeof failure.code === "number" + ? "exited" + : failure.signal !== null + ? "signaled" + : failure.code === "ENOENT" + ? "spawn_failed" + : "failed"; + return { + outcome, + code: failure.code, + signal: failure.signal, + stderr: stderr.text, + stderrBytes: Buffer.byteLength(failure.stderr, "utf8"), + stderrTruncated: stderr.truncated, + outputTruncated: failure.outputTruncated, + killed: failure.killed, + ...(outcome === "timed_out" ? { timeoutMs } : {}), + }; +}; + +const compilerOutcomeDescription = ( + outcome: DarwinCompilerOutcome | undefined, + fallbackCode: string | number, +): string => { + if (outcome === undefined) return `compiler result: ${String(fallbackCode)}`; + if (outcome.outcome === "exited") + return `compiler result: exit code ${String(outcome.code)}`; + if (outcome.outcome === "timed_out") + return `compiler result: timed out after ${String(outcome.timeoutMs)} ms`; + if (outcome.outcome === "signaled") + return `compiler result: stopped by signal ${outcome.signal ?? "an unknown signal"}`; + return `compiler result: ${String(outcome.code)}`; +}; + +const compilerInspectionError = ( + cause: unknown, + timeoutMs: number, +): DarwinProcessOwnershipInspectionError => { + const outcome = compilerOutcome(cause, timeoutMs); + const code = outcome?.code ?? "unknown"; + const description = compilerOutcomeDescription(outcome, code); + const prefix = + code === "ENOENT" + ? "macOS process ownership inspection requires the Apple Swift compiler via xcrun" + : "macOS process ownership helper compilation failed via xcrun"; + const stderr = boundedUtf8Text(outcome?.stderr ?? "", STDERR_EXCERPT_LIMIT); + const diagnostics = stderr.text.trim(); + const truncated = + stderr.truncated || + outcome?.stderrTruncated === true || + outcome?.outputTruncated === true; + const suffix = diagnostics + ? `: ${diagnostics}${truncated ? "… [diagnostics truncated]" : ""}` + : outcome === undefined + ? `: ${cause instanceof Error ? cause.message : String(cause)}` + : ": no compiler diagnostics were captured"; + return new DarwinProcessOwnershipInspectionError( + `${prefix} (${description})${suffix}`, + { + cause, + ...(outcome === undefined ? {} : { compilerOutcome: outcome }), + }, + ); +}; + interface ProcessReaderCompilation { readonly controller: AbortController; promise: Promise; @@ -223,21 +296,7 @@ export const createDarwinProcessRunTokenReader = ( ); } catch (cause: unknown) { if (isExpectedAbort(cause, signal)) throw cause; - const failure = execFileOutputFailure(cause); - // A parsed failure carries its own null code (a signaled compiler); - // only an unparsed cause falls back to the raw error property. - const code = - failure !== undefined - ? failure.code - : cause instanceof Error && "code" in cause - ? String(cause.code) - : "unknown"; - throw new DarwinProcessOwnershipInspectionError( - code === "ENOENT" - ? `macOS process ownership inspection requires the Apple Swift compiler via xcrun (compiler result: ${String(code)})` - : `macOS process ownership helper compilation failed via xcrun (compiler result: ${compilerOutcome(code, failure, compilerTimeoutMs)}): ${compilerDiagnostics(failure, cause)}`, - { cause }, - ); + throw compilerInspectionError(cause, compilerTimeoutMs); } if (signal.aborted || closed) throw abortReason(signal); executable = output; diff --git a/src/process/boundedUtf8Text.ts b/src/process/boundedUtf8Text.ts new file mode 100644 index 0000000000..5915beb65f --- /dev/null +++ b/src/process/boundedUtf8Text.ts @@ -0,0 +1,16 @@ +/** Keep a UTF-8 string within a byte budget without cutting a code point. */ +export const boundedUtf8Text = ( + value: string, + maximumBytes: number, +): { readonly text: string; readonly truncated: boolean } => { + let bytes = 0; + let end = 0; + for (const codePoint of value) { + const codePointBytes = Buffer.byteLength(codePoint, "utf8"); + if (bytes + codePointBytes > maximumBytes) + return { text: value.slice(0, end), truncated: true }; + bytes += codePointBytes; + end += codePoint.length; + } + return { text: value, truncated: false }; +}; diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index f618b8969a..7cf6f27055 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -1,11 +1,14 @@ import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js"; -import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "../../domain/analysisErrorBase.js"; import type { PartialProcessCaptureObservation, ProcessCaptureCleanupReport, } from "../../domain/process/processCapture.js"; -interface ProcessCaptureErrorOptions extends ErrorOptions { +interface ProcessCaptureErrorOptions extends AnalysisErrorOptions { readonly userMessage?: string; readonly userCategory?: "cancelled"; readonly reason?: "capture_failed" | "cleanup_incomplete" | "cancelled"; @@ -69,8 +72,28 @@ export const normalizeCaptureFailure = ( signal: AbortSignal | undefined, ): unknown => { if (cause instanceof ProcessCaptureError) return cause; - if (cause instanceof DarwinProcessOwnershipInspectionError) - return new ProcessCaptureError(cause.message, { cause }); + if (cause instanceof DarwinProcessOwnershipInspectionError) { + const compiler = cause.compilerOutcome; + return new ProcessCaptureError(cause.message, { + cause, + ...(compiler === undefined + ? {} + : { + capturedOutput: { + stdout: "", + stderr: compiler.stderr, + truncated: compiler.stderrTruncated || compiler.outputTruncated, + stderr_bytes: compiler.stderrBytes, + exit_code: + compiler.outcome === "exited" && + typeof compiler.code === "number" + ? compiler.code + : null, + signal: compiler.signal, + }, + }), + }); + } if ( signal?.aborted === true && (cause === signal.reason || diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index c1bdb67b27..db492df83f 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -27,7 +27,11 @@ import { DarwinProcessOwnershipInspectionError, } from "../../../src/process/DarwinProcessRunTokenReader.js"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; -import { normalizeCaptureFailure } from "../../../src/process/capture/ProcessCaptureError.js"; +import { + normalizeCaptureFailure, + ProcessCaptureError, +} from "../../../src/process/capture/ProcessCaptureError.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { PlaywrightElectronActiveProvider } from "../../../src/browser/PlaywrightElectronActiveProvider.js"; import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; @@ -191,14 +195,39 @@ it.skipIf(process.platform === "win32")( const executable = join(directory, "failing-xcrun"); await writeFile( executable, - '#!/usr/bin/env node\nprocess.stderr.write("module cache is not writable\\n"); process.exitCode = 1;\n', + '#!/usr/bin/env node\nprocess.stderr.write("module cache is not writable\\n" + "x".repeat(12000)); process.exitCode = 1;\n', ); await chmod(executable, 0o755); const reader = createDarwinProcessRunTokenReader({ xcrun: executable }); try { - await expect(preparationFailure(reader)).resolves.toMatchObject({ - message: - "macOS process ownership helper compilation failed via xcrun (compiler result: exit code 1): module cache is not writable", + const failure = await preparationFailure(reader); + expect(failure.compilerOutcome).toMatchObject({ + outcome: "exited", + code: 1, + signal: null, + stderr: expect.stringMatching(/^module cache is not writable\n/u), + stderrTruncated: true, + killed: false, + }); + expect(failure.message).toContain( + "(compiler result: exit code 1): module cache is not writable", + ); + expect(failure.message).toContain("[diagnostics truncated]"); + const normalized = normalizeCaptureFailure(failure, undefined); + if (!(normalized instanceof ProcessCaptureError)) + throw new Error("Expected a normalized process capture error"); + expect(projectAnalysisError(normalized)).toMatchObject({ + code: "process_capture_failed", + details: { + execution_failure: expect.stringContaining( + "compiler result: exit code 1", + ), + captured_output: { + stderr: expect.stringContaining("module cache is not writable"), + exit_code: 1, + truncated: true, + }, + }, }); } finally { await reader.close(); @@ -227,6 +256,11 @@ it.skipIf(process.platform === "win32")( await expect(preparationFailure(reader)).resolves.toMatchObject({ message: "macOS process ownership helper compilation failed via xcrun (compiler result: timed out after 3000 ms): error: PCH was compiled with module cache path that differs", + compilerOutcome: { + outcome: "timed_out", + code: "ETIMEDOUT", + timeoutMs: 3_000, + }, }); } finally { await reader.close(); @@ -251,6 +285,11 @@ it.skipIf(process.platform === "win32")( await expect(preparationFailure(reader)).resolves.toMatchObject({ message: "macOS process ownership helper compilation failed via xcrun (compiler result: stopped by signal SIGSEGV): fatal: segmentation fault during type checking", + compilerOutcome: { + outcome: "signaled", + code: null, + signal: "SIGSEGV", + }, }); } finally { await reader.close(); @@ -268,16 +307,25 @@ it.skipIf(process.platform === "win32")( const diagnostic = "x".repeat(600); await writeFile( executable, - `#!/usr/bin/env node\nprocess.stderr.write(${JSON.stringify(`${diagnostic}\\n`)}); process.exitCode = 1;\n`, + `#!/usr/bin/env node\nprocess.stderr.write(${JSON.stringify(`${diagnostic}\n`)}); process.exitCode = 1;\n`, ); await chmod(executable, 0o755); const reader = createDarwinProcessRunTokenReader({ xcrun: executable }); try { const failure = await preparationFailure(reader); - expect(failure.message).toContain("compiler result: exit code 1"); + const outcome = failure.compilerOutcome; + if (outcome === undefined) throw new Error("Expected compiler outcome"); + expect(outcome.stderrBytes).toBe(601); + expect(outcome.stderrTruncated).toBe(false); expect(failure.message).toContain( `: ${"x".repeat(512)}… [diagnostics truncated]`, ); + const excerpt = failure.message.split("): ")[1]?.split("…")[0]; + expect(excerpt).toBe("x".repeat(512)); + expect(Buffer.byteLength(excerpt ?? "", "utf8")).toBeLessThanOrEqual(512); + expect(Buffer.byteLength(outcome.stderr, "utf8")).toBeLessThanOrEqual( + 8 * 1024, + ); } finally { await reader.close(); } diff --git a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts index 134db430bd..ea81dacaa7 100644 --- a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts +++ b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts @@ -48,6 +48,39 @@ it("rejects a repeated options field even when the duplicate has no value", asyn expect(() => parseOtoolLoadCommands(output)).toThrow(/otool/u); }); +it.each([ + "vmaddr", + "vmsize", + "uuid", + "entryoff", + "addr", + "current version", + "time stamp", +])( + "rejects a required %s label with no value and retains its producer output", + async (field) => { + const original = await nativeFixture("otool-load.txt"); + const output = original.replace( + new RegExp(`^([ \\t]*${field})[ \\t]+\\S[^\\n]*$`, "mu"), + "$1", + ); + if (output === original) throw new Error(`Fixture has no ${field} value`); + const runner = new NativeFixtureRunner({ "otool:-h": output }); + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + nativeMachoTarget("/owned/fixture"), + ); + const result = await client.execute("inspect_macho", {}); + if (result.ok) throw new Error(`Expected missing ${field} value rejection`); + expect(result.error).toMatchObject({ + _tag: "AnalysisOutputError", + capturedOutput: { stdout: output, stderr: "", truncated: false }, + }); + expect(result.error.cause).toMatchObject({ + message: expect.stringContaining(field), + }); + }, +); + it("collects the actual Mach header alongside the native load commands", async () => { // The provider always invokes `otool -h -l`, which the runner resolves // via the "otool:-h" key; no bare `otool` override is needed.