Files
rea/tests/boundary/cli/reverseEngineeringCommands.test.ts
T
4aa51bfe19 fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* feat(process): add owned debugger shutdown lifecycle

* feat(reverse): add local inspection commands

* feat(gdb): add persistent debugger integration

* feat(cutter): add Cutter bridge integration

* test(reverse): enforce complete integration coverage

* fix(process): preserve debugger shutdown ownership

* fix(reverse): retain evidence across interrupted commands

* fix(gdb): honor captured provider environment

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* fix(server): snapshot provider environment at creation

* fix(reverse): close review gaps with complete coverage

* fix(reverse): address inspection review findings

* fix(gdb): bound sessions and retain cleanup ownership

* fix(ci): require reverse-tools quality lane

* fix(process): unref inherited debugger output pipes

* fix(reverse): address remaining PR 1960 findings

* fix(gdb): address remaining PR 1961 findings

* fix(reverse): account for unrestricted provider execution

* fix(process): retain debugger ownership until safe shutdown

* fix(process): preserve replacement debugger sessions

* fix(reverse): preserve admission and output provenance

* fix(process): retain exited debugger until output settles

* fix(mcp): bound provider admission and debugger cleanup

* fix(reverse): preserve failure provenance and classify E2BIG

* fix(gdb): preserve startup cleanup and detach safety

* fix(process): retain debugger ownership after detach failure

* fix(reverse): preserve mutation and failure provenance

* fix(gdb): address debugger review findings

* fix(reverse): rebind sessions after external mutation

* fix(process): cap debugger output after exit

* fix(gdb): protect one-shot detach verification

* feat(debugger): add reverse tool coverage gate

* test(debugger): cover integrated failure paths

* fix(gdb): bound and preserve session observations

* fix(gdb): address review regressions

* style: format merged integration files

* fix(reverse): preserve session and output evidence

* refactor(gdb): share repeated test and partial result setup

* fix(gdb): invalidate TTY proof for Python aliases

* fix(reverse): retain failure output byte counts

* test(process): synchronize late debugger output

* fix(gdb): invalidate TTY proof for wrapped commands

* fix(gdb): recognize wit command abbreviation

* test(reverse): wait for cancelled descendant readiness

* test(reverse): bound descendant cancellation cleanup

* style(reverse): format integration tests and adapters

* style: format reverse engineering integration files

* test(reverse): remove duplicate environment harness

* test(gdb): share session startup assertions

* fix(gdb): invalidate output proof for command abbreviations

* test(reverse): share composition edge case scenarios

* fix(ci): recognize verification and mutation tool entries

* test(rizin): cover successful fake inferior detach

* test(cli): align full output failure envelope

* test(cli): isolate reverse command lifecycle service

* test(process): share debugger output fixture setup

* fix(binary): preserve high-bit Windows signature bytes

* style(binary): format Windows signature parser

* test(mcp): preserve call coverage in unchanged comparison

* fix(ci): include reverse tools Vitest configs in lint cache

* fix(mcp): preserve target content on delivery errors

* ci: align duplication ceiling with expanded suite

* test(reverse): allow signal admission mutants

* fix(reverse): bound retained cleanup failures to process owners

* fix(test): replace drifting mutation ranges with owned selectors

* fix(reverse): admit persistent commands and retain cleanup ownership

* fix(cutter): recover timed-out commands from exact receipts

* fix(cutter): reject unsupported bridge request kinds

* fix(ci): repair client format and shared verification fixtures

* fix(native): preserve debugger cleanup ownership and bridge authentication

* test(native): retain diagnostics without perfect coverage requirements

* chore(licenses): refresh locked dependency inventory and notices

* fix(test): exclude transient command locks from mutation sandboxes

* fix(gdb): admit only platforms with safe detach verification

* fix(cutter): retain bounded completion receipts until acknowledgement

* fix(cutter): acknowledge commands rejected before UI dispatch

* test(reverse): fail cancellation checks before fixture teardown

* test(gdb): clean up the retained broken-stdin fixture owner

* docs(test): clarify focused provider verification scope

* fix(rizin): retain close cleanup failures

* fix(gdb): wait for inferior stop and bound MI buffering

* fix(cutter): retain failed receipt acknowledgements

* fix(rizin): return retained session identity on rebind failure

* fix(gdb): preserve startup cancellation and lossy status

* fix(rizin): retain launch provenance and assemble frames once

* fix(cutter): recover receipts across callers and bound discovery

* docs: describe debugger evidence and bridge recovery

* fix(process): keep cleanup incomplete while writers remain

Stopping a debugger no longer reports success when inherited output
streams stay open, so BinarySession cannot reopen a binary a child is
still changing. Concurrent Cutter cleanup retries share one receipt
acknowledgement instead of treating a consumed receipt as failure.

* fix(debuggers): recover cleanup with captured process identities

* fix(analysis): retire sessions after unrestricted commands

* fix(cli): preserve completed evidence after cleanup recovery

* fix(cutter): bound frame collection and coalesce cleanup

* fix(windows): resolve selected provider paths absolutely

* fix(gdb): report decoding loss in failed startup evidence

* fix(rizin): scope decoding loss to collected command frames

* test(mcp): cover snapshot retirement in reverse tools CI

---------

Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com>
Co-authored-by: REA Maintainers <noreply@github.com>
Co-authored-by: OpenAI Codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-14 04:47:34 +08:00

430 lines
13 KiB
TypeScript

import pino from "pino";
import { Cli, z } from "incur";
import { afterEach, expect, it, vi } from "vitest";
import { rm } from "node:fs/promises";
import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js";
import { CLI_COMMANDS } from "../../../src/cliCommandNames.js";
import {
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisOutputError,
} from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
createEvidence,
evidenceSchema,
} from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js";
import { createReverseEngineeringCommandPort } from "../../../src/composition/reverseEngineering.js";
import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
import {
createArtifact,
providerSnapshot,
} from "../../support/reverseEngineeringCompositionFixtures.js";
import type { CliInstance } from "../../../src/cli/types.js";
import type { ReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js";
const originalExitCode = process.exitCode;
afterEach(() => {
process.exitCode = originalExitCode;
});
const observation = (completionStatus: "complete" | "unknown") =>
createEvidence(
undefined,
{ id: "gnu.objdump", name: "GNU objdump", version: null },
{
predicateType: "rea.reverse-engineering.command-observation",
operation: "inspect_with_objdump",
parameters: { operation: "file_headers" },
result: { completion_status: completionStatus },
rawResult: { completion_status: completionStatus },
},
);
const runObjdump = async (
service: Partial<ReverseEngineeringService>,
path = "/tmp/sample",
) => {
const commands = new Map<
string,
{
run(input: {
args: { path: string; operation: string };
options: { followDebugLinks: boolean };
}): Promise<unknown>;
}
>();
const cli = {
command: (
name: string,
definition: {
run(input: {
args: { path: string; operation: string };
options: { followDebugLinks: boolean };
}): Promise<unknown>;
},
) => {
commands.set(name, definition);
return cli;
},
} as unknown as CliInstance;
registerReverseEngineeringCommands(
cli,
pino({ enabled: false }),
{},
undefined,
() => service as ReverseEngineeringService,
);
const command = commands.get(CLI_COMMANDS.inspectWithObjdump);
if (command === undefined)
throw new Error("objdump CLI command was not registered");
return command.run({
args: { path, operation: "file_headers" },
options: { followDebugLinks: false },
});
};
it("lists objdump architectures without inventing an artifact path", async () => {
const evidence = observation("complete");
const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence));
const commands = new Map<
string,
{
readonly args?: { safeParse(input: unknown): { success: boolean } };
run(input: { args?: unknown; options?: unknown }): Promise<unknown>;
}
>();
const cli = {
command: (_name: string, definition: unknown) => {
commands.set(
_name,
definition as {
readonly args?: {
safeParse(input: unknown): { success: boolean };
};
run(input: { args?: unknown; options?: unknown }): Promise<unknown>;
},
);
return cli;
},
} as unknown as CliInstance;
registerReverseEngineeringCommands(
cli,
pino({ enabled: false }),
{},
undefined,
() =>
({
inspectWithObjdump,
close: vi.fn(async () => undefined),
}) as unknown as ReverseEngineeringService,
);
const command = commands.get(CLI_COMMANDS.listObjdumpArchitectures);
if (command === undefined)
throw new Error("architecture command was not registered");
const result = await command.run({});
expect(result).toEqual(evidence);
expect(inspectWithObjdump).toHaveBeenCalledWith(
{ operation: "architectures" },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
const artifactCommand = commands.get(CLI_COMMANDS.inspectWithObjdump);
expect(
artifactCommand?.args?.safeParse({
path: "/tmp/sample",
operation: "dwarf",
}).success,
).toBe(true);
expect(
artifactCommand?.args?.safeParse({
path: "/tmp/sample",
operation: "architectures",
}).success,
).toBe(false);
});
it("promotes only complete production cleanup-only Evidence after CLI cleanup retry", async () => {
const { directory, path } = await createArtifact();
const owner = {
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
} as unknown as ProviderProcessSupervisor;
const service = new CoreReverseEngineeringService({
commands: createReverseEngineeringCommandPort({}),
runOwned: vi.fn(async () => {
throw new OwnedCommandFailure(
"process",
"Cleanup after process completion was incomplete.",
providerSnapshot(),
"provider process still running",
undefined,
["owned-run"],
owner,
);
}),
});
const inspect = service.inspectWithObjdump.bind(service);
let producedCleanupFailure: AnalysisOutputError | undefined;
let cleanupIncompleteBeforeClose: boolean | undefined;
let completionBeforeClose: unknown;
vi.spyOn(service, "inspectWithObjdump").mockImplementation(
async (...args) => {
const result = await inspect(...args);
if (!result.ok && result.error instanceof AnalysisOutputError) {
producedCleanupFailure = result.error;
cleanupIncompleteBeforeClose = result.error.cleanupIncomplete;
const partialEvidence = evidenceSchema.safeParse(
result.error.partialObservation,
);
if (partialEvidence.success)
completionBeforeClose = partialEvidence.data.normalized_result;
}
return result;
},
);
try {
const result = await runObjdump(service, path);
expect(result).toMatchObject({
provider: { id: "gnu.objdump" },
normalized_result: { completion_status: "complete" },
});
expect(producedCleanupFailure?.cleanupOnly).toBe(true);
expect(cleanupIncompleteBeforeClose).toBe(true);
expect(completionBeforeClose).toMatchObject({
completion_status: "complete",
});
expect(producedCleanupFailure).toMatchObject({
cleanupIncomplete: false,
cleanup: { resources: ["owned-run"] },
partialObservation: {
normalized_result: { completion_status: "complete" },
},
});
expect(owner.stop).toHaveBeenCalledOnce();
} finally {
await service.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
it("preserves an operation timeout and unknown partial Evidence after cleanup retry", async () => {
const { directory, path } = await createArtifact();
const owner = {
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
} as unknown as ProviderProcessSupervisor;
const service = new CoreReverseEngineeringService({
commands: createReverseEngineeringCommandPort({}),
runOwned: vi.fn(async () => {
throw new OwnedCommandFailure(
"timeout",
"Provider command timed out.",
providerSnapshot({ exitCode: null }),
"provider process still running",
undefined,
["owned-run"],
owner,
);
}),
});
let timeout: AnalysisTimeoutError | undefined;
const inspect = service.inspectWithObjdump.bind(service);
vi.spyOn(service, "inspectWithObjdump").mockImplementation(
async (...args) => {
const result = await inspect(...args);
if (!result.ok && result.error instanceof AnalysisTimeoutError)
timeout = result.error;
return result;
},
);
try {
const result = await runObjdump(service, path);
expect(result).toMatchObject({
code: "provider_timeout",
details: {
partial_observation: {
normalized_result: { completion_status: "unknown" },
},
},
});
expect(timeout).toBeInstanceOf(AnalysisTimeoutError);
expect(timeout?.cleanupIncomplete).toBe(false);
expect(owner.stop).toHaveBeenCalledOnce();
} finally {
await service.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
it("preserves an independent timeout after cleanup succeeds", async () => {
const evidence = observation("unknown");
const error = new AnalysisTimeoutError("inspect_with_objdump", 1_000, {
cleanup: { reason: "first stop incomplete", resources: ["owned-run"] },
partialObservation: evidence,
});
const service = {
inspectWithObjdump: vi.fn(async () => err(error)),
close: vi.fn(async () => {
error.resolveCleanup();
}),
};
const result = await runObjdump(service);
expect(result).toMatchObject({
code: "provider_timeout",
details: {
partial_observation: {
normalized_result: { completion_status: "unknown" },
},
},
});
expect(error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(error).details).not.toHaveProperty("cleanup");
});
it("does not project cancellation cleanup as incomplete after a successful retry", async () => {
const error = new AnalysisCancelledError("inspect_with_objdump", {
cleanup: { reason: "first stop incomplete", resources: ["owned-run"] },
partialObservation: observation("unknown"),
});
const service = {
inspectWithObjdump: vi.fn(async () => err(error)),
close: vi.fn(async () => {
error.resolveCleanup();
}),
};
await runObjdump(service);
expect(error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(error).details).toMatchObject({
cleanup: "complete",
});
});
it("parses the Rizin inferior TTY as an option and passes it to the manager", async () => {
const commands = new Map<
string,
{
run(input: {
args: Record<string, unknown>;
options: Record<string, unknown>;
}): Promise<unknown>;
}
>();
const cli = {
command: (
name: string,
definition: {
run(input: {
args: Record<string, unknown>;
options: Record<string, unknown>;
}): Promise<unknown>;
},
) => {
commands.set(name, definition);
return cli;
},
} as unknown as CliInstance;
const evidence = createEvidence(
undefined,
{ id: "rizin.debugger", name: "Rizin debugger", version: null },
{
predicateType: "rea.debugger.command-observation",
operation: "rizin_debug_command",
parameters: {
session_id: "session-1",
command: "dr",
inferior_tty: "/dev/pts/7",
},
result: { output: "registers" },
rawResult: { output: "registers" },
},
);
const start = vi
.spyOn(RizinDebugSessionManager.prototype, "start")
.mockResolvedValue(ok({ session_id: "session-1", backend: null }));
const execute = vi
.spyOn(RizinDebugSessionManager.prototype, "execute")
.mockResolvedValue(
ok({
evidence,
result: {
command: "dr",
output: "registers",
output_scope: "command_and_interleaved_session_output",
output_truncated: false,
completion_status: "complete",
backend: null,
},
}),
);
vi.spyOn(RizinDebugSessionManager.prototype, "close").mockResolvedValue(
ok({ target_state: "unknown" }),
);
vi.spyOn(RizinDebugSessionManager.prototype, "closeAll").mockResolvedValue(
undefined,
);
registerReverseEngineeringCommands(cli, pino({ enabled: false }), {});
const command = commands.get(CLI_COMMANDS.debugWithRizin);
if (command === undefined)
throw new Error("Rizin CLI command was not registered");
await command.run({
args: { path: "./sample.bin", command: "dr" },
options: { inferiorTty: "/dev/pts/7" },
});
expect(start).toHaveBeenCalledWith(
{ path: "./sample.bin", command: "dr", inferior_tty: "/dev/pts/7" },
expect.any(AbortSignal),
);
expect(execute).toHaveBeenCalledWith(
"session-1",
"dr",
expect.any(AbortSignal),
);
const parserCli = Cli.create("rea-test", { version: "1.0.0", sync: false });
registerReverseEngineeringCommands(
parserCli as unknown as CliInstance,
pino({ enabled: false }),
{},
);
const commandContract = Cli.toCommands
.get(parserCli)
?.get(CLI_COMMANDS.debugWithRizin);
expect(commandContract).toBeDefined();
const argsSchema =
commandContract === undefined
? undefined
: Reflect.get(commandContract, "args");
const optionsSchema =
commandContract === undefined
? undefined
: Reflect.get(commandContract, "options");
if (
!(argsSchema instanceof z.ZodObject) ||
!(optionsSchema instanceof z.ZodObject)
)
throw new Error("Rizin CLI args/options schemas were not registered");
expect(argsSchema.parse({ path: "./sample.bin", command: "dr" })).toEqual({
path: "./sample.bin",
command: "dr",
});
expect(optionsSchema.parse({ inferiorTty: "/dev/pts/7" })).toMatchObject({
inferiorTty: "/dev/pts/7",
});
});