mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
Preserve the verbatim direct CLI selection in caller_input_path, separate from canonical subject identity and provider/query parameters. Fresh publication uses the existing immutable metadata import path so provider auto-recording cannot discard request provenance. Replay returns the current selection without rewriting stored Evidence or partitioning the cache by alias. Historical records without the optional field still authenticate. Use the domain semantic projection for ledger agreement, remove the duplicate projection and subject-only replacement names, and preserve request metadata at same-invocation summary delivery. Later retained-view questions do not inherit that context. Existing one-shot parameters.input_path remains for compatibility. Validation: 149 focused tests, 28 public CLI/MCP cases, all canonical advertised schemas through the pinned SDK, and nine documentation tests passed across the affected checks. Build, typecheck/lint, and formatting passed. Two Windows-only replay cases were skipped on macOS. Native macOS and managed fixtures were exercised; no Hopper/Ghidra/IDA runtime was started.
840 lines
30 KiB
TypeScript
840 lines
30 KiB
TypeScript
import {
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js";
|
|
|
|
import {
|
|
readEvidenceBundle,
|
|
readImmutableEvidenceBundle,
|
|
writeEvidenceBundle,
|
|
} from "../../../src/application/EvidenceBundleFiles.js";
|
|
import {
|
|
compareEvidenceBundlesCommand,
|
|
deliverAnalysisEvidence,
|
|
} from "../../../src/application/EvidenceBundleCommands.js";
|
|
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
|
|
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import {
|
|
createEvidence,
|
|
parseEvidence,
|
|
withEvidenceCallerInputPath,
|
|
} from "../../../src/domain/evidence.js";
|
|
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
|
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
|
|
import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js";
|
|
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
|
|
import { JsonInputPhysicalReadFailure } from "../../../src/application/JsonInputFile.js";
|
|
import {
|
|
createEvidenceBundle,
|
|
parseEvidenceBundle,
|
|
serializeEvidenceBundle,
|
|
} from "../../../src/domain/evidenceBundle.js";
|
|
import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js";
|
|
|
|
const bundle = (result = true) =>
|
|
createEvidenceBundle([
|
|
createEvidence(
|
|
undefined,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{ operation: "health", parameters: {}, result },
|
|
),
|
|
]);
|
|
|
|
it.each([undefined, "../selected-link/./artifact"])(
|
|
"keeps same-invocation caller input on delivered summary and saved parent (%s)",
|
|
async (callerInputPath) => {
|
|
const root = await createTestTempDirectory("rea-delivery-input-path-");
|
|
const path = join(root, "bundle.json");
|
|
const original = analysisViewLayoutEvidence();
|
|
const parent =
|
|
callerInputPath === undefined
|
|
? original
|
|
: withEvidenceCallerInputPath(original, callerInputPath);
|
|
const delivered = await deliverAnalysisEvidence(parent, path, "summary");
|
|
if (!delivered.ok) throw delivered.error;
|
|
expect(delivered.value.caller_input_path).toBe(callerInputPath);
|
|
expect(delivered.value.evidence_links).toContain(parent.evidence_id);
|
|
expect(original.caller_input_path).toBeUndefined();
|
|
const saved = await readEvidenceBundle(path);
|
|
if (!saved.ok) throw saved.error;
|
|
const savedParent = saved.value.records.find(
|
|
(record) => record.evidence_id === parent.evidence_id,
|
|
);
|
|
expect(savedParent?.caller_input_path).toBe(callerInputPath);
|
|
},
|
|
);
|
|
|
|
it.each([0, 9 * 1024 * 1024])(
|
|
"preserves the format owner's BOM recovery with %i bytes of padding",
|
|
async (padding) => {
|
|
const root = await createTestTempDirectory("rea-bundle-bom-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(
|
|
path,
|
|
`\uFEFF${" ".repeat(padding)}${JSON.stringify(bundle())}`,
|
|
);
|
|
const result = await readEvidenceBundle(path);
|
|
if (result.ok) throw new Error("Expected strict JSON BOM rejection");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "evidence_integrity_mismatch",
|
|
message: JSON_BYTE_ORDER_MARK_MESSAGE,
|
|
remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE },
|
|
details: {
|
|
path,
|
|
reason: "invalid-json",
|
|
content_issues: [
|
|
{ reason: "invalid_format", message: JSON_BYTE_ORDER_MARK_MESSAGE },
|
|
],
|
|
},
|
|
});
|
|
// Ordinary malformed JSON keeps its distinct parser diagnostic and the
|
|
// existing general recovery; consumers do not infer BOMs from text.
|
|
await writeFile(path, `${" ".repeat(padding)}{`);
|
|
const malformed = await readEvidenceBundle(path);
|
|
if (malformed.ok) throw new Error("Expected malformed JSON rejection");
|
|
expect(projectAnalysisError(malformed.error).message).toContain(
|
|
"Repair or recreate",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["immutable", readImmutableEvidenceBundle],
|
|
["mutable", readEvidenceBundle],
|
|
] as const)(
|
|
"cancels a %s bundle read before opening its selected path",
|
|
async (_, read) => {
|
|
const root = await createTestTempDirectory("rea-bundle-pre-cancel-");
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel before bundle read");
|
|
controller.abort(reason);
|
|
const result = await read(join(root, "missing.json"), controller.signal);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "import_evidence_bundle",
|
|
cause: controller.signal.reason,
|
|
},
|
|
});
|
|
if (result.ok) throw new Error("Expected cancelled bundle read");
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(await readdir(root)).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["mutable", readEvidenceBundle],
|
|
["immutable", readImmutableEvidenceBundle],
|
|
] as const)(
|
|
"preserves the caller cancellation reason during %s read",
|
|
async (_, read) => {
|
|
const root = await createTestTempDirectory("rea-bundle-read-cancel-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(path, JSON.stringify(bundle()));
|
|
const reader = await holdFirstFileRead(path);
|
|
const controller = new AbortController();
|
|
const reason = new Error("stop bundle import");
|
|
try {
|
|
const pending = read(path, controller.signal);
|
|
await reader.firstRead;
|
|
controller.abort(reason);
|
|
reader.release();
|
|
const result = await pending;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "import_evidence_bundle",
|
|
},
|
|
});
|
|
if (result.ok) throw new Error("Expected cancelled bundle read");
|
|
expect(result.error.cause).toBe(reason);
|
|
} finally {
|
|
controller.abort(reason);
|
|
reader.release();
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["mutable", readEvidenceBundle],
|
|
["immutable", readImmutableEvidenceBundle],
|
|
] as const)(
|
|
"keeps the physical null read cause for %s import after cancellation",
|
|
async (_, read) => {
|
|
const root = await createTestTempDirectory("rea-bundle-late-io-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(path, JSON.stringify(bundle()));
|
|
const controller = new AbortController();
|
|
await failFileReadAfterAbort(
|
|
path,
|
|
controller,
|
|
new Error("later abort"),
|
|
null,
|
|
);
|
|
try {
|
|
const result = await read(path, controller.signal);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "io", path },
|
|
});
|
|
if (result.ok) throw new Error("Expected physical JSON read failure");
|
|
expect(result.error.cause).toBeNull();
|
|
} finally {
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["mutable", readEvidenceBundle],
|
|
["immutable", readImmutableEvidenceBundle],
|
|
] as const)(
|
|
"keeps an identical physical and abort reason for %s import",
|
|
async (_, read) => {
|
|
const root = await createTestTempDirectory("rea-bundle-identical-abort-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(path, JSON.stringify(bundle()));
|
|
const controller = new AbortController();
|
|
const physical = new JsonInputPhysicalReadFailure(
|
|
Object.assign(new Error("physical EIO"), { code: "EIO" }),
|
|
);
|
|
await failFileReadAfterAbort(path, controller, physical, physical);
|
|
try {
|
|
const result = await read(path, controller.signal);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "io" },
|
|
});
|
|
if (result.ok) throw new Error("Expected physical JSON read failure");
|
|
expect(result.error.cause).toBe(physical);
|
|
} finally {
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("preserves the failed-close owner when import is also cancelled", async () => {
|
|
const root = await createTestTempDirectory("rea-import-cancel-cleanup-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(path, JSON.stringify(bundle()));
|
|
const controller = new AbortController();
|
|
const cleanupCause = new Error("fixture close failure");
|
|
const cancellationReason = new JsonInputPhysicalReadFailure(
|
|
new Error("opaque cancellation reason"),
|
|
);
|
|
let owner: OwnedFileHandle | undefined;
|
|
const resources = new ArtifactResourceScope();
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
owner = this;
|
|
controller.abort(cancellationReason);
|
|
throw cleanupCause;
|
|
});
|
|
try {
|
|
const result = await readImmutableEvidenceBundle(
|
|
path,
|
|
controller.signal,
|
|
resources,
|
|
);
|
|
if (result.ok) throw new Error("Expected failed finalization");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "EvidenceFileError",
|
|
cleanupIncomplete: true,
|
|
cleanupPrimary: {
|
|
_tag: "AnalysisCancelledError",
|
|
cause: cancellationReason,
|
|
},
|
|
});
|
|
expect(owner?.handle.fd).toBeGreaterThanOrEqual(0);
|
|
close.mockRestore();
|
|
await resources.close();
|
|
expect(owner?.handle.fd).toBe(-1);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
close.mockRestore();
|
|
await resources.close();
|
|
}
|
|
expect(await readEvidenceBundle(path)).toMatchObject({ ok: true });
|
|
});
|
|
|
|
describe("evidence bundle export cancellation", () => {
|
|
it("retains the completed parent and exact reason when delivery is cancelled before publication", async () => {
|
|
const root = await createTestTempDirectory("rea-delivery-cancel-");
|
|
const parent = analysisViewLayoutEvidence();
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel before bundle publication");
|
|
controller.abort(reason);
|
|
const result = await deliverAnalysisEvidence(
|
|
parent,
|
|
join(root, "bundle.json"),
|
|
"summary",
|
|
controller.signal,
|
|
);
|
|
if (result.ok) throw new Error("Cancelled delivery reported success");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
operation: parent.operation,
|
|
});
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cancelled",
|
|
details: { operation: parent.operation },
|
|
});
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(result.error.partialObservation).toBe(parent);
|
|
expect(await readdir(root)).toEqual([]);
|
|
});
|
|
|
|
it("does not inspect the path or advance a source cancelled before writing", async () => {
|
|
const root = await createTestTempDirectory("rea-export-pre-cancel-");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
let advanced = false;
|
|
function* parts() {
|
|
advanced = true;
|
|
yield "unused";
|
|
}
|
|
expect(
|
|
await writeParts(parts(), join(root, "missing", "bundle.json"), false, {
|
|
signal: controller.signal,
|
|
operation: "export_evidence_bundle",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "export_evidence_bundle",
|
|
cause: controller.signal.reason,
|
|
},
|
|
});
|
|
expect(advanced).toBe(false);
|
|
expect(await readdir(root)).toEqual([]);
|
|
expect(
|
|
await writeBundle(
|
|
bundle(),
|
|
join(root, "bundle.json"),
|
|
false,
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
cause: controller.signal.reason,
|
|
},
|
|
});
|
|
expect(await readdir(root)).toEqual([]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"closes a cancelled source and preserves the destination (overwrite: %s)",
|
|
async (overwrite) => {
|
|
const root = await createTestTempDirectory("rea-export-cancel-");
|
|
const path = join(root, "bundle.json");
|
|
if (overwrite) await writeFile(path, "original");
|
|
const controller = new AbortController();
|
|
const cancellationReason = new Error("cancel staged publication");
|
|
let closed = false;
|
|
let continued = false;
|
|
function* parts() {
|
|
try {
|
|
yield "staged partial data";
|
|
controller.abort(cancellationReason);
|
|
yield "cancelled data";
|
|
continued = true;
|
|
yield "unreachable";
|
|
} finally {
|
|
closed = true;
|
|
}
|
|
}
|
|
expect(
|
|
await writeParts(parts(), path, overwrite, {
|
|
signal: controller.signal,
|
|
operation: "export_evidence_bundle",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "export_evidence_bundle",
|
|
cause: cancellationReason,
|
|
},
|
|
});
|
|
expect(closed).toBe(true);
|
|
expect(continued).toBe(false);
|
|
expect(await readdir(root)).toEqual(overwrite ? ["bundle.json"] : []);
|
|
if (overwrite) expect(await readFile(path, "utf8")).toBe("original");
|
|
expect(await writeBundle(bundle(), path, overwrite)).toMatchObject({
|
|
ok: true,
|
|
});
|
|
expect(await readEvidenceBundle(path)).toEqual({
|
|
ok: true,
|
|
value: bundle(),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("does not publish when cancellation arrives at source exhaustion", async () => {
|
|
const root = await createTestTempDirectory("rea-export-final-cancel-");
|
|
const path = join(root, "bundle.json");
|
|
await writeFile(path, "original");
|
|
const controller = new AbortController();
|
|
function* parts() {
|
|
yield "complete replacement";
|
|
controller.abort();
|
|
}
|
|
expect(
|
|
await writeParts(parts(), path, true, {
|
|
signal: controller.signal,
|
|
operation: "export_evidence_bundle",
|
|
}),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
|
expect(await readFile(path, "utf8")).toBe("original");
|
|
expect(await readdir(root)).toEqual(["bundle.json"]);
|
|
});
|
|
});
|
|
|
|
it.each([1, 2])(
|
|
"reports only unresolved cancellation cleanup after %s failed close attempts",
|
|
async (failedAttempts) => {
|
|
const root = await createTestTempDirectory("rea-delivery-cleanup-retry-");
|
|
const parent = analysisViewLayoutEvidence();
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel before bundle publication");
|
|
const cleanupCause = new Error("fixture staged-output close failure");
|
|
const ready = OwnedStagedOutput.prototype.assertReadyForPublication;
|
|
const close = OwnedStagedOutput.prototype.close;
|
|
let owner: OwnedStagedOutput | undefined;
|
|
let attempts = 0;
|
|
const publication = vi
|
|
.spyOn(OwnedStagedOutput.prototype, "assertReadyForPublication")
|
|
.mockImplementationOnce(async function (this: OwnedStagedOutput, staged) {
|
|
await ready.call(this, staged);
|
|
controller.abort(reason);
|
|
});
|
|
const cleanup = vi
|
|
.spyOn(OwnedStagedOutput.prototype, "close")
|
|
.mockImplementation(async function (this: OwnedStagedOutput) {
|
|
owner = this;
|
|
if (++attempts <= failedAttempts) throw cleanupCause;
|
|
await close.call(this);
|
|
});
|
|
try {
|
|
const result = await deliverAnalysisEvidence(
|
|
parent,
|
|
join(root, "bundle.json"),
|
|
"summary",
|
|
controller.signal,
|
|
);
|
|
if (result.ok) throw new Error("Cancelled delivery reported success");
|
|
expect(attempts).toBe(2);
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.partialObservation).toBe(parent);
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(result.error.cleanupPrimary).toMatchObject({
|
|
operation: parent.operation,
|
|
});
|
|
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
|
expect(result.error.cleanup?.reason).toBe(cleanupCause.message);
|
|
const incomplete = failedAttempts === 2;
|
|
expect(result.error.cleanupIncomplete).toBe(incomplete);
|
|
expect(result.error.cleanupResources).toEqual(
|
|
incomplete ? [owner?.directory] : [],
|
|
);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: incomplete ? "cleanup_incomplete" : "cancelled",
|
|
details: { operation: parent.operation },
|
|
});
|
|
expect((await readdir(root)).map((entry) => join(root, entry))).toEqual(
|
|
incomplete ? [owner?.directory] : [],
|
|
);
|
|
} finally {
|
|
publication.mockRestore();
|
|
cleanup.mockRestore();
|
|
await owner?.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
const writeBundle = (
|
|
value: ReturnType<typeof bundle>,
|
|
path: string,
|
|
overwrite: boolean,
|
|
signal?: AbortSignal,
|
|
) =>
|
|
withArtifactResourceScope((resources) =>
|
|
writeEvidenceBundle(value, path, overwrite, resources, signal),
|
|
);
|
|
|
|
const writeParts = (
|
|
parts: Iterable<string>,
|
|
path: string,
|
|
overwrite: boolean,
|
|
cancellation?: {
|
|
readonly signal: AbortSignal;
|
|
readonly operation: string;
|
|
},
|
|
) =>
|
|
withArtifactResourceScope((resources) =>
|
|
writeTextParts(parts, path, overwrite, resources, cancellation),
|
|
);
|
|
|
|
describe("evidence bundle publication", () => {
|
|
it("retains the committed bundle receipt when cancellation arrives during final cleanup", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-delivery-published-cancel-",
|
|
);
|
|
const path = join(root, "bundle.json");
|
|
const parent = analysisViewLayoutEvidence();
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel after actual staged-output cleanup");
|
|
const close = OwnedStagedOutput.prototype.close;
|
|
const seam = vi
|
|
.spyOn(OwnedStagedOutput.prototype, "close")
|
|
.mockImplementationOnce(async function (this: OwnedStagedOutput) {
|
|
await close.call(this);
|
|
controller.abort(reason);
|
|
});
|
|
let result: Awaited<ReturnType<typeof deliverAnalysisEvidence>>;
|
|
try {
|
|
result = await deliverAnalysisEvidence(
|
|
parent,
|
|
path,
|
|
"summary",
|
|
controller.signal,
|
|
);
|
|
} finally {
|
|
seam.mockRestore();
|
|
}
|
|
if (result.ok) throw new Error("Cancelled delivery reported success");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(reason);
|
|
const bytes = await readFile(path);
|
|
expect(result.error.diagnostics?.evidence_bundle).toEqual({
|
|
path,
|
|
bytes: bytes.length,
|
|
});
|
|
expect(
|
|
parseEvidence(result.error.partialObservation).evidence_links,
|
|
).toEqual([parent.evidence_id]);
|
|
expect(
|
|
parseEvidenceBundle(JSON.parse(bytes.toString("utf8"))).records[0]
|
|
?.evidence_id,
|
|
).toBe(parent.evidence_id);
|
|
expect(await readdir(root)).toEqual(["bundle.json"]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"never publishes a partial streamed file (overwrite: %s)",
|
|
async (overwrite) => {
|
|
const root = await createTestTempDirectory("rea-stream-publication-");
|
|
const path = join(root, "bundle.json");
|
|
if (overwrite) await writeFile(path, "original");
|
|
function* brokenParts() {
|
|
yield "partial";
|
|
throw new Error("Source stream failed");
|
|
}
|
|
expect(await writeParts(brokenParts(), path, overwrite)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "io" },
|
|
});
|
|
expect(await readdir(root)).toEqual(overwrite ? ["bundle.json"] : []);
|
|
if (overwrite) expect(await readFile(path, "utf8")).toBe("original");
|
|
},
|
|
);
|
|
|
|
it("counts complete Unicode bytes and publishes a private file", async () => {
|
|
const root = await createTestTempDirectory("rea-stream-bytes-");
|
|
const path = join(root, "bundle.json");
|
|
const parts = ["雪", "😀", "\n"];
|
|
expect(await writeParts(parts, path, false)).toEqual({
|
|
ok: true,
|
|
value: { path, bytes: Buffer.byteLength(parts.join("")) },
|
|
});
|
|
expect(await readFile(path, "utf8")).toBe(parts.join(""));
|
|
if (process.platform !== "win32")
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
|
});
|
|
it("allows only one simultaneous export without overwrite approval", async () => {
|
|
const root = await createTestTempDirectory("rea-evidence-exclusive-");
|
|
const path = join(root, "bundle.json");
|
|
const candidates = [bundle(), bundle(false)];
|
|
const results = await Promise.all(
|
|
candidates.map((value) => writeBundle(value, path, false)),
|
|
);
|
|
expect(results.filter(({ ok }) => ok)).toHaveLength(1);
|
|
expect(results.filter(({ ok }) => !ok)).toMatchObject([
|
|
{ ok: false, error: { _tag: "EvidenceFileError", reason: "exists" } },
|
|
]);
|
|
const winner = candidates[results.findIndex(({ ok }) => ok)];
|
|
expect(await readEvidenceBundle(path)).toEqual({
|
|
ok: true,
|
|
value: winner,
|
|
});
|
|
expect(await readdir(root)).toEqual(["bundle.json"]);
|
|
});
|
|
|
|
it("round trips caller-selected paths without configured roots", async () => {
|
|
const root = await createTestTempDirectory("rea-evidence-dot-child-");
|
|
const directory = join(root, "..cache");
|
|
await mkdir(directory);
|
|
const path = join(directory, "bundle.json");
|
|
const value = bundle();
|
|
expect(await writeBundle(value, path, false)).toMatchObject({
|
|
ok: true,
|
|
});
|
|
expect(await readEvidenceBundle(path)).toEqual({
|
|
ok: true,
|
|
value,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("reuses authenticated file snapshots while isolating mutable public reads", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-owned-");
|
|
const path = join(directory, "bundle.json");
|
|
const evidence = createEvidence(
|
|
{
|
|
path: "/local/observed-target",
|
|
sha256: "a".repeat(64),
|
|
format: "file",
|
|
},
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{ operation: "health", parameters: {}, result: { nested: [true] } },
|
|
);
|
|
const source = serializeEvidenceBundle(createEvidenceBundle([evidence]));
|
|
await writeFile(path, source);
|
|
const mutable = await readEvidenceBundle(path);
|
|
if (!mutable.ok) throw mutable.error;
|
|
expect(Object.isFrozen(mutable.value.records[0])).toBe(false);
|
|
const loaded = await readImmutableEvidenceBundle(path);
|
|
if (!loaded.ok) throw loaded.error;
|
|
const record = loaded.value.records[0];
|
|
if (record === undefined) throw new Error("Expected an imported record");
|
|
expect(record).toEqual(evidence);
|
|
expect(Object.isFrozen(loaded.value)).toBe(true);
|
|
expect(Object.isFrozen(record.normalized_result)).toBe(true);
|
|
expect(Reflect.set(record, "operation", "forged")).toBe(false);
|
|
|
|
const ledger = new EvidenceLedger();
|
|
const imported = ledger.import(loaded.value);
|
|
if (!imported.ok) throw imported.error;
|
|
expect(imported.value).toMatchObject({ recordsAdded: 1, changed: true });
|
|
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(record);
|
|
const detached = ledger.get(evidence.evidence_id);
|
|
if (detached === undefined) throw new Error("Expected a detached record");
|
|
expect(Reflect.set(detached, "normalized_result", false)).toBe(true);
|
|
expect(ledger.record(detached).ok).toBe(false);
|
|
expect(ledger.export()).toEqual(loaded.value);
|
|
expect(await readFile(path, "utf8")).toBe(source);
|
|
ledger.clear();
|
|
});
|
|
|
|
describe("evidence bundle filesystem adapter", () => {
|
|
it.skipIf(process.platform === "win32").each(["/", "/."])(
|
|
"preserves the directory requirement of a missing output ending in %s",
|
|
async (suffix) => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-evidence-directory-",
|
|
);
|
|
const selected = `${join(directory, "report.json")}${suffix}`;
|
|
expect(await writeBundle(bundle(), selected, false)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "not-file" },
|
|
});
|
|
expect(await readdir(directory)).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("round trips canonical bytes and requires explicit overwrite", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const path = join(directory, "bundle.json");
|
|
const evidenceBundle = bundle();
|
|
const first = await writeBundle(evidenceBundle, path, false);
|
|
expect(first).toMatchObject({ ok: true, value: { path } });
|
|
expect(await readFile(path, "utf8")).toBe(
|
|
serializeEvidenceBundle(evidenceBundle),
|
|
);
|
|
expect(await readEvidenceBundle(path)).toEqual({
|
|
ok: true,
|
|
value: evidenceBundle,
|
|
});
|
|
expect(
|
|
await compareEvidenceBundlesCommand({
|
|
leftPath: path,
|
|
rightPath: path,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
status: "unchanged",
|
|
summary: { records_unchanged: 1 },
|
|
},
|
|
});
|
|
expect(await writeBundle(evidenceBundle, path, false)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "exists" },
|
|
});
|
|
expect(await writeBundle(evidenceBundle, path, true)).toMatchObject({
|
|
ok: true,
|
|
});
|
|
});
|
|
|
|
it("reads the caller-selected symlink target and refuses to replace a symlink", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const outside = join(directory, "outside");
|
|
await mkdir(outside);
|
|
const outsidePath = join(outside, "bundle.json");
|
|
await writeFile(outsidePath, serializeEvidenceBundle(bundle()));
|
|
const link = join(directory, "escaped.json");
|
|
await symlink(outsidePath, link);
|
|
expect(await readEvidenceBundle(link)).toMatchObject({
|
|
ok: true,
|
|
value: bundle(),
|
|
});
|
|
expect(await writeBundle(bundle(), link, true)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "not-file" },
|
|
});
|
|
});
|
|
|
|
it("reports a missing file or output directory with the selected path", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const absent = join(directory, "absent.json");
|
|
const read = await readEvidenceBundle(absent);
|
|
expect(read).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "missing", path: absent },
|
|
});
|
|
if (read.ok) throw new Error("Expected a missing-file failure");
|
|
expect(projectAnalysisError(read.error)).toMatchObject({
|
|
message: expect.stringContaining("does not exist at the selected path"),
|
|
details: { operation: "read", reason: "missing", path: absent },
|
|
});
|
|
|
|
const orphan = join(directory, "absent", "bundle.json");
|
|
const written = await writeBundle(bundle(), orphan, false);
|
|
expect(written).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "missing", path: orphan },
|
|
});
|
|
if (written.ok) throw new Error("Expected a missing-directory failure");
|
|
expect(projectAnalysisError(written.error).message).toContain(
|
|
"output directory does not exist",
|
|
);
|
|
});
|
|
|
|
it.each([readEvidenceBundle, readImmutableEvidenceBundle])(
|
|
"rejects malformed and tampered input (%s)",
|
|
async (readBundle) => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const malformed = join(directory, "malformed.json");
|
|
await writeFile(malformed, "{");
|
|
expect(await readBundle(malformed)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "invalid-json" },
|
|
});
|
|
|
|
const tampered = bundle();
|
|
const tamperedPath = join(directory, "tampered.json");
|
|
await writeFile(
|
|
tamperedPath,
|
|
JSON.stringify({
|
|
...tampered,
|
|
records: [{ ...tampered.records[0], normalized_result: "changed" }],
|
|
}),
|
|
);
|
|
expect(await readBundle(tamperedPath)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "EvidenceIntegrityError",
|
|
userMessage: expect.stringContaining(
|
|
"at records.0.evidence_id: Evidence semantic identifier does not match its record",
|
|
),
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it("names schema mismatches that prevent bundle parsing", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const partial = join(directory, "partial.json");
|
|
await writeFile(partial, JSON.stringify({ evidence_id: "typo" }));
|
|
const rejected = await readEvidenceBundle(partial);
|
|
expect(rejected).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
userMessage: expect.stringContaining(
|
|
"does not match the bundle schema",
|
|
),
|
|
},
|
|
});
|
|
expect(rejected).not.toMatchObject({
|
|
error: { userMessage: expect.stringContaining("one Evidence record") },
|
|
});
|
|
|
|
const mismatched = join(directory, "mismatched.json");
|
|
await writeFile(mismatched, JSON.stringify({ ...bundle(), records: {} }));
|
|
expect(await readEvidenceBundle(mismatched)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "EvidenceIntegrityError",
|
|
userMessage: expect.stringContaining(
|
|
"does not match the bundle schema at records:",
|
|
),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("standalone Evidence bundle inputs", () => {
|
|
it("classifies a record only after authenticating it", async () => {
|
|
const directory = await createTestTempDirectory("rea-evidence-");
|
|
const record = bundle().records[0];
|
|
const single = join(directory, "record.json");
|
|
await writeFile(single, JSON.stringify(record));
|
|
expect(await readEvidenceBundle(single)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "EvidenceFileError",
|
|
reason: "wrong-kind",
|
|
path: single,
|
|
userMessage: expect.stringContaining(
|
|
`this JSON is one Evidence record (${String(record?.evidence_id)})`,
|
|
),
|
|
},
|
|
});
|
|
|
|
const tamperedRecord = join(directory, "tampered-record.json");
|
|
await writeFile(
|
|
tamperedRecord,
|
|
JSON.stringify({ ...record, normalized_result: "changed" }),
|
|
);
|
|
expect(await readEvidenceBundle(tamperedRecord)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "EvidenceIntegrityError",
|
|
userMessage: expect.stringContaining(
|
|
"does not match the bundle schema",
|
|
),
|
|
},
|
|
});
|
|
});
|
|
});
|