mirror of
https://github.com/morluto/rea.git
synced 2026-10-22 13:52:32 -05:00
637 lines
21 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|