mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
* fix(rizin): keep debug targets off the debugger command stream A Rizin debug target inherited Rizin's stdin, which is REA's command stream. Once resumed, a target that reads stdin consumed REA's commands, so close returned cleanup_incomplete and the one-shot CLI hung. Route the target's stdin to the caller-selected TTY alongside stdout and stderr. Before detaching, refuse a target whose stdin is still the command stream, so a Rizin or backend that ignores the key gets a specific, retained failure instead of a resumed target and a timeout. Fixes #8642 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(rizin): keep the stdin test launcher's descriptors under dash The launcher gated its target with `cat "$2" >/dev/null`. dash applies a simple command's redirection to the shell itself until the command returns, so on Ubuntu the waiting shell's fd 1 was /dev/null, and that shell is the target REA reads in /proc. All 8 Linux-gated tests then failed the existing output-descriptor check. macOS /bin/sh is bash, which redirects only in the forked child, so this did not show locally. Gate without any redirection. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(rizin): reject unmodeled commands and own cleanup at acquisition Address review of the stdin regression tests. The launcher accepted any unknown line, so the marker that followed could complete it, and assertions only checked that commands were present. An added `dc` or a repeated `olj` still passed. It now records an unknown line and exits, and the tests pin the exact command sequence. Cleanup hung off module state assigned after setup finished, so a failed setup left its directory and terminal child behind. Each resource now registers its own finish hook when acquired, using createTestTempDirectory. Success checks throw the typed error instead of asserting `.ok`, and the refusal probe no longer sits inside a loop and conditional. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
import { z } from "incur";
|
|
import type { Logger } from "pino";
|
|
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
|
|
|
|
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import type { Result } from "../domain/result.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
OBJDUMP_ARTIFACT_OPERATIONS,
|
|
reverseCommandTimeoutSchema,
|
|
} from "../domain/reverseEngineering.js";
|
|
import { logCliCommand } from "../cliLogging.js";
|
|
import type { CliInstance } from "./types.js";
|
|
import { withCommandCancellation } from "./commandCancellation.js";
|
|
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
|
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
|
import { gdbOneShotCommandMayInvalidateDetachProof } from "../gdb/GdbSessionManager.js";
|
|
import {
|
|
createGdbSessionManager,
|
|
createRizinDebugSessionManager,
|
|
} from "../composition/debuggerSessions.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import {
|
|
retainedDebuggerDiagnostics,
|
|
retainedDebuggerProcessIds,
|
|
retainedDebuggerRemediation,
|
|
} from "../process/ownedDebuggerCleanupError.js";
|
|
import {
|
|
analysisEvidenceDeliveryOptions,
|
|
cliEvidenceDeliveryInputError,
|
|
deliverCliAnalysisEvidence,
|
|
publishCliAnalysisEvidence,
|
|
} from "./analysisEvidenceDelivery.js";
|
|
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
|
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
|
|
|
const executeWithCleanup = async (
|
|
service: ReverseEngineeringService,
|
|
signal: AbortSignal,
|
|
execute: () => Promise<Result<Evidence, AnalysisError>>,
|
|
): Promise<Result<Evidence, AnalysisError>> => {
|
|
let result: Result<Evidence, AnalysisError>;
|
|
try {
|
|
result = await execute();
|
|
} catch (cause) {
|
|
await service.close().catch(() => undefined);
|
|
throw cause;
|
|
}
|
|
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
|
|
try {
|
|
await service.close();
|
|
} catch (cause) {
|
|
if (result.ok) return err(asCleanupError(cause));
|
|
const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined;
|
|
const error = new AnalysisOutputError(
|
|
"reverse_engineering_cleanup",
|
|
"Provider cleanup remains incomplete after the command failed.",
|
|
{
|
|
cause: result.error,
|
|
...(cleanup === undefined ? {} : { cleanup }),
|
|
...(result.error.capturedOutput === undefined
|
|
? {}
|
|
: { capturedOutput: result.error.capturedOutput }),
|
|
...(result.error.partialObservation === undefined
|
|
? {}
|
|
: { partialObservation: result.error.partialObservation }),
|
|
},
|
|
);
|
|
return err(error);
|
|
}
|
|
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
|
|
const evidence = cleanupOnlyCompleteEvidence(result.error);
|
|
if (evidence !== undefined)
|
|
return cancelledAfterCleanup({ ok: true, value: evidence }, signal);
|
|
}
|
|
return cancelledAfterCleanup(result, signal);
|
|
};
|
|
|
|
/** Late cancellation observed during finalization keeps the completed Evidence as partial data. */
|
|
const cancelledAfterCleanup = (
|
|
result: Result<Evidence, AnalysisError>,
|
|
signal: AbortSignal,
|
|
): Result<Evidence, AnalysisError> =>
|
|
result.ok && signal.aborted
|
|
? err(
|
|
new AnalysisCancelledError(result.value.operation, {
|
|
cause: signal.reason,
|
|
partialObservation: result.value,
|
|
}),
|
|
)
|
|
: result;
|
|
|
|
const cleanupOnlyCompleteEvidence = (
|
|
error: AnalysisError,
|
|
): Evidence | undefined => {
|
|
if (
|
|
!(error instanceof AnalysisOutputError) ||
|
|
!error.cleanupOnly ||
|
|
error.partialObservation === undefined
|
|
)
|
|
return undefined;
|
|
const parsed = evidenceSchema.safeParse(error.partialObservation);
|
|
if (!parsed.success) return undefined;
|
|
const normalized = parsed.data.normalized_result;
|
|
return typeof normalized === "object" &&
|
|
normalized !== null &&
|
|
!Array.isArray(normalized) &&
|
|
normalized.completion_status === "complete"
|
|
? parsed.data
|
|
: undefined;
|
|
};
|
|
|
|
const asCleanupError = (cause: unknown): AnalysisError =>
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new AnalysisOutputError(
|
|
"reverse_engineering_cleanup",
|
|
"Provider process cleanup failed.",
|
|
{ cause },
|
|
);
|
|
|
|
/** One-shot debugger cleanup: the retained owner's closeAll decides whether cleanup remains incomplete. */
|
|
const closeDebuggerCommandSession = async (
|
|
manager: {
|
|
close(sessionId: string): Promise<Result<unknown, AnalysisError>>;
|
|
closeAll(): Promise<void>;
|
|
},
|
|
sessionId: string | undefined,
|
|
providerId: string,
|
|
operation: string,
|
|
log: (cleanup: JsonValue | string) => void,
|
|
): Promise<void> => {
|
|
if (sessionId !== undefined) {
|
|
try {
|
|
const closed = await manager.close(sessionId);
|
|
if (!closed.ok) log(projectAnalysisError(closed.error));
|
|
} catch (cause) {
|
|
log(cause instanceof Error ? cause.message : "Unknown cleanup failure");
|
|
}
|
|
}
|
|
try {
|
|
await manager.closeAll();
|
|
} catch (cause) {
|
|
// This process is the only owner, so a caller gets the debugger to check
|
|
// rather than a retry that no later command could perform.
|
|
const retainedPids = retainedDebuggerProcessIds(cause);
|
|
throw new ProviderCleanupError(
|
|
providerId,
|
|
sessionId === undefined ? [] : [sessionId],
|
|
{
|
|
reason:
|
|
cause instanceof AggregateError
|
|
? cause.errors
|
|
.map((error: unknown) =>
|
|
error instanceof Error ? error.message : String(error),
|
|
)
|
|
.join("; ")
|
|
: cause instanceof Error
|
|
? cause.message
|
|
: String(cause),
|
|
...retainedDebuggerDiagnostics(cause),
|
|
},
|
|
{
|
|
cause,
|
|
operation,
|
|
...(retainedPids.length === 0
|
|
? {}
|
|
: { remediationAction: retainedDebuggerRemediation(retainedPids) }),
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Add one-shot reverse-engineering commands using the shared service layer. */
|
|
export const registerReverseEngineeringCommands = (
|
|
cli: CliInstance,
|
|
logger: Logger,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
createGdbManager: (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
) => ReturnType<typeof createGdbSessionManager> = createGdbSessionManager,
|
|
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
|
|
): void => {
|
|
const service = createService(environment);
|
|
cli.command(CLI_COMMANDS.inspectWithObjdump, {
|
|
description:
|
|
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
|
|
args: z.object({
|
|
path: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Path to the local artifact to inspect."),
|
|
operation: z
|
|
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
|
.describe("GNU-style objdump operation to run."),
|
|
}),
|
|
options: z.object({
|
|
...analysisEvidenceDeliveryOptions,
|
|
symbol: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Native symbol to select with the disassemble operation; the selected executable controls matching and boundaries.",
|
|
),
|
|
followDebugLinks: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe(
|
|
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
|
|
),
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
|
|
const delivery = {
|
|
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
|
|
operation: "inspect_with_objdump",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined) return projectAnalysisError(inputError);
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.inspectWithObjdump(
|
|
{
|
|
...args,
|
|
follow_debug_links: options.followDebugLinks,
|
|
...(options.symbol === undefined
|
|
? {}
|
|
: { symbol: options.symbol }),
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok
|
|
? publishCliAnalysisEvidence(
|
|
{ ok: true, value: result.value },
|
|
delivery,
|
|
)
|
|
: projectAnalysisError(result.error);
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
|
|
description: "Capture the selected objdump command response to -i",
|
|
args: z.object({}),
|
|
options: z.object({
|
|
...analysisEvidenceDeliveryOptions,
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(
|
|
logger,
|
|
CLI_COMMANDS.listObjdumpArchitectures,
|
|
async () => {
|
|
const delivery = {
|
|
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
|
|
operation: "inspect_with_objdump",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined)
|
|
return projectAnalysisError(inputError);
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.inspectWithObjdump(
|
|
{
|
|
operation: "architectures",
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok
|
|
? publishCliAnalysisEvidence(
|
|
{ ok: true, value: result.value },
|
|
delivery,
|
|
)
|
|
: projectAnalysisError(result.error);
|
|
},
|
|
),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.executeRizinCommand, {
|
|
description: "Execute one Rizin command against a local artifact",
|
|
args: z.object({
|
|
path: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Path to the local artifact to analyze."),
|
|
command: z.string().min(1).describe("One caller-selected Rizin command."),
|
|
}),
|
|
options: z.object({
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.executeRizinCommand(
|
|
{
|
|
...args,
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok ? result.value : projectAnalysisError(result.error);
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.debugWithGdb, {
|
|
description:
|
|
"Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached",
|
|
args: z.object({
|
|
command: z
|
|
.string()
|
|
.min(1)
|
|
.describe("One unrestricted GDB console command."),
|
|
}),
|
|
options: z.object({
|
|
inferiorTty: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"Caller-selected durable TTY for output from a live GDB inferior.",
|
|
),
|
|
...analysisEvidenceDeliveryOptions,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
|
|
const delivery = {
|
|
providerId: "gdb",
|
|
operation: "gdb_console",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined) return projectAnalysisError(inputError);
|
|
if (gdbOneShotCommandMayInvalidateDetachProof(args.command))
|
|
return projectAnalysisError(
|
|
new AnalysisInputError("gdb_console", undefined, [
|
|
{
|
|
path: ["command"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"This command can invalidate the verified inferior output route; use the persistent GDB session workflow.",
|
|
},
|
|
]),
|
|
);
|
|
const manager = createGdbManager(environment);
|
|
let sessionId: string | undefined;
|
|
return deliverCliAnalysisEvidence({
|
|
...delivery,
|
|
run: async () => {
|
|
const started = await manager.start(signal, {
|
|
inferiorTty: options.inferiorTty,
|
|
});
|
|
if (!started.ok) return err(started.error);
|
|
sessionId = started.value.session_id;
|
|
const executed = await manager.execute(
|
|
sessionId,
|
|
args.command,
|
|
signal,
|
|
);
|
|
return executed.ok
|
|
? ok(executed.value.evidence)
|
|
: err(executed.error);
|
|
},
|
|
service: {
|
|
close: () =>
|
|
closeDebuggerCommandSession(
|
|
manager,
|
|
sessionId,
|
|
"gdb",
|
|
"gdb_console",
|
|
(cleanup) =>
|
|
logger.error(
|
|
{ command: CLI_COMMANDS.debugWithGdb, cleanup },
|
|
"GDB cleanup failed after collecting command output",
|
|
),
|
|
),
|
|
},
|
|
});
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.debugWithRizin, {
|
|
description:
|
|
"Run one unrestricted Rizin debugger command in an ephemeral session",
|
|
args: z.object({
|
|
path: z.string().min(1).describe("Path to the local artifact to debug."),
|
|
command: z
|
|
.string()
|
|
.min(1)
|
|
.describe("One unrestricted Rizin debugger command.")
|
|
.refine((value) => !/[\r\n]/u.test(value)),
|
|
}),
|
|
options: z.object({
|
|
inferiorTty: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"Caller-selected Linux TTY for inferior stdin, stdout and stderr.",
|
|
),
|
|
backend: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Optional Rizin IO debugger backend name."),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => {
|
|
const manager = createRizinDebugSessionManager(environment);
|
|
let sessionId: string | undefined;
|
|
return finishAnalysisCommand(
|
|
async () => {
|
|
const started = await manager.start(
|
|
{
|
|
...args,
|
|
inferior_tty: options.inferiorTty,
|
|
...(options.backend === undefined
|
|
? {}
|
|
: { backend: options.backend }),
|
|
},
|
|
signal,
|
|
);
|
|
if (!started.ok) return err(started.error);
|
|
sessionId = started.value.session_id;
|
|
const executed = await manager.execute(
|
|
sessionId,
|
|
args.command,
|
|
signal,
|
|
);
|
|
return executed.ok
|
|
? ok(executed.value.evidence)
|
|
: err(executed.error);
|
|
},
|
|
{
|
|
close: () =>
|
|
closeDebuggerCommandSession(
|
|
manager,
|
|
sessionId,
|
|
"rizin",
|
|
"rizin_debug_command",
|
|
(cleanup) =>
|
|
logger.error(
|
|
{ command: CLI_COMMANDS.debugWithRizin, cleanup },
|
|
"Rizin cleanup failed after collecting command output",
|
|
),
|
|
),
|
|
},
|
|
"rizin",
|
|
"rizin_debug_command",
|
|
signal,
|
|
).then(projectCliAnalysisResult);
|
|
}),
|
|
),
|
|
});
|
|
};
|