fs-safe/test/json.test.ts
sallyom e335490a5b
add non-durable atomic write option
Signed-off-by: sallyom <somalley@redhat.com>
2026-05-07 10:26:06 +01:00

271 lines
9.1 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAsyncLock } from "../src/async-lock.js";
import { writeTextAtomic } from "../src/atomic.js";
import {
JsonFileReadError,
readRootJsonObjectSync,
readRootStructuredFileSync,
readJson,
readJsonIfExists,
readJsonSync,
tryReadJson,
writeJson,
writeJsonSync,
} from "../src/json.js";
const tempDirs: string[] = [];
async function tempRoot(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
});
function mockOpenForSyncCounting(): { readonly syncCalls: number; restore: () => void } {
let syncCalls = 0;
const openSpy = vi.spyOn(fs, "open").mockImplementation(async () => {
return {
sync: async () => {
syncCalls += 1;
},
close: async () => undefined,
} as Awaited<ReturnType<typeof fs.open>>;
});
return {
get syncCalls() {
return syncCalls;
},
restore: () => openSpy.mockRestore(),
};
}
describe("json file helpers", () => {
it("writes formatted JSON atomically with an optional trailing newline", async () => {
const root = await tempRoot("fs-safe-json-");
const filePath = path.join(root, "nested", "state.json");
await writeJson(filePath, { ok: true }, { mode: 0o600, trailingNewline: true });
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("{\n \"ok\": true\n}\n");
await expect(tryReadJson(filePath)).resolves.toEqual({ ok: true });
await expect(readJson(filePath)).resolves.toEqual({ ok: true });
});
it("uses dirMode and trailingNewline consistently for text writes", async () => {
const root = await tempRoot("fs-safe-json-");
const filePath = path.join(root, "nested", "note.txt");
await writeTextAtomic(filePath, "hello", {
dirMode: 0o700,
mode: 0o600,
trailingNewline: true,
});
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("hello\n");
if (process.platform !== "win32") {
const dirStat = await fs.stat(path.dirname(filePath));
const fileStat = await fs.stat(filePath);
expect(dirStat.mode & 0o777).toBe(0o700);
expect(fileStat.mode & 0o777).toBe(0o600);
}
});
it("syncs temp file and parent directory by default for text writes", async () => {
const root = await tempRoot("fs-safe-json-");
const filePath = path.join(root, "default-durable.txt");
const syncCounter = mockOpenForSyncCounting();
try {
await writeTextAtomic(filePath, "data");
} finally {
syncCounter.restore();
}
expect(syncCounter.syncCalls).toBe(2);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("data");
});
it("skips fsync when text writes opt out of durability", async () => {
const root = await tempRoot("fs-safe-json-");
const filePath = path.join(root, "store.json");
await fs.writeFile(filePath, "old", "utf8");
const syncCounter = mockOpenForSyncCounting();
try {
await writeTextAtomic(filePath, "new", { durable: false });
} finally {
syncCounter.restore();
}
expect(syncCounter.syncCalls).toBe(0);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new");
const dirEntries = await fs.readdir(root);
expect(dirEntries.some((entry) => entry.endsWith(".tmp"))).toBe(false);
});
it("threads durable option through JSON writes", async () => {
const root = await tempRoot("fs-safe-json-");
const filePath = path.join(root, "state.json");
const syncCounter = mockOpenForSyncCounting();
try {
await writeJson(filePath, { ok: true }, { durable: false });
} finally {
syncCounter.restore();
}
expect(syncCounter.syncCalls).toBe(0);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("{\n \"ok\": true\n}");
});
it("separates nullable and durable read failure semantics", async () => {
const root = await tempRoot("fs-safe-json-");
const missing = path.join(root, "missing.json");
const invalid = path.join(root, "invalid.json");
await fs.writeFile(invalid, "{", "utf8");
await expect(tryReadJson(missing)).resolves.toBeNull();
await expect(tryReadJson(invalid)).resolves.toBeNull();
await expect(readJsonIfExists(missing)).resolves.toBeNull();
await expect(readJsonIfExists(invalid)).rejects.toMatchObject({
name: "JsonFileReadError",
reason: "parse",
} satisfies Partial<JsonFileReadError>);
expect(() => readJsonSync(invalid)).toThrow(JsonFileReadError);
});
it("does not follow symlink swaps while reading", async () => {
const root = await tempRoot("fs-safe-json-swap-");
const filePath = path.join(root, "state.json");
const secretPath = path.join(root, "secret.json");
await fs.writeFile(filePath, "{\"ok\":true}", "utf8");
await fs.writeFile(secretPath, "{\"secret\":true}", "utf8");
const originalLstat = fs.lstat.bind(fs);
let swapped = false;
const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
const stat = await originalLstat(...args);
if (!swapped && args[0] === filePath) {
swapped = true;
await fs.rm(filePath, { force: true });
await fs.symlink(secretPath, filePath);
}
return stat;
});
try {
await expect(readJson(filePath)).rejects.toMatchObject({
name: "JsonFileReadError",
reason: "read",
} satisfies Partial<JsonFileReadError>);
await expect(tryReadJson(filePath)).resolves.toBeNull();
} finally {
lstatSpy.mockRestore();
}
});
it.runIf(process.platform !== "win32")("replaces symlink leaves on sync writes", async () => {
const root = await tempRoot("fs-safe-json-link-");
const outsidePath = path.join(root, "outside.json");
const linkPath = path.join(root, "state.json");
await fs.writeFile(outsidePath, "{\"secret\":true}\n", "utf8");
await fs.symlink(outsidePath, linkPath);
writeJsonSync(linkPath, { ok: true });
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("{\"secret\":true}\n");
await expect(fs.readFile(linkPath, "utf8")).resolves.toBe("{\n \"ok\": true\n}\n");
expect((await fs.lstat(linkPath)).isSymbolicLink()).toBe(false);
});
it("serializes work through createAsyncLock", async () => {
const lock = createAsyncLock();
const events: string[] = [];
let releaseFirst: (() => void) | undefined;
const first = lock(async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
return 1;
});
const second = lock(async () => {
events.push("second");
return 2;
});
await Promise.resolve();
expect(events).toEqual(["first:start"]);
releaseFirst?.();
await expect(Promise.all([first, second])).resolves.toEqual([1, 2]);
expect(events).toEqual(["first:start", "first:end", "second"]);
});
it("reads JSON objects through a root-bounded open", async () => {
const root = await tempRoot("fs-safe-root-json-");
await fs.writeFile(path.join(root, "config.json"), JSON.stringify({ name: "demo" }), "utf8");
const result = readRootJsonObjectSync({
rootDir: root,
relativePath: "config.json",
boundaryLabel: "test root",
rejectHardlinks: true,
});
expect(result).toMatchObject({ ok: true, value: { name: "demo" } });
});
it("rejects invalid root-bounded JSON shapes and escapes", async () => {
const root = await tempRoot("fs-safe-root-json-");
const outside = path.join(path.dirname(root), `${path.basename(root)}.json`);
await fs.writeFile(path.join(root, "array.json"), "[]", "utf8");
await fs.writeFile(outside, JSON.stringify({ name: "outside" }), "utf8");
try {
expect(
readRootJsonObjectSync({
rootDir: root,
relativePath: "array.json",
boundaryLabel: "test root",
}),
).toMatchObject({ ok: false, reason: "invalid" });
expect(
readRootJsonObjectSync({
rootDir: root,
relativePath: "../outside-root-json-test.json",
boundaryLabel: "test root",
}),
).toMatchObject({ ok: false, reason: "open" });
} finally {
await fs.rm(outside, { force: true });
}
});
it("lets callers provide parser and validation for root-bounded structured files", async () => {
const root = await tempRoot("fs-safe-root-structured-");
await fs.writeFile(path.join(root, "config.txt"), "name=demo", "utf8");
const result = readRootStructuredFileSync<{ name: string }>({
rootDir: root,
relativePath: "config.txt",
boundaryLabel: "test root",
parse: (raw) => ({ name: raw.split("=")[1]?.trim() }),
validate: (value): value is { name: string } =>
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string",
});
expect(result).toMatchObject({ ok: true, value: { name: "demo" } });
});
});