185 lines
5.4 KiB
TypeScript
185 lines
5.4 KiB
TypeScript
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
readEvidenceBundle,
|
|
writeEvidenceBundle,
|
|
} from "../src/application/EvidenceBundleFiles.js";
|
|
import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js";
|
|
import { createEvidence } from "../src/domain/evidence.js";
|
|
import {
|
|
createEvidenceBundle,
|
|
serializeEvidenceBundle,
|
|
type EvidenceFilePolicy,
|
|
} from "../src/domain/evidenceBundle.js";
|
|
|
|
let directory: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (directory !== undefined)
|
|
await rm(directory, { recursive: true, force: true });
|
|
directory = undefined;
|
|
});
|
|
|
|
const bundle = () =>
|
|
createEvidenceBundle([
|
|
createEvidence(
|
|
undefined,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{ operation: "health", parameters: {}, result: true },
|
|
),
|
|
]);
|
|
|
|
const policy = (root: string): EvidenceFilePolicy => ({
|
|
roots: [root],
|
|
maxBytes: 1024 * 1024,
|
|
maxDepth: 64,
|
|
maxStringLength: 1024,
|
|
maxNodes: 10_000,
|
|
});
|
|
|
|
describe("evidence bundle filesystem adapter", () => {
|
|
it("round trips canonical bytes and requires explicit overwrite", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-evidence-"));
|
|
const path = join(directory, "bundle.json");
|
|
const evidenceBundle = bundle();
|
|
const first = await writeEvidenceBundle(
|
|
evidenceBundle,
|
|
path,
|
|
false,
|
|
policy(directory),
|
|
);
|
|
expect(first).toMatchObject({ ok: true, value: { path } });
|
|
expect(await readFile(path, "utf8")).toBe(
|
|
serializeEvidenceBundle(evidenceBundle),
|
|
);
|
|
expect(await readEvidenceBundle(path, policy(directory))).toEqual({
|
|
ok: true,
|
|
value: evidenceBundle,
|
|
});
|
|
expect(
|
|
await compareEvidenceBundlesCommand({
|
|
leftPath: path,
|
|
rightPath: path,
|
|
offset: 0,
|
|
limit: 100,
|
|
policy: policy(directory),
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
status: "unchanged",
|
|
summary: { records_unchanged: 1 },
|
|
},
|
|
});
|
|
expect(
|
|
await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "exists" },
|
|
});
|
|
expect(
|
|
await writeEvidenceBundle(evidenceBundle, path, true, policy(directory)),
|
|
).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("uses valid evidence roots when another configured root is missing", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-evidence-roots-"));
|
|
const path = join(directory, "bundle.json");
|
|
const configured = {
|
|
...policy(directory),
|
|
roots: [join(directory, "missing"), directory],
|
|
};
|
|
expect(
|
|
await writeEvidenceBundle(bundle(), path, false, configured),
|
|
).toMatchObject({ ok: true });
|
|
expect(await readEvidenceBundle(path, configured)).toMatchObject({
|
|
ok: true,
|
|
});
|
|
});
|
|
|
|
it("rejects traversal and symlink escape from approved roots", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-evidence-"));
|
|
const root = join(directory, "approved");
|
|
const outside = join(directory, "outside");
|
|
await mkdir(root);
|
|
await mkdir(outside);
|
|
const outsidePath = join(outside, "bundle.json");
|
|
await writeFile(outsidePath, serializeEvidenceBundle(bundle()));
|
|
const link = join(root, "escaped.json");
|
|
await symlink(outsidePath, link);
|
|
expect(await readEvidenceBundle(link, policy(root))).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "outside-root" },
|
|
});
|
|
expect(
|
|
await writeEvidenceBundle(bundle(), outsidePath, true, policy(root)),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "outside-root" },
|
|
});
|
|
});
|
|
|
|
it("rejects malformed, tampered, oversized, and deeply nested input", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-evidence-"));
|
|
const malformed = join(directory, "malformed.json");
|
|
await writeFile(malformed, "{");
|
|
expect(
|
|
await readEvidenceBundle(malformed, policy(directory)),
|
|
).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 readEvidenceBundle(tamperedPath, policy(directory)),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceIntegrityError" },
|
|
});
|
|
|
|
const oversized = join(directory, "oversized.json");
|
|
await writeFile(oversized, "x".repeat(100));
|
|
expect(
|
|
await readEvidenceBundle(oversized, {
|
|
...policy(directory),
|
|
maxBytes: 10,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "too-large" },
|
|
});
|
|
|
|
const deep = join(directory, "deep.json");
|
|
await writeFile(deep, JSON.stringify({ one: { two: { three: null } } }));
|
|
expect(
|
|
await readEvidenceBundle(deep, {
|
|
...policy(directory),
|
|
maxDepth: 1,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceFileError", reason: "too-large" },
|
|
});
|
|
});
|
|
});
|