add non-durable atomic write option
Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
parent
7ca0af4bac
commit
e335490a5b
@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Add a `durable: false` option to async atomic text and JSON writes so callers can preserve replace semantics while skipping temp-file and parent-directory fsync.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Harden temp filename prefixes, local-root reads, private store imports, durable queue reads, and regular-file byte caps against Deepsec-reported path traversal, symlink, and oversized-read races.
|
||||
|
||||
@ -114,6 +114,21 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
|
||||
});
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```ts
|
||||
type WriteTextAtomicOptions = {
|
||||
mode?: number; // file mode (default 0o600)
|
||||
dirMode?: number; // mode for parent dirs created on demand
|
||||
trailingNewline?: boolean; // append "\n" if missing
|
||||
durable?: boolean; // default true; false skips temp/parent fsync
|
||||
};
|
||||
```
|
||||
|
||||
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
||||
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
||||
metadata where lower latency matters more than crash-durability.
|
||||
|
||||
## `movePathWithCopyFallback`
|
||||
|
||||
Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
|
||||
|
||||
@ -115,9 +115,14 @@ type WriteJsonOptions = {
|
||||
mode?: number; // file mode (default 0o600)
|
||||
dirMode?: number; // mode for parent dirs created on demand
|
||||
trailingNewline?: boolean; // append "\n" if missing (default false)
|
||||
durable?: boolean; // default true; false skips temp/parent fsync
|
||||
};
|
||||
```
|
||||
|
||||
`durable: false` preserves atomic temp-file replacement but skips the temp-file
|
||||
and parent-directory `fsync` calls. Use it only for reconstructible JSON state
|
||||
where lower latency matters more than crash-durability.
|
||||
|
||||
### `writeJsonSync(pathname, data)`
|
||||
|
||||
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
|
||||
|
||||
10
src/json.ts
10
src/json.ts
@ -3,7 +3,7 @@ import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
|
||||
import { openRootFileSync, type RootFileOpenFailure } from "./root-file.js";
|
||||
import { writeTextAtomic } from "./text-atomic.js";
|
||||
import { writeTextAtomic, type WriteTextAtomicOptions } from "./text-atomic.js";
|
||||
|
||||
const JSON_FILE_MODE = 0o600;
|
||||
const JSON_DIR_MODE = 0o700;
|
||||
@ -289,15 +289,21 @@ export function readJsonSync<T = unknown>(filePath: string): T {
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteJsonOptions = Pick<
|
||||
WriteTextAtomicOptions,
|
||||
"dirMode" | "durable" | "mode" | "trailingNewline"
|
||||
>;
|
||||
|
||||
export async function writeJson(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
options?: { mode?: number; trailingNewline?: boolean; dirMode?: number },
|
||||
options?: WriteJsonOptions,
|
||||
) {
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
await writeTextAtomic(filePath, text, {
|
||||
mode: options?.mode,
|
||||
dirMode: options?.dirMode,
|
||||
trailingNewline: options?.trailingNewline,
|
||||
durable: options?.durable,
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,6 +4,13 @@ export type WriteTextAtomicOptions = {
|
||||
mode?: number;
|
||||
dirMode?: number;
|
||||
trailingNewline?: boolean;
|
||||
/**
|
||||
* When false, skip the temp-file and parent-directory fsync calls while
|
||||
* preserving the temp-file replace/rename behavior.
|
||||
*
|
||||
* Defaults to true.
|
||||
*/
|
||||
durable?: boolean;
|
||||
};
|
||||
|
||||
export async function writeTextAtomic(
|
||||
@ -12,13 +19,14 @@ export async function writeTextAtomic(
|
||||
options?: WriteTextAtomicOptions,
|
||||
): Promise<void> {
|
||||
const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
|
||||
const durable = options?.durable ?? true;
|
||||
await replaceFileAtomic({
|
||||
filePath,
|
||||
content: payload,
|
||||
mode: options?.mode ?? 0o600,
|
||||
dirMode: options?.dirMode ?? (0o777 & ~process.umask()),
|
||||
copyFallbackOnPermissionError: true,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
syncTempFile: durable,
|
||||
syncParentDir: durable,
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,6 +28,24 @@ 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-");
|
||||
@ -59,6 +77,54 @@ describe("json file helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user