add non-durable atomic write option

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
sallyom 2026-05-06 16:27:02 -04:00 committed by Peter Steinberger
parent 7ca0af4bac
commit e335490a5b
No known key found for this signature in database
6 changed files with 108 additions and 4 deletions

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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");