diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 18360ec3d9..386b7b6d59 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -53,6 +53,12 @@ CIL decoding, and projected inspection records have a separate conservative output occurrences still consume that budget. These are inspection resource policies, not PE format limits or a hard bound on total V8 heap use. +Path-based member comparison uses the same per-input snapshot policy and +inspects its two selected paths sequentially, retaining each inspection rather +than both full byte snapshots. The limit therefore remains a per-file resource +admission rule, not an aggregate comparison limit. Comparison Evidence keeps +the caller-selected paths as its parameters and artifact locations. + Input overflow returns a resource constraint. Decode or projection overflow reports `resource-limit` with unavailable or partial coverage; omitted facts do not become empty observations. Ambiguous PE section mappings are rejected diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index cd424a7e0f..bced0278aa 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -207,18 +207,26 @@ export const parseBinaryTarget = async ( if (cause instanceof AnalysisCancelledError) return err(cause); if (cause instanceof BinaryTargetError) return err(cause); if (!(cause instanceof Error)) throw cause; - const code = filesystemErrorCode(cause); - if (code === undefined) throw cause; - return err( - new BinaryTargetError( - candidate, - targetFailureReason(code, cause.message), - { cause }, - ), - ); + const failure = classifyBinaryTargetFilesystemFailure(candidate, cause); + if (failure === undefined) throw cause; + return err(failure); } }; +/** Classify a filesystem failure while reading a previously selected target. */ +export const classifyBinaryTargetFilesystemFailure = ( + path: string, + cause: unknown, +): BinaryTargetError | undefined => { + if (!(cause instanceof Error)) return undefined; + const code = filesystemErrorCode(cause); + return code === undefined + ? undefined + : new BinaryTargetError(path, targetFailureReason(code, cause.message), { + cause, + }); +}; + const filesystemErrorCode = (cause: Error): string | undefined => { const code: unknown = Reflect.get(cause, "code"); return typeof code === "string" ? code : undefined; diff --git a/src/application/binary/AnalysisClientCleanup.test.ts b/src/application/binary/AnalysisClientCleanup.test.ts index e3dae7646a..3ca21b06ff 100644 --- a/src/application/binary/AnalysisClientCleanup.test.ts +++ b/src/application/binary/AnalysisClientCleanup.test.ts @@ -71,5 +71,9 @@ it("preserves the original explanation and collected output alongside cleanup un }, }, }); - expect(combined.cause).toBe(primary); + expect(combined.cause).toBeInstanceOf(AggregateError); + expect(combined.cause).toMatchObject({ + cause: primary, + errors: [primary, cleanup], + }); }); diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index 1c0ac8456b..252feb8ca0 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -1,6 +1,3 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; - import { z } from "zod"; import { @@ -9,14 +6,20 @@ import { type CompareManagedMembersInput, } from "../../domain/managed/managedMemberComparison.js"; import { parseManagedMemberEvidence } from "../../domain/managed/managedMemberComparisonMatch.js"; +import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; -import { parseBinaryTarget } from "../BinaryTargetResolver.js"; +import { + classifyBinaryTargetFilesystemFailure, + parseBinaryTarget, +} from "../BinaryTargetResolver.js"; import { err, ok, type Result } from "../../domain/result.js"; import { inspectManagedMembersBytes } from "../../dotnet/ManagedMemberInspector.js"; +import { readManagedSnapshot } from "../../dotnet/ManagedSnapshot.js"; import { MANAGED_STATIC_PROVIDER, MANAGED_WORKFLOW_PROVIDER, @@ -43,109 +46,95 @@ export const compareManagedMembersEvidenceValidated = ( interface ManagedMemberComparisonPathDependencies { readonly resolveTarget: typeof parseBinaryTarget; - readonly readBytes: (path: string) => Promise; + readonly readSnapshot: typeof readManagedSnapshot; } -const DEFAULT_PATH_DEPENDENCIES: ManagedMemberComparisonPathDependencies = { - resolveTarget: parseBinaryTarget, - readBytes: readFile, -}; - /** Inspect two local PE artifacts and return one derived comparison Evidence. */ export const compareManagedMemberPaths = async ( input: { readonly leftPath: string; readonly rightPath: string; }, - dependencies: ManagedMemberComparisonPathDependencies = DEFAULT_PATH_DEPENDENCIES, + dependencies: Partial = {}, + resources?: ArtifactResourceScope, ): Promise> => { const operation = "compare_managed_members"; + const resolveTarget = dependencies.resolveTarget ?? parseBinaryTarget; + const readSnapshot = dependencies.readSnapshot ?? readManagedSnapshot; + let partialEvidence: Evidence | undefined; try { const [leftTarget, rightTarget] = await Promise.all([ - dependencies.resolveTarget(input.leftPath), - dependencies.resolveTarget(input.rightPath), + resolveTarget(input.leftPath), + resolveTarget(input.rightPath), ]); // Report an unopenable path like every other path-based command does, // with the failed path and constraint, rather than a bare input error. if (!leftTarget.ok) return err(leftTarget.error); if (!rightTarget.ok) return err(rightTarget.error); - const [leftBytes, rightBytes] = await Promise.all([ - dependencies.readBytes(leftTarget.value.path), - dependencies.readBytes(rightTarget.value.path), - ]); - for (const [target, bytes] of [ - [leftTarget.value, leftBytes], - [rightTarget.value, rightBytes], - ] as const) { - const observedSha256 = createHash("sha256").update(bytes).digest("hex"); - if (observedSha256 !== target.sha256) - return err( - new EvidenceIntegrityError( - `Managed artifact digest changed after open: expected ${target.sha256}, observed ${observedSha256} at ${target.path}`, - ), + const inspectTarget = async ( + target: BinaryTarget, + selectedPath: string, + ) => { + const { path } = target; + let snapshot: Awaited>; + try { + snapshot = await readSnapshot( + path, + operation, + resources === undefined ? {} : { resources }, ); - } - const leftInspection = inspectManagedMembersBytes( - leftBytes, - leftTarget.value, - ); - const rightInspection = inspectManagedMembersBytes( - rightBytes, - rightTarget.value, - ); - const leftEvidence = createEvidence( - leftTarget.value, - MANAGED_STATIC_PROVIDER, - { + } catch (cause: unknown) { + const failure = classifyBinaryTargetFilesystemFailure(path, cause); + if (failure !== undefined) throw failure; + throw cause; + } + if (snapshot.sha256 !== target.sha256) + throw new EvidenceIntegrityError( + `Managed artifact digest changed after open: expected ${target.sha256}, observed ${snapshot.sha256} at ${target.path}`, + ); + const inspection = inspectManagedMembersBytes(snapshot.bytes, target); + const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, { operation: "inspect_managed_members", parameters: {}, - result: jsonValueSchema.parse(leftInspection), + result: jsonValueSchema.parse(inspection), rawResult: null, - limitations: leftInspection.limitations, - locations: [{ kind: "artifact-path", path: leftTarget.value.path }], - }, - ); - const rightEvidence = createEvidence( - rightTarget.value, - MANAGED_STATIC_PROVIDER, - { - operation: "inspect_managed_members", - parameters: {}, - result: jsonValueSchema.parse(rightInspection), - rawResult: null, - limitations: rightInspection.limitations, - locations: [{ kind: "artifact-path", path: rightTarget.value.path }], - }, - ); + limitations: inspection.limitations, + locations: [{ kind: "artifact-path", path: selectedPath }], + }); + return { inspection, evidence }; + }; + const left = await inspectTarget(leftTarget.value, input.leftPath); + partialEvidence = left.evidence; + const right = await inspectTarget(rightTarget.value, input.rightPath); const result = compareManagedMembers( { - evidenceId: leftEvidence.evidence_id, - result: leftInspection, + evidenceId: left.evidence.evidence_id, + result: left.inspection, }, { - evidenceId: rightEvidence.evidence_id, - result: rightInspection, + evidenceId: right.evidence.evidence_id, + result: right.inspection, }, ); return ok( createManagedMemberComparisonEvidence( { - left: leftEvidence, - right: rightEvidence, + left: left.evidence, + right: right.evidence, }, result, + { leftPath: input.leftPath, rightPath: input.rightPath }, ), ); } catch (cause: unknown) { - return workflowFailure(operation, cause); + return workflowFailure(operation, cause, partialEvidence); } }; -/** ECMA-335 representable limits for path-based member comparison. */ - const createManagedMemberComparisonEvidence = ( parameters: Pick, result: z.infer, + sourcePaths?: { readonly leftPath: string; readonly rightPath: string }, ): Evidence => createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, { predicateType: "rea.managed-member-comparison", @@ -153,6 +142,12 @@ const createManagedMemberComparisonEvidence = ( parameters: { left_evidence_id: parameters.left.evidence_id, right_evidence_id: parameters.right.evidence_id, + ...(sourcePaths === undefined + ? {} + : { + left_path: sourcePaths.leftPath, + right_path: sourcePaths.rightPath, + }), }, result: jsonValueSchema.parse(result), rawResult: null, @@ -160,18 +155,33 @@ const createManagedMemberComparisonEvidence = ( authority: "analyst-inference", environment: null, limitations: result.limitations, + locations: + sourcePaths === undefined + ? [] + : [ + { kind: "artifact-path", path: sourcePaths.leftPath }, + { kind: "artifact-path", path: sourcePaths.rightPath }, + ], evidenceLinks: result.evidence_links, }); const workflowFailure = ( operation: string, cause: unknown, -): Result => - err( - cause instanceof z.ZodError || cause instanceof TypeError - ? workflowInputError(operation, cause) - : new AnalysisProtocolError( - "Managed member comparison produced an invalid result", - { cause }, - ), + partialEvidence?: Evidence, +): Result => { + const failure = + cause instanceof AnalysisError + ? cause + : cause instanceof z.ZodError || cause instanceof TypeError + ? workflowInputError(operation, cause) + : new AnalysisProtocolError( + "Managed member comparison produced an invalid result", + { cause }, + ); + return err( + partialEvidence === undefined + ? failure + : failure.retainPartialObservation(partialEvidence), ); +}; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index 7dc74ae3e4..f922b9532b 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -4,8 +4,13 @@ import { compareManagedMemberPaths } from "../application/managed/ManagedMemberC import { verifyManagedNativeBoundariesEvidence } from "../application/managed/ManagedNativeVerificationService.js"; import { importManagedReconstructionEvidence } from "../application/managed/ManagedReconstructionService.js"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; +import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; @@ -112,10 +117,44 @@ const registerCompareManagedMembers = ( args: compareManagedMemberArgs, run: ({ args }) => logCliCommand(logger, "compare-managed-members", async () => { - const result = await compareManagedMemberPaths({ - leftPath: args.leftPath, - rightPath: args.rightPath, - }); + const resources = new ArtifactResourceScope(); + const result = await compareManagedMemberPaths( + { + leftPath: args.leftPath, + rightPath: args.rightPath, + }, + {}, + resources, + ); + try { + await resources.close(); + } catch (cause: unknown) { + const cleanup = ArtifactReaderFailure.cleanupObservation( + cause, + "Managed member comparison resources", + ); + const failure = new ProviderCleanupError( + MANAGED_STATIC_PROVIDER.id, + cleanup.resources, + { reason: cleanup.reason }, + { + cause, + operation: "compare_managed_members", + ...(result.ok ? { partialObservation: result.value } : {}), + }, + ); + const error = result.ok + ? failure + : analysisErrorWithCleanupFailure( + result.error, + failure, + "compare_managed_members", + ); + return { + error: "Managed member comparison failed", + ...projectAnalysisError(error), + }; + } return result.ok ? result.value : { diff --git a/src/domain/analysisErrorCleanup.ts b/src/domain/analysisErrorCleanup.ts index df1f95b756..c6a796f905 100644 --- a/src/domain/analysisErrorCleanup.ts +++ b/src/domain/analysisErrorCleanup.ts @@ -13,7 +13,11 @@ export const analysisErrorWithCleanupFailure = ( cleanup instanceof ProviderAdapterError ? cleanup.providerId : "rea", operation, { - cause: primary, + cause: new AggregateError( + [primary, cleanup], + "Analysis failed and cleanup remains incomplete.", + { cause: primary }, + ), userMessage: projectAnalysisError(primary).message, ...(primary.capturedOutput === undefined ? {} diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index 41a3c77561..b7dc0a7aeb 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -135,7 +135,7 @@ const requireRange = ( ); }; -const align4 = (value: number): number => (value + 3) & ~3; +const align4 = (value: number): number => Math.ceil(value / 4) * 4; const readStreamName = ( bytes: Buffer, diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index fdbda8008e..7c507cdd97 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -132,7 +132,7 @@ export const methodBody = ( "utf8", ); const methodEnd = ilOffset + header.ilSize; - const sectionOffset = (methodEnd + 3) & ~3; + const sectionOffset = Math.ceil(methodEnd / 4) * 4; const exceptionRegions = header.format === "fat" && (header.flags & 8) !== 0 ? validateExceptionRegionRanges( diff --git a/src/dotnet/ManagedSnapshot.ts b/src/dotnet/ManagedSnapshot.ts new file mode 100644 index 0000000000..788f93000b --- /dev/null +++ b/src/dotnet/ManagedSnapshot.ts @@ -0,0 +1,190 @@ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; + +import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js"; +import { classifyBinaryTargetFilesystemFailure } from "../application/BinaryTargetResolver.js"; +import { withRegularFile } from "../application/RegularFileRead.js"; +import { + RegularFileCleanupFailure, + type RegularFileReadOptions, +} from "../application/RegularFileRead.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; + +// Managed static inspection retains the complete PE snapshot in memory. +export const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024; + +interface ManagedSourceObservation { + readonly byteLength: number; + readonly sha256: string; + readonly bytes?: Buffer; +} + +interface ManagedSnapshot extends ManagedSourceObservation { + readonly bytes: Buffer; +} + +const managedSnapshotCapacityError = ( + operation: string, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + operation, + "memory", + "Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.", + { max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES }, + ); + +export const readManagedSnapshot = async ( + path: string, + operation: string, + options: RegularFileReadOptions = {}, +): Promise => { + return readManagedFile( + path, + operation, + async (handle, metadata) => { + const signal = options.signal; + if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES) + throw managedSnapshotCapacityError(operation); + const bytes = await readBoundedFileBytes( + handle, + MAX_MANAGED_SNAPSHOT_BYTES, + signal, + ); + if (bytes === undefined) throw managedSnapshotCapacityError(operation); + signal?.throwIfAborted(); + return { + byteLength: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes, + }; + }, + options, + ); +}; + +export const hashManagedSource = async ( + path: string, + operation: string, + options: RegularFileReadOptions = {}, +): Promise => + readManagedFile( + path, + operation, + async (handle, metadata) => { + const signal = options.signal; + if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES) + throw managedSnapshotCapacityError(operation); + const digest = createHash("sha256"); + let byteLength = 0; + const stream = handle.createReadStream({ + autoClose: false, + ...(signal === undefined ? {} : { signal }), + }); + for await (const chunk of stream) { + signal?.throwIfAborted(); + if (!Buffer.isBuffer(chunk)) + throw new TypeError("Managed source stream returned non-buffer data"); + if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength) + throw managedSnapshotCapacityError(operation); + byteLength += chunk.length; + digest.update(chunk); + } + return { byteLength, sha256: digest.digest("hex") }; + }, + options, + ); + +const readManagedFile = ( + path: string, + operation: string, + read: (handle: FileHandle, metadata: Stats) => Promise, + options: RegularFileReadOptions, +): Promise => + withRegularFile(path, read, options).catch((cause: unknown) => { + throw normalizeManagedReadFailure(cause, path, operation, options.signal); + }); + +/** Keep cleanup ownership and the original typed read failure at this boundary. */ +const normalizeManagedReadFailure = ( + cause: unknown, + fallbackPath: string, + operation: string, + signal?: AbortSignal, +): unknown => { + const cleanupError = managedCleanupError( + cause, + fallbackPath, + operation, + signal, + ); + if (cleanupError !== undefined) return cleanupError; + return normalizeReadFailure(cause, fallbackPath, operation, signal); +}; + +/** Project cleanup failures owned by managed file reads or their shared scope. */ +export const managedCleanupError = ( + cause: unknown, + fallbackPath: string, + operation: string, + signal?: AbortSignal, +): AnalysisError | undefined => { + if (cause instanceof RegularFileCleanupFailure) { + const cleanup = new ProviderCleanupError( + MANAGED_STATIC_PROVIDER.id, + [cause.path], + { reason: "A managed input file handle could not be closed" }, + { cause, operation }, + ); + const failure = + cause.outcome.kind === "failed" + ? normalizeReadFailure( + cause.outcome.cause, + cause.path, + operation, + signal, + ) + : undefined; + return failure instanceof AnalysisError + ? analysisErrorWithCleanupFailure(failure, cleanup, operation) + : cleanup; + } + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + return new ProviderCleanupError( + MANAGED_STATIC_PROVIDER.id, + cause.cleanup.resources.length > 0 + ? cause.cleanup.resources + : [fallbackPath], + { reason: cause.cleanup.reason }, + { cause, operation }, + ); + return undefined; +}; + +const normalizeReadFailure = ( + cause: unknown, + path: string, + operation: string, + signal?: AbortSignal, +): unknown => + cause instanceof RegularFileChangedError + ? new EvidenceIntegrityError( + `Managed source changed while ${operation} was reading ${path}.`, + { cause }, + ) + : cause instanceof AnalysisError + ? cause + : (classifyBinaryTargetFilesystemFailure(path, cause) ?? + (signal?.aborted === true + ? new AnalysisCancelledError(operation, { cause }) + : cause)); diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index e3827c3460..ba09a1939f 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - import { createAnalysisExecution, type AnalysisClient, @@ -9,6 +7,8 @@ import { type ExecutionOptions, type ProviderIdentity, } from "../application/AnalysisProvider.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { managedStaticCapabilities } from "./ManagedStaticProviderMetadata.js"; import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js"; import { @@ -22,10 +22,10 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, - AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { @@ -37,23 +37,12 @@ import { err, ok, type Result } from "../domain/result.js"; import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js"; - -import { openRegularFile } from "../filesystem/RegularFile.js"; -import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; - -// This provider eagerly retains the full PE snapshot. Keep its input resource -// policy separate from decoded metadata/output capacity; it is not a PE format limit. -const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024; - -const snapshotCapacityError = ( - operation: string, -): AnalysisResourceConstraintError => - new AnalysisResourceConstraintError( - operation, - "memory", - "Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.", - { max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES }, - ); +import { RegularFileCleanupFailure } from "../application/RegularFileRead.js"; +import { + hashManagedSource, + managedCleanupError, + readManagedSnapshot, +} from "./ManagedSnapshot.js"; /** Execution-free managed PE/CLI auxiliary provider. */ export class ManagedStaticProvider implements AnalysisProvider { @@ -75,6 +64,7 @@ export class ManagedStaticProvider implements AnalysisProvider { class ManagedStaticClient implements AnalysisClient { #snapshotBytes: Buffer | undefined; + readonly #resources = new ArtifactResourceScope(); constructor(private readonly target: BinaryTarget) {} @@ -107,16 +97,14 @@ class ManagedStaticClient implements AnalysisClient { const snapshot = this.#snapshotBytes; const observed = snapshot === undefined - ? await readManagedSnapshot( - this.target.path, - operation, - options?.signal, - ) - : await hashManagedSource( - this.target.path, - operation, - options?.signal, - ); + ? await readManagedSnapshot(this.target.path, operation, { + signal: options?.signal, + resources: this.#resources, + }) + : await hashManagedSource(this.target.path, operation, { + signal: options?.signal, + resources: this.#resources, + }); if (observed.sha256 !== this.target.sha256) return err( new EvidenceIntegrityError( @@ -142,9 +130,11 @@ class ManagedStaticClient implements AnalysisClient { }), ); } catch (cause: unknown) { + if (isCleanupFailure(cause)) + return err(this.#cleanupError(operation, cause)); + if (cause instanceof AnalysisError) return err(cause); if (options?.signal?.aborted === true) return err(new AnalysisCancelledError(operation)); - if (cause instanceof AnalysisError) return err(cause); return err( new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, { cause, @@ -153,74 +143,37 @@ class ManagedStaticClient implements AnalysisClient { } } - close(): Promise> { + async close(): Promise> { this.#snapshotBytes = undefined; - return Promise.resolve(ok(null)); - } -} - -interface ManagedSourceObservation { - readonly byteLength: number; - readonly sha256: string; - readonly bytes?: Buffer; -} - -const readManagedSnapshot = async ( - path: string, - operation: string, - signal?: AbortSignal, -): Promise => { - const handle = await openRegularFile(path, { symlinks: "follow", signal }); - try { - const metadata = await handle.stat(); - if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES) - throw snapshotCapacityError(operation); - const bytes = await readBoundedFileBytes( - handle, - MAX_MANAGED_SNAPSHOT_BYTES, - signal, - ); - if (bytes === undefined) throw snapshotCapacityError(operation); - signal?.throwIfAborted(); - return { - byteLength: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - bytes, - }; - } finally { - await handle.close(); - } -}; - -const hashManagedSource = async ( - path: string, - operation: string, - signal?: AbortSignal, -): Promise => { - const handle = await openRegularFile(path, { symlinks: "follow", signal }); - try { - if ((await handle.stat()).size > MAX_MANAGED_SNAPSHOT_BYTES) - throw snapshotCapacityError(operation); - const digest = createHash("sha256"); - let byteLength = 0; - const stream = handle.createReadStream({ - autoClose: false, - ...(signal === undefined ? {} : { signal }), - }); - for await (const chunk of stream) { - signal?.throwIfAborted(); - if (!Buffer.isBuffer(chunk)) - throw new TypeError("Managed source stream returned non-buffer data"); - if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength) - throw snapshotCapacityError(operation); - byteLength += chunk.length; - digest.update(chunk); + try { + await this.#resources.close(); + return ok(null); + } catch (cause: unknown) { + return err(this.#cleanupError("close", cause)); } - return { byteLength, sha256: digest.digest("hex") }; - } finally { - await handle.close(); } -}; + + #cleanupError(operation: string, cause: unknown): ProviderCleanupError { + const failure = managedCleanupError(cause, this.target.path, operation); + if (failure instanceof ProviderCleanupError) return failure; + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; + return new ProviderCleanupError( + MANAGED_STATIC_PROVIDER.id, + cleanup?.resources ?? [this.target.path], + { + reason: + cleanup?.reason ?? + (cause instanceof Error ? cause.message : String(cause)), + }, + { cause, operation }, + ); + } +} + +const isCleanupFailure = (cause: unknown): boolean => + cause instanceof RegularFileCleanupFailure || + (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined); const isManagedOperation = ( operation: AnalysisOperation, diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index 7c7bd03ae0..2c72909fa2 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -1,8 +1,17 @@ import { execFile } from "node:child_process"; import { once } from "node:events"; -import { lstat, mkdir, mkdtemp, readdir, rm, rmdir } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + rm, + rmdir, + symlink, + writeFile, +} from "node:fs/promises"; import { createServer, type Socket } from "node:net"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; @@ -25,8 +34,9 @@ afterEach(async () => { describe("Hopper target leases", () => { it("reports the owning REA session for a duplicate target and profile", async () => { const directory = await temporaryDirectory(); + const targetPath = await temporaryTarget(); const first = await acquireHopperTargetLease({ - ...leaseInput, + ...leaseInput(targetPath), directory, runId: "session-first", }); @@ -34,7 +44,7 @@ describe("Hopper target leases", () => { if (!first.acquired) return; const duplicate = await acquireHopperTargetLease({ - ...leaseInput, + ...leaseInput(targetPath), directory, runId: "session-second", }); @@ -45,7 +55,7 @@ describe("Hopper target leases", () => { await first.lease.release(); const reopened = await acquireHopperTargetLease({ - ...leaseInput, + ...leaseInput(targetPath), directory, runId: "session-third", }); @@ -55,13 +65,14 @@ describe("Hopper target leases", () => { it("keeps different Hopper profiles independent", async () => { const directory = await temporaryDirectory(); + const targetPath = await temporaryTarget(); const first = await acquireHopperTargetLease({ - ...leaseInput, + ...leaseInput(targetPath), directory, runId: "session-one", }); const second = await acquireHopperTargetLease({ - ...leaseInput, + ...leaseInput(targetPath), directory, loaderArgs: ["--aarch64"], runId: "session-two", @@ -71,6 +82,63 @@ describe("Hopper target leases", () => { if (first.acquired) await first.lease.release(); if (second.acquired) await second.lease.release(); }); + + it.skipIf(process.platform === "win32")( + "shares one lease for symlink aliases of the same target", + async () => { + const directory = await temporaryDirectory(); + const targetPath = await temporaryTarget(); + const alias = join(dirname(targetPath), "sample-alias"); + await symlink(targetPath, alias); + const first = await acquireHopperTargetLease({ + ...leaseInput(targetPath), + directory, + runId: "canonical-owner", + }); + expect(first.acquired).toBe(true); + if (!first.acquired) return; + try { + expect( + await acquireHopperTargetLease({ + ...leaseInput(alias), + directory, + runId: "alias-request", + }), + ).toMatchObject({ + acquired: false, + owner: { runId: "canonical-owner" }, + }); + } finally { + await first.lease.release(); + } + }, + ); + + it.each(["missing", "not-directory"] as const)( + "does not create a lease endpoint when target canonicalization fails for %s", + async (failure) => { + const directory = await temporaryDirectory(); + const parent = await temporaryDirectory(); + const targetPath = + failure === "missing" + ? join(parent, "missing-target") + : join(await temporaryTarget(parent), "child"); + await expect( + acquireHopperTargetLease({ + ...leaseInput(targetPath), + directory, + runId: "unresolvable-target", + }), + ).rejects.toMatchObject({ + _tag: "HopperStartError", + userMessage: expect.stringContaining("canonical path"), + cause: expect.objectContaining({ + code: failure === "missing" ? "ENOENT" : "ENOTDIR", + }), + }); + expect(await readdir(directory)).toEqual([]); + }, + ); }); describe("Linux Hopper application leases", () => { @@ -212,10 +280,17 @@ const releasedApplicationSocket = async ( return join(directory, name); }; -const leaseInput = { - targetPath: "/tmp/sample-binary", +const leaseInput = (targetPath: string) => ({ + targetPath, targetKind: "executable" as const, loaderArgs: [], +}); + +const temporaryTarget = async (parent?: string): Promise => { + const directory = parent ?? (await temporaryDirectory()); + const targetPath = join(directory, "sample-binary"); + await writeFile(targetPath, "admitted target bytes"); + return targetPath; }; const temporaryDirectory = async (): Promise => { diff --git a/src/hopper/HopperTargetLease.ts b/src/hopper/HopperTargetLease.ts index af65b729fc..1507002670 100644 --- a/src/hopper/HopperTargetLease.ts +++ b/src/hopper/HopperTargetLease.ts @@ -6,7 +6,7 @@ import { type Server, type Socket, } from "node:net"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { HopperStartError } from "../domain/hopperErrors.js"; import { safeParseJson } from "../domain/safeJson.js"; @@ -37,16 +37,17 @@ export const acquireHopperTargetLease = async (input: { readonly runId: string; readonly directory?: string; }): Promise => { - // The canonical path only derives the lease key; it never authorizes access. - // Common expected `realpath` failures include a missing target or path - // component (ENOENT, ENOTDIR), an unreadable component (EACCES), or a - // symlink loop (ELOOP). This catch also falls back for every other error, - // which may hide a real filesystem problem. The lexical `resolve` fallback - // still yields a deterministic key, but aliases may take separate leases - // whenever `realpath` cannot resolve them. - const targetPath = await realpath(input.targetPath).catch(() => - resolve(input.targetPath), - ); + // Canonicalization is required for lease identity. A lexical fallback could + // let two aliases reserve independent sockets for the same Hopper document. + let targetPath: string; + try { + targetPath = await realpath(input.targetPath); + } catch (cause: unknown) { + throw new HopperStartError({ + cause, + userMessage: `REA could not resolve Hopper target ${JSON.stringify(input.targetPath)} to a canonical path before reserving the document. Confirm that the target exists and its path is accessible.`, + }); + } return acquireLease({ ...input, identity: { diff --git a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts index 18003b0397..dfb4729720 100644 --- a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts @@ -19,6 +19,7 @@ import { readSkillFile, skillDestinations, } from "../../../src/application/SetupSkill.js"; +import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js"; import { systemSetupHost } from "../../../src/application/SetupHost.js"; import { runUninstall, @@ -67,7 +68,7 @@ const failFirstCloses = (path: string) => { }; describe("skill regular-file cleanup ownership", () => { - it("keeps setup skill reads owned until setup host disposal", async () => { + it("retains a failed setup skill read until setup host disposal", async () => { const home = await createTestTempDirectory("rea-setup-skill-close-"); const [destination] = skillDestinations(home, []); if (destination === undefined) throw new Error("Shared skill root missing"); @@ -81,7 +82,18 @@ describe("skill regular-file cleanup ownership", () => { ); try { - expect(await host.skillNeedsInstall([])).toBe(true); + const failure = await host.skillNeedsInstall([]).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(RegularFileCleanupFailure); + expect(failure).toMatchObject({ + path: manifest, + outcome: { kind: "completed" }, + cleanupCause: expect.objectContaining({ + message: "test holds the skill handle open", + }), + }); expect(close.attempts.length).toBeGreaterThan(0); expect(new Set(close.attempts).size).toBe(1); const attemptsBeforeClose = close.attempts.length; @@ -147,7 +159,7 @@ describe("skill regular-file cleanup ownership", () => { }); describe("skill cleanup diagnostics", () => { - it("preserves a doctor report and cleanup diagnostic when host close still fails", async () => { + it("preserves read and host cleanup failures when doctor identity cannot complete", async () => { const home = await createTestTempDirectory("rea-doctor-skill-close-"); const [destination] = skillDestinations(home, []); if (destination === undefined) throw new Error("Shared skill root missing"); @@ -172,15 +184,23 @@ describe("skill cleanup diagnostics", () => { }); try { - const report = await runDoctorOwned(undefined, host, { skill: true }); - - expect(report.healthy).toBe(false); - expect(report.identity?.skill.installed_version).toBeNull(); - expect(report.checks).toContainEqual( + const failure = await runDoctorOwned(undefined, host, { + skill: true, + }).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(AggregateError); + if (!(failure instanceof AggregateError)) + throw new Error("Doctor cleanup should preserve both failures"); + expect(failure.errors[0]).toBeInstanceOf(RegularFileCleanupFailure); + expect(failure.errors[0]).toMatchObject({ + path: manifest, + outcome: { kind: "completed" }, + }); + expect(failure.errors[1]).toEqual( expect.objectContaining({ - name: "doctor-resource-cleanup", - ok: false, - detail: expect.stringContaining(manifest), + message: expect.stringContaining(manifest), }), ); expect(close.attempts.length).toBeGreaterThan(0); diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 9b2ccee025..402f2c1223 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -1,9 +1,11 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { compareManagedMembersEvidenceValidated, @@ -19,6 +21,11 @@ import { compareManagedMembersInputSchema } from "../../../../src/domain/managed import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; import { managedPeFixtureTarget } from "../../../../src/dotnet/ManagedPe.fixture.js"; import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js"; +import { + MAX_MANAGED_SNAPSHOT_BYTES, + readManagedSnapshot, +} from "../../../../src/dotnet/ManagedSnapshot.js"; +import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js"; it.each([ ["accessibility", 6, 0x0011], @@ -184,6 +191,14 @@ describe("managed member comparison path workflow", () => { confidence: "inferred", authority: "analyst-inference", }); + expect(result.value.parameters).toMatchObject({ + left_path: leftPath, + right_path: rightPath, + }); + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: leftPath }, + { kind: "artifact-path", path: rightPath }, + ]); expect( managedMemberComparisonResultSchema.parse(result.value.normalized_result) .matching.exact_il_signature, @@ -205,7 +220,179 @@ describe("managed member comparison path workflow", () => { details: { path: expect.stringContaining("missing.dll") }, }); }); +}); +describe("managed comparison post-admission read failures", () => { + it.each([ + ["left", "disappearance", "ENOENT"], + ["left", "permission denial", "EACCES"], + ["right", "disappearance", "ENOENT"], + ["right", "permission denial", "EACCES"], + ])( + "classifies a %s-side %s during the post-resolution read", + async (side, _name, code) => { + const directory = await createTestTempDirectory( + "rea-managed-read-failure-", + ); + const leftPath = join(directory, "left.dll"); + const rightPath = join(directory, "right.dll"); + await writeFile(leftPath, buildManagedPeFixture()); + await writeFile(rightPath, buildManagedPeFixture()); + + const result = await compareManagedMemberPaths( + { leftPath, rightPath }, + { + resolveTarget: parseBinaryTarget, + readSnapshot: async (path, operation, options) => { + if (path === (side === "left" ? leftPath : rightPath)) { + const failure = Object.assign(new Error(`read failed: ${code}`), { + code, + }); + throw failure; + } + return readManagedSnapshot(path, operation, options); + }, + }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("BinaryTargetError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { path: side === "left" ? leftPath : rightPath }, + }); + if (side === "right") + expect(result.error.partialObservation).toMatchObject({ + operation: "inspect_managed_members", + subject: { + local_path: leftPath, + digest: { sha256: expect.stringMatching(/^[0-9a-f]{64}$/u) }, + }, + }); + else expect(result.error.partialObservation).toBeUndefined(); + if (code === "EACCES") + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + }); + }, + ); +}); + +describe("managed comparison snapshot admission and cleanup", () => { + it("rejects a sparse oversized snapshot through the default reader before reading it", async () => { + const directory = await createTestTempDirectory( + "rea-managed-oversized-snapshot-", + ); + const leftPath = join(directory, "oversized.dll"); + const rightPath = join(directory, "right.dll"); + const fixture = buildManagedPeFixture(); + await writeFile(leftPath, Buffer.alloc(0)); + await truncate(leftPath, MAX_MANAGED_SNAPSHOT_BYTES + 1); + await writeFile(rightPath, fixture); + const template = managedPeFixtureTarget(fixture); + const read = vi.spyOn(boundedFileBytes, "readBoundedFileBytes"); + try { + const result = await compareManagedMemberPaths( + { leftPath, rightPath }, + { + resolveTarget: async (path) => ({ + ok: true, + value: { ...template, path }, + }), + }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + operation: "compare_managed_members", + reportedLimits: { + max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES, + }, + }); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } + }); + + it("retries a transient snapshot close failure through the same resource owner", async () => { + const directory = await createTestTempDirectory( + "rea-managed-compare-close-", + ); + const leftPath = join(directory, "left.dll"); + const rightPath = join(directory, "right.dll"); + await writeFile(leftPath, buildManagedPeFixture()); + await writeFile(rightPath, buildManagedPeFixture()); + const resources = new ArtifactResourceScope(); + const originalClose = OwnedFileHandle.prototype.close; + const owners: OwnedFileHandle[] = []; + let attempts = 0; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + attempts += 1; + if (attempts === 1) throw new Error("injected transient close failure"); + await originalClose.call(this); + }); + try { + const result = await compareManagedMemberPaths( + { leftPath, rightPath }, + {}, + resources, + ); + + expect(result.ok).toBe(true); + expect(owners[0]).toBe(owners[1]); + expect(attempts).toBe(3); + } finally { + close.mockRestore(); + await resources.close(); + } + }); + + it("projects persistent snapshot cleanup failure and retains its exact owner", async () => { + const directory = await createTestTempDirectory( + "rea-managed-compare-cleanup-", + ); + const leftPath = join(directory, "left.dll"); + const rightPath = join(directory, "right.dll"); + await writeFile(leftPath, buildManagedPeFixture()); + await writeFile(rightPath, buildManagedPeFixture()); + const resources = new ArtifactResourceScope(); + const cleanupCause = new Error("injected persistent close failure"); + const owners: OwnedFileHandle[] = []; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + throw cleanupCause; + }); + let result: Awaited>; + try { + result = await compareManagedMemberPaths( + { leftPath, rightPath }, + {}, + resources, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete", resources: [leftPath] }, + }); + expect(owners[0]).toBe(owners[1]); + expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + } finally { + close.mockRestore(); + await resources.close(); + } + }); +}); + +describe("managed comparison target identity", () => { it("rejects a file that changes after target identity is resolved", async () => { const directory = await createTestTempDirectory( "rea-managed-compare-race-", @@ -227,7 +414,6 @@ describe("managed member comparison path workflow", () => { await writeFile(path, replacement); return target; }, - readBytes: (path) => readFile(path), }, ); diff --git a/tests/boundary/providers/managed/managedStaticProvider.test.ts b/tests/boundary/providers/managed/managedStaticProvider.test.ts index 1d1a750c33..eaabc9c30b 100644 --- a/tests/boundary/providers/managed/managedStaticProvider.test.ts +++ b/tests/boundary/providers/managed/managedStaticProvider.test.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -15,6 +15,180 @@ import { } from "../../../../src/domain/managed/managedArtifact.js"; import { ManagedStaticProvider } from "../../../../src/dotnet/ManagedStaticProvider.js"; import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js"; +import { + MAX_MANAGED_SNAPSHOT_BYTES, + readManagedSnapshot, +} from "../../../../src/dotnet/ManagedSnapshot.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { RegularFileCleanupFailure } from "../../../../src/application/RegularFileRead.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js"; + +describe("managed static snapshot capacity failure", () => { + it("preserves a typed capacity failure when closing the admitted file also fails", async () => { + const directory = await createTestTempDirectory( + "rea-managed-capacity-cleanup-", + ); + const path = join(directory, "sparse.exe"); + await writeFile(path, Buffer.alloc(0)); + await truncate(path, MAX_MANAGED_SNAPSHOT_BYTES + 1); + const resources = new ArtifactResourceScope(); + const originalClose = OwnedFileHandle.prototype.close; + let permitClose = false; + const owners: OwnedFileHandle[] = []; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (!permitClose) throw new Error("fixture handle close failure"); + await originalClose.call(this); + }); + try { + const failure = await readManagedSnapshot( + path, + "inspect_managed_artifact", + { + resources, + }, + ).catch((cause: unknown) => cause); + + expect(failure).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: [path], + diagnostics: { primary_error: { code: "resource_constraint" } }, + }); + expect(owners.length).toBeGreaterThan(0); + expect(owners.every((owner) => owner === owners[0])).toBe(true); + + permitClose = true; + await resources.close(); + } finally { + permitClose = true; + await resources.close(); + close.mockRestore(); + } + }); +}); + +describe("managed static snapshot cancellation", () => { + it("preserves cancellation when snapshot cleanup also fails", async () => { + const directory = await createTestTempDirectory( + "rea-managed-cancel-cleanup-", + ); + const path = join(directory, "fixture.exe"); + await writeFile(path, buildManagedPeFixture()); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + const client = new ManagedStaticProvider().createClient(parsed.value); + const controller = new AbortController(); + const originalRead = boundedFileBytes.readBoundedFileBytes; + const read = vi + .spyOn(boundedFileBytes, "readBoundedFileBytes") + .mockImplementation(async (...arguments_) => { + controller.abort(new Error("fixture cancellation")); + return originalRead(...arguments_); + }); + const originalClose = OwnedFileHandle.prototype.close; + let permitClose = false; + const owners: OwnedFileHandle[] = []; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (!permitClose) throw new Error("fixture handle close failure"); + await originalClose.call(this); + }); + + try { + const result = await client.execute( + "inspect_managed_artifact", + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + primary_error: { code: "cancelled" }, + }, + }, + }); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (!(result.error.cause instanceof AggregateError)) return; + const cleanupError = result.error.cause.errors[1]; + expect(cleanupError).toBeInstanceOf(Error); + if (!(cleanupError instanceof Error)) return; + expect(cleanupError.cause).toBeInstanceOf(RegularFileCleanupFailure); + if (!(cleanupError.cause instanceof RegularFileCleanupFailure)) return; + expect(cleanupError.cause.owner).toBe(owners[0]); + expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + + permitClose = true; + expect(await client.close()).toMatchObject({ ok: true }); + expect(owners.every((owner) => owner === owners[0])).toBe(true); + } finally { + permitClose = true; + await client.close(); + close.mockRestore(); + read.mockRestore(); + } + }); +}); + +describe("managed static file ownership", () => { + it("retains the same failed file owner, blocks reads, and retries it on client close", async () => { + const directory = await createTestTempDirectory("rea-managed-close-"); + const bytes = buildManagedPeFixture(); + const path = join(directory, "fixture.exe"); + await writeFile(path, bytes); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + const client = new ManagedStaticProvider().createClient(parsed.value); + const originalClose = OwnedFileHandle.prototype.close; + const owners: OwnedFileHandle[] = []; + let permitClose = false; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (!permitClose) throw new Error("fixture handle close failure"); + await originalClose.call(this); + }); + try { + const failed = await client.execute("inspect_managed_artifact", {}); + expect(failed).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [path], + }, + }); + + const blocked = await client.execute("inspect_managed_artifact", {}); + expect(blocked).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [path], + }, + }); + expect(owners).toHaveLength(3); + expect(owners.every((owner) => owner === owners[0])).toBe(true); + + permitClose = true; + expect(await client.close()).toMatchObject({ ok: true }); + expect(owners).toHaveLength(4); + expect(owners.every((owner) => owner === owners[0])).toBe(true); + } finally { + permitClose = true; + await client.close(); + close.mockRestore(); + } + }); +}); describe("managed static provider path boundary", () => { it("executes as a read-only auxiliary provider with digest, cancellation, and format boundaries", async () => { diff --git a/tests/composition/directAnalysisCleanup.test.ts b/tests/composition/directAnalysisCleanup.test.ts index fcbdbddcba..51992d0696 100644 --- a/tests/composition/directAnalysisCleanup.test.ts +++ b/tests/composition/directAnalysisCleanup.test.ts @@ -219,7 +219,11 @@ describe("managed one-shot cleanup outcomes", () => { ); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.cause).toBe(primaryError); + expect(result.error.cause).toBeInstanceOf(AggregateError); + expect(result.error.cause).toMatchObject({ + cause: primaryError, + errors: [primaryError, cleanupError], + }); expect(projectAnalysisError(result.error)).toMatchObject({ code: "cleanup_incomplete", details: {