mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
1679 lines
58 KiB
TypeScript
1679 lines
58 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { isUtf8 } from "node:buffer";
|
|
import { open, readlink, realpath, stat } from "node:fs/promises";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { isatty } from "node:tty";
|
|
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisInputError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import type {
|
|
AnalysisError,
|
|
AnalysisErrorOptions,
|
|
} from "../domain/analysisErrorBase.js";
|
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
type SpawnedOwnedProviderProcess,
|
|
} from "../process/ProviderProcess.js";
|
|
import {
|
|
closeOwnedDebuggerSessions,
|
|
stopOwnedDebuggerProcess,
|
|
readLinuxProcessStartTime,
|
|
verifyOwnedInferiorsEnded,
|
|
type OwnedInferiorIdentity,
|
|
type OwnedDebuggerDetachResult,
|
|
} from "../process/ownedDebuggerSession.js";
|
|
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
|
|
|
const MAX_SESSIONS = 8;
|
|
const MAX_PENDING_COMMANDS = 16;
|
|
const FRAME_TIMEOUT_MS = 120_000;
|
|
const CLEANUP_COMMAND_TIMEOUT_MS = 1_000;
|
|
const STARTUP_TIMEOUT_MS = 30_000;
|
|
|
|
/** Fixed debugger deadline. Callers cannot raise it in this release. */
|
|
const fixedDeadlineGuidance = (
|
|
what: string,
|
|
timeoutMs: number,
|
|
): { readonly userMessage: string; readonly remediationAction: string } => ({
|
|
userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`,
|
|
remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
|
});
|
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
const MAX_QUEUED_FRAME_BYTES = 4 * 1024 * 1024;
|
|
const MAX_QUEUED_FRAMES = 4096;
|
|
const MIN_QUEUED_FRAME_CHARGE_BYTES = 64;
|
|
const MAX_COMMAND_FRAMES = 4096;
|
|
const MAX_RECENT_FRAME_BYTES = 16 * 1024;
|
|
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
|
|
export const RIZIN_DEBUGGER_PROVIDER_IDENTITY = {
|
|
id: "rizin.debugger",
|
|
name: "Rizin debugger",
|
|
version: null,
|
|
} as const;
|
|
|
|
interface RizinLaunchProvenance {
|
|
readonly caller_selected_path: string;
|
|
readonly resolved_path: string;
|
|
}
|
|
|
|
const RIZIN_LAUNCH_PROVENANCE_LIMITATION =
|
|
"Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.";
|
|
|
|
interface RizinCommandEvidenceInput {
|
|
readonly sessionId: string;
|
|
readonly command: string;
|
|
readonly result: RizinDebugCommandResult;
|
|
readonly inferiorTty: string;
|
|
readonly launchProvenance: RizinLaunchProvenance;
|
|
readonly limitations: readonly string[];
|
|
}
|
|
|
|
const createRizinCommandEvidence = ({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty,
|
|
launchProvenance,
|
|
limitations,
|
|
}: RizinCommandEvidenceInput): Evidence =>
|
|
createEvidence(undefined, RIZIN_DEBUGGER_PROVIDER_IDENTITY, {
|
|
predicateType: "rea.debugger.command-observation",
|
|
operation: "rizin_debug_command",
|
|
parameters: {
|
|
session_id: sessionId,
|
|
command,
|
|
inferior_tty: inferiorTty,
|
|
launch_provenance: { ...launchProvenance },
|
|
},
|
|
result: { ...result },
|
|
rawResult: { ...result },
|
|
subjectUnavailableReason:
|
|
"The current debugger target identity is not established by the Rizin session identifier.",
|
|
limitations: [
|
|
...limitations,
|
|
...(result.completion_status === "unknown"
|
|
? [
|
|
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
|
]
|
|
: []),
|
|
...(result.output_invalid_utf8
|
|
? [
|
|
"Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.",
|
|
]
|
|
: []),
|
|
RIZIN_LAUNCH_PROVENANCE_LIMITATION,
|
|
],
|
|
});
|
|
|
|
type RizinOutputLimit =
|
|
| "command_output_bytes"
|
|
| "frame_bytes"
|
|
| "command_frames"
|
|
| "queued_frames";
|
|
|
|
interface RizinCommandCapture {
|
|
readonly output: string;
|
|
readonly outputTruncated: boolean;
|
|
readonly outputInvalidUtf8: boolean;
|
|
/** Cap that first stopped this capture. Absent when output is complete. */
|
|
readonly outputLimit?: RizinOutputLimit;
|
|
}
|
|
|
|
interface RizinCommandInterruption {
|
|
readonly kind: "cancelled" | "exited" | "timeout" | "failed";
|
|
readonly cause: unknown;
|
|
readonly capture: RizinCommandCapture;
|
|
}
|
|
|
|
export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean =>
|
|
remainingMs <= 0;
|
|
|
|
interface RizinCommandTimeoutEvidenceInput {
|
|
readonly sessionId: string;
|
|
readonly command: string;
|
|
readonly result: RizinDebugCommandResult;
|
|
readonly inferiorTty: string;
|
|
readonly cleanupFailure: string | undefined;
|
|
readonly launchProvenance: RizinLaunchProvenance;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
export const createRizinCommandTimeoutEvidence = ({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty,
|
|
cleanupFailure,
|
|
launchProvenance,
|
|
timeoutMs = FRAME_TIMEOUT_MS,
|
|
}: RizinCommandTimeoutEvidenceInput): Evidence =>
|
|
createRizinCommandEvidence({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty,
|
|
launchProvenance,
|
|
limitations: [
|
|
`The command timed out before its completion marker at ${String(timeoutMs)} ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.`,
|
|
...(result.output_invalid_utf8
|
|
? [
|
|
"Collected command frame bytes did not form valid UTF-8; displayed output contains replacement characters.",
|
|
]
|
|
: []),
|
|
...(cleanupFailure === undefined
|
|
? [
|
|
"The owned debugger session was stopped because output correlation could no longer be guaranteed.",
|
|
]
|
|
: [
|
|
`The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`,
|
|
]),
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
],
|
|
});
|
|
|
|
interface RizinDebugSession {
|
|
readonly id: string;
|
|
readonly launched: SpawnedOwnedProviderProcess;
|
|
readonly supervisor: ProviderProcessSupervisor;
|
|
readonly frames: Array<{
|
|
readonly frame: RizinFrame;
|
|
readonly queueChargeBytes: number;
|
|
}>;
|
|
readonly frameWaiters: Array<{
|
|
readonly resolve: (frame: RizinFrame) => void;
|
|
readonly reject: (cause: Error) => void;
|
|
}>;
|
|
readonly backend: string | null;
|
|
readonly launchProvenance: RizinLaunchProvenance;
|
|
readonly inferiorTty: string;
|
|
readonly inferiorTtyDevice: number;
|
|
readonly inferiorTtyInode: number;
|
|
readonly knownInferiorIdentities: Map<string, OwnedInferiorIdentity>;
|
|
inferiorInventoryComplete: boolean;
|
|
inferiorStateUnknown: () => boolean;
|
|
verifyInferiorsEndedAfterExit: () => Promise<boolean>;
|
|
readonly detachInferior: () => Promise<OwnedDebuggerDetachResult>;
|
|
cleanupCommandTurnHeld: boolean;
|
|
stopAttempt: ReturnType<typeof stopOwnedDebuggerProcess> | undefined;
|
|
readonly cleanupFailures: Set<AnalysisError>;
|
|
pendingCommandCount: number;
|
|
invalidUtf8Observed: boolean;
|
|
closing: boolean;
|
|
readonly history: string[];
|
|
queuedFrameBytes: number;
|
|
historyTruncated: boolean;
|
|
frameChunks: Buffer[];
|
|
bufferedFrameBytes: number;
|
|
ready: boolean;
|
|
exited: boolean;
|
|
commandTail: Promise<void>;
|
|
protocolError: string | undefined;
|
|
commandCaptureActive: boolean;
|
|
commandOutputDropped: boolean;
|
|
discardingOversizedFrame: boolean;
|
|
}
|
|
|
|
interface RizinFrame {
|
|
readonly text: string;
|
|
readonly truncated: boolean;
|
|
readonly invalidUtf8: boolean;
|
|
}
|
|
|
|
class RizinProcessExitError extends Error {
|
|
constructor(message = "Rizin exited before completing the active command") {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
class RizinFrameTimeoutError extends Error {
|
|
constructor() {
|
|
super("Timed out waiting for the next Rizin NUL frame");
|
|
}
|
|
}
|
|
|
|
/** Owns persistent Rizin debugger processes independently from BinarySession. */
|
|
export class RizinDebugSessionManager {
|
|
readonly #sessions = new Map<string, RizinDebugSession>();
|
|
#activeStarts = 0;
|
|
#sessionReservations = 0;
|
|
readonly #environment: Readonly<NodeJS.ProcessEnv>;
|
|
#closingAll = false;
|
|
readonly #startDrainWaiters: Array<() => void> = [];
|
|
readonly #command: string;
|
|
readonly #frameTimeoutMs: number;
|
|
readonly #startupTimeoutMs: number;
|
|
readonly #maxCommandOutputBytes: number;
|
|
readonly #platform: NodeJS.Platform;
|
|
readonly #stopProcess: typeof stopOwnedDebuggerProcess;
|
|
readonly #spawnProcess: typeof spawnOwnedProviderProcess;
|
|
|
|
constructor(
|
|
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
options: {
|
|
readonly frameTimeoutMs?: number;
|
|
/** @internal Test seam; production callers should use the 30 s default. */
|
|
readonly startupTimeoutMs?: number;
|
|
/** @internal Test seam; production callers should use the 16 MiB default. */
|
|
readonly maxCommandOutputBytes?: number;
|
|
readonly platform?: NodeJS.Platform;
|
|
readonly stopProcess?: typeof stopOwnedDebuggerProcess;
|
|
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
|
|
} = {},
|
|
) {
|
|
this.#environment = { ...environment };
|
|
this.#command = environment.REA_RIZIN_COMMAND ?? "rizin";
|
|
this.#frameTimeoutMs = options.frameTimeoutMs ?? FRAME_TIMEOUT_MS;
|
|
this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS;
|
|
this.#maxCommandOutputBytes =
|
|
options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_BYTES;
|
|
this.#platform = options.platform ?? process.platform;
|
|
this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess;
|
|
this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess;
|
|
}
|
|
|
|
async start(
|
|
input: {
|
|
readonly path: string;
|
|
readonly inferior_tty: string;
|
|
readonly backend?: string;
|
|
},
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
Result<
|
|
{ readonly session_id: string; readonly backend: string | null },
|
|
AnalysisError
|
|
>
|
|
> {
|
|
if (this.#closingAll)
|
|
return err(new AnalysisInputError("start_rizin_debug_session"));
|
|
const occupied = this.#sessions.size + this.#sessionReservations;
|
|
if (occupied >= MAX_SESSIONS)
|
|
return err(
|
|
new AnalysisResourceConstraintError(
|
|
"start_rizin_debug_session",
|
|
"memory",
|
|
`Rizin session capacity reached (${String(MAX_SESSIONS)} sessions). This release has no runtime override.`,
|
|
{ max_sessions: MAX_SESSIONS, active_or_reserved_sessions: occupied },
|
|
{
|
|
remediationAction: `Close a Rizin debug session before starting another. This capacity of ${String(MAX_SESSIONS)} sessions has no runtime override.`,
|
|
},
|
|
),
|
|
);
|
|
this.#activeStarts += 1;
|
|
this.#sessionReservations += 1;
|
|
let reserved = true;
|
|
const releaseReservation = () => {
|
|
if (!reserved) return;
|
|
reserved = false;
|
|
this.#sessionReservations -= 1;
|
|
};
|
|
try {
|
|
return await this.#start(input, signal, releaseReservation);
|
|
} finally {
|
|
releaseReservation();
|
|
this.#activeStarts -= 1;
|
|
if (this.#activeStarts === 0)
|
|
for (const resolve of this.#startDrainWaiters.splice(0)) resolve();
|
|
}
|
|
}
|
|
|
|
async #start(
|
|
input: {
|
|
readonly path: string;
|
|
readonly inferior_tty: string;
|
|
readonly backend?: string;
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
releaseReservation: () => void,
|
|
): Promise<
|
|
Result<
|
|
{ readonly session_id: string; readonly backend: string | null },
|
|
AnalysisError
|
|
>
|
|
> {
|
|
if (signal?.aborted)
|
|
return err(new AnalysisCancelledError("start_rizin_debug_session"));
|
|
if (this.#platform !== "linux")
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"start_rizin_debug_session",
|
|
"Persistent Rizin debugger sessions require Linux /proc descriptor readback to verify inferior detachment and output routing.",
|
|
),
|
|
);
|
|
if (input.inferior_tty === undefined || input.inferior_tty.length === 0)
|
|
return err(new AnalysisInputError("start_rizin_debug_session"));
|
|
let ttyPath: string;
|
|
let ttyDevice: number;
|
|
let ttyInode: number;
|
|
try {
|
|
ttyPath = await realpath(input.inferior_tty);
|
|
const ttyHandle = await open(
|
|
ttyPath,
|
|
fsConstants.O_RDWR | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK,
|
|
);
|
|
try {
|
|
const ttyStat = await ttyHandle.stat();
|
|
if (
|
|
!isatty(ttyHandle.fd) ||
|
|
(ttyStat.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR
|
|
)
|
|
throw new Error("The selected inferior_tty is not a character TTY.");
|
|
ttyDevice = ttyStat.dev;
|
|
ttyInode = ttyStat.ino;
|
|
} finally {
|
|
await ttyHandle.close();
|
|
}
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisInputError("start_rizin_debug_session", { cause }),
|
|
);
|
|
}
|
|
// TTY identity validation is asynchronous. Cancellation may arrive while
|
|
// realpath/open/stat are pending, and must prevent the debugger spawn.
|
|
if (signal?.aborted)
|
|
return err(new AnalysisCancelledError("start_rizin_debug_session"));
|
|
const id = randomUUID();
|
|
const resolvedPath = absoluteCallerPath(input.path);
|
|
const launchProvenance: RizinLaunchProvenance = {
|
|
caller_selected_path: input.path,
|
|
resolved_path: resolvedPath,
|
|
};
|
|
const arguments_ = [
|
|
"-N",
|
|
"-0",
|
|
"-d",
|
|
"-R",
|
|
`stdout=${ttyPath}`,
|
|
"-R",
|
|
`stderr=${ttyPath}`,
|
|
"-e",
|
|
"dbg.exitkills=false",
|
|
...(input.backend === undefined ? [] : ["-D", input.backend]),
|
|
resolvedPath,
|
|
];
|
|
let launched: SpawnedOwnedProviderProcess;
|
|
try {
|
|
launched = await this.#spawnProcess({
|
|
command: this.#command,
|
|
arguments: arguments_,
|
|
hostEnvironment: this.#environment,
|
|
runId: id,
|
|
stdin: "pipe",
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
} catch (cause) {
|
|
if (signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError("start_rizin_debug_session", { cause }),
|
|
);
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"start_rizin_debug_session",
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Unable to start Rizin debugger",
|
|
),
|
|
);
|
|
}
|
|
const session: RizinDebugSession = {
|
|
id,
|
|
launched,
|
|
launchProvenance,
|
|
backend: input.backend ?? null,
|
|
inferiorTty: ttyPath,
|
|
inferiorTtyDevice: ttyDevice,
|
|
inferiorTtyInode: ttyInode,
|
|
knownInferiorIdentities: new Map(),
|
|
inferiorInventoryComplete: false,
|
|
inferiorStateUnknown: () => !session.inferiorInventoryComplete,
|
|
verifyInferiorsEndedAfterExit: async () => {
|
|
if (!session.inferiorInventoryComplete) return false;
|
|
const ended = await verifyOwnedInferiorsEnded(
|
|
session.knownInferiorIdentities.values(),
|
|
);
|
|
if (ended) session.knownInferiorIdentities.clear();
|
|
return ended;
|
|
},
|
|
detachInferior: () =>
|
|
session.cleanupCommandTurnHeld
|
|
? this.#detachInferiorUnlocked(session)
|
|
: this.#detachInferior(session),
|
|
cleanupCommandTurnHeld: false,
|
|
stopAttempt: undefined,
|
|
cleanupFailures: new Set(),
|
|
pendingCommandCount: 0,
|
|
invalidUtf8Observed: false,
|
|
closing: false,
|
|
supervisor: new ProviderProcessSupervisor(
|
|
{ process: launched.process, ownsProcessLifetime: false },
|
|
{
|
|
captureStdout: false,
|
|
maxDiagnosticBytes: MAX_DIAGNOSTIC_BYTES,
|
|
},
|
|
),
|
|
frames: [],
|
|
frameWaiters: [],
|
|
history: [],
|
|
queuedFrameBytes: 0,
|
|
historyTruncated: false,
|
|
frameChunks: [],
|
|
bufferedFrameBytes: 0,
|
|
ready: false,
|
|
exited: false,
|
|
commandTail: Promise.resolve(),
|
|
protocolError: undefined,
|
|
commandCaptureActive: false,
|
|
commandOutputDropped: false,
|
|
discardingOversizedFrame: false,
|
|
};
|
|
this.#sessions.set(id, session);
|
|
releaseReservation();
|
|
launched.process.stdout?.on("data", (chunk: Buffer | string) =>
|
|
this.#receive(session, Buffer.from(chunk)),
|
|
);
|
|
launched.process.stdin?.on("error", (cause: Error) =>
|
|
this.#failProtocol(
|
|
session,
|
|
`Rizin stdin stream failed: ${cause.message}`,
|
|
),
|
|
);
|
|
launched.process.stdin?.once("close", () => {
|
|
if (!session.exited && session.protocolError === undefined)
|
|
this.#failProtocol(session, "Rizin stdin stream closed unexpectedly.");
|
|
});
|
|
launched.process.once("exit", () => {
|
|
session.exited = true;
|
|
for (const waiter of session.frameWaiters)
|
|
waiter.reject(new RizinProcessExitError());
|
|
session.frameWaiters.length = 0;
|
|
});
|
|
try {
|
|
await waitForFrame(session, this.#startupTimeoutMs, signal);
|
|
// Let process and stream lifecycle notifications queued alongside the
|
|
// initial frame run before publishing the session as ready.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
if (signal?.aborted)
|
|
throw new AnalysisCancelledError("start_rizin_debug_session");
|
|
if (session.exited)
|
|
throw new RizinProcessExitError("Rizin exited during startup.");
|
|
if (session.protocolError !== undefined)
|
|
throw new Error(session.protocolError);
|
|
session.ready = true;
|
|
return ok({ session_id: id, backend: input.backend ?? null });
|
|
} catch (cause) {
|
|
const stopped = await this.#stopDebuggerOnly(session);
|
|
const capturedOutput = this.#startupCapturedOutput(session);
|
|
if (stopped.status !== "incomplete") this.#sessions.delete(id);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"start_rizin_debug_session",
|
|
stopped.reason,
|
|
{ cause, capturedOutput },
|
|
),
|
|
);
|
|
if (signal?.aborted || cause instanceof AnalysisCancelledError)
|
|
return err(
|
|
new AnalysisCancelledError("start_rizin_debug_session", {
|
|
cause,
|
|
capturedOutput,
|
|
}),
|
|
);
|
|
if (session.protocolError !== undefined)
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"start_rizin_debug_session",
|
|
session.protocolError,
|
|
{ cause, capturedOutput },
|
|
),
|
|
);
|
|
if (cause instanceof RizinProcessExitError) {
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"start_rizin_debug_session",
|
|
cause.message,
|
|
{
|
|
cause,
|
|
capturedOutput,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
return err(
|
|
new AnalysisTimeoutError(
|
|
"start_rizin_debug_session",
|
|
this.#startupTimeoutMs,
|
|
{
|
|
cause,
|
|
capturedOutput,
|
|
...fixedDeadlineGuidance(
|
|
"Rizin debug session startup",
|
|
this.#startupTimeoutMs,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async execute(
|
|
sessionId: string,
|
|
command: string,
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
Result<
|
|
{ readonly result: RizinDebugCommandResult; readonly evidence: Evidence },
|
|
AnalysisError
|
|
>
|
|
> {
|
|
const session = this.#sessions.get(sessionId);
|
|
if (
|
|
session === undefined ||
|
|
!session.ready ||
|
|
session.exited ||
|
|
session.protocolError !== undefined ||
|
|
session.closing ||
|
|
this.#closingAll
|
|
)
|
|
return err(new AnalysisInputError("rizin_debug_command"));
|
|
if (signal?.aborted)
|
|
return err(new AnalysisCancelledError("rizin_debug_command"));
|
|
if (session.pendingCommandCount >= MAX_PENDING_COMMANDS)
|
|
return err(
|
|
new AnalysisResourceConstraintError(
|
|
"rizin_debug_command",
|
|
"transport",
|
|
`Rizin command queue capacity reached (${String(MAX_PENDING_COMMANDS)} pending commands). This release has no runtime override.`,
|
|
{
|
|
max_pending_commands: MAX_PENDING_COMMANDS,
|
|
active_or_queued_commands: session.pendingCommandCount,
|
|
},
|
|
{
|
|
remediationAction: `Wait for a Rizin debug command on this session to finish, then retry. This queue of ${String(MAX_PENDING_COMMANDS)} commands has no runtime override.`,
|
|
},
|
|
),
|
|
);
|
|
session.pendingCommandCount += 1;
|
|
let release: (() => void) | undefined;
|
|
let acquired = false;
|
|
let commandSent = false;
|
|
let commandCapture: RizinCommandCapture | undefined;
|
|
let interruption: RizinCommandInterruption | undefined;
|
|
const commandResult = (
|
|
capture: RizinCommandCapture,
|
|
completion: "complete" | "unknown",
|
|
): RizinDebugCommandResult => ({
|
|
command,
|
|
output: capture.output,
|
|
output_scope: "command_and_interleaved_session_output",
|
|
output_truncated: capture.outputTruncated,
|
|
completion_status: completion,
|
|
backend: session.backend,
|
|
...(capture.outputInvalidUtf8
|
|
? { output_invalid_utf8: true as const }
|
|
: {}),
|
|
});
|
|
const previous = session.commandTail;
|
|
session.commandTail = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
try {
|
|
await waitForTurn(previous, signal);
|
|
acquired = true;
|
|
if (signal?.aborted)
|
|
return err(new AnalysisCancelledError("rizin_debug_command"));
|
|
if (
|
|
!session.ready ||
|
|
session.exited ||
|
|
session.closing ||
|
|
this.#closingAll ||
|
|
session.protocolError !== undefined
|
|
)
|
|
return err(new AnalysisInputError("rizin_debug_command"));
|
|
if (hasUnclaimedRizinFrames(session)) {
|
|
const message =
|
|
"Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.";
|
|
this.#failProtocol(session, message);
|
|
return err(
|
|
await this.#stopAfterProtocolFailure(session, undefined, {
|
|
commandTurnHeld: true,
|
|
}),
|
|
);
|
|
}
|
|
const marker = `__REA_COMMAND_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
|
|
// Arbitrary debugger commands can create or replace inferiors. Discard
|
|
// the prior inventory before any command bytes reach the provider.
|
|
session.inferiorInventoryComplete = false;
|
|
session.knownInferiorIdentities.clear();
|
|
commandSent = true;
|
|
session.commandCaptureActive = true;
|
|
session.commandOutputDropped = false;
|
|
const stdin = session.launched.process.stdin;
|
|
if (
|
|
stdin === null ||
|
|
stdin === undefined ||
|
|
stdin.destroyed ||
|
|
!stdin.writable
|
|
) {
|
|
this.#failProtocol(session, "Rizin stdin stream is closed.");
|
|
} else {
|
|
try {
|
|
stdin.write(
|
|
`${command}\n!echo ${marker}\n`,
|
|
"utf8",
|
|
(cause?: Error | null) => {
|
|
if (cause)
|
|
this.#failProtocol(
|
|
session,
|
|
`Rizin stdin write failed: ${cause.message}`,
|
|
);
|
|
},
|
|
);
|
|
} catch (cause) {
|
|
this.#failProtocol(
|
|
session,
|
|
`Rizin stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`,
|
|
);
|
|
}
|
|
}
|
|
const received = await waitForCommandFrames(
|
|
session,
|
|
marker,
|
|
this.#frameTimeoutMs,
|
|
signal,
|
|
this.#maxCommandOutputBytes,
|
|
);
|
|
commandCapture = received.ok ? received.value : received.error.capture;
|
|
if (!received.ok) {
|
|
interruption = received.error;
|
|
throw interruption.cause;
|
|
}
|
|
const captured = received.value;
|
|
if (signal?.aborted)
|
|
throw new AnalysisCancelledError("rizin_debug_command");
|
|
let cleanupFailure: string | undefined;
|
|
if (captured.outputTruncated) {
|
|
// Stryker disable next-line BooleanLiteral -- `waitForCommandFrames` sets `protocolError` before reporting truncation; both detach wrappers therefore fail closed before a queued command can be awaited, so the held-tail flag cannot change this public path.
|
|
const stopped = await this.#stopDebuggerOnly(session, true);
|
|
if (stopped.status === "incomplete") cleanupFailure = stopped.reason;
|
|
}
|
|
const result = commandResult(
|
|
captured,
|
|
captured.outputTruncated ? "unknown" : "complete",
|
|
);
|
|
const evidence = createRizinCommandEvidence({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty: session.inferiorTty,
|
|
launchProvenance: session.launchProvenance,
|
|
limitations: [
|
|
"Rizin commands and debugger effects depend on the loaded IO plugin and target.",
|
|
"Output contains all NUL frames through an explicit command-completion marker; backend output interleaved before that marker cannot be distinguished from command output.",
|
|
...(captured.outputTruncated
|
|
? [
|
|
rizinCaptureStoppedSentence(
|
|
captured.outputLimit,
|
|
this.#maxCommandOutputBytes,
|
|
cleanupFailure,
|
|
),
|
|
]
|
|
: []),
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
],
|
|
});
|
|
if (cleanupFailure !== undefined)
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
cleanupFailure,
|
|
{
|
|
partialObservation: evidence,
|
|
},
|
|
),
|
|
);
|
|
return ok({ result, evidence });
|
|
} catch (cause) {
|
|
// Capture is owned by this dispatched command, independently of which
|
|
// cancellation, protocol or cleanup outcome ultimately takes precedence.
|
|
const partialResult =
|
|
commandCapture === undefined
|
|
? undefined
|
|
: commandResult(commandCapture, "unknown");
|
|
const capturedOutput =
|
|
commandCapture === undefined
|
|
? undefined
|
|
: {
|
|
stdout: commandCapture.output,
|
|
stderr: "",
|
|
truncated: commandCapture.outputTruncated,
|
|
};
|
|
const partialEvidence = (
|
|
limitations: readonly string[],
|
|
): Evidence | undefined =>
|
|
partialResult === undefined
|
|
? undefined
|
|
: createRizinCommandEvidence({
|
|
sessionId,
|
|
command,
|
|
result: partialResult,
|
|
inferiorTty: session.inferiorTty,
|
|
launchProvenance: session.launchProvenance,
|
|
limitations,
|
|
});
|
|
if (signal?.aborted || cause instanceof AnalysisCancelledError) {
|
|
if (!commandSent)
|
|
return err(
|
|
new AnalysisCancelledError("rizin_debug_command", { cause }),
|
|
);
|
|
const evidence = partialEvidence([
|
|
"The Rizin command was cancelled; collected frame output is partial and command completion is unknown.",
|
|
"The owned debugger session was stopped because command output can no longer be correlated safely.",
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
]);
|
|
session.protocolError =
|
|
"A Rizin command was cancelled; the owned session was stopped because command output can no longer be correlated safely.";
|
|
const stopped = await this.#stopDebuggerOnly(session, true);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
stopped.reason,
|
|
{
|
|
cause: new AnalysisCancelledError("rizin_debug_command", {
|
|
cause,
|
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
|
}),
|
|
...(evidence === undefined
|
|
? {}
|
|
: { partialObservation: evidence }),
|
|
},
|
|
),
|
|
);
|
|
return err(
|
|
new AnalysisCancelledError("rizin_debug_command", {
|
|
cause,
|
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
|
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
|
}),
|
|
);
|
|
}
|
|
if (session.protocolError !== undefined) {
|
|
const evidence = partialEvidence([
|
|
session.protocolError,
|
|
"The Rizin protocol failed; retained command output is partial and completion is unknown.",
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
]);
|
|
return err(
|
|
await this.#stopAfterProtocolFailure(session, cause, {
|
|
commandTurnHeld: true,
|
|
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
|
}),
|
|
);
|
|
}
|
|
if (interruption?.kind === "timeout") {
|
|
session.protocolError = `Rizin command timed out before its completion marker at ${String(this.#frameTimeoutMs)} ms (deadlineExpired). This release has no runtime override. Output correlation is no longer safe.`;
|
|
const stopped = await this.#stopDebuggerOnly(session, true);
|
|
const result = {
|
|
...commandResult(interruption.capture, "unknown"),
|
|
output_truncated: true,
|
|
};
|
|
const evidence = createRizinCommandTimeoutEvidence({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty: session.inferiorTty,
|
|
cleanupFailure:
|
|
stopped.status === "incomplete" ? stopped.reason : undefined,
|
|
launchProvenance: session.launchProvenance,
|
|
timeoutMs: this.#frameTimeoutMs,
|
|
});
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
stopped.reason,
|
|
{
|
|
cause,
|
|
partialObservation: evidence,
|
|
},
|
|
),
|
|
);
|
|
return ok({ result, evidence });
|
|
}
|
|
if (interruption?.kind === "exited") {
|
|
const result = commandResult(interruption.capture, "unknown");
|
|
const evidence = createRizinCommandEvidence({
|
|
sessionId,
|
|
command,
|
|
result,
|
|
inferiorTty: session.inferiorTty,
|
|
launchProvenance: session.launchProvenance,
|
|
limitations: [
|
|
"Rizin exited before the command-completion marker; retained frame output is partial and command completion is unknown.",
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
],
|
|
});
|
|
const stopped = await this.#stopDebuggerOnly(session, true);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
stopped.reason,
|
|
{ cause, partialObservation: evidence },
|
|
),
|
|
);
|
|
return ok({ result, evidence });
|
|
}
|
|
session.protocolError =
|
|
"Rizin did not return the current command-completion marker; the session was stopped to prevent late output from being attributed to a later command.";
|
|
const evidence = partialEvidence([
|
|
session.protocolError,
|
|
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
|
]);
|
|
const errorOptions = {
|
|
cause,
|
|
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
|
};
|
|
const stopped = await this.#stopDebuggerOnly(session, true);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
stopped.reason,
|
|
errorOptions,
|
|
),
|
|
);
|
|
return err(
|
|
new AnalysisTimeoutError("rizin_debug_command", this.#frameTimeoutMs, {
|
|
...errorOptions,
|
|
...fixedDeadlineGuidance(
|
|
"The Rizin debug command",
|
|
this.#frameTimeoutMs,
|
|
),
|
|
}),
|
|
);
|
|
} finally {
|
|
if (acquired) {
|
|
session.commandCaptureActive = false;
|
|
session.pendingCommandCount -= 1;
|
|
release?.();
|
|
} else
|
|
void previous.then(() => {
|
|
session.pendingCommandCount -= 1;
|
|
release?.();
|
|
});
|
|
}
|
|
}
|
|
|
|
status(sessionId: string):
|
|
| {
|
|
readonly session_id: string;
|
|
readonly state: "ready" | "closed";
|
|
readonly inferior_tty: string;
|
|
readonly recent_output: readonly string[];
|
|
readonly recent_output_truncated: boolean;
|
|
readonly diagnostics_truncated: boolean;
|
|
readonly output_invalid_utf8: boolean;
|
|
}
|
|
| undefined {
|
|
const session = this.#sessions.get(sessionId);
|
|
return session === undefined
|
|
? undefined
|
|
: {
|
|
session_id: session.id,
|
|
state:
|
|
session.exited ||
|
|
session.protocolError !== undefined ||
|
|
session.closing ||
|
|
!session.ready
|
|
? "closed"
|
|
: "ready",
|
|
inferior_tty: session.inferiorTty,
|
|
recent_output: [...session.history],
|
|
recent_output_truncated: session.historyTruncated,
|
|
diagnostics_truncated:
|
|
session.supervisor.snapshot().diagnosticTruncated === true,
|
|
output_invalid_utf8: session.invalidUtf8Observed,
|
|
};
|
|
}
|
|
|
|
async close(
|
|
sessionId: string,
|
|
): Promise<Result<{ readonly target_state: "unknown" }, AnalysisError>> {
|
|
if (this.#closingAll)
|
|
return err(new AnalysisInputError("close_rizin_debug_session"));
|
|
const session = this.#sessions.get(sessionId);
|
|
if (session === undefined)
|
|
return err(new AnalysisInputError("close_rizin_debug_session"));
|
|
session.closing = true;
|
|
const stopped = await this.#stopDebuggerOnly(session);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"close_rizin_debug_session",
|
|
stopped.reason,
|
|
),
|
|
);
|
|
this.#sessions.delete(sessionId);
|
|
return ok({ target_state: "unknown" });
|
|
}
|
|
|
|
async retryCleanupFailures(): Promise<Result<null, AnalysisError>> {
|
|
for (const session of this.#sessions.values()) {
|
|
if (session.cleanupFailures.size === 0) continue;
|
|
const stopped = await this.#stopDebuggerOnly(session);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
[...session.cleanupFailures].at(-1) ??
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"close_rizin_debug_session",
|
|
stopped.reason,
|
|
),
|
|
);
|
|
this.#sessions.delete(session.id);
|
|
}
|
|
return ok(null);
|
|
}
|
|
|
|
async closeAll(): Promise<void> {
|
|
this.#closingAll = true;
|
|
if (this.#activeStarts > 0)
|
|
await new Promise<void>((resolve) =>
|
|
this.#startDrainWaiters.push(resolve),
|
|
);
|
|
for (const session of this.#sessions.values()) session.closing = true;
|
|
return closeOwnedDebuggerSessions(
|
|
this.#sessions,
|
|
(session) => this.#stopDebuggerOnly(session),
|
|
"Rizin",
|
|
);
|
|
}
|
|
|
|
#receive(session: RizinDebugSession, chunk: Buffer): void {
|
|
let offset = 0;
|
|
while (offset < chunk.length) {
|
|
const boundary = chunk.indexOf(0, offset);
|
|
const end = boundary < 0 ? chunk.length : boundary;
|
|
const nextOffset = boundary + 1;
|
|
if (boundary >= 0 && nextOffset <= offset) {
|
|
this.#failProtocol(
|
|
session,
|
|
"Rizin frame parser failed to advance past a NUL delimiter.",
|
|
);
|
|
return;
|
|
}
|
|
const piece = chunk.subarray(offset, end);
|
|
if (session.discardingOversizedFrame) {
|
|
if (boundary < 0) return;
|
|
session.discardingOversizedFrame = false;
|
|
offset = nextOffset;
|
|
continue;
|
|
}
|
|
if (session.bufferedFrameBytes + piece.length > MAX_FRAME_BYTES) {
|
|
const message = `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`;
|
|
if (!session.commandCaptureActive) {
|
|
this.#failProtocol(session, message);
|
|
return;
|
|
}
|
|
session.protocolError = message;
|
|
session.historyTruncated = true;
|
|
const remainingBytes = MAX_FRAME_BYTES - session.bufferedFrameBytes;
|
|
if (remainingBytes > 0) {
|
|
session.frameChunks.push(
|
|
Buffer.from(piece.subarray(0, remainingBytes)),
|
|
);
|
|
session.bufferedFrameBytes += remainingBytes;
|
|
}
|
|
if (boundary < 0) {
|
|
session.discardingOversizedFrame = true;
|
|
this.#deliverBufferedFrame(session, true);
|
|
return;
|
|
}
|
|
this.#deliverBufferedFrame(session, true);
|
|
offset = nextOffset;
|
|
continue;
|
|
}
|
|
if (piece.length > 0) {
|
|
session.frameChunks.push(Buffer.from(piece));
|
|
session.bufferedFrameBytes += piece.length;
|
|
}
|
|
if (boundary < 0) return;
|
|
this.#deliverBufferedFrame(session, false);
|
|
offset = nextOffset;
|
|
}
|
|
}
|
|
|
|
#deliverBufferedFrame(session: RizinDebugSession, truncated: boolean): void {
|
|
const captured = bufferedRizinFrame(session, truncated);
|
|
session.frameChunks = [];
|
|
session.bufferedFrameBytes = 0;
|
|
this.#deliverFrame(session, captured.frame, captured.bytes);
|
|
}
|
|
|
|
#startupCapturedOutput(session: RizinDebugSession) {
|
|
const output = session.supervisor.snapshot();
|
|
const partialFrame = bufferedRizinFrame(session, false).frame.text;
|
|
const protocolOutput = [...session.history, partialFrame]
|
|
.filter((frame) => frame.length > 0)
|
|
.join("\n");
|
|
const stdout = truncateUtf8(protocolOutput, MAX_DIAGNOSTIC_BYTES);
|
|
return {
|
|
stdout,
|
|
stderr: output.stderr.text,
|
|
truncated:
|
|
session.historyTruncated ||
|
|
protocolOutput.length > stdout.length ||
|
|
output.diagnosticTruncated === true ||
|
|
output.stdout.observedBytes > output.stdout.bytes ||
|
|
output.stderr.observedBytes > output.stderr.bytes,
|
|
};
|
|
}
|
|
|
|
#deliverFrame(
|
|
session: RizinDebugSession,
|
|
frame: RizinFrame,
|
|
frameBytes: number,
|
|
): void {
|
|
const historyPrefix = truncateUtf8(frame.text, MAX_RECENT_FRAME_BYTES);
|
|
const historyTruncated =
|
|
historyPrefix.length < frame.text.length || frame.truncated;
|
|
if (historyTruncated) session.historyTruncated = true;
|
|
session.history.push(
|
|
historyTruncated
|
|
? `${historyPrefix}[recent frame truncated]`
|
|
: historyPrefix,
|
|
);
|
|
if (session.history.length > 64) {
|
|
session.history.splice(0, session.history.length - 64);
|
|
session.historyTruncated = true;
|
|
}
|
|
const waiter = session.frameWaiters.shift();
|
|
if (waiter !== undefined) {
|
|
waiter.resolve(frame);
|
|
return;
|
|
}
|
|
const queueChargeBytes = Math.max(
|
|
frameBytes,
|
|
MIN_QUEUED_FRAME_CHARGE_BYTES,
|
|
);
|
|
if (
|
|
session.frames.length >= MAX_QUEUED_FRAMES ||
|
|
session.queuedFrameBytes + queueChargeBytes > MAX_QUEUED_FRAME_BYTES
|
|
) {
|
|
session.commandOutputDropped ||= session.commandCaptureActive;
|
|
this.#failProtocol(
|
|
session,
|
|
`Rizin produced unsolicited frames exceeding the bounded queue limit of ${String(MAX_QUEUED_FRAMES)} frames or ${String(MAX_QUEUED_FRAME_BYTES)} bytes. This release has no runtime override.`,
|
|
);
|
|
return;
|
|
}
|
|
session.frames.push({ frame, queueChargeBytes });
|
|
session.queuedFrameBytes += queueChargeBytes;
|
|
}
|
|
|
|
#failProtocol(session: RizinDebugSession, message: string): void {
|
|
session.protocolError = message;
|
|
if (!session.commandCaptureActive) {
|
|
session.frameChunks = [];
|
|
session.bufferedFrameBytes = 0;
|
|
}
|
|
for (const waiter of session.frameWaiters)
|
|
waiter.reject(new Error(message));
|
|
session.frameWaiters.length = 0;
|
|
}
|
|
|
|
#stopDebuggerOnly(session: RizinDebugSession, commandTurnHeld = false) {
|
|
session.closing = true;
|
|
if (session.stopAttempt !== undefined) return session.stopAttempt;
|
|
session.cleanupCommandTurnHeld = commandTurnHeld;
|
|
session.stopAttempt = Promise.resolve()
|
|
.then(() =>
|
|
this.#stopProcess(session, "Rizin debugger", {
|
|
platform: this.#platform,
|
|
}),
|
|
)
|
|
.then((stopped) => {
|
|
if (stopped.status !== "incomplete") {
|
|
for (const failure of session.cleanupFailures)
|
|
failure.resolveCleanup();
|
|
session.cleanupFailures.clear();
|
|
}
|
|
return stopped;
|
|
})
|
|
.finally(() => {
|
|
session.stopAttempt = undefined;
|
|
});
|
|
return session.stopAttempt;
|
|
}
|
|
|
|
async #detachInferior(
|
|
session: RizinDebugSession,
|
|
): Promise<OwnedDebuggerDetachResult> {
|
|
if (session.exited || session.protocolError !== undefined)
|
|
throw new Error(
|
|
"Rizin protocol state is ambiguous; inferior detach is unverified.",
|
|
);
|
|
const previous = session.commandTail;
|
|
let release!: () => void;
|
|
session.commandTail = new Promise<void>((resolve) => (release = resolve));
|
|
await previous;
|
|
try {
|
|
return await this.#detachInferiorUnlocked(session);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async #detachInferiorUnlocked(
|
|
session: RizinDebugSession,
|
|
): Promise<OwnedDebuggerDetachResult> {
|
|
if (session.exited || session.protocolError !== undefined)
|
|
throw new Error(
|
|
"Rizin protocol state is ambiguous; inferior detach is unverified.",
|
|
);
|
|
session.inferiorInventoryComplete = false;
|
|
session.knownInferiorIdentities.clear();
|
|
const inferior = parseRizinPtraceRows(
|
|
await this.#runInternalCommand(session, "olj"),
|
|
);
|
|
if (inferior.length === 0) {
|
|
session.knownInferiorIdentities.clear();
|
|
session.inferiorInventoryComplete = true;
|
|
return { status: "no-inferior" };
|
|
}
|
|
session.knownInferiorIdentities.clear();
|
|
for (const [, pid] of inferior) {
|
|
if (session.knownInferiorIdentities.has(pid)) continue;
|
|
const startTime = await readLinuxProcessStartTime(pid);
|
|
if (startTime === null)
|
|
throw new Error(
|
|
`Inferior PID ${pid} ended before its identity could be established.`,
|
|
);
|
|
session.knownInferiorIdentities.set(pid, {
|
|
pid,
|
|
startTime,
|
|
});
|
|
}
|
|
session.inferiorInventoryComplete = true;
|
|
if (inferior.length !== 1)
|
|
throw new Error("Rizin reported multiple or ambiguous ptrace inferiors.");
|
|
const [fd, pid] = inferior[0]!;
|
|
const identity = session.knownInferiorIdentities.get(pid)!;
|
|
assertRizinProcessStartTimeStable(
|
|
identity.startTime,
|
|
await readLinuxProcessStartTime(pid),
|
|
);
|
|
await this.#verifyDebuggerExitPolicy(session);
|
|
await this.#verifyInferiorOutputDescriptors(session, pid);
|
|
await this.#runInternalCommand(session, `o- ${fd}`);
|
|
const after = parseRizinPtraceRows(
|
|
await this.#runInternalCommand(session, "olj"),
|
|
);
|
|
if (
|
|
after.some(
|
|
([activeFd, activePid]) => activeFd === fd || activePid === pid,
|
|
) ||
|
|
after.length !== 0
|
|
)
|
|
throw new Error(
|
|
"Rizin did not confirm that the selected inferior FD was detached.",
|
|
);
|
|
// Protocol readback must never be waived by proof that a process ended.
|
|
await this.#verifyDebuggerExitPolicy(session);
|
|
try {
|
|
assertRizinProcessStartTimeStable(
|
|
identity.startTime,
|
|
await readLinuxProcessStartTime(pid),
|
|
);
|
|
await this.#verifyInferiorOutputDescriptors(session, pid);
|
|
} catch (cause) {
|
|
// Detach resumes the inferior. Its identity or descriptors may disappear
|
|
// before readback; only a previously identified process can prove its end.
|
|
if (!(await verifyOwnedInferiorsEnded([identity]))) throw cause;
|
|
session.knownInferiorIdentities.clear();
|
|
return { status: "no-inferior" };
|
|
}
|
|
return { status: "detached", outputSink: "durable" };
|
|
}
|
|
|
|
async #verifyDebuggerExitPolicy(session: RizinDebugSession): Promise<void> {
|
|
const exitKills = (
|
|
await this.#runInternalCommand(session, "e dbg.exitkills")
|
|
).trim();
|
|
if (exitKills !== "false")
|
|
throw new Error("Rizin dbg.exitkills readback did not confirm false.");
|
|
}
|
|
|
|
async #verifyInferiorOutputDescriptors(
|
|
session: RizinDebugSession,
|
|
pid: string,
|
|
): Promise<void> {
|
|
for (const descriptor of ["1", "2"]) {
|
|
const descriptorPath = `/proc/${pid}/fd/${descriptor}`;
|
|
const destination = await readlink(descriptorPath);
|
|
const destinationStat = await stat(descriptorPath);
|
|
if (
|
|
destinationStat.dev !== session.inferiorTtyDevice ||
|
|
destinationStat.ino !== session.inferiorTtyInode ||
|
|
(destinationStat.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR ||
|
|
(await realpath(destination)) !== session.inferiorTty
|
|
)
|
|
throw new Error(
|
|
`Inferior descriptor ${descriptor} is not routed to the selected TTY.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async #runInternalCommand(
|
|
session: RizinDebugSession,
|
|
command: string,
|
|
): Promise<string> {
|
|
if (hasUnclaimedRizinFrames(session))
|
|
throw new Error(
|
|
"Unsolicited Rizin output prevents correlated cleanup readback.",
|
|
);
|
|
const marker = `__REA_CLEANUP_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
|
|
const stdin = session.launched.process.stdin;
|
|
if (
|
|
stdin === null ||
|
|
stdin === undefined ||
|
|
stdin.destroyed ||
|
|
!stdin.writable
|
|
)
|
|
throw new Error("Rizin stdin is unavailable during cleanup.");
|
|
stdin.write(`${command}\n!echo ${marker}\n`);
|
|
const captured = await waitForCommandFrames(
|
|
session,
|
|
marker,
|
|
CLEANUP_COMMAND_TIMEOUT_MS,
|
|
undefined,
|
|
this.#maxCommandOutputBytes,
|
|
);
|
|
if (!captured.ok) throw captured.error.cause;
|
|
if (captured.value.outputTruncated)
|
|
throw new Error("Rizin cleanup readback was truncated.");
|
|
return captured.value.output;
|
|
}
|
|
|
|
#retainCleanupFailure(
|
|
session: RizinDebugSession,
|
|
operation: string,
|
|
reason: string,
|
|
options: AnalysisErrorOptions = {},
|
|
): AnalysisError {
|
|
const failure = new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
operation,
|
|
`The owned Rizin process could not be confirmed stopped: ${reason}`,
|
|
{ ...options, cleanup: { reason, resources: [session.id] } },
|
|
);
|
|
session.cleanupFailures.add(failure);
|
|
return failure;
|
|
}
|
|
|
|
async #stopAfterProtocolFailure(
|
|
session: RizinDebugSession,
|
|
cause: unknown,
|
|
options: {
|
|
readonly commandTurnHeld?: boolean;
|
|
readonly partialObservation?: Evidence;
|
|
} = {},
|
|
): Promise<AnalysisError> {
|
|
const stopped = await this.#stopDebuggerOnly(
|
|
session,
|
|
options.commandTurnHeld,
|
|
);
|
|
const errorOptions = {
|
|
cause,
|
|
...(options.partialObservation === undefined
|
|
? {}
|
|
: { partialObservation: options.partialObservation }),
|
|
};
|
|
return stopped.status === "incomplete"
|
|
? this.#retainCleanupFailure(
|
|
session,
|
|
"rizin_debug_command",
|
|
stopped.reason,
|
|
errorOptions,
|
|
)
|
|
: new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"rizin_debug_command",
|
|
session.protocolError!,
|
|
errorOptions,
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface RizinDebugCommandResult {
|
|
readonly command: string;
|
|
readonly output: string;
|
|
readonly output_scope: "command_and_interleaved_session_output";
|
|
readonly output_truncated: boolean;
|
|
readonly output_invalid_utf8?: true;
|
|
readonly completion_status: "complete" | "unknown";
|
|
readonly backend: string | null;
|
|
}
|
|
|
|
const parseRizinPtraceRows = (output: string): Array<[string, string]> => {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(output);
|
|
} catch (cause) {
|
|
throw new Error("Rizin returned malformed JSON for `olj`.", { cause });
|
|
}
|
|
if (!Array.isArray(parsed))
|
|
throw new Error("Rizin `olj` response is not an array.");
|
|
const rows: Array<[string, string]> = [];
|
|
for (const row of parsed) {
|
|
if (
|
|
typeof row !== "object" ||
|
|
row === null ||
|
|
typeof (row as { fd?: unknown }).fd !== "number" ||
|
|
!Number.isSafeInteger((row as { fd: number }).fd) ||
|
|
(row as { fd: number }).fd < 0 ||
|
|
typeof (row as { raised?: unknown }).raised !== "boolean" ||
|
|
typeof (row as { uri?: unknown }).uri !== "string"
|
|
)
|
|
throw new Error(
|
|
"Rizin `olj` response contains a malformed descriptor row.",
|
|
);
|
|
const uri = (row as { uri: string }).uri;
|
|
const match = /^ptrace:\/\/(\d+)$/u.exec(uri);
|
|
if (match !== null) {
|
|
if ((row as { raised: boolean }).raised !== true)
|
|
throw new Error("Rizin `olj` ptrace descriptor is not active.");
|
|
rows.push([String((row as { fd: number }).fd), match[1]!]);
|
|
} else if (uri.startsWith("ptrace://"))
|
|
throw new Error("Rizin `olj` returned a malformed ptrace URI.");
|
|
}
|
|
return rows;
|
|
};
|
|
|
|
/** @internal Reject a PID that changed identity during inferior detach. */
|
|
export const assertRizinProcessStartTimeStable = (
|
|
before: string,
|
|
after: string | null,
|
|
): void => {
|
|
if (after !== before)
|
|
throw new Error(
|
|
"The inferior PID disappeared or was reused during detach verification.",
|
|
);
|
|
};
|
|
|
|
const hasUnclaimedRizinFrames = (session: RizinDebugSession): boolean =>
|
|
session.frames.length > 0 ||
|
|
session.bufferedFrameBytes > 0 ||
|
|
session.discardingOversizedFrame;
|
|
|
|
/** Parse the receiver's bounded bytes once for normal, startup and interrupted reads. */
|
|
const bufferedRizinFrame = (session: RizinDebugSession, truncated: boolean) => {
|
|
const bytes = Buffer.concat(session.frameChunks, session.bufferedFrameBytes);
|
|
const invalidUtf8 = !isUtf8(bytes);
|
|
session.invalidUtf8Observed ||= invalidUtf8;
|
|
return {
|
|
bytes: bytes.length,
|
|
frame: { text: bytes.toString("utf8"), truncated, invalidUtf8 },
|
|
};
|
|
};
|
|
|
|
const rizinCaptureStoppedSentence = (
|
|
limit: RizinOutputLimit | undefined,
|
|
maxCommandOutputBytes: number,
|
|
cleanupFailure: string | undefined,
|
|
): string => {
|
|
const cap =
|
|
limit === "command_frames"
|
|
? `the ${String(MAX_COMMAND_FRAMES)} frame command limit`
|
|
: limit === "frame_bytes"
|
|
? `the ${String(MAX_FRAME_BYTES)} byte frame limit`
|
|
: limit === "queued_frames"
|
|
? `the queued frame limit of ${String(MAX_QUEUED_FRAMES)} frames or ${String(MAX_QUEUED_FRAME_BYTES)} bytes`
|
|
: limit === "command_output_bytes"
|
|
? `the ${String(maxCommandOutputBytes)} byte output limit`
|
|
: "a capture limit";
|
|
const cleanup =
|
|
cleanupFailure === undefined
|
|
? " The owned debugger session was stopped."
|
|
: ` The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`;
|
|
return `The Rizin command exceeded ${cap}. This release has no runtime override. Retained output is partial and command completion is unknown.${cleanup}`;
|
|
};
|
|
|
|
const waitForCommandFrames = async (
|
|
session: RizinDebugSession,
|
|
marker: string,
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES,
|
|
): Promise<Result<RizinCommandCapture, RizinCommandInterruption>> => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const output: string[] = [];
|
|
let outputBytes = 0;
|
|
let outputInvalidUtf8 = false;
|
|
let outputTruncated = false;
|
|
let outputLimit: RizinOutputLimit | undefined;
|
|
let frameCount = 0;
|
|
const noteLimit = (limit: RizinOutputLimit): void => {
|
|
outputTruncated = true;
|
|
outputLimit ??= limit;
|
|
};
|
|
const snapshot = (): RizinCommandCapture => {
|
|
const dropped = session.commandOutputDropped;
|
|
const limit = outputLimit ?? (dropped ? "queued_frames" : undefined);
|
|
return {
|
|
output: output.join("\n"),
|
|
outputTruncated: outputTruncated || dropped,
|
|
outputInvalidUtf8,
|
|
...(limit === undefined ? {} : { outputLimit: limit }),
|
|
};
|
|
};
|
|
const append = (frame: RizinFrame): boolean => {
|
|
outputInvalidUtf8 ||= frame.invalidUtf8;
|
|
if (frame.truncated) noteLimit("frame_bytes");
|
|
const markerIndex = frame.text.indexOf(marker);
|
|
const text =
|
|
markerIndex < 0 ? frame.text : frame.text.slice(0, markerIndex);
|
|
if (markerIndex >= 0 && text.length === 0) return true;
|
|
const separatorBytes = output.length > 0 ? 1 : 0;
|
|
const remaining = maxCommandOutputBytes - outputBytes - separatorBytes;
|
|
const bytes = Buffer.byteLength(text, "utf8");
|
|
if (bytes > remaining) {
|
|
noteLimit("command_output_bytes");
|
|
const retained = truncateUtf8(text, remaining);
|
|
if (retained.length > 0) output.push(retained);
|
|
outputBytes = maxCommandOutputBytes;
|
|
} else {
|
|
output.push(text);
|
|
outputBytes += separatorBytes + bytes;
|
|
}
|
|
return markerIndex >= 0;
|
|
};
|
|
// After dispatch, queued complete frames and the unfinished frame belong to
|
|
// this command interval. Never publish the private completion marker or bytes
|
|
// following it, and apply the same decoded UTF-8 budget as normal frames.
|
|
const interrupted = () => {
|
|
let markerSeen = false;
|
|
for (;;) {
|
|
const queued = session.frames.shift();
|
|
if (queued === undefined) break;
|
|
session.queuedFrameBytes -= queued.queueChargeBytes;
|
|
frameCount += 1;
|
|
if (frameCount > MAX_COMMAND_FRAMES) {
|
|
noteLimit("command_frames");
|
|
break;
|
|
}
|
|
markerSeen = append(queued.frame);
|
|
if (markerSeen || outputTruncated) break;
|
|
}
|
|
if (!markerSeen && !outputTruncated && session.bufferedFrameBytes > 0)
|
|
append(
|
|
bufferedRizinFrame(session, session.discardingOversizedFrame).frame,
|
|
);
|
|
else if (session.bufferedFrameBytes > 0 && !markerSeen)
|
|
outputTruncated = true;
|
|
session.frameChunks = [];
|
|
session.bufferedFrameBytes = 0;
|
|
return snapshot();
|
|
};
|
|
for (;;) {
|
|
if (session.protocolError !== undefined) {
|
|
const capture = interrupted();
|
|
// Capacity failures already have a bounded partial-result contract.
|
|
// Truncation still forces debugger cleanup before that result is returned.
|
|
if (session.commandOutputDropped) return ok(capture);
|
|
return err({
|
|
kind: "failed",
|
|
cause: new Error(session.protocolError),
|
|
capture,
|
|
});
|
|
}
|
|
const remaining = deadline - Date.now();
|
|
if (isRizinCommandDeadlineExpired(remaining))
|
|
return err({
|
|
kind: "timeout",
|
|
cause: new RizinFrameTimeoutError(),
|
|
capture: interrupted(),
|
|
});
|
|
let received: RizinFrame;
|
|
try {
|
|
received = await waitForFrame(session, remaining, signal);
|
|
} catch (cause) {
|
|
let drained = true;
|
|
if (cause instanceof RizinProcessExitError) {
|
|
// `exit` observes the leader, not its pipe lifetime. Keep the bounded
|
|
// receiver active until the supervisor observes output closure, using
|
|
// the same settlement interval as owned-debugger cleanup.
|
|
drained = await session.supervisor.waitForOutputClose(
|
|
CLEANUP_COMMAND_TIMEOUT_MS,
|
|
);
|
|
}
|
|
const captured = interrupted();
|
|
return err({
|
|
kind:
|
|
cause instanceof AnalysisCancelledError
|
|
? "cancelled"
|
|
: cause instanceof RizinProcessExitError
|
|
? "exited"
|
|
: cause instanceof RizinFrameTimeoutError
|
|
? "timeout"
|
|
: "failed",
|
|
cause,
|
|
capture: {
|
|
...captured,
|
|
outputTruncated: captured.outputTruncated || !drained,
|
|
},
|
|
});
|
|
}
|
|
frameCount += 1;
|
|
if (frameCount > MAX_COMMAND_FRAMES) {
|
|
session.protocolError = `Rizin emitted more than ${String(MAX_COMMAND_FRAMES)} frames for one command. This release has no runtime override. The session was stopped because output correlation is ambiguous.`;
|
|
noteLimit("command_frames");
|
|
return ok(snapshot());
|
|
}
|
|
const markerSeen = append(received);
|
|
if (outputTruncated) {
|
|
if (outputLimit === "command_output_bytes")
|
|
session.protocolError ??= `Rizin command output exceeded the ${String(maxCommandOutputBytes)} byte (16 MiB) command limit. This release has no runtime override.`;
|
|
else if (outputLimit === "frame_bytes")
|
|
session.protocolError ??= `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`;
|
|
return ok(snapshot());
|
|
}
|
|
if (markerSeen) return ok(snapshot());
|
|
}
|
|
};
|
|
|
|
export const truncateUtf8 = (value: string, maximumBytes: number): string => {
|
|
if (maximumBytes <= 0) return "";
|
|
// A UTF-16 code unit needs at most three UTF-8 bytes. Buffer.write stops
|
|
// before a partial character; only decode bytes it actually initialized.
|
|
const bytes = Buffer.allocUnsafe(Math.min(value.length * 3, maximumBytes));
|
|
const written = bytes.write(value, "utf8");
|
|
return bytes.toString("utf8", 0, written);
|
|
};
|
|
|
|
const waitForFrame = (
|
|
session: RizinDebugSession,
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
): Promise<RizinFrame> => {
|
|
if (signal?.aborted)
|
|
return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
|
|
const queued = session.frames.shift();
|
|
if (queued !== undefined) {
|
|
session.queuedFrameBytes -= queued.queueChargeBytes;
|
|
return Promise.resolve(queued.frame);
|
|
}
|
|
if (session.exited) return Promise.reject(new RizinProcessExitError());
|
|
return new Promise((resolve, reject) => {
|
|
// Settlement removes all three trigger paths before resolving or rejecting.
|
|
const cleanup = (): void => {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
const index = session.frameWaiters.indexOf(waiter);
|
|
if (index >= 0) session.frameWaiters.splice(index, 1);
|
|
};
|
|
const onAbort = (): void => {
|
|
cleanup();
|
|
reject(new AnalysisCancelledError("rizin_debug_command"));
|
|
};
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new RizinFrameTimeoutError());
|
|
}, timeoutMs);
|
|
const waiter = {
|
|
resolve: (frame: RizinFrame): void => {
|
|
cleanup();
|
|
resolve(frame);
|
|
},
|
|
reject: (cause: Error): void => {
|
|
cleanup();
|
|
reject(cause);
|
|
},
|
|
};
|
|
session.frameWaiters.push(waiter);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
if (signal?.aborted) onAbort();
|
|
});
|
|
};
|
|
|
|
const waitForTurn = (
|
|
previous: Promise<void>,
|
|
signal?: AbortSignal,
|
|
): Promise<void> => {
|
|
if (signal === undefined) return previous;
|
|
if (signal.aborted)
|
|
return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onAbort = (): void =>
|
|
reject(new AnalysisCancelledError("rizin_debug_command"));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
previous.then(() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
});
|
|
});
|
|
};
|