fix(providers): retain native parsing and compiler failure diagnostics

Parse otool field occurrences once and reject valueless required fields while retaining empty dylib options. Preserve compiler exit outcomes and stderr through capture errors and nested Ghidra causes, with explicit UTF-8 truncation after redaction.

Strengthen provider failure assertions and document the manual Ghidra 12.1.4 macOS x64 decompiler build using upstream instructions.

Validation: focused producer tests, nine fake-compiler preparation and cancellation tests, temporary-directory hygiene, static and documentation checks. No real Swift or Ghidra compilation was run locally.
This commit is contained in:
morluto committed 2026-10-12 08:59:14 +08:00
1 parent aada0ca812
commit b5ededd0f9
11 files changed
+593 -134

No files matched your search

+32 -2
View File
@@ -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/<platform>/` or
`build/os/<platform>/`; 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.
+8 -2
View File
@@ -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([
+117
View File
@@ -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");
});
+114 -10
View File
@@ -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<string, JsonValue> => {
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<string, JsonValue> => {
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<string, JsonValue> => {
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<string, JsonValue> => {
if (!("cause" in cause)) return {};
if (depth >= MAX_CAUSE_DEPTH) return { cause_truncated: true };
return { cause: describeCause(cause.cause, redact, depth + 1) };
};
+50 -26
View File
@@ -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",
+32 -33
View File
@@ -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<Record<string, string | number | null>>,
): 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<string, string | number | null> => {
interface ParsedFields {
readonly values: Record<string, string | number | null>;
readonly rows: readonly (readonly [string, string] | undefined)[];
}
const parseFields = (block: string): ParsedFields => {
const fields: Record<string, string | number | null> = {};
const rows: Array<readonly [string, string] | undefined> = [];
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<string, string | number | null> => {
? 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")),
+109 -50
View File
@@ -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<string>;
@@ -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;
+16
View File
@@ -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 };
};
+27 -4
View File
@@ -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 ||
@@ -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();
}
@@ -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.