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, path = "/tmp/sample", ) => { const commands = new Map< string, { run(input: { args: { path: string; operation: string }; options: { followDebugLinks: boolean }; }): Promise; } >(); const cli = { command: ( name: string, definition: { run(input: { args: { path: string; operation: string }; options: { followDebugLinks: boolean }; }): Promise; }, ) => { 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; } >(); 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; }, ); 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; options: Record; }): Promise; } >(); const cli = { command: ( name: string, definition: { run(input: { args: Record; options: Record; }): Promise; }, ) => { 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", }); });