diff --git a/tests/boundary/filesystem/stableArtifact.test.ts b/tests/boundary/filesystem/stableArtifact.test.ts index 38c9f7d6c..97b0b5718 100644 --- a/tests/boundary/filesystem/stableArtifact.test.ts +++ b/tests/boundary/filesystem/stableArtifact.test.ts @@ -13,6 +13,7 @@ import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js import { ArtifactFileCleanupFailure, ArtifactFileCancelled, + withArtifactFileHandle, } from "../../../src/artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -254,3 +255,41 @@ it("transfers the exact owner even when the caller's retention boundary rejects await failure.cleanupLease.release(); expect(file.handle.fd).toBe(-1); }); + +it("does not turn a retention callback abort into completed-file cancellation", async () => { + const file = await heldDescriptor(); + const controller = new AbortController(); + class AbortingScope extends ArtifactResourceScope { + override retain( + owner: Parameters[0], + ): void { + controller.abort(null); + super.retain(owner); + } + } + const scope = new AbortingScope(); + try { + const failure: unknown = await withArtifactFileHandle( + file.path, + file.handle, + scope, + controller.signal, + async () => file.bytes, + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Missing file cleanup owner"); + expect(failure.fileCleanupFailure.outcome).toEqual({ + kind: "completed", + value: file.bytes, + }); + expect(failure.fileCleanupFailure.completionCancellation).toBeUndefined(); + expect(failure.reason).not.toBe("cancelled"); + file.release(); + await failure.cleanupLease.release(); + expect(file.handle.fd).toBe(-1); + } finally { + file.release(); + await scope.close(); + } +}); diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 6b16cdf61..8f07d6c7c 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -7,12 +7,15 @@ * refusal paths, and cancellation before spawn. */ import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it, onTestFinished } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { PcapProvider } from "../../../src/pcap/PcapProvider.js"; +import { PcapSnapshot } from "../../../src/pcap/PcapSnapshot.js"; +import * as regularFile from "../../../src/filesystem/RegularFile.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import type { PcapCommandLauncher } from "../../../src/pcap/PcapCommand.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import type { PcapRequest } from "../../../src/domain/pcap/pcapAnalysis.js"; @@ -200,3 +203,37 @@ it.runIf(!hasNativeBound)( expect(projected.remediation.action).toBe(projected.message); }, ); + +it("retains a verification admission owner before translating cancellation", async () => { + const root = await createTestTempDirectory("rea-pcap-verify-admission-"); + const path = join(root, "held.bin"); + await writeFile(path, "held capture bytes"); + const handle = await open(path, "r"); + const owner = new OwnedFileHandle(handle); + const controller = new AbortController(); + const reason = null; + const snapshot = await PcapSnapshot.create(); + const admission = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementationOnce(async () => { + controller.abort(reason); + throw new regularFile.RegularFileAdmissionFailure( + snapshot.path, + new regularFile.RegularFileOperationCancelled(reason), + new Error("admission close unavailable"), + owner, + ); + }); + try { + await expect( + snapshot.verify({ bytes: 0, sha256: "unused" }, path, controller.signal), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason }); + expect(handle.fd).toBeGreaterThanOrEqual(0); + await snapshot.close(); + expect(handle.fd).toBe(-1); + } finally { + admission.mockRestore(); + await snapshot.close(); + await owner.close(); + } +}); diff --git a/tests/boundary/providers/adb/adbHashCleanupConsumers.test.ts b/tests/boundary/providers/adb/adbHashCleanupConsumers.test.ts index 4dd6b9fec..93b385b98 100644 --- a/tests/boundary/providers/adb/adbHashCleanupConsumers.test.ts +++ b/tests/boundary/providers/adb/adbHashCleanupConsumers.test.ts @@ -1,13 +1,15 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, onTestFinished, test, vi } from "vitest"; import { AdbProvider } from "../../../../src/adb/AdbProvider.js"; +import * as regularFile from "../../../../src/filesystem/RegularFile.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { execFileBytes, execFileOutput, } from "../../../../src/process/ExecFileOutput.js"; +import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -76,7 +78,7 @@ const fixture = async () => { return { root, provider, failFirstClose }; }; -test.each([ +const digestCases = [ { label: "single-file pull", retainedPath: (root: string) => join(root, "payload.bin"), @@ -122,7 +124,9 @@ test.each([ }), expected: { bytes: 12, serial: "device", sha256: expect.any(String) }, }, -])( +] as const; + +test.each(digestCases)( "preserves the linked hash cleanup error through $label", async ({ request, expected, retainedPath }) => { const f = await fixture(); @@ -150,3 +154,142 @@ test.each([ ); }, ); + +const errorChainSome = ( + error: unknown, + predicate: (error: unknown) => boolean, +): boolean => { + if (predicate(error)) return true; + if (!(error instanceof Error)) return false; + return ( + errorChainSome(error.cause, predicate) || + (error instanceof AggregateError && + error.errors.some((child: unknown) => errorChainSome(child, predicate))) + ); +}; + +for (const closeFails of [false, true]) { + test.each(digestCases)( + `preserves a read failure through $label when close aborts (close fails: ${String(closeFails)})`, + async ({ request, retainedPath }) => { + const f = await fixture(); + const path = retainedPath(f.root); + const controller = new AbortController(); + const primary = Object.assign( + new Error("injected physical digest read EIO"), + { code: "EIO" }, + ); + const nativeOpen = regularFile.openRegularFile; + let opened: FileHandle | undefined; + vi.spyOn(regularFile, "openRegularFile").mockImplementation( + async (selected, options) => { + const file = await nativeOpen(selected, options); + if (selected === path) { + opened = file; + vi.spyOn(file, "read").mockRejectedValueOnce(primary); + } + return file; + }, + ); + const nativeClose = OwnedFileHandle.prototype.close; + let allowClose = !closeFails; + let owner: OwnedFileHandle | undefined; + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation( + async function (this: OwnedFileHandle) { + if (this.handle === opened) { + owner = this; + controller.abort("cancelled after the physical failure"); + if (!allowClose) throw new Error("digest close remains blocked"); + } + return nativeClose.call(this); + }, + ); + try { + const result = await f.provider.execute(request(f.root), { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected digest read failure"); + expect(projectAnalysisError(result.error).code).not.toBe("cancelled"); + expect( + errorChainSome( + result.error, + (cause) => cause instanceof AnalysisCancelledError, + ), + ).toBe(false); + expect(errorChainSome(result.error, (cause) => cause === primary)).toBe( + true, + ); + if (closeFails) expect(result.error.cleanupIncomplete).toBe(true); + allowClose = true; + await f.provider.close(); + expect(owner?.handle.fd).toBe(-1); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await f.provider.close(); + if (owner !== undefined) await nativeClose.call(owner); + } + }, + ); +} + +for (const closeFails of [false, true]) { + test.each(digestCases)( + `retains exact cancellation and completed digest through $label (close fails: ${String(closeFails)})`, + async ({ request, retainedPath }) => { + const f = await fixture(); + const path = retainedPath(f.root); + const controller = new AbortController(); + const reason = null; + const nativeOpen = regularFile.openRegularFile; + let opened: FileHandle | undefined; + vi.spyOn(regularFile, "openRegularFile").mockImplementation( + async (selected, options) => { + const handle = await nativeOpen(selected, options); + if (selected === path) opened = handle; + return handle; + }, + ); + const nativeClose = OwnedFileHandle.prototype.close; + let allowClose = !closeFails; + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation( + async function (this: OwnedFileHandle) { + if (this.handle === opened) { + controller.abort(reason); + if (!allowClose) + throw new Error("digest close remains unavailable"); + } + await nativeClose.call(this); + }, + ); + try { + const result = await f.provider.execute(request(f.root), { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected completion cancellation"); + expect(projectAnalysisError(result.error).code).toBe( + closeFails ? "cleanup_incomplete" : "cancelled", + ); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect( + JSON.stringify( + projectAnalysisError(result.error).details?.partial_observation, + ), + ).toContain('"sha256"'); + expect(result.error.cleanupIncomplete).toBe(closeFails); + allowClose = true; + await f.provider.close(); + expect(opened?.fd).toBe(-1); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await f.provider.close(); + if (opened !== undefined && opened.fd >= 0) + await nativeClose.call(new OwnedFileHandle(opened)); + } + }, + ); +}