855 lines
28 KiB
TypeScript
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" });
|
|
});
|
|
});
|