diff --git a/src/dotnet/ManagedSnapshot.ts b/src/dotnet/ManagedSnapshot.ts index 788f93000b..45d1111cf3 100644 --- a/src/dotnet/ManagedSnapshot.ts +++ b/src/dotnet/ManagedSnapshot.ts @@ -4,8 +4,8 @@ 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 { + withRegularFile, RegularFileCleanupFailure, type RegularFileReadOptions, } from "../application/RegularFileRead.js"; @@ -87,18 +87,21 @@ export const hashManagedSource = async ( 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) { + const chunk = Buffer.allocUnsafe(64 * 1024); + for (;;) { signal?.throwIfAborted(); - if (!Buffer.isBuffer(chunk)) - throw new TypeError("Managed source stream returned non-buffer data"); - if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength) + const { bytesRead } = await handle.read( + chunk, + 0, + Math.min(chunk.length, MAX_MANAGED_SNAPSHOT_BYTES - byteLength + 1), + null, + ); + signal?.throwIfAborted(); + if (bytesRead === 0) break; + if (bytesRead > MAX_MANAGED_SNAPSHOT_BYTES - byteLength) throw managedSnapshotCapacityError(operation); - byteLength += chunk.length; - digest.update(chunk); + byteLength += bytesRead; + digest.update(chunk.subarray(0, bytesRead)); } return { byteLength, sha256: digest.digest("hex") }; }, @@ -122,6 +125,9 @@ const normalizeManagedReadFailure = ( operation: string, signal?: AbortSignal, ): unknown => { + // An exact caller reason is opaque, even if it resembles a cleanup record. + if (signal?.aborted === true && cause === signal.reason) + return normalizeReadFailure(cause, fallbackPath, operation, signal); const cleanupError = managedCleanupError( cause, fallbackPath, @@ -176,15 +182,19 @@ const normalizeReadFailure = ( 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)); +): unknown => { + if (signal?.aborted === true && cause === signal.reason) + return new AnalysisCancelledError(operation, { cause }); + if (cause instanceof RegularFileChangedError) + return new EvidenceIntegrityError( + `Managed source changed while ${operation} was reading ${path}.`, + { cause }, + ); + if (cause instanceof AnalysisError) return cause; + return ( + classifyBinaryTargetFilesystemFailure(path, cause) ?? + (signal?.aborted === true + ? new AnalysisCancelledError(operation, { cause }) + : cause) + ); +}; diff --git a/tests/boundary/providers/managed/managedHashCancellation.test.ts b/tests/boundary/providers/managed/managedHashCancellation.test.ts new file mode 100644 index 0000000000..63be19e8bc --- /dev/null +++ b/tests/boundary/providers/managed/managedHashCancellation.test.ts @@ -0,0 +1,179 @@ +import { open, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, vi } from "vitest"; + +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js"; +import { ManagedStaticProvider } from "../../../../src/dotnet/ManagedStaticProvider.js"; +import { RegularFileCleanupFailure } from "../../../../src/application/RegularFileRead.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +it.each([ + { denyClose: false, reasonKind: "plain" }, + { denyClose: true, reasonKind: "plain" }, + { denyClose: false, reasonKind: "filesystem" }, + { denyClose: true, reasonKind: "filesystem" }, + { denyClose: false, reasonKind: "cleanup" }, + { denyClose: true, reasonKind: "cleanup" }, +])( + "cancels a cached managed source hash and recovers its exact owner (close denied: $denyClose, reason: $reasonKind)", + async ({ denyClose, reasonKind }) => { + const directory = await createTestTempDirectory("rea-managed-hash-cancel-"); + const path = join(directory, "fixture.exe"); + await writeFile( + path, + Buffer.concat([buildManagedPeFixture(), Buffer.alloc(512 * 1024)]), + ); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + const client = new ManagedStaticProvider().createClient(parsed.value); + const initial = await client.execute("inspect_managed_artifact", {}); + if (!initial.ok) throw initial.error; + + const admitted = await open(path, "r"); + const prototype = Object.getPrototypeOf(admitted) as FileHandle; + await admitted.close(); + const originalRead = prototype.read; + const controller = new AbortController(); + const reason = + reasonKind === "cleanup" + ? new RegularFileCleanupFailure( + join(directory, "other-input.dll"), + new OwnedFileHandle(admitted), + { kind: "completed", value: null }, + new Error("opaque caller reason, not this operation's cleanup"), + ) + : Object.assign( + new Error("cancel after actual managed source bytes arrive"), + reasonKind === "filesystem" ? { code: "EACCES" } : {}, + ); + let cancelledHandle: FileHandle | undefined; + let readCount = 0; + let permitClose = !denyClose; + const deniedHandles: FileHandle[] = []; + let restoreClose: (() => void) | undefined; + const read = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args + ) { + readCount += 1; + const result: Awaited> = + await Reflect.apply(originalRead, this, args); + if (result.bytesRead > 0 && cancelledHandle === undefined) { + cancelledHandle = this; + if (denyClose) { + const originalClose = this.close.bind(this); + const close = vi.spyOn(this, "close").mockImplementation(async () => { + if (!permitClose) { + deniedHandles.push(this); + throw new Error("fixture managed descriptor close denied"); + } + await originalClose(); + }); + restoreClose = () => close.mockRestore(); + } + controller.abort(reason); + } + return result; + }); + try { + const cancelled = await client.execute( + "inspect_managed_members", + {}, + { signal: controller.signal }, + ); + expect(cancelledHandle).toBeDefined(); + if (cancelled.ok) throw new Error("Cancelled managed hashing succeeded"); + if (denyClose) { + expect(projectAnalysisError(cancelled.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { diagnostics: { primary_error: { code: "cancelled" } } }, + }); + expect(cancelledHandle?.fd).toBeGreaterThanOrEqual(0); + const readsBeforeRetry = readCount; + const blocked = await client.execute("inspect_managed_members", {}); + if (blocked.ok) + throw new Error("Unreleased managed source admitted a read"); + expect(projectAnalysisError(blocked.error).code).toBe( + "cleanup_incomplete", + ); + expect(readCount).toBe(readsBeforeRetry); + expect(deniedHandles.length).toBeGreaterThan(0); + expect( + deniedHandles.every((handle) => handle === cancelledHandle), + ).toBe(true); + } else { + expect(projectAnalysisError(cancelled.error)).toMatchObject({ + code: "cancelled", + }); + expect(cancelled.error.cause).toBe(reason); + expect(cancelledHandle?.fd).toBe(-1); + } + + permitClose = true; + read.mockRestore(); + const recovered = await client.execute("inspect_managed_members", {}); + if (!recovered.ok) throw recovered.error; + expect(recovered.value.subject?.sha256).toBe(parsed.value.sha256); + expect(cancelledHandle?.fd).toBe(-1); + expect(await client.close()).toMatchObject({ ok: true }); + } finally { + permitClose = true; + read.mockRestore(); + await client.close(); + restoreClose?.(); + } + }, +); + +it("preserves an independent read failure when cancellation races with it", async () => { + const directory = await createTestTempDirectory( + "rea-managed-hash-read-failure-", + ); + 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 initial = await client.execute("inspect_managed_artifact", {}); + if (!initial.ok) throw initial.error; + const admitted = await open(path, "r"); + const prototype = Object.getPrototypeOf(admitted) as FileHandle; + await admitted.close(); + const originalRead = prototype.read; + const controller = new AbortController(); + const failure = Object.assign(new Error("fixture source read failure"), { + code: "EACCES", + }); + let failedHandle: FileHandle | undefined; + const read = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args + ) { + await Reflect.apply(originalRead, this, args); + failedHandle = this; + controller.abort(new Error("independent cancellation")); + throw failure; + }); + try { + const result = await client.execute( + "inspect_managed_members", + {}, + { + signal: controller.signal, + }, + ); + if (result.ok) throw new Error("Failed managed hashing succeeded"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + }); + expect(result.error.cause).toBe(failure); + expect(failedHandle?.fd).toBe(-1); + } finally { + read.mockRestore(); + expect(await client.close()).toMatchObject({ ok: true }); + } +});