Files
reverse-engineering-anything/tests/boundary/filesystem/evidenceBundleFiles.test.ts
morluto d8c76b6296 fix(io): retain cleanup ownership and honor cancellation
Keep failed JSON read handles in the existing server resource scope and fail admission until cleanup succeeds. Thread cancellation through every process CLI input read and replace timer-driven cancellation checks with a shared first-read gate.

Classify authenticated standalone Evidence as wrong-kind input while preserving tampered-record integrity failures and actionable messages across ledger, CLI, and MCP consumers. Record unrelated CS_RESTRICT process neighbors as unverified without weakening related-process ownership checks. Update stale ADB fixtures for the actual shell protocol.

Validated focused CLI, MCP, filesystem, ownership, and error projection tests; static, docs, and duplication gates pass. The host-level CS_RESTRICT load reproduction was not run. Refs #2050, #2035, #2064.
2026-10-12 14:12:44 +08:00

502 lines
17 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 } from "../../../src/application/EvidenceBundleCommands.js";
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { writeTextParts } from "../../../src/application/JsonFiles.js";
import {
createEvidenceBundle,
serializeEvidenceBundle,
} from "../../../src/domain/evidenceBundle.js";
const bundle = (result = true) =>
createEvidenceBundle([
createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{ operation: "health", parameters: {}, result },
),
]);
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();
controller.abort(new Error("cancel before bundle read"));
expect(
await read(join(root, "missing.json"), controller.signal),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "import_evidence_bundle",
},
});
expect(await readdir(root)).toEqual([]);
},
);
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 close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
controller.abort(new Error("cancel during cleanup"));
throw cleanupCause;
});
let failure: unknown;
try {
try {
await readImmutableEvidenceBundle(path, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected the retained file handle owner");
expect(failure.cleanupCause).toBe(cleanupCause);
} finally {
close.mockRestore();
if (failure instanceof RegularFileCleanupFailure)
await failure.owner.close();
}
expect(await readEvidenceBundle(path)).toMatchObject({ ok: true });
});
describe("evidence bundle export cancellation", () => {
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",
},
});
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" } });
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();
let closed = false;
let continued = false;
function* parts() {
try {
yield "staged partial data";
controller.abort();
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",
},
});
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"]);
});
});
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.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("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",
),
},
});
});
});