diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index e8ddd666c2..0def72ccae 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -584,22 +584,6 @@ const hardLinksUnsupported = (cause: unknown): boolean => const describeAdbFailure = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); -const cleanupLeasesForRetryFailure = ( - cause: unknown, -): readonly AnalysisCleanupLease[] | undefined => { - if (!(cause instanceof ArtifactResourceRetryFailure)) return undefined; - const leases = new Set(); - for (const owner of cause.failedOwners) { - if ( - owner.kind !== "lease" || - !(owner.lease instanceof AnalysisCleanupLease) - ) - return undefined; - leases.add(owner.lease); - } - return leases.size > 0 ? [...leases] : undefined; -}; - interface ScreenPublicationCloseFailure { readonly cause: unknown; readonly resource: string; @@ -1046,7 +1030,10 @@ export class AdbProvider { const leases = (cause instanceof AnalysisError ? this.#outputDirectoryRetryLeases.get(cause) - : undefined) ?? cleanupLeasesForRetryFailure(cause); + : undefined) ?? + (cause instanceof ArtifactResourceRetryFailure + ? cause.cleanupLeases + : undefined); if (leases === undefined) allFailuresHaveExactLeaseOwners = false; for (const lease of leases ?? []) failedLeases.add(lease); } @@ -1390,7 +1377,10 @@ export class AdbProvider { [cause], this.#cleanupResources(cause, "ADB provider-owned resource"), ); - const leases = cleanupLeasesForRetryFailure(cause); + const leases = + cause instanceof ArtifactResourceRetryFailure + ? cause.cleanupLeases + : undefined; if (leases !== undefined) new AnalysisCleanupLeaseGroup(error, leases); return err(error); } diff --git a/src/application/rust/RustBinaryPort.ts b/src/application/rust/RustBinaryPort.ts index b0956aea11..9007f8cc28 100644 --- a/src/application/rust/RustBinaryPort.ts +++ b/src/application/rust/RustBinaryPort.ts @@ -12,6 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline dependency reader with no target-execution authority. */ export interface RustBinaryPort { readonly identity: ProviderIdentity; + close?(): Promise; inspect( input: InspectRustBinaryInput, options?: ExecutionOptions, diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 2732cac768..50ad8047f1 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -36,6 +36,10 @@ const architectureForSubject = (architecture: string) => { export class RustBinaryService { constructor(readonly provider: RustBinaryPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Inspect one explicitly selected file without acquiring a binary session. */ async inspect( rawInput: unknown, diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index e045e1575b..222fac4887 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -68,7 +68,9 @@ export const webArtifactReadError = ( if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { const primary = signal?.aborted === true - ? new AnalysisCancelledError(context.operation, { cause }) + ? new AnalysisCancelledError(context.operation, { + cause: signal.reason, + }) : new ArtifactOperationError( context.operation, cause.reason, @@ -88,7 +90,9 @@ export const webArtifactReadError = ( ); } if (signal?.aborted === true) - return new AnalysisCancelledError(context.operation, { cause }); + return new AnalysisCancelledError(context.operation, { + cause: signal.reason, + }); const selection = context.callerSelected ? selectedFileError(cause, context) : undefined; diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index b525126a5b..1b35aea7d5 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -1,5 +1,6 @@ import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../../artifacts/ArtifactFileOperation.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisAccessDeniedError, @@ -90,7 +91,10 @@ export const historicalCaptureFailure = ( cause, ); } - if (options?.signal?.aborted) return new AnalysisCancelledError(OPERATION); + if (options?.signal?.aborted) + return new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }); if ( phase === "capture-read" && cause instanceof ArtifactReaderFailure && @@ -107,7 +111,9 @@ export const historicalCaptureFailure = ( cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" ) - return new AnalysisCancelledError(OPERATION); + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); if ( phase === "capture-read" && cause instanceof ArtifactReaderFailure && diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index 68ca0f28b4..f98218eb87 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -2,7 +2,7 @@ import { z } from "incur"; import { createRustBinaryService } from "../composition/rust.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -13,7 +13,6 @@ export const registerRustCommands = ( cli: CliInstance, logger: Logger, ): void => { - const service = createRustBinaryService(); cli.command(CLI_COMMANDS.inspectRustBinary, { description: "Inspect an embedded cargo-auditable Rust dependency list without target execution", @@ -21,14 +20,22 @@ export const registerRustCommands = ( path: z.string().describe("Local ELF, PE or thin Mach-O image"), }), run: ({ args }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { - const result = await service.inspect( - { path: absoluteCallerPath(args.path) }, - { signal }, - ); - return result.ok ? result.value : projectAnalysisError(result.error); - }), - ), + withCommandCancellation((signal) => { + const service = createRustBinaryService(); + return logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, () => + finishAnalysisCommand( + () => + service.inspect( + { path: absoluteCallerPath(args.path) }, + { signal }, + ), + service, + "rust-auditable", + "inspect_rust_binary", + undefined, + signal, + ), + ); + }), }); }; diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 1868945a98..a1eb2a7c60 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -341,7 +341,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION, { - cause, + cause: options.signal.reason, capturedOutput, ...(cleanup === undefined ? {} : { cleanup }), }); diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index a399bfcb7c..efc2f21dd7 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -1,4 +1,10 @@ import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + assertArtifactReadActive, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; @@ -40,19 +46,16 @@ const OPERATION = "inspect_go_binary"; const INPUT_BYTES = 256 * 1024 * 1024; const OUTPUT_BYTES = 8 * 1024 * 1024; -const failureForPrimary = ( - cause: unknown, - path: string, - signal?: AbortSignal, -): AnalysisError => { +const failureForPrimary = (cause: unknown, path: string): AnalysisError => { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { cause, }); if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION, { cause }); - if (signal?.aborted) return new AnalysisCancelledError(OPERATION, { cause }); + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); if (cause instanceof GoBinaryResourceFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -119,7 +122,13 @@ const failureFor = ( path: string, signal?: AbortSignal, ): AnalysisError => { - const primary = failureForPrimary(cause, path, signal); + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : failureForPrimary(cause, path); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( @@ -128,7 +137,9 @@ const failureFor = ( { reason: cause.cleanup.reason }, { operation: OPERATION, cause }, ); - return analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; }; const outputLimit = (path: string) => @@ -174,8 +185,24 @@ export class GoBinaryProvider implements GoBinaryPort { readonly identity = GO_BINARY_PROVIDER_IDENTITY; readonly #resources = new ArtifactResourceScope(); - close(): Promise { - return this.#resources.close(); + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + GO_BINARY_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } } /** Read complete compiler/module metadata while preserving original byte identity and source ranges. */ @@ -184,57 +211,57 @@ export class GoBinaryProvider implements GoBinaryPort { options?: ExecutionOptions, ): Promise> { try { - const snapshot = await this.#resources.run(() => - readStableArtifact( + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( input.path, INPUT_BYTES, options?.signal, undefined, this.#resources, - ), - ); - options?.signal?.throwIfAborted(); - const image = readGoBinaryImage(snapshot.bytes); - const info = image.build_info; - if ( - info !== null && - ((info.module_text !== null && - Buffer.byteLength(info.module_text) > OUTPUT_BYTES) || - Buffer.byteLength(info.module_bytes_base64) > OUTPUT_BYTES) - ) - throw outputLimit(input.path); - const module = - info === null - ? null - : info.module_text === null - ? parseGoModuleBytes( - Buffer.from(info.module_bytes_base64, "base64").subarray( - 16, - -16, - ), - ) - : parseGoModuleText(info.module_text); - const report = { - ...image, - artifact: { - path: input.path, - sha256: snapshot.sha256, - bytes: snapshot.bytes.length, - }, - build_info: info === null ? null : { ...info, module }, - limitations: limitationsFor(info, module), - }; - if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES) - throw outputLimit(input.path); - const parsed = goBinarySchema.safeParse(report); - if (!parsed.success) - throw new AnalysisOutputError( - OPERATION, - "Go reader produced invalid source ranges or report values.", - { cause: parsed.error }, ); - options?.signal?.throwIfAborted(); - return ok(parsed.data); + assertArtifactReadActive(options?.signal); + const image = readGoBinaryImage(snapshot.bytes); + const info = image.build_info; + if ( + info !== null && + ((info.module_text !== null && + Buffer.byteLength(info.module_text) > OUTPUT_BYTES) || + Buffer.byteLength(info.module_bytes_base64) > OUTPUT_BYTES) + ) + throw outputLimit(input.path); + const module = + info === null + ? null + : info.module_text === null + ? parseGoModuleBytes( + Buffer.from(info.module_bytes_base64, "base64").subarray( + 16, + -16, + ), + ) + : parseGoModuleText(info.module_text); + const report = { + ...image, + artifact: { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + build_info: info === null ? null : { ...info, module }, + limitations: limitationsFor(info, module), + }; + if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES) + throw outputLimit(input.path); + const parsed = goBinarySchema.safeParse(report); + if (!parsed.success) + throw new AnalysisOutputError( + OPERATION, + "Go reader produced invalid source ranges or report values.", + { cause: parsed.error }, + ); + assertArtifactReadActive(options?.signal); + return ok(parsed.data); + }); } catch (cause: unknown) { return err(failureFor(cause, input.path, options?.signal)); } diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 56f812971f..1f7829268a 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import type { ArtifactReaderBoundary } from "../../artifacts/ArtifactReader.js"; import { jsonParts } from "../../domain/jsonSerialization.js"; @@ -41,7 +42,7 @@ export const parsePeResources = async ( input: InspectPeResourcesInput, signal?: AbortSignal, ): Promise => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const layout = readPeResourceLayout(bytes); const directories: PeResources["directories"] = []; const resources: PeResources["resources"] = []; @@ -145,7 +146,7 @@ export const parsePeResources = async ( for (let index = 0; index < count; index++) { if (index % 128 === 0) { await setImmediate(); - signal?.throwIfAborted(); + assertArtifactReadActive(signal); } const entry = header + 16 + index * 8; const encoded = bytes.readUInt32LE(entry); @@ -212,7 +213,7 @@ export const parsePeResources = async ( const hash = createHash("sha256"); for (let offset = 0; offset < range.bytes; offset += 1048576) { await setImmediate(); - signal?.throwIfAborted(); + assertArtifactReadActive(signal); hash.update( bytes.subarray( range.offset + offset, @@ -252,7 +253,7 @@ export const parsePeResources = async ( "Exact shared byte ranges are retained; non-identical overlapping resource structures or payloads are rejected.", ], }; - signal?.throwIfAborted(); + assertArtifactReadActive(signal); if (jsonBytes(report, METADATA_BYTES) > METADATA_BYTES) limit("PE resource result exceeds the 8 MiB allocation budget.", "memory"); return report; diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 5e0e872a38..bd62e9e972 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,5 +1,10 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import type { PeResourcesPort } from "../../application/binaryDiagnostics/PeResourcesPort.js"; @@ -27,33 +32,49 @@ export class PeResourcesProvider implements PeResourcesPort { readonly identity = PE_RESOURCES_PROVIDER; readonly #resources = new ArtifactResourceScope(); - close(): Promise { - return this.#resources.close(); + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + PE_RESOURCES_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: "inspect_pe_resources" }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } } async inspect(input: InspectPeResourcesInput, options?: ExecutionOptions) { try { - const snapshot = await this.#resources.run(() => - readStableArtifact( + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, undefined, this.#resources, - ), - ); - return ok( - await parsePeResources( - snapshot.bytes, - { - path: input.path, - sha256: snapshot.sha256, - bytes: snapshot.bytes.length, - }, - input, - options?.signal, - ), - ); + ); + return ok( + await parsePeResources( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + options?.signal, + ), + ); + }); } catch (cause) { return err(peResourcesFailure(cause, input, options?.signal)); } @@ -80,7 +101,6 @@ export const inputBudgetRemediation = ( const peResourcesFailurePrimary = ( cause: unknown, input: InspectPeResourcesInput, - signal: AbortSignal | undefined, ) => { const operation = "inspect_pe_resources"; if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") @@ -91,8 +111,9 @@ const peResourcesFailurePrimary = ( { cause }, ); if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") - return new AnalysisCancelledError(operation, { cause }); - if (signal?.aborted) return new AnalysisCancelledError(operation, { cause }); + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); if (cause instanceof ArtifactReaderFailure) { // An input byte budget is a file-size bound, not a memory constraint, and @@ -160,7 +181,15 @@ const peResourcesFailure = ( input: InspectPeResourcesInput, signal: AbortSignal | undefined, ) => { - const primary = peResourcesFailurePrimary(cause, input, signal); + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? peResourcesFailurePrimary(cause.fileCleanupFailure.outcome.cause, input) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError("inspect_pe_resources", { + cause: signal.reason, + }) + : peResourcesFailurePrimary(cause, input); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( @@ -169,9 +198,11 @@ const peResourcesFailure = ( { reason: cause.cleanup.reason }, { operation: "inspect_pe_resources", cause }, ); - return analysisErrorWithCleanupFailure( + const error = analysisErrorWithCleanupFailure( primary, cleanup, "inspect_pe_resources", ); + bindArtifactFileCleanup(cause, error); + return error; }; diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index db8386d26b..760ca842d3 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -1,3 +1,8 @@ +import { + bindArtifactFileCleanup, + ArtifactFileCleanupFailure, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; import { access, mkdir, stat, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; import { randomUUID } from "node:crypto"; @@ -17,6 +22,7 @@ import { type RecordedCrash, } from "../../domain/native/recordedCrash.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { @@ -186,19 +192,40 @@ export const inspectPwndbgCore = async ({ ); reply = replySchema.parse(JSON.parse(raw.bytes.toString("utf8"))); } catch (cause: unknown) { - if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION, { - cause, + if ( + cause instanceof ArtifactReaderFailure && + (cause.reason === "cancelled" || + (cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed" && + options?.signal?.aborted)) + ) { + const error = new AnalysisCancelledError(OPERATION, { + cause: + cause.reason === "cancelled" + ? artifactFileCancellationCause(cause) + : options?.signal?.reason, capturedOutput: recordedCrashStageOutput(diagnostics, output), + ...(cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), }); - throw new AnalysisOutputError( + bindArtifactFileCleanup(cause, error); + throw error; + } + const error = new AnalysisOutputError( OPERATION, `Core-only debugger reply failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause, + ...(cause instanceof ArtifactReaderFailure + ? { cleanup: cause.cleanup } + : {}), capturedOutput: recordedCrashStageOutput(diagnostics, output), }, ); + bindArtifactFileCleanup(cause, error); + throw error; } if (!reply.ok) { if (reply.reason === "unavailable" || reply.reason === "unsupported") diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 1f407d1475..5fbd6a1c32 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -1,3 +1,8 @@ +import { + bindArtifactFileCleanup, + ArtifactFileCleanupFailure, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; import { pwntoolsDecoderFailure, pwntoolsUnavailable, @@ -322,16 +327,40 @@ export class PwntoolsDecoder< ); reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); } catch (cause: unknown) { - if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION, { - cause, + if ( + cause instanceof ArtifactReaderFailure && + (cause.reason === "cancelled" || + (cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed" && + options?.signal?.aborted)) + ) { + const error = new AnalysisCancelledError(OPERATION, { + cause: + cause.reason === "cancelled" + ? artifactFileCancellationCause(cause) + : options?.signal?.reason, capturedOutput, + ...(cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), }); - throw new AnalysisOutputError( + bindArtifactFileCleanup(cause, error); + throw error; + } + const error = new AnalysisOutputError( OPERATION, `Owned pwntools decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`, - { cause, capturedOutput }, + { + cause, + capturedOutput, + ...(cause instanceof ArtifactReaderFailure + ? { cleanup: cause.cleanup } + : {}), + }, ); + bindArtifactFileCleanup(cause, error); + throw error; } if (!reply.ok) { if (reply.reason === "unsupported-target") diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 9808b615c2..7e1b826677 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,4 +1,12 @@ import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + assertArtifactReadActive, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js"; @@ -39,6 +47,27 @@ const OPERATION = "inspect_rust_binary"; /** Stable in-process dependency inspection without subprocesses, temporary files, or a Rust toolchain. */ export class RustBinaryProvider implements RustBinaryPort { readonly identity = RUST_BINARY_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + RUST_BINARY_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } /** Read one dependency section and any inferred rustc commit from the selected file. */ async inspect( @@ -46,33 +75,39 @@ export class RustBinaryProvider implements RustBinaryPort { options?: ExecutionOptions, ): Promise> { try { - const snapshot = await readStableArtifact( - input.path, - RUST_BINARY_INPUT_BYTES, - options?.signal, - ); - options?.signal?.throwIfAborted(); - const image = readRustBinaryImage(snapshot.bytes); - const report = { - ...image, - artifact: { - path: input.path, - sha256: snapshot.sha256, - bytes: snapshot.bytes.length, - }, - limitations: limitationsFor(image), - }; - if (Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES) - throw outputLimit(input.path); - const parsed = rustBinarySchema.safeParse(report); - if (!parsed.success) - throw new AnalysisOutputError( - OPERATION, - "Rust reader produced an invalid dependency report.", - { cause: parsed.error }, + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + RUST_BINARY_INPUT_BYTES, + options?.signal, + undefined, + this.#resources, ); - options?.signal?.throwIfAborted(); - return ok(parsed.data); + assertArtifactReadActive(options?.signal); + const image = readRustBinaryImage(snapshot.bytes); + const report = { + ...image, + artifact: { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + limitations: limitationsFor(image), + }; + if ( + Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES + ) + throw outputLimit(input.path); + const parsed = rustBinarySchema.safeParse(report); + if (!parsed.success) + throw new AnalysisOutputError( + OPERATION, + "Rust reader produced an invalid dependency report.", + { cause: parsed.error }, + ); + assertArtifactReadActive(options?.signal); + return ok(parsed.data); + }); } catch (cause: unknown) { return err(failureFor(cause, input.path, options?.signal)); } @@ -143,13 +178,8 @@ const rustcLimitation = ( ]; }; -const failureFor = ( - cause: unknown, - path: string, - signal?: AbortSignal, -): AnalysisError => { +const failureForPrimary = (cause: unknown, path: string): AnalysisError => { if (cause instanceof AnalysisError) return cause; - if (signal?.aborted) return new AnalysisCancelledError(OPERATION); if (cause instanceof RustAuditableResourceFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -173,6 +203,31 @@ const failureFor = ( return artifactFailure(cause, path); }; +const failureFor = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : failureForPrimary(cause, path); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + RUST_BINARY_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; + const artifactFailure = (cause: unknown, path: string): AnalysisError => { if (cause instanceof ArtifactReaderFailure) return artifactReaderFailure(cause, path); @@ -212,7 +267,7 @@ const artifactReaderFailure = ( cause, }); if (cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION); + return new AnalysisCancelledError(OPERATION, { cause: cause.cause }); if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 0a5775ee10..ae93fd47c7 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -310,6 +310,7 @@ export const createServer = ( const webModuleTrace = options.webModuleTrace ?? createWebModuleTraceService(environment); const goBinary = options.goBinary ?? createGoBinaryService(); + const rustBinary = options.rustBinary ?? createRustBinaryService(); const peResources = createPeResourcesService(); const webNetworkCapture = options.webNetworkCapture ?? createWebNetworkCaptureService(environment); @@ -387,6 +388,7 @@ export const createServer = ( webSourceLocation.close(), webModuleTrace.close(), goBinary.close(), + rustBinary.close(), peResources.close(), webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), @@ -444,6 +446,7 @@ export const createServer = ( withAdmittedAnalysis: analysisAdmission, artifactResources, goBinary, + rustBinary, peResources, webNetworkCapture, }; @@ -642,6 +645,7 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, goBinary, + rustBinary, peResources, webNetworkCapture, }: ServerToolContext, @@ -698,7 +702,7 @@ const registerConfiguredAnalysisTools = ( ); registerRustTools( server, - options.rustBinary ?? createRustBinaryService(), + rustBinary, toolLogger, recordEvidence, withAdmittedAnalysis, @@ -809,6 +813,7 @@ const createSessionRecorders = ( interface ServerToolContext extends ReturnType { readonly goBinary: GoBinaryService; + readonly rustBinary: RustBinaryService; readonly peResources: PeResourcesService; readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index 2e67b94067..39ba0f6972 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -1,4 +1,9 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../artifacts/ArtifactFileOperation.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisError, @@ -49,25 +54,32 @@ export const sqliteDatabaseFailure = ( const operation = "inspect_sqlite_database"; if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure) { - if (cause.cleanup !== undefined) - return new ProviderCleanupError( + if (cause.cleanup !== undefined) { + const physicalRead = + cause instanceof ArtifactFileCleanupFailure + ? cause.fileCleanupFailure.outcome + : undefined; + const previous = + physicalRead?.kind === "failed" + ? sqliteDatabaseFailure(physicalRead.cause, path, phase) + : signal?.aborted + ? new AnalysisCancelledError(operation, { cause: signal.reason }) + : physicalRead?.kind === "completed" || cause.cause === undefined + ? undefined + : sqliteDatabaseFailure(cause.cause, path, phase); + const error = new ProviderCleanupError( SQLITE_PROVIDER_IDENTITY.id, cause.cleanup.resources, { reason: cause.cleanup.reason, previous_error: - cause.cause === undefined - ? null - : projectAnalysisError( - signal?.aborted - ? new AnalysisCancelledError(operation, { - cause: cause.cause, - }) - : sqliteDatabaseFailure(cause.cause, path, phase), - ), + previous === undefined ? null : projectAnalysisError(previous), }, { operation, cause }, ); + bindArtifactFileCleanup(cause, error); + return error; + } if (cause.reason === "io" && cause.cause !== undefined) return sqliteDatabaseFailure(cause.cause, path, phase, signal); if (cause.reason === "integrity") @@ -75,7 +87,9 @@ export const sqliteDatabaseFailure = ( cause, }); if (cause.reason === "cancelled") - return new AnalysisCancelledError(operation, { cause }); + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); return new AnalysisInputError(operation, { cause }, [ { path: ["path"], diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index aa7112cfdf..7905c03609 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -9,7 +9,10 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/sqliteDatabase.js"; -import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { + withArtifactFileHandle, + assertArtifactReadActive, +} from "../artifacts/ArtifactFileOperation.js"; import { readStableArtifact, type StableArtifactFileSystem, @@ -180,61 +183,51 @@ const verifyInactiveJournal = async ( constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, ), ); - const owner = new OwnedFileHandle(file); - let failure: unknown; - try { - if (!sameState(before, await file.stat({ bigint: true }))) - throw new ArtifactReaderFailure( - "integrity", - `SQLite journal changed before open: ${path}`, - ); - // TRUNCATE commits leave an empty file; PERSIST commits zero the complete - // 28-byte fixed header. A new active journal may zero only its magic bytes. - const header = Buffer.alloc(28); - let bytesRead = 0; - if (before.size > 0n) { - while (bytesRead < header.length) { - signal?.throwIfAborted(); - const read = await file.read( - header, - bytesRead, - header.length - bytesRead, - bytesRead, + await withArtifactFileHandle( + path, + file, + resourceScope, + signal, + async (file) => { + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed before open: ${path}`, ); - if (read.bytesRead === 0) break; - bytesRead += read.bytesRead; + // TRUNCATE commits leave an empty file; PERSIST commits zero the complete + // 28-byte fixed header. A new active journal may zero only its magic bytes. + const header = Buffer.alloc(28); + let bytesRead = 0; + if (before.size > 0n) { + while (bytesRead < header.length) { + assertArtifactReadActive(signal); + const read = await file.read( + header, + bytesRead, + header.length - bytesRead, + bytesRead, + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } } - } - if (!sameState(before, await file.stat({ bigint: true }))) - throw new ArtifactReaderFailure( - "integrity", - `SQLite journal changed during inspection: ${path}`, - ); - if ( - before.size !== 0n && - (bytesRead !== header.length || header.some((byte) => byte !== 0)) - ) - throw new ArtifactReaderFailure( - phase === "initial" ? "format" : "integrity", - phase === "initial" - ? `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.` - : `Rollback journal became active or malformed during database acquisition: ${path}`, - ); - } catch (cause: unknown) { - failure = cause; - throw cause; - } finally { - const cleanup = await resourceScope.release({ - kind: "file-handle", - resource: path, - handle: owner, - }); - if (cleanup.kind === "failed") - throw ArtifactReaderFailure.withCleanup( - failure ?? cleanup.cause, - ArtifactReaderFailure.cleanupObservation(cleanup.cause, path), - ); - } + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed during inspection: ${path}`, + ); + if ( + before.size !== 0n && + (bytesRead !== header.length || header.some((byte) => byte !== 0)) + ) + throw new ArtifactReaderFailure( + phase === "initial" ? "format" : "integrity", + phase === "initial" + ? `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.` + : `Rollback journal became active or malformed during database acquisition: ${path}`, + ); + }, + ); }; // Absence is a caller value to correct, not malformed path or content. diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 930a871a46..4d06f9c07a 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -1,3 +1,8 @@ +import { + bindArtifactFileCleanup, + ArtifactFileCleanupFailure, + artifactFileCancellationCause, +} from "../artifacts/ArtifactFileOperation.js"; import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -176,13 +181,30 @@ export const executeSqliteDatabaseWorker = async (request: { ? { cleanup: cause.cleanup } : {}), }; - if (request.signal?.aborted) - throw new AnalysisCancelledError(operation, options); - throw new AnalysisOutputError( + if ( + cause instanceof ArtifactReaderFailure && + (cause.reason === "cancelled" || + (cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed" && + request.signal?.aborted)) + ) { + const error = new AnalysisCancelledError(operation, { + ...options, + cause: + cause.reason === "cancelled" + ? artifactFileCancellationCause(cause) + : request.signal?.reason, + }); + bindArtifactFileCleanup(cause, error); + throw error; + } + const error = new AnalysisOutputError( operation, `Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`, options, ); + bindArtifactFileCleanup(cause, error); + throw error; } if (!reply.ok) throw rejectedReply(reply, capturedOutput); return reply.inspection; diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index af4a7dc310..cceb14ef0b 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -164,7 +164,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION, { - cause, + cause: options.signal.reason, ...(cleanup === undefined ? {} : { cleanup }), }); throw new AnalysisArtifactChangedError( diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index b679f76406..54ee98d79a 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -1,4 +1,5 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -48,7 +49,10 @@ export const wabtFailure = ( (cause instanceof Error && cause.name === "AbortError") ) return new AnalysisCancelledError("inspect_wasm_artifact", { - cause, + cause: + cause instanceof ArtifactReaderFailure + ? artifactFileCancellationCause(cause) + : cause, ...cleanupOptions, }); if (cause instanceof OwnedCommandFailure) { diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index ea8ec302df..213838cc7e 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -92,6 +92,103 @@ const expectPeInputFailuresAcrossAdapters = async (input: { const contract = toolContract("inspect_pe_resources"); +const expectPeMappingOwnershipAcrossAdapters = async ( + cli: TestCli, + client: Client, + root: string, +): Promise => { + for (const { name, payloadRva, payloadBytes, section, expectedOffset } of [ + { + name: "empty-header", + payloadRva: 0x300, + payloadBytes: 0, + section: null, + expectedOffset: 0x300, + }, + { + name: "header-section-collision", + payloadRva: 0x300, + payloadBytes: 0, + section: { rva: 0x300, bytes: 4 }, + expectedOffset: null, + }, + { + name: "empty-section-in-header", + payloadRva: 0x300, + payloadBytes: 4, + section: { rva: 0x301, bytes: 0 }, + expectedOffset: 0x300, + }, + { + name: "empty-section-in-resources", + payloadRva: null, + payloadBytes: 4, + section: { rva: 0x2001, bytes: 0 }, + expectedOffset: "original", + }, + ]) { + const fixture = peResourceFixture(false, [ + { type: 10, name: 1, language: 1033, payload: Buffer.from("DATA") }, + ]); + const data = fixture.dataOffsets[0] ?? 0; + let bytes = fixture.bytes; + const originalPayloadOffset = bytes.readUInt32LE(data) - 0x1c00; + if (payloadRva !== null) bytes.writeUInt32LE(payloadRva, data); + bytes.writeUInt32LE(payloadBytes, data + 4); + if (section !== null) { + bytes.writeUInt16LE(2, 0x86); + const at = fixture.sectionAt + 40; + bytes.writeUInt32LE(section.bytes, at + 8); + bytes.writeUInt32LE(section.rva, at + 12); + bytes.writeUInt32LE(section.bytes, at + 16); + bytes.writeUInt32LE(bytes.length, at + 20); + bytes = Buffer.concat([bytes, Buffer.alloc(section.bytes)]); + } + const path = join(root, `${name}.exe`); + await writeFile(path, bytes); + if (expectedOffset === null) { + await expectPeInputFailureAcrossAdapters({ + cli, + client, + toolName: contract.name, + path, + reason: "invalid_format", + }); + continue; + } + const result = await cli.run({ + arguments: ["inspect-pe-resources", path, "--json"], + }); + expect(result.exitCode, name).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + const response = await client.callTool({ + name: contract.name, + arguments: { path }, + }); + expect(response.isError, name).not.toBe(true); + const mcp = contract.outputSchema.parse(response.structuredContent); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + expect(evidence.normalized_result).toMatchObject({ + coverage: { status: "complete", resources: 1 }, + resources: [ + { + payload: { + location: { + offset: + expectedOffset === "original" + ? originalPayloadOffset + : expectedOffset, + bytes: payloadBytes, + }, + }, + }, + ], + }); + expect(await readFile(path)).toEqual(bytes); + } +}; + const expectPeInventoriesAcrossAdapters = async ( cli: TestCli, client: Client, @@ -201,6 +298,7 @@ cliTest( "inspect_pe_resources", ); await expectPeInventoriesAcrossAdapters(cli, client, root); + await expectPeMappingOwnershipAcrossAdapters(cli, client, root); await expectPePaddingFailureAcrossAdapters({ cli, client, diff --git a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts index 76ec81c4a0..77efb2286c 100644 --- a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts @@ -308,6 +308,10 @@ it("retains reply-read cancellation and retries the failed reply descriptor clos descriptorCleanup.denied = false; await selected.provider.close(); expect(handle.fd).toBe(-1); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + }); for (const root of selected.roots) await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/tests/process-global/peResourcesLifetime.test.ts b/tests/process-global/peResourcesLifetime.test.ts new file mode 100644 index 0000000000..258d1bcc84 --- /dev/null +++ b/tests/process-global/peResourcesLifetime.test.ts @@ -0,0 +1,94 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { beforeEach, expect, it, vi } from "vitest"; + +const parsing = vi.hoisted(() => { + const gate = () => { + let resolve!: () => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; + }; + return { gate, entered: gate(), release: gate() }; +}); + +vi.mock("../../src/native/pe/PeResourceParser.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../../src/native/pe/PeResourceParser.js") + >(); + return { + ...original, + parsePeResources: async ( + ...arguments_: Parameters + ) => { + parsing.entered.resolve(); + await parsing.release.promise; + return original.parsePeResources(...arguments_); + }, + }; +}); + +import { inspectPeResourcesInputSchema } from "../../src/domain/native/peResources.js"; +import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js"; +import { peResourceFixture } from "../../src/native/pe/PeResources.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +beforeEach(() => { + parsing.entered = parsing.gate(); + parsing.release = parsing.gate(); +}); + +it.each(["completion", "cancellation"])( + "waits for admitted PE parsing through %s before closing", + async (outcome) => { + const root = await createTestTempDirectory("rea-pe-parsing-lifetime-"); + const path = join(root, "image.exe"); + await writeFile(path, peResourceFixture().bytes); + const provider = new PeResourcesProvider(); + const controller = new AbortController(); + const reason = new Error("cancelled during admitted parsing"); + const inspection = provider.inspect( + inspectPeResourcesInputSchema.parse({ path }), + { signal: controller.signal }, + ); + await parsing.entered.promise; + let closed = false; + const closing = provider.close().then(() => { + closed = true; + }); + try { + if (outcome === "cancellation") controller.abort(reason); + await setImmediate(); + expect( + closed, + "close reported release while parsing was still admitted", + ).toBe(false); + parsing.release.resolve(); + const result = await inspection; + if (outcome === "cancellation") { + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + } else + expect(result).toMatchObject({ + ok: true, + value: { coverage: { status: "complete" } }, + }); + await closing; + expect(closed).toBe(true); + expect( + (await provider.inspect(inspectPeResourcesInputSchema.parse({ path }))) + .ok, + ).toBe(false); + } finally { + parsing.release.resolve(); + await inspection; + await closing; + } + }, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts new file mode 100644 index 0000000000..825c8f838f --- /dev/null +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -0,0 +1,421 @@ +import { writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + path: "", + blockNew: true, + blocked: new Set(), + handles: [] as FileHandle[], + onClose: undefined as (() => void) | undefined, + readFailure: undefined as { cause: unknown } | undefined, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + open: async (...arguments_: Parameters) => { + const handle = await original.open(...arguments_); + if (String(arguments_[0]) !== fault.path) return handle; + const close = handle.close.bind(handle); + fault.handles.push(handle); + if (fault.blockNew) fault.blocked.add(handle); + if (fault.readFailure !== undefined) { + const failure = fault.readFailure; + handle.stat = async () => { + throw failure.cause; + }; + } + handle.close = async () => { + fault.onClose?.(); + if (fault.blocked.has(handle)) + throw new Error("controlled Rust descriptor close failure"); + await close(); + }; + return handle; + }, + }; +}); + +import { RustBinaryProvider } from "../../src/rust/RustBinaryProvider.js"; +import { RustBinaryService } from "../../src/application/rust/RustBinaryService.js"; +import { AnalysisError } from "../../src/domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisArtifactChangedError, + AnalysisAccessDeniedError, + AnalysisResourceConstraintError, +} from "../../src/domain/analysisErrorCore.js"; +import { + ArtifactFileCleanupFailure, + withArtifactRegularFile, +} from "../../src/artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js"; +import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js"; +import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js"; +import { inspectPeResourcesInputSchema } from "../../src/domain/native/peResources.js"; +import { peResourceFixture } from "../../src/native/pe/PeResources.fixture.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { createRustImage } from "../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { mcpTest } from "../support/mcp/mcpFixture.js"; + +beforeEach(() => { + fault.path = ""; + fault.blockNew = true; + fault.blocked.clear(); + fault.handles = []; + fault.onClose = undefined; + fault.readFailure = undefined; +}); + +const staticProviders = [ + ["Rust", () => new RustBinaryProvider()], + ["Go", () => new GoBinaryProvider()], + ["PE", () => new PeResourcesProvider()], +] as const; + +it.each(staticProviders)( + "preserves the exact late-close cancellation cause through %s inspection", + async (_name, makeProvider) => { + const path = await selectedImage(); + const provider = makeProvider(); + if (provider instanceof PeResourcesProvider) + await writeFile(path, peResourceFixture().bytes); + const controller = new AbortController(); + const reason = new Error("cancel during confirmed descriptor close"); + fault.blockNew = false; + fault.onClose = () => controller.abort(reason); + try { + const result = + provider instanceof PeResourcesProvider + ? await provider.inspect( + inspectPeResourcesInputSchema.parse({ path }), + { signal: controller.signal }, + ) + : await provider.inspect({ path }, { signal: controller.signal }); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + } finally { + fault.blocked.clear(); + await provider.close(); + } + }, +); + +it.each( + staticProviders.flatMap(([name, makeProvider]) => [ + [ + name, + makeProvider, + new ArtifactReaderFailure("integrity", "source replacement"), + AnalysisArtifactChangedError, + ] as const, + [ + name, + makeProvider, + Object.assign(new Error("read denied"), { code: "EACCES" }), + AnalysisAccessDeniedError, + ] as const, + [ + name, + makeProvider, + new ArtifactReaderFailure("limit", "read limit"), + AnalysisResourceConstraintError, + ] as const, + ]), +)( + "preserves %s read failure after successful close observes cancellation", + async (_name, makeProvider, primary, expected) => { + const path = await selectedImage(); + const provider = makeProvider(); + fault.blockNew = false; + fault.readFailure = { cause: primary }; + const controller = new AbortController(); + fault.onClose = () => controller.abort(new Error("later cancellation")); + try { + const result = + provider instanceof PeResourcesProvider + ? await provider.inspect( + inspectPeResourcesInputSchema.parse({ path }), + { signal: controller.signal }, + ) + : await provider.inspect({ path }, { signal: controller.signal }); + if (result.ok) throw new Error("Expected primary read failure"); + expect(result.error).toBeInstanceOf(expected); + expect(result.error.cause).toBe(primary); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + } finally { + await provider.close(); + } + }, +); + +it.each(staticProviders)( + "keeps the primary %s read failure when cancellation arrives during failed close", + async (_name, makeProvider) => { + const path = await selectedImage(); + const provider = makeProvider(); + const primary = new ArtifactReaderFailure( + "integrity", + "controlled source replacement", + ); + fault.readFailure = { cause: primary }; + const controller = new AbortController(); + fault.onClose = () => controller.abort(new Error("later cancellation")); + try { + const result = + provider instanceof PeResourcesProvider + ? await provider.inspect( + inspectPeResourcesInputSchema.parse({ path }), + { signal: controller.signal }, + ) + : await provider.inspect({ path }, { signal: controller.signal }); + if (result.ok) + throw new Error("Expected primary failure and retained descriptor"); + expect(result.error.cleanupIncomplete).toBe(true); + const aggregate = result.error.cause; + if (!(aggregate instanceof AggregateError)) + throw new Error("Missing primary/cleanup aggregate"); + const [original] = aggregate.errors; + expect(original).toBeInstanceOf(AnalysisArtifactChangedError); + expect((original as AnalysisArtifactChangedError).cause).toBe(primary); + fault.blocked.clear(); + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + fault.blocked.clear(); + await provider.close(); + } + }, +); +afterEach(async () => { + fault.blockNew = false; + fault.blocked.clear(); + fault.onClose = undefined; + for (const handle of fault.handles) if (handle.fd >= 0) await handle.close(); +}); + +const selectedImage = async () => { + const root = await createTestTempDirectory("rea-rust-artifact-owner-"); + const path = join(root, "image.elf"); + await writeFile(path, createRustImage({ format: "elf" })); + fault.path = path; + return path; +}; + +mcpTest( + "retains Rust descriptor cleanup and recovers in the same MCP connection", + async ({ mcp }) => { + const path = await selectedImage(); + const service = new RustBinaryService(new RustBinaryProvider()); + const failures: AnalysisError[] = []; + const inspect = service.inspect.bind(service); + vi.spyOn(service, "inspect").mockImplementation(async (...arguments_) => { + const result = await inspect(...arguments_); + if (!result.ok) failures.push(result.error); + return result; + }); + const session = createTestBinarySession(() => { + throw new Error("Unexpected native provider"); + }); + const server = createServer( + { kind: "session", session }, + { rustBinary: service }, + ); + const client = await mcp.connect(server); + try { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: [path] }, + }); + } + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0); + expect(failures.map((error) => error.cleanupIncomplete)).toEqual([ + true, + true, + ]); + fault.blockNew = false; + fault.blocked.clear(); + const recovered = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(recovered.isError).not.toBe(true); + expect(failures.map((error) => error.cleanupIncomplete)).toEqual([ + false, + false, + ]); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + } finally { + fault.blockNew = false; + fault.blocked.clear(); + await server.close(); + await session.close(); + } + }, +); + +it("keeps mixed descriptor cleanup and its aggregate bound to the exact unresolved owners", async () => { + const path = await selectedImage(); + const service = new RustBinaryService(new RustBinaryProvider()); + try { + const inspections = await Promise.all([ + service.inspect({ path }), + service.inspect({ path }), + ]); + expect(fault.handles).toHaveLength(2); + const errors = inspections.map((result) => { + if (result.ok) throw new Error("Expected retained cleanup"); + return result.error; + }); + const firstClose = await service.close().catch((cause: unknown) => cause); + if (!(firstClose instanceof AnalysisError)) + throw new Error("Missing typed shutdown failure"); + expect(firstClose.cleanupIncomplete).toBe(true); + const handlesByError = errors.map((error) => { + const aggregate = error.cause; + if (!(aggregate instanceof AggregateError)) + throw new Error("Missing cleanup aggregate"); + const cleanup = aggregate.errors[1]; + if ( + !(cleanup instanceof AnalysisError) || + !(cleanup.cause instanceof ArtifactFileCleanupFailure) + ) + throw new Error("Missing exact descriptor owner"); + return cleanup.cause.fileCleanupFailure.owner.handle; + }); + expect(new Set(handlesByError)).toEqual(new Set(fault.handles)); + const [first, second] = handlesByError; + if (first === undefined || second === undefined) + throw new Error("Missing distinct descriptors"); + fault.blocked.delete(first); + const mixedClose = await service.close().catch((cause: unknown) => cause); + if (!(mixedClose instanceof AnalysisError)) + throw new Error("Missing mixed shutdown failure"); + expect(first.fd).toBe(-1); + expect(second.fd).toBeGreaterThanOrEqual(0); + expect(errors.map((error) => error.cleanupIncomplete)).toEqual([ + false, + true, + ]); + expect(firstClose.cleanupIncomplete).toBe(true); + expect(mixedClose.cleanupIncomplete).toBe(true); + fault.blocked.clear(); + await service.close(); + expect(errors.map((error) => error.cleanupIncomplete)).toEqual([ + false, + false, + ]); + expect(firstClose.cleanupIncomplete).toBe(false); + expect(mixedClose.cleanupIncomplete).toBe(false); + } finally { + fault.blockNew = false; + fault.blocked.clear(); + await service.close(); + } +}); + +mcpTest( + "server shutdown releases Rust descriptors retained by a failed tool call", + async ({ mcp }) => { + const path = await selectedImage(); + const session = createTestBinarySession(() => { + throw new Error("Unexpected native provider"); + }); + const server = createServer({ kind: "session", session }); + const client = await mcp.connect(server); + try { + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(parseMcpToolError(response).error.code).toBe("cleanup_incomplete"); + expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0); + fault.blockNew = false; + fault.blocked.clear(); + await server.close(); + expect(fault.handles[0]?.fd).toBe(-1); + } finally { + fault.blockNew = false; + fault.blocked.clear(); + await server.close(); + await session.close(); + } + }, +); + +it("preserves the caller's cancellation cause when descriptor close also fails", async () => { + const path = await selectedImage(); + const provider = new RustBinaryProvider(); + const controller = new AbortController(); + const reason = new Error("cancel during Rust descriptor close"); + fault.onClose = () => controller.abort(reason); + try { + const result = await provider.inspect( + { path }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Expected cancellation with retained cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + const aggregate = result.error.cause; + if (!(aggregate instanceof AggregateError)) + throw new Error("Missing primary/cleanup aggregate"); + const [primary] = aggregate.errors; + expect(primary).toBeInstanceOf(AnalysisCancelledError); + expect((primary as AnalysisCancelledError).cause).toBe(reason); + fault.blockNew = false; + fault.blocked.clear(); + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + fault.blockNew = false; + fault.blocked.clear(); + await provider.close(); + fault.onClose = undefined; + } +}); + +it("preserves upstream resource-bound facts through an unconfirmed descriptor close", async () => { + const path = await selectedImage(); + const scope = new ArtifactResourceScope(); + const primary = new ArtifactReaderFailure( + "limit", + "Controlled input budget refusal", + { boundary: "file-size" }, + ); + try { + const failure = await scope + .run(() => + withArtifactRegularFile(path, scope, undefined, async () => { + throw primary; + }), + ) + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure); + expect(failure).toMatchObject({ reason: "limit", boundary: "file-size" }); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected exact descriptor cleanup failure"); + expect(failure.fileCleanupFailure.outcome).toEqual({ + kind: "failed", + cause: primary, + }); + } finally { + fault.blocked.clear(); + await scope.close(); + } +});