Files
rea/tests/investigationWorkspace.test.ts
T

855 lines
28 KiB
TypeScript

import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPackageWithOptions } from "@electron/asar";
import canonicalize from "canonicalize";
import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js";
import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} from "../src/application/InvestigationWorkspaceStore.js";
import { compareArtifacts } from "../src/domain/artifactComparison.js";
import { artifactInventoryResultSchema } from "../src/domain/artifactGraph.js";
import {
changedBehaviorResultSchema,
findChangedBehavior,
} from "../src/domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../src/domain/evidence.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
investigationRunSummarySchema,
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
} from "../src/domain/investigationWorkspace.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { ok } from "../src/domain/result.js";
const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined) throw new TypeError("fixture is not canonical");
return createHash("sha256").update(encoded).digest("hex");
};
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
const policy = (root: string): EvidenceFilePolicy => ({
roots: [root],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const recreateEvidence = (
evidence: Evidence,
overrides: {
readonly parameters?: Readonly<Record<string, JsonValue>>;
readonly result?: JsonValue;
readonly evidenceLinks?: readonly string[];
},
): Evidence =>
createEvidence(
evidence.subject === null
? undefined
: {
path: evidence.subject.local_path,
sha256: evidence.subject.digest.sha256,
format: evidence.subject.format,
...(evidence.subject.architecture === null
? {}
: { architecture: evidence.subject.architecture }),
},
evidence.provider,
{
predicateType: evidence.predicate_type,
operation: evidence.operation,
parameters: overrides.parameters ?? evidence.parameters,
result: overrides.result ?? evidence.normalized_result,
rawResult: evidence.raw_result,
confidence: evidence.confidence,
authority: evidence.authority,
environment: evidence.environment,
limitations: evidence.limitations,
locations: evidence.locations,
evidenceLinks: overrides.evidenceLinks ?? evidence.evidence_links,
},
);
const fixture = async () => {
directory = await mkdtemp(join(tmpdir(), "rea-workspace-"));
const left = join(directory, "left");
const right = join(directory, "right");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "app.txt"), "version one\n"),
writeFile(join(right, "app.txt"), "version two\n"),
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
]);
const path = join(directory, "workspace.json");
const input = crossVersionInvestigationInputSchema.parse({
approved: true,
workspace_path: path,
workspace_name: "release-diff",
left_path: left,
right_path: right,
options: { page_size: 2, change_limit: 100 },
});
return { left, right, path, input };
};
describe("persistent cross-version investigation workspace", () => {
it("migrates legacy v1 runs without losing their revision chain", async () => {
const { input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
const current = completed.value.workspace;
const currentRun = current.runs[0];
if (currentRun === undefined) throw new Error("missing completed run");
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
}),
).toThrow(/migrated strict runs/iu);
const legacyIdentity = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: currentRun.left,
right: currentRun.right,
options: currentRun.options,
});
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
run_id: `run_${legacyIdentity}`,
request_sha256: legacyIdentity,
max_integrity_mismatches: 100,
}),
).toThrow(/migrated strict runs/iu);
const legacyRuns = current.runs.map((run) => {
const requestSha256 = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: run.left,
right: run.right,
options: run.options,
});
const {
integrity_policy: _integrityPolicy,
integrity_continue_approved: _integrityApproved,
max_integrity_mismatches: _integrityLimit,
...legacy
} = run;
return {
...legacy,
run_id: `run_${requestSha256}`,
request_sha256: requestSha256,
};
});
const legacySemantic = {
workspace_version: 1 as const,
workspace_id: current.workspace_id,
name: current.name,
revision: current.revision,
previous_revision_digest: current.previous_revision_digest,
bundle: current.bundle,
runs: legacyRuns,
};
const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`;
expect(() =>
parseInvestigationWorkspace({
...legacySemantic,
runs: legacyRuns.map((run) => ({
...run,
max_integrity_mismatches: 100,
})),
revision_digest: legacyDigest,
}),
).toThrow(/unrecognized key/iu);
const migrated = parseInvestigationWorkspace({
...legacySemantic,
revision_digest: legacyDigest,
});
expect(migrated).toMatchObject({
revision: current.revision,
previous_revision_digest: current.previous_revision_digest,
runs: [
{
legacy_request_identity: true,
integrity_policy: "fail",
integrity_continue_approved: false,
max_integrity_mismatches: 10,
},
],
});
const encodedLegacy = canonicalize({
...legacySemantic,
revision_digest: legacyDigest,
});
if (encodedLegacy === undefined)
throw new TypeError("legacy fixture is not canonical");
await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 });
await expect(
runCrossVersionInvestigation(
{ ...input, expected_workspace_revision: current.revision },
policy(directory),
{ inputRoots: [directory] },
),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
await expect(
runCrossVersionInvestigation(
{ ...input, integrity_continue_approved: true },
policy(directory),
{ inputRoots: [directory] },
),
).resolves.toMatchObject({ ok: true, value: { reused: false } });
});
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const first = await runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [directory],
});
expect(first).toMatchObject({
ok: true,
value: { reused: false, workspace: { revision: 3 } },
});
if (!first.ok) throw first.error;
const result = changedBehaviorResultSchema.parse(
first.value.evidence.normalized_result,
);
expect(result).toMatchObject({
behavior_status: "unknown",
summary: { static_candidates: 3 },
investigation_run: {
workspace_id: first.value.workspace.workspace_id,
run_id: first.value.workspace.runs[0]?.run_id,
},
});
expect(result.limitations).toContain(
"No process comparison Evidence was supplied.",
);
expect(first.value.workspace.bundle.records).toHaveLength(5);
expect((await stat(path)).mode & 0o777).toBe(0o600);
expect(await readFile(path, "utf8")).toBe(
serializeInvestigationWorkspace(first.value.workspace),
);
const second = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
expect(second).toMatchObject({
ok: true,
value: {
reused: true,
evidence: { evidence_id: first.value.evidence.evidence_id },
workspace: { revision: 3 },
},
});
expect(await readInvestigationWorkspace(path, policy(directory))).toEqual({
ok: true,
value: first.value.workspace,
});
});
it("replays an explicitly selected complete run without input access", async () => {
const { left, right, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
const run = completed.value.workspace.runs[0];
if (run === undefined) throw new Error("missing completed run");
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{ ...input, replay_run_id: run.run_id },
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: true,
value: {
reused: true,
evidence: { evidence_id: completed.value.evidence.evidence_id },
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
for (const replayInput of [
{ ...input, replay_run_id: `run_${"f".repeat(64)}` },
{ ...input, right_path: `${right}-other`, replay_run_id: run.run_id },
])
await expect(
runCrossVersionInvestigation(replayInput, policy(directory), {
inputRoots: [],
authorizeInputRead,
}),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
const byId = new Map(
completed.value.workspace.bundle.records.map((record) => [
record.evidence_id,
record,
]),
);
const evidenceFor = (ids: readonly string[]): Evidence[] =>
ids.map((id) => {
const evidence = byId.get(id);
if (evidence === undefined)
throw new Error("missing inventory fixture Evidence");
return evidence;
});
const leftPages = evidenceFor(run.left_inventory_evidence_ids);
const rightPages = evidenceFor(run.right_inventory_evidence_ids);
const comparison = byId.get(run.comparison_evidence_id ?? "");
const result = byId.get(run.result_evidence_id ?? "");
if (comparison === undefined || result === undefined)
throw new Error("missing completed fixture Evidence");
const forgeWorkspace = (forgedLeftPages: readonly Evidence[]) => {
const comparisonResult = compareArtifacts(
forgedLeftPages,
rightPages,
0,
run.options.change_limit,
);
const forgedComparison = recreateEvidence(comparison, {
parameters: {
left_evidence_ids: forgedLeftPages.map(({ evidence_id: id }) => id),
right_evidence_ids: rightPages.map(({ evidence_id: id }) => id),
offset: 0,
limit: run.options.change_limit,
},
result: comparisonResult,
evidenceLinks: [
...forgedLeftPages.map(({ evidence_id: id }) => id),
...rightPages.map(({ evidence_id: id }) => id),
],
});
const resultLimit = Math.min(run.options.change_limit, 100);
const changed = findChangedBehavior([forgedComparison], 0, resultLimit);
const summary = investigationRunSummarySchema.parse({
schema_version: 1,
workspace_id: completed.value.workspace.workspace_id,
run_id: run.run_id,
left_manifest_id: run.left.manifest_id,
right_manifest_id: run.right.manifest_id,
inventory_evidence_count: forgedLeftPages.length + rightPages.length,
comparison_evidence_id: forgedComparison.evidence_id,
limitations: run.limitations,
});
const forgedResult = recreateEvidence(result, {
parameters: {
workspace_id: completed.value.workspace.workspace_id,
run_id: run.run_id,
comparison_evidence_ids: [forgedComparison.evidence_id],
offset: 0,
limit: resultLimit,
},
result: { ...changed, investigation_run: summary },
evidenceLinks: changed.evidence_links,
});
const forgedRun = investigationRunSchema.parse({
...run,
left_inventory_evidence_ids: forgedLeftPages.map(
({ evidence_id: id }) => id,
),
comparison_evidence_id: forgedComparison.evidence_id,
result_evidence_id: forgedResult.evidence_id,
});
const records = new Map(
[
...completed.value.workspace.bundle.records,
...forgedLeftPages,
forgedComparison,
forgedResult,
].map((record) => [record.evidence_id, record]),
);
return createInvestigationWorkspace(
input.workspace_name,
createEvidenceBundle([...records.values()]),
[forgedRun],
);
};
const firstPage = leftPages[0]!;
const firstPageResult = artifactInventoryResultSchema.parse(
firstPage.normalized_result,
);
const extraOffset = leftPages.length * input.options.page_size;
const extraPage = recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
node_offset: extraOffset,
occurrence_offset: extraOffset,
edge_offset: extraOffset,
},
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
items: [],
offset: extraOffset,
next_offset: null,
},
occurrences: {
...firstPageResult.occurrences,
items: [],
offset: extraOffset,
next_offset: null,
},
edges: {
...firstPageResult.edges,
items: [],
offset: extraOffset,
next_offset: null,
},
},
});
const forgedVariants = [
{
name: "controls",
pages: [
recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
max_entries: input.options.max_entries - 1,
},
}),
...leftPages.slice(1),
],
},
{
name: "page-total",
pages: [
recreateEvidence(firstPage, {
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
total: firstPageResult.nodes.total + 1,
},
},
}),
...leftPages.slice(1),
],
},
{ name: "page-count", pages: [...leftPages, extraPage] },
] as const;
for (const variant of forgedVariants) {
const forgedPath = join(directory, `forged-${variant.name}.json`);
expect(
await writeInvestigationWorkspace(
forgeWorkspace(variant.pages),
forgedPath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: forgedPath,
replay_run_id: run.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
}
expect(authorizeInputRead).not.toHaveBeenCalled();
});
it("enforces shared JSON depth limits while reading workspaces", async () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
await expect(
readInvestigationWorkspace(path, {
...policy(directory),
maxDepth: 1,
}),
).resolves.toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "too-large" },
});
});
it("resumes from a comparison checkpoint without recomputing its identity", async () => {
const { input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
const run = completed.value.workspace.runs[0];
if (run === undefined || run.comparison_evidence_id === null)
throw new Error("missing completed run");
const partialRun = investigationRunSchema.parse({
...run,
status: "running",
completed_stages: [
"inventory_left",
"inventory_right",
"compare_artifacts",
],
result_evidence_id: null,
});
const records = completed.value.workspace.bundle.records.filter(
({ evidence_id: id }) => id !== run.result_evidence_id,
);
const partial = createInvestigationWorkspace(
"release-diff",
createEvidenceBundle(records),
[partialRun],
);
const resumePath = join(directory, "resume.json");
expect(
await writeInvestigationWorkspace(
partial,
resumePath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: resumePath,
replay_run_id: partialRun.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
const resumed = await runCrossVersionInvestigation(
{ ...input, workspace_path: resumePath },
policy(directory),
{ inputRoots: [directory] },
);
expect(resumed).toMatchObject({
ok: true,
value: {
reused: false,
evidence: { evidence_id: completed.value.evidence.evidence_id },
workspace: { revision: 2 },
},
});
});
it("appends a changed-content run without replacing earlier Evidence", async () => {
const { right, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const first = await runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [directory],
});
if (!first.ok) throw first.error;
const firstRun = first.value.workspace.runs[0];
if (firstRun === undefined || firstRun.result_evidence_id === null)
throw new Error("missing first completed run");
await writeFile(join(right, "app.txt"), "version three\n");
const second = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
expect(second).toMatchObject({
ok: true,
value: {
reused: false,
workspace: {
revision: 6,
runs: [{ status: "complete" }, { status: "complete" }],
},
},
});
if (!second.ok) throw second.error;
expect(second.value.workspace.runs).toHaveLength(2);
expect(second.value.workspace.runs.map(({ run_id: id }) => id)).toContain(
firstRun.run_id,
);
expect(
second.value.workspace.bundle.records.some(
({ evidence_id: id }) => id === firstRun.result_evidence_id,
),
).toBe(true);
});
it("checkpoints bounded integrity contradictions and safely reuses them", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-workspace-integrity-"));
const source = join(directory, "source");
await mkdir(source);
await writeFile(join(source, "addon.node"), "verified native addon\n");
const left = join(directory, "left.asar");
const right = join(directory, "right.asar");
await Promise.all([
createPackageWithOptions(source, left, { unpack: "*.node" }),
createPackageWithOptions(source, right, { unpack: "*.node" }),
]);
await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n");
const base = {
approved: true as const,
workspace_path: join(directory, "strict.json"),
workspace_name: "integrity-diff",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
};
const strict = await runCrossVersionInvestigation(
crossVersionInvestigationInputSchema.parse(base),
policy(directory),
{ inputRoots: [directory] },
);
expect(strict).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "integrity" },
});
const continuedInput = crossVersionInvestigationInputSchema.parse({
...base,
workspace_path: join(directory, "continued.json"),
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
max_integrity_mismatches: 2,
});
const continued = await runCrossVersionInvestigation(
continuedInput,
policy(directory),
{ inputRoots: [directory], integrityContinueEnabled: true },
);
expect(continued).toMatchObject({
ok: true,
value: { reused: false, workspace: { revision: 3 } },
});
if (!continued.ok) throw continued.error;
expect(
continued.value.workspace.bundle.records.some((record) =>
JSON.stringify(record.normalized_result).includes(
'"integrity_contradictions":[{',
),
),
).toBe(true);
expect(
continued.value.workspace.bundle.records.find(
({ operation }) => operation === "compare_artifacts",
)?.normalized_result,
).toMatchObject({ status: "contradiction" });
await expect(
runCrossVersionInvestigation(continuedInput, policy(directory), {
inputRoots: [directory],
integrityContinueEnabled: true,
}),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({
...base,
workspace_path: continuedInput.workspace_path,
});
await expect(
runCrossVersionInvestigation(strictAfterContinuation, policy(directory), {
inputRoots: [directory],
}),
).resolves.toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "integrity" },
});
});
it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
await writeFile(`${path}.lock`, `${String(process.pid)}\n`, {
mode: 0o600,
});
expect(
await runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [directory],
}),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
});
await rm(`${path}.lock`);
await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 });
expect(
await runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [directory],
}),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
});
await rm(`${path}.lock`);
await writeFile(`${path}.lock`, "2147483647\n", { mode: 0o600 });
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
await expect(stat(`${path}.lock`)).rejects.toMatchObject({
code: "ENOENT",
});
expect(
await runCrossVersionInvestigation(
{ ...input, expected_workspace_revision: 2 },
policy(directory),
{ inputRoots: [directory] },
),
).toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
const decoded = JSON.parse(await readFile(path, "utf8")) as unknown;
if (typeof decoded !== "object" || decoded === null)
throw new Error("invalid workspace fixture");
await chmod(path, 0o600);
await writeFile(path, JSON.stringify({ ...decoded, revision: 99 }));
expect(
await readInvestigationWorkspace(path, policy(directory)),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "integrity" },
});
const cancelledPath = join(directory, "cancelled.json");
const controller = new AbortController();
controller.abort();
expect(
await runCrossVersionInvestigation(
{ ...input, workspace_path: cancelledPath },
policy(directory),
{ inputRoots: [directory], signal: controller.signal },
),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it("rejects workspace paths and symlinks outside approved roots", async () => {
const { input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const approved = join(directory, "approved");
await mkdir(approved);
expect(
await runCrossVersionInvestigation(input, policy(approved), {
inputRoots: [directory],
}),
).toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "outside-root",
},
});
const outside = join(directory, "outside.json");
await writeFile(outside, "{}", { mode: 0o600 });
const escaped = join(approved, "escaped.json");
await symlink(outside, escaped);
expect(
await runCrossVersionInvestigation(
{ ...input, workspace_path: escaped },
policy(approved),
{ inputRoots: [directory] },
),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "not-file" },
});
});
it("rejects artifact inputs outside independently approved roots", async () => {
const { input, path } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const approvedInputs = join(directory, "approved-inputs");
await mkdir(approvedInputs);
expect(
await runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [approvedInputs],
}),
).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "path" },
});
await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" });
});
});