* 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>
1048 lines
33 KiB
TypeScript
1048 lines
33 KiB
TypeScript
import { rm } from "node:fs/promises";
|
|
import type { Stats } from "node:fs";
|
|
|
|
import { expect, it, vi } from "vitest";
|
|
import { registerSharedReverseEngineeringEdgeCaseTests } from "../support/reverseEngineeringCompositionEdgeCases.js";
|
|
import { registerOwnedRizinCommandEvidenceTests } from "../support/ownedRizinCommandEvidenceTests.js";
|
|
import {
|
|
createArtifact,
|
|
providerSnapshot,
|
|
} from "../support/reverseEngineeringCompositionFixtures.js";
|
|
|
|
import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../src/domain/analysisErrorCore.js";
|
|
import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js";
|
|
import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js";
|
|
import { AnalysisResourceConstraintError } from "../../src/domain/analysisErrorCore.js";
|
|
import { objdumpCommand } from "../../src/objdump/ObjdumpCommand.js";
|
|
import {
|
|
OwnedCommandFailure,
|
|
runOwnedCommand,
|
|
} from "../../src/process/OwnedCommand.js";
|
|
import type { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js";
|
|
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js";
|
|
|
|
registerSharedReverseEngineeringEdgeCaseTests();
|
|
registerOwnedRizinCommandEvidenceTests({ sweepTokenOwnedProcesses: true });
|
|
|
|
it("records pathless objdump architecture support as provider capability evidence", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
expect(objdumpCommand({ operation: "architectures" })).toEqual(["-i"]);
|
|
const digest = vi.fn(async () => "a".repeat(64));
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort(process.env),
|
|
digest,
|
|
run: async () => ({ stdout: "elf64-x86-64", stderr: "" }),
|
|
});
|
|
try {
|
|
const result = await service.inspectWithObjdump({
|
|
operation: "architectures",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.subject).toBeNull();
|
|
expect(result.value.parameters).not.toHaveProperty("path");
|
|
expect(result.value.parameters).not.toHaveProperty(
|
|
"artifact_sha256_before",
|
|
);
|
|
expect(result.value.normalized_result).toMatchObject({
|
|
artifact_sha256_after: null,
|
|
artifact_changed: null,
|
|
});
|
|
const contract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
|
|
({ name }) => name === "inspect_with_objdump",
|
|
);
|
|
expect(contract?.outputSchema.safeParse(result.value).success).toBe(true);
|
|
expect(digest).not.toHaveBeenCalled();
|
|
expect(
|
|
await service.inspectWithObjdump({ path, operation: "architectures" }),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
|
const ordinaryInspection = await service.inspectWithObjdump({
|
|
path,
|
|
operation: "file_headers",
|
|
});
|
|
expect(ordinaryInspection.ok).toBe(true);
|
|
expect(digest).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses the host environment when the service environment is omitted", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const key = "REA_REVERSE_TOOLS_ENV_FALLBACK_TEST";
|
|
const previous = process.env[key];
|
|
process.env[key] = "host-value";
|
|
let observedEnvironment: NodeJS.ProcessEnv | undefined;
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort(process.env),
|
|
run: async (_command, _args, options) => {
|
|
observedEnvironment = options?.env;
|
|
return { stdout: "ok", stderr: "" };
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump({
|
|
path,
|
|
operation: "file_headers",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(observedEnvironment?.[key]).toBe("host-value");
|
|
} finally {
|
|
if (previous === undefined) delete process.env[key];
|
|
else process.env[key] = previous;
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["cancellation", "deadline"] as const)(
|
|
"interrupts a pending stat on %s and observes its eventual rejection",
|
|
async (interruption) => {
|
|
const { directory, path } = await createArtifact();
|
|
const controller = new AbortController();
|
|
let rejectStat: ((cause: Error) => void) | undefined;
|
|
let announceStat: (() => void) | undefined;
|
|
const statStarted = new Promise<void>((resolve) => {
|
|
announceStat = resolve;
|
|
});
|
|
const service = new ReverseEngineeringService({
|
|
stat: async () => {
|
|
announceStat?.();
|
|
return await new Promise<Stats>((_resolve, reject) => {
|
|
rejectStat = reject;
|
|
});
|
|
},
|
|
run: async () => ({ stdout: "", stderr: "" }),
|
|
});
|
|
|
|
try {
|
|
if (interruption === "deadline") vi.useFakeTimers();
|
|
const resultPromise = service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal: controller.signal },
|
|
);
|
|
await statStarted;
|
|
if (interruption === "cancellation") controller.abort();
|
|
else await vi.advanceTimersByTimeAsync(120_000);
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag:
|
|
interruption === "cancellation"
|
|
? "AnalysisCancelledError"
|
|
: "AnalysisTimeoutError",
|
|
},
|
|
});
|
|
rejectStat?.(new Error("late stat failure"));
|
|
await Promise.resolve();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("handles cancellation that races with stat listener registration", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let aborted = false;
|
|
const signal = {
|
|
get aborted() {
|
|
return aborted;
|
|
},
|
|
addEventListener: (
|
|
_type: string,
|
|
listener: Parameters<EventTarget["addEventListener"]>[1],
|
|
) => {
|
|
aborted = true;
|
|
if (typeof listener === "function") listener(new Event("abort"));
|
|
else listener.handleEvent(new Event("abort"));
|
|
},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => true,
|
|
onabort: null,
|
|
reason: undefined,
|
|
throwIfAborted: () => {},
|
|
} as AbortSignal;
|
|
const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats);
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ stat, run });
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(stat).not.toHaveBeenCalled();
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("handles a synchronous stat provider throw as an artifact input failure", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const stat = vi.fn(() => {
|
|
throw Object.assign(new Error("stat failed before returning a promise"), {
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({
|
|
stat: stat as unknown as (path: string) => Promise<Stats>,
|
|
run,
|
|
});
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump({
|
|
path,
|
|
operation: "file_headers",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisInputError" },
|
|
});
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cancels an artifact stream when cancellation races with its listener registration", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let aborted = false;
|
|
let registrations = 0;
|
|
const signal = {
|
|
get aborted() {
|
|
return aborted;
|
|
},
|
|
addEventListener: (
|
|
_type: string,
|
|
listener: Parameters<EventTarget["addEventListener"]>[1],
|
|
) => {
|
|
registrations += 1;
|
|
if (registrations === 2) {
|
|
aborted = true;
|
|
if (typeof listener === "function") listener(new Event("abort"));
|
|
else listener.handleEvent(new Event("abort"));
|
|
}
|
|
},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => true,
|
|
onabort: null,
|
|
reason: undefined,
|
|
throwIfAborted: () => {},
|
|
} as AbortSignal;
|
|
const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats);
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ stat, run });
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(registrations).toBe(2);
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cancels before stream listeners when the signal changes after stat", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let abortedReads = 0;
|
|
const signal = {
|
|
get aborted() {
|
|
abortedReads += 1;
|
|
return abortedReads >= 5;
|
|
},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => true,
|
|
onabort: null,
|
|
reason: undefined,
|
|
throwIfAborted: () => {},
|
|
} as AbortSignal;
|
|
const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats);
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ stat, run });
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
// Cancellation checks may be repeated by the digest cleanup path.
|
|
expect(abortedReads).toBeGreaterThanOrEqual(5);
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects malformed Rizin requests before touching the provider", async () => {
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ run });
|
|
|
|
const result = await service.executeRizinCommand({ path: "sample.bin" });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisInputError", operation: "execute_rizin_command" },
|
|
});
|
|
expect(run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects directories as artifacts before invoking the provider", async () => {
|
|
const { directory } = await createArtifact();
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ run });
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump({
|
|
path: directory,
|
|
operation: "file_headers",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisInputError", operation: "inspect_with_objdump" },
|
|
});
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects new operations after service shutdown begins", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ run });
|
|
|
|
try {
|
|
await service.close();
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe("AnalysisOutputError");
|
|
expect(result.error.message).toContain(
|
|
"Reverse-engineering service is closed.",
|
|
);
|
|
}
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects work after reaching the concurrent provider-operation limit", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let releaseProviders: (() => void) | undefined;
|
|
const providerGate = new Promise<void>((resolve) => {
|
|
releaseProviders = resolve;
|
|
});
|
|
let providerCalls = 0;
|
|
const service = new ReverseEngineeringService({
|
|
run: async () => {
|
|
providerCalls += 1;
|
|
await providerGate;
|
|
return { stdout: "ok", stderr: "" };
|
|
},
|
|
});
|
|
|
|
try {
|
|
const admitted = Array.from({ length: 8 }, () =>
|
|
service.executeRizinCommand({ path, command: "iI" }),
|
|
);
|
|
const rejected = await service.executeRizinCommand({ path, command: "iI" });
|
|
|
|
expect(rejected.ok).toBe(false);
|
|
if (!rejected.ok) {
|
|
expect(rejected.error._tag).toBe("AnalysisResourceConstraintError");
|
|
expect(rejected.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
|
if (rejected.error instanceof AnalysisResourceConstraintError)
|
|
expect(rejected.error.reportedLimits).toMatchObject({
|
|
max_concurrent_operations: 8,
|
|
active_operations: 8,
|
|
});
|
|
expect(rejected.error.message).toContain(
|
|
"Concurrent reverse-engineering operation capacity reached.",
|
|
);
|
|
}
|
|
expect(providerCalls).toBe(0);
|
|
|
|
releaseProviders?.();
|
|
const results = await Promise.all(admitted);
|
|
expect(results.every((result) => result.ok)).toBe(true);
|
|
expect(providerCalls).toBe(8);
|
|
} finally {
|
|
releaseProviders?.();
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cancels when an AbortSignal becomes aborted during listener registration", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const controller = new AbortController();
|
|
let abortedReads = 0;
|
|
const signal = new Proxy(controller.signal, {
|
|
get(target, property) {
|
|
if (property === "aborted") return ++abortedReads > 1;
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === "function" ? value.bind(target) : value;
|
|
},
|
|
});
|
|
const run = vi.fn(async () => ({ stdout: "", stderr: "" }));
|
|
const service = new ReverseEngineeringService({ run });
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(abortedReads).toBe(2);
|
|
expect(run).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("drains an active provider operation before service shutdown resolves", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let signal: AbortSignal | undefined;
|
|
let releaseRun: (() => void) | undefined;
|
|
let runStarted: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
runStarted = resolve;
|
|
});
|
|
const service = new ReverseEngineeringService({
|
|
run: async (_command, _args, options) => {
|
|
signal = options?.signal;
|
|
runStarted?.();
|
|
await new Promise<void>((resolve) => {
|
|
releaseRun = resolve;
|
|
});
|
|
return { stdout: "finished", stderr: "" };
|
|
},
|
|
});
|
|
|
|
try {
|
|
const operation = service.executeRizinCommand({ path, command: "iI" });
|
|
await started;
|
|
let closed = false;
|
|
const closing = service.close().then(() => {
|
|
closed = true;
|
|
});
|
|
expect(signal?.aborted).toBe(true);
|
|
expect(closed).toBe(false);
|
|
releaseRun?.();
|
|
await closing;
|
|
expect(closed).toBe(true);
|
|
await operation;
|
|
} finally {
|
|
releaseRun?.();
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("relays caller cancellation to the admitted provider signal", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const controller = new AbortController();
|
|
const addAbortListener = vi.spyOn(controller.signal, "addEventListener");
|
|
let announceStarted: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
announceStarted = resolve;
|
|
});
|
|
let releaseRun: (() => void) | undefined;
|
|
let providerSignal: AbortSignal | undefined;
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort(process.env),
|
|
run: async (_command, _args, options) => {
|
|
providerSignal = options?.signal;
|
|
announceStarted?.();
|
|
await new Promise<void>((resolve) => {
|
|
releaseRun = resolve;
|
|
});
|
|
return { stdout: "captured", stderr: "" };
|
|
},
|
|
});
|
|
let operation: ReturnType<typeof service.inspectWithObjdump> | undefined;
|
|
|
|
try {
|
|
operation = service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal: controller.signal },
|
|
);
|
|
await started;
|
|
controller.abort();
|
|
expect(addAbortListener).toHaveBeenCalledWith(
|
|
"abort",
|
|
expect.any(Function),
|
|
{ once: true },
|
|
);
|
|
expect(providerSignal?.aborted).toBe(true);
|
|
releaseRun?.();
|
|
await expect(operation).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
} finally {
|
|
releaseRun?.();
|
|
if (operation !== undefined) await operation.catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("passes an AbortSignal-like caller value through without relaying it", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const signal = {
|
|
aborted: false,
|
|
addEventListener: vi.fn(() => {
|
|
throw new Error(
|
|
"A non-native signal must not register a relay listener.",
|
|
);
|
|
}),
|
|
removeEventListener: vi.fn(),
|
|
} as unknown as AbortSignal;
|
|
const digest = vi.fn(async (_path: string, receivedSignal?: AbortSignal) => {
|
|
expect(receivedSignal).toBe(signal);
|
|
return "a".repeat(64);
|
|
});
|
|
const run = vi.fn(async (_command, _args, options) => {
|
|
expect(options?.signal).toBe(signal);
|
|
return { stdout: "ok", stderr: "" };
|
|
});
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort(process.env),
|
|
digest,
|
|
run,
|
|
});
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(signal.addEventListener).not.toHaveBeenCalled();
|
|
expect(signal.removeEventListener).toHaveBeenCalledTimes(1);
|
|
expect(digest).toHaveBeenCalledTimes(2);
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cancels after operation admission and before starting the artifact digest", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
let reads = 0;
|
|
const signal = {
|
|
get aborted() {
|
|
reads += 1;
|
|
return reads > 1;
|
|
},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
throwIfAborted() {},
|
|
onabort: null,
|
|
reason: undefined,
|
|
dispatchEvent: () => true,
|
|
} as AbortSignal;
|
|
let digestStarted = false;
|
|
const service = new ReverseEngineeringService({
|
|
digest: async () => {
|
|
digestStarted = true;
|
|
return "a".repeat(64);
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.inspectWithObjdump(
|
|
{ path, operation: "file_headers" },
|
|
{ signal },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(reads).toBeGreaterThanOrEqual(2);
|
|
expect(digestStarted).toBe(false);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves raw retained byte counts when output text replaces invalid UTF-8", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const snapshot = providerSnapshot({
|
|
stdout: { text: "�", bytes: 1, observedBytes: 1, invalidUtf8: true },
|
|
stderr: { text: "�", bytes: 2, observedBytes: 2, invalidUtf8: true },
|
|
exitCode: 7,
|
|
});
|
|
const service = new ReverseEngineeringService({
|
|
runOwned: async () => {
|
|
throw new OwnedCommandFailure(
|
|
"cancelled",
|
|
"Command was cancelled after output capture",
|
|
snapshot,
|
|
);
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
capturedOutput: {
|
|
stdout: "�",
|
|
stderr: "�",
|
|
stdout_bytes: 1,
|
|
stderr_bytes: 2,
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves the provider remediation when owned process preparation fails", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const remediation = new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"execute_rizin_command",
|
|
"Rizin needs permission to inspect this process",
|
|
{ userMessage: "Grant process inspection permission, then retry." },
|
|
);
|
|
const service = new ReverseEngineeringService({
|
|
runOwned: async () => {
|
|
throw new OwnedCommandFailure(
|
|
"process",
|
|
"Could not prepare process ownership",
|
|
null,
|
|
null,
|
|
{ cause: remediation },
|
|
);
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
providerId: "rizin",
|
|
reason: remediation.reason,
|
|
userMessage: remediation.userMessage,
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["Error", new Error("cleanup state unknown"), "cleanup state unknown"],
|
|
["non-Error", "cleanup state unknown", "cleanup state unknown"],
|
|
] as const)(
|
|
"preserves a thrown %s cleanup reason while blocking new provider work",
|
|
async (_label, cause, expectedReason) => {
|
|
const { directory, path } = await createArtifact();
|
|
const cleanupOwner = {
|
|
stop: vi.fn(async () => Promise.reject(cause)),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const runOwned = vi.fn<typeof runOwnedCommand>(async () => {
|
|
throw new OwnedCommandFailure(
|
|
"cancelled",
|
|
"command cancelled",
|
|
providerSnapshot({ exitCode: null }),
|
|
"cleanup pending",
|
|
undefined,
|
|
["run-id", "pid:42"],
|
|
cleanupOwner,
|
|
);
|
|
});
|
|
const service = new ReverseEngineeringService({ runOwned });
|
|
|
|
try {
|
|
const first = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(first.ok).toBe(false);
|
|
|
|
const blocked = await service.executeRizinCommand({
|
|
path,
|
|
command: "iI",
|
|
});
|
|
expect(blocked).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisOutputError",
|
|
cleanup: { reason: expectedReason, resources: ["run-id", "pid:42"] },
|
|
},
|
|
});
|
|
|
|
await expect(service.close()).rejects.toMatchObject({
|
|
cleanup: { reason: expectedReason, resources: ["run-id", "pid:42"] },
|
|
});
|
|
expect(cleanupOwner.stop).toHaveBeenCalledTimes(2);
|
|
expect(runOwned).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await service.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("releases a retained provider owner after cleanup succeeds", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const cleanupOwner = {
|
|
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const runOwned = vi
|
|
.fn<typeof runOwnedCommand>()
|
|
.mockRejectedValueOnce(
|
|
new OwnedCommandFailure(
|
|
"cancelled",
|
|
"command cancelled",
|
|
providerSnapshot({ exitCode: null }),
|
|
"cleanup pending",
|
|
undefined,
|
|
["run-id", "pid:42"],
|
|
cleanupOwner,
|
|
),
|
|
)
|
|
.mockResolvedValue(providerSnapshot());
|
|
const service = new ReverseEngineeringService({ runOwned });
|
|
|
|
try {
|
|
const first = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(first.ok).toBe(false);
|
|
const recovered = await service.executeRizinCommand({
|
|
path,
|
|
command: "iI",
|
|
});
|
|
|
|
expect(recovered.ok).toBe(true);
|
|
expect(cleanupOwner.stop).toHaveBeenCalledTimes(1);
|
|
expect(runOwned).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await service.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retains a capability failure when its injected owned runner also reports incomplete cleanup", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const capability = new AnalysisCapabilityUnavailableError(
|
|
"rizin",
|
|
"execute_rizin_command",
|
|
"Ownership inspection is unavailable.",
|
|
{ diagnostics: { source: "owned-runner" } },
|
|
);
|
|
const cleanupOwner = {
|
|
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const runOwned = vi
|
|
.fn<typeof runOwnedCommand>()
|
|
.mockRejectedValueOnce(
|
|
new OwnedCommandFailure(
|
|
"process",
|
|
"Ownership inspection failed after launch.",
|
|
null,
|
|
"owned descendant remains",
|
|
{ cause: capability },
|
|
["run-id", "pid:42"],
|
|
cleanupOwner,
|
|
),
|
|
)
|
|
.mockResolvedValue(providerSnapshot());
|
|
const service = new ReverseEngineeringService({ runOwned });
|
|
|
|
try {
|
|
const first = await service.executeRizinCommand({ path, command: "iI" });
|
|
if (first.ok) throw new Error("Expected the capability failure");
|
|
expect(first.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
|
expect(first.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
|
expect(first.error.diagnostics).toEqual({ source: "owned-runner" });
|
|
expect(first.error.cleanupIncomplete).toBe(true);
|
|
expect(first.error.cleanup).toMatchObject({
|
|
reason: "owned descendant remains",
|
|
resources: ["run-id", "pid:42"],
|
|
});
|
|
|
|
const recovered = await service.executeRizinCommand({
|
|
path,
|
|
command: "iI",
|
|
});
|
|
if (!recovered.ok) throw recovered.error;
|
|
|
|
expect(cleanupOwner.stop).toHaveBeenCalledOnce();
|
|
expect(first.error.cleanupIncomplete).toBe(false);
|
|
expect(runOwned).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await service.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves a provider timeout when cancellation arrives during the final digest", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const controller = new AbortController();
|
|
let digests = 0;
|
|
const service = new ReverseEngineeringService({
|
|
digest: async () => {
|
|
digests += 1;
|
|
if (digests === 2) controller.abort();
|
|
return "a".repeat(64);
|
|
},
|
|
run: async () => {
|
|
throw Object.assign(new Error("provider timed out"), {
|
|
stdout: "partial timeout output",
|
|
stderr: "timeout diagnostic",
|
|
code: "ETIMEDOUT",
|
|
signal: null,
|
|
killed: true,
|
|
});
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand(
|
|
{ path, command: "iI" },
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisTimeoutError",
|
|
capturedOutput: {
|
|
stdout: "partial timeout output",
|
|
stderr: "timeout diagnostic",
|
|
},
|
|
partialObservation: {
|
|
raw_result: {
|
|
stdout: "partial timeout output",
|
|
completion_status: "unknown",
|
|
},
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining("AnalysisTimeoutError"),
|
|
]),
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("classifies E2BIG as a provider command resource constraint", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const cleanupOwner = {
|
|
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const runOwned = vi
|
|
.fn<typeof runOwnedCommand>()
|
|
.mockRejectedValueOnce(
|
|
new OwnedCommandFailure(
|
|
"process",
|
|
"Command launch failed.",
|
|
null,
|
|
"owned descendant remains",
|
|
{
|
|
cause: Object.assign(new Error("argument list too long"), {
|
|
code: "E2BIG",
|
|
}),
|
|
},
|
|
["run-id", "pid:42"],
|
|
cleanupOwner,
|
|
),
|
|
)
|
|
.mockResolvedValue(providerSnapshot());
|
|
const service = new ReverseEngineeringService({
|
|
runOwned,
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "transport",
|
|
operation: "execute_rizin_command",
|
|
},
|
|
});
|
|
if (!result.ok)
|
|
expect(result.error.message).toContain(
|
|
"The host rejected the provider command because its argument list exceeds the process launch limit.",
|
|
);
|
|
if (!result.ok) {
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanup?.resources).toEqual(["run-id", "pid:42"]);
|
|
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
|
if (result.error instanceof AnalysisResourceConstraintError)
|
|
expect(result.error.reportedLimits).toMatchObject({
|
|
argument_bytes: expect.any(Number),
|
|
});
|
|
}
|
|
const retried = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(retried.ok).toBe(true);
|
|
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(cleanupOwner.stop).toHaveBeenCalledOnce();
|
|
expect(runOwned).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves completed command Evidence while its post-run digest awaits cleanup", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const service = new ReverseEngineeringService({
|
|
runOwned: async () => {
|
|
throw new OwnedCommandFailure(
|
|
"process",
|
|
"Owned process cleanup remains incomplete.",
|
|
providerSnapshot({ exitCode: 0 }),
|
|
"descendant remains active",
|
|
undefined,
|
|
["run-id", "pid:42"],
|
|
);
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisOutputError",
|
|
partialObservation: {
|
|
raw_result: {
|
|
exit_code: 0,
|
|
completion_status: "complete",
|
|
artifact_sha256_after: null,
|
|
artifact_changed: null,
|
|
},
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(
|
|
"owned provider cleanup was incomplete at observation time",
|
|
),
|
|
]),
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("records an explicit loss marker when provider bytes are invalid UTF-8", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const service = new ReverseEngineeringService({
|
|
runOwned: async () =>
|
|
providerSnapshot({
|
|
stdout: {
|
|
text: "captured � output",
|
|
bytes: 17,
|
|
observedBytes: 17,
|
|
invalidUtf8: true,
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.value).toMatchObject({
|
|
raw_result: { stdout: "captured � output" },
|
|
});
|
|
expect(result.value.limitations).toContain(
|
|
"Captured stdout bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.",
|
|
);
|
|
}
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"stderr only",
|
|
{
|
|
stderr: {
|
|
text: "diagnostic � output",
|
|
bytes: 22,
|
|
observedBytes: 22,
|
|
invalidUtf8: true,
|
|
},
|
|
},
|
|
"stderr",
|
|
],
|
|
[
|
|
"both streams",
|
|
{
|
|
stdout: {
|
|
text: "captured � output",
|
|
bytes: 17,
|
|
observedBytes: 17,
|
|
invalidUtf8: true,
|
|
},
|
|
stderr: {
|
|
text: "diagnostic � output",
|
|
bytes: 22,
|
|
observedBytes: 22,
|
|
invalidUtf8: true,
|
|
},
|
|
},
|
|
"stdout and stderr",
|
|
],
|
|
] as const)(
|
|
"records invalid UTF-8 loss for %s",
|
|
async (_label, overrides, streams) => {
|
|
const { directory, path } = await createArtifact();
|
|
const service = new ReverseEngineeringService({
|
|
runOwned: async () => providerSnapshot(overrides),
|
|
});
|
|
|
|
try {
|
|
const result = await service.executeRizinCommand({ path, command: "iI" });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok)
|
|
expect(result.value.limitations).toContain(
|
|
`Captured ${streams} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`,
|
|
);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|