Files
rea/tests/composition/directAnalysisCleanup.test.ts

637 lines
21 KiB
TypeScript

import { readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
runDirectAnalysis,
runManagedProviderExecution,
runProviderAnalysis,
} from "../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js";
import { parseConfig } from "../../src/config/parseConfig.js";
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
import type { AnalysisError } from "../../src/domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisTimeoutError,
} from "../../src/domain/analysisErrorCore.js";
import {
projectAnalysisError,
projectAnalysisErrorWithoutCapturedOutput,
} from "../../src/domain/analysisErrorProjection.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { ghidraFunctionDossier } from "../../src/domain/ghidraValues.fixture.js";
import {
createAnalysisProfile,
type AnalysisProfileCommitment,
} from "../../src/domain/analysisProfile.js";
import { OwnedStagedOutput } from "../../src/filesystem/OwnedStagedOutput.js";
import { readAnalysisSnapshot } from "../../src/application/binary/AnalysisSnapshotFiles.js";
import { EvidenceFileError } from "../../src/domain/evidenceErrors.js";
import { err, ok } from "../../src/domain/result.js";
import { isCliOperationFailure } from "../../src/cliLogging.js";
import { observed } from "../fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../fixtures/binarySession.js";
const cleanupError = new ProviderCleanupError(
"fixture",
["fixture-provider-process"],
{ reason: "process exit could not be confirmed" },
{ remediationAction: "Close the retained fixture process before retrying." },
);
const primaryError = new AnalysisTimeoutError("read_bytes", 100, {
capturedOutput: {
stdout: "retained observation",
stderr: "",
truncated: false,
},
});
const factories = (
options: {
readonly profile?: AnalysisProfileCommitment;
readonly executionError?: AnalysisError;
readonly thrownExecution?: Error;
readonly cleanupError?: AnalysisError;
readonly thrownCleanup?: Error;
readonly onClose?: () => void;
} = {},
) => {
const profile = options.profile;
const session = createTestBinarySession(
() => ({
execute: async (operation) => {
if (operation === "health") return observed(null);
if (options.thrownExecution !== undefined)
throw options.thrownExecution;
return options.executionError === undefined
? observed(
operation === "analyze_function"
? ghidraFunctionDossier()
: { operation, observation: "retained result" },
)
: err(options.executionError);
},
close: async () => {
if (options.thrownCleanup !== undefined) throw options.thrownCleanup;
options.onClose?.();
return options.cleanupError === undefined
? ok(null)
: err(options.cleanupError);
},
}),
profile === undefined
? {}
: { resolveAnalysisProfile: async () => ok({ profile }) },
);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
return { dependencies, session };
};
describe("one-shot finalization cancellation", () => {
it.each(["read_bytes", "analyze_function"] as const)(
"retains completed %s Evidence when cancellation arrives during final close",
async (tool) => {
const [path] = await createBinarySessionTargets();
const parameters =
tool === "analyze_function" ? { procedure: "fixture" } : {};
const original = parseEvidence(
await runDirectAnalysis(
factories().dependencies,
path,
tool,
parameters,
),
);
const controller = new AbortController();
const reason = { request: "cancel during native close" };
const { dependencies, session } = factories({
onClose: () => controller.abort(reason),
});
const result = await runDirectAnalysis(
dependencies,
path,
tool,
parameters,
{ signal: controller.signal },
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(
new AnalysisCancelledError(tool, {
cause: reason,
partialObservation: original,
}),
),
});
expect(session.activeTarget()).toBeUndefined();
},
);
it.each([false, true])(
"preserves the execution failure during close cancellation (cleanup fails: %s)",
async (closeFails) => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const { dependencies } = factories({
executionError: primaryError,
onClose: () => controller.abort({ later: true }),
...(closeFails ? { cleanupError } : {}),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal },
);
expect(result).toMatchObject({
code: closeFails ? "cleanup_incomplete" : "provider_timeout",
message: projectAnalysisError(primaryError).message,
details: {
captured_output: primaryError.capturedOutput,
...(closeFails
? { cleanup_error: projectAnalysisError(cleanupError) }
: {}),
},
});
},
);
it("retains completed Evidence and cancellation alongside unsuccessful cleanup", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const { dependencies } = factories({
cleanupError,
onClose: () => controller.abort({ during: "close" }),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal },
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
category: "cancelled",
details: {
execution_failure: "cancelled",
resources: ["fixture-provider-process"],
partial_observation: {
operation: "read_bytes",
normalized_result: { observation: "retained result" },
},
cleanup_error: projectAnalysisError(cleanupError),
},
});
});
});
describe("snapshot publication finalization", () => {
it.each([
"success",
"cancel",
"recovered-write-cancel",
"close-failure",
"cancel-close-failure",
] as const)(
"retains the actual snapshot publication through finalization: %s",
async (mode) => {
const [path] = await createBinarySessionTargets();
const snapshotPath = join(dirname(path), "snapshot.json");
const controller = new AbortController();
const profile = createAnalysisProfile(
{ id: "fixture", name: "Fixture analysis provider", version: "1" },
{ fixture: true },
);
const cancelled = mode.includes("cancel");
const { dependencies, session } = factories({
profile,
onClose: () => {
if (cancelled) controller.abort({ during: "snapshot finalization" });
},
...(mode.includes("close-failure") ? { cleanupError } : {}),
});
const stagingCleanup =
mode === "recovered-write-cancel"
? vi
.spyOn(OwnedStagedOutput.prototype, "close")
.mockRejectedValueOnce(
new Error("transient staging cleanup failure"),
)
: undefined;
try {
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal, snapshotPath },
);
const loaded = await readAnalysisSnapshot(snapshotPath);
if (!loaded.ok) throw loaded.error;
const evidence = loaded.value.evidence_bundle.records.find(
(record) => record.operation === "read_bytes",
);
expect(evidence).toBeDefined();
const receipt = {
path: snapshotPath,
bytes: (await stat(snapshotPath)).size,
};
expect((await readFile(snapshotPath)).byteLength).toBe(receipt.bytes);
if (mode === "success") expect(result).toEqual(evidence);
else {
const failure = analysisCliErrorEnvelopeSchema.parse(result);
expect(failure.code).toBe(
mode.includes("close-failure") ? "cleanup_incomplete" : "cancelled",
);
expect(failure.details).toMatchObject({
partial_observation: evidence,
diagnostics: { analysis_snapshot: receipt },
});
}
expect(session.activeTarget()).toBeUndefined();
expect((await readdir(dirname(path))).toSorted()).toEqual([
"first.hop",
"second.hop",
"snapshot.json",
]);
if (stagingCleanup !== undefined)
expect(stagingCleanup).toHaveBeenCalledTimes(2);
} finally {
stagingCleanup?.mockRestore();
}
},
);
it("preserves a snapshot write failure over later cancellation without a publication receipt", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const profile = createAnalysisProfile(
{ id: "fixture", name: "Fixture analysis provider", version: "1" },
{ fixture: true },
);
const { dependencies, session } = factories({
profile,
onClose: () => controller.abort({ later: true }),
});
const failure = new EvidenceFileError("write", "io", {
path: join(dirname(path), "failed.json"),
});
const write = vi
.spyOn(session, "writeAnalysisSnapshot")
.mockResolvedValue(err(failure));
try {
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal, snapshotPath: failure.path },
),
);
expect(result.code).toBe(projectAnalysisError(failure).code);
expect(result.details).toMatchObject({
partial_observation: { operation: "read_bytes" },
});
expect(result.details).not.toHaveProperty(
"diagnostics.analysis_snapshot",
);
expect(await readdir(dirname(path))).not.toContain("failed.json");
} finally {
write.mockRestore();
}
});
});
describe("one-shot analysis cleanup outcomes", () => {
it("retains successful evidence and reports CLI failure when close fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories({ cleanupError });
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
details: {
resources: ["fixture-provider-process"],
partial_observation: {
operation: "read_bytes",
subject: { local_path: path },
normalized_result: { observation: "retained result" },
raw_result: null,
},
},
});
expect(isCliOperationFailure(result)).toBe(true);
expect(session.activeTarget()).toBeUndefined();
});
it("preserves primary failure diagnostics alongside an unsuccessful close", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
executionError: primaryError,
cleanupError,
});
const primary = projectAnalysisError(primaryError);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
expect(callerError).toMatchObject({
code: "cleanup_incomplete",
category: primary.category,
message: primary.message,
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
execution_failure: primary.code,
captured_output: primary.details?.captured_output,
cleanup_error: projectAnalysisError(cleanupError),
},
});
expect(callerError.details).not.toHaveProperty("primary_error");
expect(
JSON.stringify(callerError).match(/retained observation/g),
).toHaveLength(1);
expect(isCliOperationFailure(result)).toBe(true);
});
it("reports provider close rejection with the successful observation", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
thrownCleanup: new Error("unexpected close rejection"),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: ["provider-client"],
diagnostics: { reason: "provider client close rejected unexpectedly" },
partial_observation: {
normalized_result: { observation: "retained result" },
},
},
});
});
it.each([false, true])(
"retains original cleanup diagnostics when final close fails: %s",
async (closeFails) => {
const [path] = await createBinarySessionTargets();
const executionError = new AnalysisTimeoutError("read_bytes", 100, {
cleanup: {
reason: "snapshot removal failed during execution",
resources: ["primary-snapshot"],
},
capturedOutput: primaryError.capturedOutput,
});
const primary = projectAnalysisError(executionError);
const { dependencies } = factories({
executionError,
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, path, "read_bytes", {}),
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
...primary.details,
...(closeFails
? { cleanup_error: projectAnalysisError(cleanupError) }
: {}),
},
});
expect(
JSON.stringify(result).match(/retained observation/g),
).toHaveLength(1);
if (!closeFails)
expect(result.details).not.toHaveProperty("cleanup_error");
},
);
it("retains both unexpected execution failure and cleanup failure", async () => {
const [path] = await createBinarySessionTargets();
const thrownExecution = new Error("unexpected execution rejection");
const { dependencies } = factories({ thrownExecution, cleanupError });
await expect(
runDirectAnalysis(dependencies, path, "read_bytes", {}),
).rejects.toMatchObject({
cause: thrownExecution,
errors: [thrownExecution, cleanupError],
});
});
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories();
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(parseEvidence(result)).toMatchObject({
operation: "read_bytes",
normalized_result: { observation: "retained result" },
});
expect(isCliOperationFailure(result)).toBe(false);
expect(session.activeTarget()).toBeUndefined();
});
});
it.each(["complete", "summary"] as const)(
"retains completed Evidence when cancellation arrives during managed close before %s delivery",
async (detail) => {
const [path] = await createBinarySessionTargets();
const files = await readdir(dirname(path));
const original = parseEvidence(
await runProviderAnalysis(
factories().dependencies,
path,
"inspect_managed_members",
{},
),
);
const controller = new AbortController();
const reason = new Error("cancel during managed close");
const { dependencies, session } = factories({
onClose: () => controller.abort(reason),
});
const result =
detail === "summary"
? await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
{
detail,
evidenceBundlePath: join(dirname(path), "bundle.json"),
},
)
: await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(
new AnalysisCancelledError("inspect_managed_members", {
cause: reason,
partialObservation: original,
}),
),
});
expect(session.activeTarget()).toBeUndefined();
expect(await readdir(dirname(path))).toEqual(files);
},
);
it("preserves a managed provider failure when cancellation arrives during close", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const failure = new AnalysisInputError("inspect_managed_members", undefined, [
{ path: ["path"], reason: "invalid_format" },
]);
const { dependencies, session } = factories({
executionError: failure,
onClose: () => controller.abort(new Error("later cancellation")),
});
const result = await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(failure),
});
expect(session.activeTarget()).toBeUndefined();
});
describe("managed one-shot cleanup outcomes", () => {
it("returns a managed cleanup error retaining the complete successful execution", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({ cleanupError });
const result = await runManagedProviderExecution(
dependencies,
path,
"inspect_managed_artifact",
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
resources: ["fixture-provider-process"],
diagnostics: {
cleanup_error: projectAnalysisError(cleanupError),
partial_observation: {
result: {
operation: "inspect_managed_artifact",
observation: "retained result",
},
rawResult: null,
provider: { id: "fixture" },
},
},
},
});
});
it("retains the typed managed primary error when close also fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
executionError: primaryError,
cleanupError,
});
const result = await runManagedProviderExecution(
dependencies,
path,
"inspect_managed_artifact",
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect(result.error.cause).toMatchObject({
cause: primaryError,
errors: [primaryError, cleanupError],
});
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
captured_output: primaryError.capturedOutput,
diagnostics: {
primary_error:
projectAnalysisErrorWithoutCapturedOutput(primaryError),
cleanup_error: projectAnalysisError(cleanupError),
},
},
});
expect(projected).not.toHaveProperty(
"details.diagnostics.primary_error.details.captured_output",
);
expect(
JSON.stringify(projected).split("retained observation"),
).toHaveLength(2);
});
it("exposes managed cleanup failure to the CLI error classifier", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({ cleanupError });
const result = await runProviderAnalysis(
dependencies,
path,
"inspect_managed_artifact",
{},
);
expect(isCliOperationFailure(result)).toBe(true);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
});
});
});