import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; import { createAnalysisExecution, type AnalysisClient, type ProviderIdentity, } from "../AnalysisProvider.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; import { BinarySession } from "./BinarySession.js"; import { SessionProviderRouter } from "./SessionProviderRouter.js"; import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js"; import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { err } from "../../domain/result.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { createEvidence } from "../../domain/evidence.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; import { ok } from "../../domain/result.js"; import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js"; const identity: ProviderIdentity = { id: "bundle-resource-fixture", name: "Bundle resource fixture", version: "1", }; const binaryIdentity: ProviderIdentity = { id: "fixture", name: "Fixture analysis provider", version: "1", }; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((path) => rm(path, { recursive: true, force: true })), ); }); it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => { const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-")); temporaryDirectories.push(rootPath); await writeFile( join(rootPath, "Info.plist"), '', ); let openedRoot: string | undefined; let closed = false; const client: AnalysisClient = { execute: async (operation) => { if (operation === "health") return { ok: true, value: createAnalysisExecution(null, identity) }; return { ok: true, value: createAnalysisExecution( { operation, target_sha256: null }, identity, ), }; }, close: async () => { closed = true; return { ok: true, value: null }; }, }; const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: (root) => { openedRoot = root.canonicalPath; return { client, capabilities: [] }; }, }, ); const opened = await session.openBundleResources(rootPath); expect(opened.ok).toBe(true); if (!opened.ok) throw opened.error; expect(opened.value.executableSha256).toBeNull(); expect(openedRoot).toBe(opened.value.canonicalPath); expect( bundleResourceRootSchema.parse({ caller_path: opened.value.callerPath, canonical_path: opened.value.canonicalPath, info_plist_path: opened.value.infoPlistPath, layout: opened.value.layout, executable_sha256: opened.value.executableSha256, }).executable_sha256, ).toBeNull(); const result = await session.execute("inspect_plist", {}); expect(result.ok).toBe(true); if (result.ok) expect(result.value.subject).toBeNull(); const binaryOnly = await session.execute("binary_overview", {}); expect(binaryOnly.ok).toBe(false); expect(await session.status()).toMatchObject({ open: true, session_kind: "bundle_resources", resource_root: { canonical_path: opened.value.canonicalPath }, }); const snapshot = session.exportAnalysisSnapshot(); expect(snapshot.ok).toBe(false); if (!snapshot.ok) expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError); const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot); expect(imported.ok).toBe(false); if (!imported.ok) expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError); const closedResult = await session.close(); expect(closedResult.ok).toBe(true); expect(closed).toBe(true); }); it("keeps the exact caller reason for cancellation before resource-root resolution", async () => { const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-pre-cancel-")); temporaryDirectories.push(rootPath); const reason = { origin: "caller-pre-aborted" }; const controller = new AbortController(); controller.abort(reason); let clientCreated = false; const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: () => { clientCreated = true; throw new Error("pre-aborted call must not create a client"); }, }, ); const result = await session.openBundleResources(rootPath, { signal: controller.signal, }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", cause: reason }, }); expect(clientCreated).toBe(false); }); it("retains ownership when an unhealthy resource client cannot be closed", async () => { const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cleanup-")); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let closeAttempts = 0; const client: AnalysisClient = { execute: async () => err(new ProviderAdapterError(identity.id, "health")), close: async () => { closeAttempts += 1; return closeAttempts === 1 ? err(new ProviderAdapterError(identity.id, "close_binary")) : { ok: true, value: null }; }, }; const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: () => ({ client, capabilities: [] }) }, ); const opened = await session.openBundleResources(rootPath); expect(opened.ok).toBe(false); expect(closeAttempts).toBe(1); expect(await session.status()).toMatchObject({ cleanup_pending: true }); expect(await session.status()).toMatchObject({ open: false }); const closed = await session.close(); expect(closed.ok).toBe(true); expect(closeAttempts).toBe(2); expect(await session.status()).toMatchObject({ cleanup_pending: false }); }); it("cancels a resource switch during prior-client retirement and closes its candidate", async () => { const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-switch-")); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let closeStarted!: () => void; const closing = new Promise((resolve) => { closeStarted = resolve; }); let releaseClose!: () => void; const closeGate = new Promise((resolve) => { releaseClose = resolve; }); let created = 0; let candidateCloseCalls = 0; const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: () => { created += 1; const candidate = created > 1; return { client: { execute: async () => ({ ok: true, value: createAnalysisExecution(null, identity), }), close: async () => { if (candidate) candidateCloseCalls += 1; else { closeStarted(); await closeGate; } return { ok: true, value: null }; }, }, capabilities: [], }; }, }, ); expect((await session.openBundleResources(rootPath)).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "prior-resource-retirement" }; const opening = session.openBundleResources(rootPath, { signal: controller.signal, }); await Promise.race([ closing, new Promise((_, reject) => setTimeout( () => reject(new Error("prior resource client close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseClose(); const result = await opening; expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); if (!result.ok) expect(result.error.cause).toBe(reason); expect(candidateCloseCalls).toBe(1); expect(await session.status()).toMatchObject({ open: false }); expect(await session.close()).toMatchObject({ ok: true }); }); it("keeps cancellation primary when resource retirement fails and retries the owner", async () => { const rootPath = await mkdtemp( join(tmpdir(), "rea-resource-cancel-close-failure-"), ); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let closeStarted!: () => void; const closing = new Promise((resolve) => { closeStarted = resolve; }); let releaseClose!: () => void; const closeGate = new Promise((resolve) => { releaseClose = resolve; }); let created = 0; let oldCloseAttempts = 0; let candidateCloseAttempts = 0; const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: () => { created += 1; const candidate = created > 1; return { client: { execute: async () => ({ ok: true, value: createAnalysisExecution(null, identity), }), close: async () => { if (candidate) { candidateCloseAttempts += 1; return { ok: true, value: null }; } oldCloseAttempts += 1; if (oldCloseAttempts === 1) { closeStarted(); await closeGate; } return oldCloseAttempts < 3 ? err(new ProviderAdapterError(identity.id, "close_binary")) : { ok: true, value: null }; }, }, capabilities: [], }; }, }, ); expect((await session.openBundleResources(rootPath)).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "resource-close-failed" }; const opening = session.openBundleResources(rootPath, { signal: controller.signal, }); await Promise.race([ closing, new Promise((_, reject) => setTimeout( () => reject(new Error("resource close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed resource switch"); expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cleanupPrimary?.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(true); expect(candidateCloseAttempts).toBe(1); expect(oldCloseAttempts).toBe(2); expect(await session.status()).toMatchObject({ open: false, cleanup_pending: true, }); expect(await session.close()).toMatchObject({ ok: true }); expect(oldCloseAttempts).toBe(3); }); it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => { const rootPath = await mkdtemp( join(tmpdir(), "rea-resource-close-before-cancel-"), ); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let candidateCloseStarted!: () => void; const candidateClosing = new Promise((resolve) => { candidateCloseStarted = resolve; }); let releaseCandidateClose!: () => void; const candidateCloseGate = new Promise((resolve) => { releaseCandidateClose = resolve; }); let created = 0; let oldCloseAttempts = 0; const originalFailure = new ProviderAdapterError(identity.id, "close_binary"); const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), new InvestigationRecords(), { createClient: () => { created += 1; const candidate = created > 1; return { client: { execute: async () => ({ ok: true, value: createAnalysisExecution(null, identity), }), close: async () => { if (candidate) { candidateCloseStarted(); await candidateCloseGate; return { ok: true, value: null }; } oldCloseAttempts += 1; return oldCloseAttempts === 1 ? err(originalFailure) : { ok: true, value: null }; }, }, capabilities: [], }; }, }, ); expect((await session.openBundleResources(rootPath)).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "after-physical-close-failure" }; const opening = session.openBundleResources(rootPath, { signal: controller.signal, }); await Promise.race([ candidateClosing, new Promise((_, reject) => setTimeout( () => reject(new Error("candidate close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseCandidateClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed resource switch"); expect(result.error).toBe(originalFailure); expect(result.error.cleanupPrimary).toBeUndefined(); expect(oldCloseAttempts).toBe(2); expect(await session.close()).toMatchObject({ ok: true }); }); it("keeps cancellation primary when binary retirement fails before opening resources", async () => { const rootPath = await mkdtemp( join(tmpdir(), "rea-binary-close-cancel-resource-"), ); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let closeStarted!: () => void; const closing = new Promise((resolve) => { closeStarted = resolve; }); let releaseClose!: () => void; const closeGate = new Promise((resolve) => { releaseClose = resolve; }); let binaryCloseAttempts = 0; let resourceCloseAttempts = 0; const binaryClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : "fixture-name", binaryIdentity, ), }), close: async () => { binaryCloseAttempts += 1; if (binaryCloseAttempts === 1) { closeStarted(); await closeGate; return err(new ProviderAdapterError(binaryIdentity.id, "close_binary")); } return { ok: true, value: null }; }, }; const resourceClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : { operation, target_sha256: null }, identity, { subject: null }, ), }), close: async () => { resourceCloseAttempts += 1; return { ok: true, value: null }; }, }; const session = new BinarySession( createTestProviderRouter(() => binaryClient, { resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile( { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, { fixture: true }, ), }), }), new InvestigationRecords(), { createClient: () => ({ client: resourceClient, capabilities: [] }), }, ); expect((await session.open(process.execPath)).ok).toBe(true); expect((await session.execute("address_name", {})).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "binary-retirement" }; const opening = session.openBundleResources(rootPath, { signal: controller.signal, }); await Promise.race([ closing, new Promise((_, reject) => setTimeout(() => reject(new Error("binary close did not start")), 1_000), ), ]); controller.abort(reason); releaseClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed resource open"); expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cleanupPrimary?.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(true); expect(resourceCloseAttempts).toBe(1); expect(await session.status()).toMatchObject({ open: false, cleanup_pending: true, }); expect(await session.close()).toMatchObject({ ok: true }); expect(binaryCloseAttempts).toBe(2); }); it("keeps an earlier resource close failure primary when binary candidate cleanup is cancelled", async () => { const rootPath = await mkdtemp( join(tmpdir(), "rea-resource-close-before-binary-cancel-"), ); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let candidateCloseStarted!: () => void; const candidateClosing = new Promise((resolve) => { candidateCloseStarted = resolve; }); let releaseCandidateClose!: () => void; const candidateCloseGate = new Promise((resolve) => { releaseCandidateClose = resolve; }); let resourceCloseAttempts = 0; let binaryCloseAttempts = 0; const originalFailure = new ProviderAdapterError(identity.id, "close_binary"); const resourceClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : { operation, target_sha256: null }, identity, { subject: null }, ), }), close: async () => { resourceCloseAttempts += 1; return resourceCloseAttempts === 1 ? err(originalFailure) : { ok: true, value: null }; }, }; const binaryClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : "fixture-name", binaryIdentity, ), }), close: async () => { binaryCloseAttempts += 1; if (binaryCloseAttempts === 1) { candidateCloseStarted(); await candidateCloseGate; } return { ok: true, value: null }; }, }; const router = createTestProviderRouter(() => binaryClient); const route = router.initialRoute(); const parsedTarget = await parseBinaryTarget(process.execPath); if (!parsedTarget.ok) throw parsedTarget.error; const session = new BinarySession(router, new InvestigationRecords(), { createClient: () => ({ client: resourceClient, capabilities: [] }), }); expect((await session.openBundleResources(rootPath)).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "after-resource-close-failure" }; const opening = session.openResolvedTarget( { target: parsedTarget.value, route }, { signal: controller.signal }, ); await Promise.race([ candidateClosing, new Promise((_, reject) => setTimeout( () => reject(new Error("binary candidate close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseCandidateClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed binary open"); expect(result.error).toBe(originalFailure); expect(result.error.cleanupPrimary).toBeUndefined(); expect(binaryCloseAttempts).toBe(1); expect(resourceCloseAttempts).toBe(1); expect(await session.close()).toMatchObject({ ok: true }); expect(resourceCloseAttempts).toBe(2); }); it("keeps an earlier binary close failure primary when candidate cleanup is cancelled", async () => { const rootPath = await mkdtemp( join(tmpdir(), "rea-binary-close-before-cancel-"), ); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let candidateCloseStarted!: () => void; const candidateClosing = new Promise((resolve) => { candidateCloseStarted = resolve; }); let releaseCandidateClose!: () => void; const candidateCloseGate = new Promise((resolve) => { releaseCandidateClose = resolve; }); let binaryCloseAttempts = 0; let resourceCloseAttempts = 0; const originalFailure = new ProviderAdapterError( binaryIdentity.id, "close_binary", ); const binaryClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : "fixture-name", binaryIdentity, ), }), close: async () => { binaryCloseAttempts += 1; return binaryCloseAttempts === 1 ? err(originalFailure) : { ok: true, value: null }; }, }; const resourceClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : { operation, target_sha256: null }, identity, { subject: null }, ), }), close: async () => { resourceCloseAttempts += 1; if (resourceCloseAttempts === 1) { candidateCloseStarted(); await candidateCloseGate; } return { ok: true, value: null }; }, }; const session = new BinarySession( createTestProviderRouter(() => binaryClient, { resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile( { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, { fixture: true }, ), }), }), new InvestigationRecords(), { createClient: () => ({ client: resourceClient, capabilities: [] }), }, ); expect((await session.open(process.execPath)).ok).toBe(true); expect((await session.execute("address_name", {})).ok).toBe(true); const controller = new AbortController(); const reason = { phase: "after-binary-close-failure" }; const opening = session.openBundleResources(rootPath, { signal: controller.signal, }); await Promise.race([ candidateClosing, new Promise((_, reject) => setTimeout( () => reject(new Error("resource candidate close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseCandidateClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed resource open"); expect(result.error).toBe(originalFailure); expect(result.error.cleanupPrimary).toBeUndefined(); expect(binaryCloseAttempts).toBe(1); expect(resourceCloseAttempts).toBe(1); expect(await session.close()).toMatchObject({ ok: true }); expect(binaryCloseAttempts).toBe(2); }); it("cancels a binary switch during resource retirement and retains null-subject Evidence", async () => { const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-binary-")); temporaryDirectories.push(rootPath); await writeFile(join(rootPath, "Info.plist"), ""); let resourceCloseStarted!: () => void; const closing = new Promise((resolve) => { resourceCloseStarted = resolve; }); let releaseResourceClose!: () => void; const closeGate = new Promise((resolve) => { releaseResourceClose = resolve; }); let resourceCloseAttempts = 0; const binaryClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : { document: "fixture", procedure_count: 0, string_count: 0 }, binaryIdentity, ), }), close: async () => ({ ok: true, value: null }), }; const resourceClient: AnalysisClient = { execute: async (operation) => ({ ok: true, value: createAnalysisExecution( operation === "health" ? null : { operation, target_sha256: null }, identity, { subject: null }, ), }), close: async () => { resourceCloseAttempts += 1; resourceCloseStarted(); if (resourceCloseAttempts === 1) { await closeGate; return err(new ProviderAdapterError(identity.id, "close_binary")); } return { ok: true, value: null }; }, }; const session = new BinarySession( createTestProviderRouter(() => binaryClient, { resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile( { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, { fixture: true }, ), }), }), new InvestigationRecords(), { createClient: () => ({ client: resourceClient, capabilities: [] }), }, ); expect((await session.openBundleResources(rootPath)).ok).toBe(true); const observed = await session.execute("inspect_plist", {}); expect(observed.ok).toBe(true); if (!observed.ok) throw observed.error; const recorded = session.recordEvidence( createEvidence(undefined, identity, { operation: "inspect_plist", parameters: {}, result: observed.value.result, }), ); expect(recorded.ok).toBe(true); const recordsBefore = session.exportEvidenceBundle().records; expect(recordsBefore).toHaveLength(1); const evidenceId = recordsBefore[0]?.evidence_id; if (evidenceId === undefined) throw new Error("Expected retained Evidence"); const controller = new AbortController(); const reason = { phase: "resource-retirement" }; const opening = session.open(process.execPath, { signal: controller.signal }); await Promise.race([ closing, new Promise((_, reject) => setTimeout( () => reject(new Error("resource client close did not start")), 1_000, ), ), ]); controller.abort(reason); releaseResourceClose(); const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected cancelled binary open"); expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cleanupPrimary?.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(true); expect(await session.status()).toMatchObject({ open: false }); expect(session.exportEvidenceBundle().records).toHaveLength(1); expect(session.evidenceById(evidenceId)).toMatchObject({ subject: null }); expect(await session.status()).toMatchObject({ cleanup_pending: true }); expect(await session.close()).toMatchObject({ ok: true }); expect(resourceCloseAttempts).toBe(2); });