feat(browse): add writeSecureFileAtomic for torn-write-safe state

Adds an atomic variant of writeSecureFile: write to a same-directory temp
file (owner-only), best-effort fsync, then rename over the destination. A
crash or concurrent reader sees either the old or new file, never a partial.
Needed by the upcoming auto-cookie persistence layer, which rewrites its
state file on every mutating command; a torn write there would drop a live
auth cookie on the next load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
현성 2026-07-07 19:16:50 +09:00
parent c7ae63201a
commit ecc88f1621
1 changed files with 48 additions and 0 deletions

View File

@ -39,6 +39,7 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let warnedOnce = false;
@ -123,6 +124,53 @@ export function writeSecureFile(
restrictFilePermissions(filePath);
}
/**
* Atomically write a file and restrict it to owner-only access, cross-platform.
*
* `writeSecureFile` writes in place, so a crash (or a concurrent reader)
* mid-write can observe a truncated/half-written file. For state that is
* rewritten repeatedly by a long-lived daemon e.g. the auto-cookie state,
* which checkpoints on every mutating command a torn write means the next
* load reads corrupt JSON and silently drops a live auth cookie. This variant
* writes to a same-directory temp file (owner-only), best-effort fsyncs it,
* then `rename`s over the destination. `rename` within a directory is atomic
* on POSIX and on NTFS (ReplaceFile semantics via Node), so a reader sees
* either the old file or the new one, never a partial.
*
* Same-directory is load-bearing: a temp file on another filesystem would make
* `rename` fall back to copy+unlink, which is NOT atomic. The temp name embeds
* the pid so two daemons racing the same destination don't clobber each other's
* temp file before their own rename (the destination itself still needs a lock
* this only guarantees each write is individually atomic).
*/
export function writeSecureFileAtomic(
filePath: string,
data: string | NodeJS.ArrayBufferView,
): void {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
let fd: number | null = null;
try {
fd = fs.openSync(tmp, 'w', 0o600);
fs.writeSync(fd, data as any);
try { fs.fsyncSync(fd); } catch { /* fsync is best-effort — rename still gives atomicity */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* already closed / best-effort */ }
}
}
// On Windows, openSync with mode does not set an ACL; restrict explicitly
// before the rename so the destination never exists unrestricted.
restrictFilePermissions(tmp);
try {
fs.renameSync(tmp, filePath);
} catch (err) {
// Rename failed — clean up the temp file so it doesn't accumulate.
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
throw err;
}
}
/**
* Append to a file with owner-only permissions, cross-platform.
* Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.