mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 14:51:52 +00:00
* fix(fs): centralize directory durability * chore: keep release notes in PR body * test(sqlite): canonicalize read-only race paths * test(fs): clean durability fixtures * chore(lint): prune sqlite snapshot baseline * fix(backup): reject unsupported commit sync * fix(fs): enforce strict durability outcomes * refactor(tls): flatten preserved-output failures * fix(reef): require durable journal commits * fix(fs): route durability through policy boundaries * fix(reef): preserve Windows journal compatibility * fix(fs): bind publication to canonical directories * fix(ci): align durability boundary fixtures
218 lines
9.4 KiB
TypeScript
218 lines
9.4 KiB
TypeScript
import fsSync from "node:fs";
|
|
import fs, { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { appendAudit, verifyChain } from "./audit.js";
|
|
import { generateIdentity } from "./identity.js";
|
|
import { JsonlAuditStore, FileReplayStore } from "./node.js";
|
|
import { signReceipt } from "./receipts.js";
|
|
import { useReefTempDirs } from "./test-support.js";
|
|
|
|
const durabilityTestState = vi.hoisted(() => ({
|
|
afterEnsure: undefined as ((receipt: { path: string }) => Promise<void> | void) | undefined,
|
|
parentSyncOutcome: undefined as
|
|
| { status: "synced" }
|
|
| { status: "unsupported"; code?: string }
|
|
| undefined,
|
|
syncOutcome: undefined as
|
|
| { status: "synced" }
|
|
| { status: "unsupported"; code?: string }
|
|
| undefined,
|
|
}));
|
|
|
|
vi.mock("@openclaw/fs-safe/durability", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@openclaw/fs-safe/durability")>();
|
|
return {
|
|
...actual,
|
|
ensureDurableDirectory: async (...args: Parameters<typeof actual.ensureDurableDirectory>) => {
|
|
const receipt = await actual.ensureDurableDirectory(...args);
|
|
await durabilityTestState.afterEnsure?.(receipt);
|
|
return durabilityTestState.parentSyncOutcome
|
|
? { ...receipt, parentSync: durabilityTestState.parentSyncOutcome }
|
|
: receipt;
|
|
},
|
|
syncDirectory: async (...args: Parameters<typeof actual.syncDirectory>) =>
|
|
durabilityTestState.syncOutcome ?? (await actual.syncDirectory(...args)),
|
|
};
|
|
});
|
|
|
|
const auditKey = Uint8Array.from({ length: 32 }, (_, index) => index + 1);
|
|
const replayBodyKey = Uint8Array.from({ length: 32 }, (_, index) => 255 - index);
|
|
const receiptId = "01JZ0000000000000000000000";
|
|
const tempDirs = useReefTempDirs(afterEach);
|
|
|
|
afterEach(() => {
|
|
durabilityTestState.afterEnsure = undefined;
|
|
durabilityTestState.parentSyncOutcome = undefined;
|
|
durabilityTestState.syncOutcome = undefined;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Node stores", () => {
|
|
it("persists serialized audit JSONL", async () => {
|
|
const directory = tempDirs.make("reef-audit-");
|
|
const path = join(directory, "audit.jsonl");
|
|
const store = new JsonlAuditStore(path, auditKey);
|
|
await Promise.all(
|
|
Array.from({ length: 20 }, (_, index) =>
|
|
appendAudit(store, "test", { id: index }, 10 + index),
|
|
),
|
|
);
|
|
const reopened = await new JsonlAuditStore(path, auditKey).entries();
|
|
expect(reopened).toHaveLength(20);
|
|
expect(verifyChain(reopened)).toBe(true);
|
|
});
|
|
|
|
it("drops a torn final JSONL record and permits a durable append", async () => {
|
|
const directory = tempDirs.make("reef-audit-torn-");
|
|
const path = join(directory, "audit.jsonl");
|
|
const store = new JsonlAuditStore(path, auditKey);
|
|
await store.appendEvent("one", { id: 1 }, 10);
|
|
await store.appendEvent("two", { id: 2 }, 11);
|
|
await appendFile(path, '{"event":{"seq":3');
|
|
const recovered = new JsonlAuditStore(path, auditKey);
|
|
expect(await recovered.entries()).toHaveLength(2);
|
|
await recovered.appendEvent("three", { id: 3 }, 12);
|
|
expect(await new JsonlAuditStore(path, auditKey).entries()).toHaveLength(3);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects an append when the journal directory cannot be synchronized",
|
|
async () => {
|
|
const directory = tempDirs.make("reef-audit-sync-failure-");
|
|
const path = join(directory, "nested", "audit.jsonl");
|
|
const parentPath = dirname(path);
|
|
const originalOpen = fs.open.bind(fs);
|
|
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => {
|
|
const handle = await originalOpen(filePath, flags, mode);
|
|
if (
|
|
typeof flags === "number" &&
|
|
(flags & fsSync.constants.O_DIRECTORY) !== 0 &&
|
|
resolve(String(filePath)) === (await fs.realpath(parentPath).catch(() => parentPath))
|
|
) {
|
|
vi.spyOn(handle, "sync").mockRejectedValue(
|
|
Object.assign(new Error("journal directory sync failed"), { code: "EIO" }),
|
|
);
|
|
}
|
|
return handle;
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
new JsonlAuditStore(path, auditKey).appendEvent("one", { id: 1 }, 10),
|
|
).rejects.toMatchObject({ code: "EIO" });
|
|
} finally {
|
|
openSpy.mockRestore();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["parent", "final"] as const)(
|
|
"rejects an append when %s journal directory synchronization is unsupported",
|
|
async (stage) => {
|
|
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
|
const directory = tempDirs.make(`reef-audit-${stage}-unsupported-`);
|
|
const path = join(directory, "nested", "audit.jsonl");
|
|
if (stage === "parent") {
|
|
durabilityTestState.parentSyncOutcome = { status: "unsupported", code: "ENOTSUP" };
|
|
} else {
|
|
durabilityTestState.syncOutcome = { status: "unsupported", code: "ENOTSUP" };
|
|
}
|
|
|
|
await expect(
|
|
new JsonlAuditStore(path, auditKey).appendEvent("one", { id: 1 }, 10),
|
|
).rejects.toThrow(
|
|
/Reef journal directory does not support crash-durable synchronization \(ENOTSUP\)/u,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"appends through the pinned canonical directory after a symlink retarget",
|
|
async () => {
|
|
const root = tempDirs.make("reef-audit-parent-retarget-");
|
|
const firstDirectory = join(root, "first");
|
|
const secondDirectory = join(root, "second");
|
|
const requestedDirectory = join(root, "requested");
|
|
await fs.mkdir(firstDirectory);
|
|
await fs.mkdir(secondDirectory);
|
|
await fs.symlink(firstDirectory, requestedDirectory);
|
|
const requestedPath = join(requestedDirectory, "nested", "audit.jsonl");
|
|
durabilityTestState.afterEnsure = async () => {
|
|
await fs.unlink(requestedDirectory);
|
|
await fs.symlink(secondDirectory, requestedDirectory);
|
|
};
|
|
|
|
await new JsonlAuditStore(requestedPath, auditKey).appendEvent("one", { id: 1 }, 10);
|
|
|
|
await expect(
|
|
readFile(join(firstDirectory, "nested", "audit.jsonl"), "utf8"),
|
|
).resolves.toContain('"type":"one"');
|
|
await expect(fs.readdir(secondDirectory)).resolves.toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("accepts unsupported journal directory synchronization on Windows", async () => {
|
|
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
const directory = tempDirs.make("reef-audit-windows-unsupported-");
|
|
const path = join(directory, "nested", "audit.jsonl");
|
|
durabilityTestState.parentSyncOutcome = { status: "unsupported", code: "EPERM" };
|
|
durabilityTestState.syncOutcome = { status: "unsupported", code: "EPERM" };
|
|
|
|
await expect(
|
|
new JsonlAuditStore(path, auditKey).appendEvent("one", { id: 1 }, 10),
|
|
).resolves.toMatchObject({ event: { seq: 1, type: "one" } });
|
|
});
|
|
|
|
it("rejects a corrupt middle JSONL record", async () => {
|
|
const directory = tempDirs.make("reef-audit-corrupt-");
|
|
const path = join(directory, "audit.jsonl");
|
|
const store = new JsonlAuditStore(path, auditKey);
|
|
await store.appendEvent("one", { id: 1 }, 10);
|
|
await store.appendEvent("two", { id: 2 }, 11);
|
|
const lines = (await readFile(path, "utf8")).trimEnd().split("\n");
|
|
await writeFile(path, `${lines[0]}\n{"broken"\n${lines[1]}\n`);
|
|
await expect(new JsonlAuditStore(path, auditKey).entries()).rejects.toThrow();
|
|
});
|
|
|
|
it("persists replay bindings and completed receipts", async () => {
|
|
const directory = tempDirs.make("reef-replay-");
|
|
const path = join(directory, "replay.jsonl");
|
|
const identity = generateIdentity();
|
|
const receipt = signReceipt(
|
|
{
|
|
id: receiptId,
|
|
bodyHash: "a".repeat(64),
|
|
auditHead: "b".repeat(64),
|
|
status: "accepted",
|
|
},
|
|
identity.signing.secretKey,
|
|
);
|
|
const body = { text: "RECOVERABLE SECRET BODY" };
|
|
const store = new FileReplayStore(path, replayBodyKey, () => new Uint8Array(12).fill(7));
|
|
expect(await store.claim("alice", receiptId, "c".repeat(64))).toBe("new");
|
|
expect(await store.claim("alice", receiptId, "c".repeat(64))).toBe("in_flight");
|
|
await store.complete("alice", receiptId, receipt, body);
|
|
expect(await readFile(path, "utf8")).not.toContain(body.text);
|
|
const reopened = new FileReplayStore(path, replayBodyKey);
|
|
expect(await reopened.claim("alice", receiptId, "c".repeat(64))).toBe("duplicate");
|
|
expect(await reopened.completed("alice", receiptId)).toEqual({ receipt, body });
|
|
expect(await reopened.claim("alice", receiptId, "d".repeat(64))).toBe("mismatch");
|
|
expect(await reopened.claim("carol", receiptId, "d".repeat(64))).toBe("new");
|
|
});
|
|
|
|
it("persists consumed replay bindings without receipts", async () => {
|
|
const directory = tempDirs.make("reef-replay-consumed-");
|
|
const path = join(directory, "replay.jsonl");
|
|
const store = new FileReplayStore(path, replayBodyKey);
|
|
expect(await store.claim("alice", receiptId, "c".repeat(64))).toBe("new");
|
|
await store.release("alice", receiptId);
|
|
expect(await store.claim("alice", receiptId, "c".repeat(64))).toBe("new");
|
|
await store.consume("alice", receiptId);
|
|
const reopened = new FileReplayStore(path, replayBodyKey);
|
|
expect(await reopened.claim("alice", receiptId, "c".repeat(64))).toBe("duplicate");
|
|
expect(await reopened.completed("alice", receiptId)).toBeUndefined();
|
|
expect(await reopened.claim("alice", receiptId, "d".repeat(64))).toBe("mismatch");
|
|
});
|
|
});
|