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);
});