diff --git a/src/application/pcap/PcapAnalysisService.test.ts b/src/application/pcap/PcapAnalysisService.test.ts new file mode 100644 index 0000000000..ec7e64763f --- /dev/null +++ b/src/application/pcap/PcapAnalysisService.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "vitest"; + +import { PcapAnalysisService } from "./PcapAnalysisService.js"; + +it("preserves the exact caller reason when pcap admission is already aborted", async () => { + const reason = Object.freeze({ requested: "stop" }); + const controller = new AbortController(); + controller.abort(reason); + let calls = 0; + const service = new PcapAnalysisService({ + inspectAvailability: () => Promise.reject(new Error("unused")), + close: () => Promise.resolve(), + execute: () => { + calls += 1; + return Promise.reject(new Error("provider must not run")); + }, + }); + const result = await service.execute( + "inspect_pcap_client", + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(calls).toBe(0); +}); diff --git a/src/application/pcap/PcapAnalysisService.ts b/src/application/pcap/PcapAnalysisService.ts index 23b968e582..cf2c8a94c3 100644 --- a/src/application/pcap/PcapAnalysisService.ts +++ b/src/application/pcap/PcapAnalysisService.ts @@ -24,7 +24,11 @@ export class PcapAnalysisService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { + cause: options.signal.reason, + }), + ); const request = pcapRequestSchema.safeParse({ operation, input }); if (!request.success) return err(new AnalysisInputError(operation, { cause: request.error })); diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 0979313365..b082ac9d14 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -250,7 +250,9 @@ export class ReverseEngineeringService { signal?: AbortSignal, ): Promise> { if (signal?.aborted) - return Promise.resolve(err(new AnalysisCancelledError(operation))); + return Promise.resolve( + err(new AnalysisCancelledError(operation, { cause: signal.reason })), + ); if (this.#closing) return Promise.resolve( err( @@ -311,7 +313,10 @@ export class ReverseEngineeringService { signal?: AbortSignal, ): Promise> { const { executable: command, arguments: args } = profile; - if (signal?.aborted) return err(new AnalysisCancelledError(operation)); + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); const cleanup = await this.retryCleanup(operation); if (!cleanup.ok) return cleanup; const absolutePath = @@ -322,8 +327,12 @@ export class ReverseEngineeringService { digest = await this.#digest(absolutePath, signal, this.#stat); } catch (cause) { const code = commandFailureCode(cause); - if (signal?.aborted) - return err(new AnalysisCancelledError(operation, { cause })); + if (preflightCancelled(signal, cause, code)) + return err( + new AnalysisCancelledError(operation, { + cause: callerCancellationCause(signal, cause), + }), + ); if (code === "EACCES" || code === "EPERM") { const deniedPath = absolutePath === undefined ? command : absolutePath; return err( @@ -464,9 +473,9 @@ export class ReverseEngineeringService { code === "ENOENT" || code === "ERR_PROVIDER_LAUNCH") ) { - if (signal?.aborted || code === "ABORT_ERR") + if (commandCancelled(signal, cause, code)) earlyFailure = new AnalysisCancelledError(operation, { - cause, + cause: callerCancellationCause(signal, cause), ...ownedCleanupOptions(cause), }); else if (code === "ETIMEDOUT") @@ -520,9 +529,9 @@ export class ReverseEngineeringService { : null, signal: capture.signal, }; - if (signal?.aborted || code === "ABORT_ERR") + if (commandCancelled(signal, cause, code)) failure = new AnalysisCancelledError(operation, { - cause, + cause: callerCancellationCause(signal, cause), capturedOutput, ...ownedCleanupOptions(cause), }); @@ -605,6 +614,7 @@ export class ReverseEngineeringService { } if (signal?.aborted && failure === undefined) { failure = new AnalysisCancelledError(operation, { + cause: signal.reason, capturedOutput: { stdout, stderr, @@ -972,6 +982,48 @@ const digestFile = async ( }); }; +const PHYSICAL_FAILURE_CODES = new Set([ + "EACCES", + "EPERM", + "ETIMEDOUT", + "ENOENT", + "ENOTDIR", + "EINVAL", + "ELOOP", + "ENAMETOOLONG", + "E2BIG", + "EBUSY", + "ERR_PROVIDER_LAUNCH", +]); + +/** + * A late abort does not replace a recognized physical failure. Other rejections + * observed while the caller signal is aborted remain cancellation. A bare + * `ABORT_ERR` is not cancellation until the command boundary, which already + * used that code for an aborted provider process. + */ +const preflightCancelled = ( + signal: AbortSignal | undefined, + cause: unknown, + code: string | number | undefined, +): boolean => { + if (cause instanceof AnalysisCancelledError) return true; + if (signal?.aborted !== true) return false; + if (cause === signal.reason) return true; + return typeof code !== "string" || !PHYSICAL_FAILURE_CODES.has(code); +}; + +const commandCancelled = ( + signal: AbortSignal | undefined, + cause: unknown, + code: string | number | undefined, +): boolean => code === "ABORT_ERR" || preflightCancelled(signal, cause, code); + +const callerCancellationCause = ( + signal: AbortSignal | undefined, + cause: unknown, +): unknown => (signal?.aborted === true ? signal.reason : cause); + const commandFailureCode = (cause: unknown): string | number | undefined => { if (typeof cause !== "object" || cause === null) return undefined; const code: unknown = Reflect.get(cause, "code"); diff --git a/src/application/sqlite/SqliteDatabaseService.test.ts b/src/application/sqlite/SqliteDatabaseService.test.ts index 543cd0b027..4fbd6e5172 100644 --- a/src/application/sqlite/SqliteDatabaseService.test.ts +++ b/src/application/sqlite/SqliteDatabaseService.test.ts @@ -145,3 +145,25 @@ it("preserves completed observed evidence when cancellation arrives after provid value: { normalized_result: value }, }); }); + +it("preserves the exact caller reason when inspection is already aborted", async () => { + const reason = Object.freeze({ requested: "stop" }); + const controller = new AbortController(); + controller.abort(reason); + let calls = 0; + const service = new SqliteDatabaseService({ + inspect: () => { + calls += 1; + return Promise.reject(new Error("provider must not run")); + }, + }); + const result = await service.inspect( + { path: "/selected.db" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(calls).toBe(0); +}); diff --git a/src/application/sqlite/SqliteDatabaseService.ts b/src/application/sqlite/SqliteDatabaseService.ts index 7f8aaeb73e..b6b47a1a2f 100644 --- a/src/application/sqlite/SqliteDatabaseService.ts +++ b/src/application/sqlite/SqliteDatabaseService.ts @@ -71,7 +71,11 @@ export class SqliteDatabaseService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/tests/composition/reverseEngineeringAdmission.test.ts b/tests/composition/reverseEngineeringAdmission.test.ts index 1b92f587a1..8440827b34 100644 --- a/tests/composition/reverseEngineeringAdmission.test.ts +++ b/tests/composition/reverseEngineeringAdmission.test.ts @@ -101,3 +101,207 @@ it("bounds concurrent reverse-engineering provider calls", async () => { await rm(directory, { recursive: true, force: true }); } }); + +it("preserves the exact caller reason when objdump admission is already aborted", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-preabort-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const reason = Object.freeze({ requested: "stop" }); + const controller = new AbortController(); + controller.abort(reason); + let runs = 0; + const service = new TestReverseEngineeringService({ + run: async () => { + runs += 1; + return { stdout: "", stderr: "" }; + }, + }); + try { + const result = await service.inspectWithObjdump( + { path, operation: "symbols" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(runs).toBe(0); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves the caller reason when cancellation arrives after provider output", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-postabort-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const reason = Object.freeze({ requested: "stop-after-output" }); + const controller = new AbortController(); + const service = new TestReverseEngineeringService({ + run: async () => { + controller.abort(reason); + return { stdout: "symbols", stderr: "" }; + }, + }); + try { + const result = await service.inspectWithObjdump( + { path, operation: "symbols" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + raw_result: { stdout: "symbols" }, + }); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("keeps a physical digest denial primary when the caller aborts during that failure", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-digest-deny-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const reason = Object.freeze({ requested: "stop-during-denial" }); + const controller = new AbortController(); + const denied = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + let runs = 0; + const service = new TestReverseEngineeringService({ + digest: async () => { + controller.abort(reason); + throw denied; + }, + run: async () => { + runs += 1; + return { stdout: "", stderr: "" }; + }, + }); + try { + const result = await service.inspectWithObjdump( + { path, operation: "symbols" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisAccessDeniedError" }, + }); + if (result.ok) return; + expect(result.error.cause).toBe(denied); + expect(runs).toBe(0); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("keeps an unrelated digest ABORT_ERR as an adapter failure", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-digest-abort-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const denied = Object.assign(new Error("digest aborted elsewhere"), { + code: "ABORT_ERR", + }); + const service = new TestReverseEngineeringService({ + digest: async () => { + throw denied; + }, + }); + try { + const result = await service.inspectWithObjdump({ + path, + operation: "symbols", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ProviderAdapterError" }, + }); + if (result.ok) return; + expect(result.error.cause).toBe(denied); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + { + captured: true, + code: "EACCES", + tag: "AnalysisAccessDeniedError", + }, + { + captured: true, + code: "ETIMEDOUT", + tag: "AnalysisTimeoutError", + }, + { + captured: true, + code: "ABORT_ERR", + tag: "AnalysisCancelledError", + }, + { + captured: false, + code: "EACCES", + tag: "AnalysisAccessDeniedError", + }, + { + captured: false, + code: "ETIMEDOUT", + tag: "AnalysisTimeoutError", + }, + { + captured: false, + code: "ABORT_ERR", + tag: "AnalysisCancelledError", + }, +] as const)( + "classifies a $code command failure with captured output $captured while the caller is aborted", + async ({ captured, code, tag }) => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-command-abort-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const reason = Object.freeze({ requested: `stop-${code}` }); + const controller = new AbortController(); + const failure = Object.assign(new Error("provider failure"), { + code, + ...(captured + ? { stdout: "partial stdout", stderr: "partial stderr" } + : {}), + }); + const service = new TestReverseEngineeringService({ + run: async () => { + controller.abort(reason); + throw failure; + }, + }); + try { + const result = await service.inspectWithObjdump( + { path, operation: "symbols" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ ok: false, error: { _tag: tag } }); + if (result.ok) return; + expect(result.error.cause).toBe( + code === "ABORT_ERR" ? reason : failure, + ); + if (captured && code === "ABORT_ERR") { + expect(result.error.capturedOutput).toMatchObject({ + stdout: "partial stdout", + stderr: "partial stderr", + }); + expect(result.error.partialObservation).toMatchObject({ + raw_result: { stdout: "partial stdout" }, + }); + } + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +);