From fe2a398dd9ab290ba6cb3411403d319cec8d07a2 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:49:10 +0800 Subject: [PATCH] fix(managed): retain descriptor ownership through hash cancellation Fixes #4381. Follow-up to the Linux lifecycle audit in #4227. Keep managed source hashing on bounded reads through its existing FileHandle owner. Preserve exact opaque cancellation reasons before filesystem or cleanup interpretation, retain genuine failed closes, block unsafe admission, and recover the same provider after verified cleanup. Validated with 484 related tests, source/docs checks, seven real Linux provider cancellation/recovery scenarios using an unchanged official Microsoft .NET assembly, and paired actual CLI/stdio MCP Evidence workflows. Normal completed-task timing, CPU, memory and token proxies remained comparable. Adopts xu91102/rea commit ebeec580c839f2f72144282a44f4e11ea5759843 with independent Linux verification and cancellation-reason hardening. Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- src/dotnet/ManagedSnapshot.ts | 56 +++--- .../managed/managedHashCancellation.test.ts | 179 ++++++++++++++++++ 2 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 tests/boundary/providers/managed/managedHashCancellation.test.ts 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 }); + } +});