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: "<22>", bytes: 1, observedBytes: 1, invalidUtf8: true },
|
||
stderr: { text: "<22>", 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: "<22>",
|
||
stderr: "<22>",
|
||
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 <20> 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 <20> 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 <20> output",
|
||
bytes: 22,
|
||
observedBytes: 22,
|
||
invalidUtf8: true,
|
||
},
|
||
},
|
||
"stderr",
|
||
],
|
||
[
|
||
"both streams",
|
||
{
|
||
stdout: {
|
||
text: "captured <20> output",
|
||
bytes: 17,
|
||
observedBytes: 17,
|
||
invalidUtf8: true,
|
||
},
|
||
stderr: {
|
||
text: "diagnostic <20> 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 });
|
||
}
|
||
},
|
||
);
|