src/fs-atomic.js
Rama master · réplica de lectura del repositorio original.
/**
* Atomic JSON file writer.
*
* The naive `writeFileSync(path, JSON.stringify(...))` truncates the
* target file and then writes — if the process gets killed (SIGTERM
* during docker stop, SIGKILL on OOM, panic-style crash) between the
* truncate and the final write, the file is left empty or partial.
* Next start, JSON.parse fails, the load() handler logs a warning and
* silently falls back to defaults — user loses every persisted
* setting, model-access list, proxy config, etc.
*
* Pattern: write the new contents to a unique sibling `${target}.*.tmp` first,
* fsync(2) the tmp so the bytes actually reach disk, then `rename(2)` it onto
* the target. rename is atomic on POSIX and replaces an existing target on
* Windows (per Node's documented fs.renameSync behavior). A crash between the
* durable tmp write and renameSync leaves the target intact; a crash after
* renameSync leaves the (already-fsynced) new contents in place. Either way,
* no truncated JSON and no rename-published-but-never-flushed tmp inode.
*
* Tmp file gets unlinked on write failure so repeated failures don't
* leak garbage in DATA_DIR.
*
* Used by every dashboard config persister: model-access.json,
* proxy.json, stats.json, runtime-config.json. accounts.json already
* uses the same pattern hand-rolled in src/auth.js (kept inline there
* because it has its own coalescing/_saveInFlight machinery).
*/
import { randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { renameSync, unlinkSync, openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
import { userInfo } from 'node:os';
import { dirname } from 'node:path';
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EBUSY']);
function sleepSync(ms) {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {}
}
export function fsyncParentDirectorySync(targetPath) {
let fd;
try {
fd = openSync(dirname(targetPath), 'r');
fsyncSync(fd);
} catch (err) {
// Windows does not consistently allow directories to be opened/fsynced.
// The file itself is still fsynced before rename; POSIX hosts, where the
// directory-entry durability guarantee is available, must not silently
// ignore a real fsync failure.
if (process.platform === 'win32' && ['EACCES', 'EINVAL', 'EISDIR', 'EPERM'].includes(err?.code)) return;
throw err;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export function renameSyncWithRetry(sourcePath, targetPath, {
attempts = 6,
baseDelayMs = 10,
rename = renameSync,
fsyncParent = fsyncParentDirectorySync,
} = {}) {
const maxAttempts = Math.max(1, attempts | 0);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
rename(sourcePath, targetPath);
break;
} catch (err) {
if (!RETRYABLE_RENAME_CODES.has(err?.code) || attempt === maxAttempts) throw err;
sleepSync(baseDelayMs * attempt);
}
}
// fsyncing the file before rename protects its contents; fsyncing the
// containing directory protects the rename itself across power loss. This is
// deliberately outside the retry loop: once rename succeeds, replaying it
// after a directory-fsync error would operate on a source path that no longer
// exists and misreport the already-published state.
try {
fsyncParent(targetPath);
} catch (err) {
// Callers may need to distinguish "rename never happened" from "the new
// name is visible in this process but its directory entry could not be
// proven power-loss durable". Never retry the rename in this state.
err.renamePublished = true;
err.renameTarget = targetPath;
throw err;
}
}
/**
* Write bytes to `path` and fsync(2) before returning. A plain
* writeFileSync + rename is atomic w.r.t. *other readers* (rename swaps the
* inode), but the newly-written data may still sit in the OS page cache: a
* power loss or kernel panic in the window after rename can leave the target
* pointing at a tmp inode whose contents never reached the platter, i.e. a
* zero-length or partial file. fsync forces the data out before we rename, so
* the rename only ever publishes fully-durable contents. This is the
* "temp → fsync → rename" durability guarantee (K7).
*/
export function writeFileSyncDurable(path, data, { mode = 0o600 } = {}) {
const fd = openSync(path, 'w', mode);
try {
writeSync(fd, data);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
export function writeJsonAtomic(targetPath, value, { spaces = 2, mode = 0o600 } = {}) {
// mode defaults to 0600 (owner-only): these config files can carry the runtime
// API key / dashboard password hash / upstream tokens, and must not be
// world-readable by other users on a shared host. rename preserves the temp
// file's mode, so it has to be set at creation. Callers can override for
// non-sensitive files, but 0600 is the safe default.
const tmp = `${targetPath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
try {
writeFileSyncDurable(tmp, JSON.stringify(value, null, spaces), { mode });
renameSyncWithRetry(tmp, targetPath);
} catch (err) {
try { unlinkSync(tmp); } catch {}
throw err;
}
}
// ── Windows ACL hardening ──────────────────────────────────
//
// `mode: 0o600` is a POSIX contract; on Windows Node maps it (at best) to the
// read-only bit and the file keeps the ACL it inherits from its directory —
// `BUILTIN\Users:(RX)` and `NT AUTHORITY\Authenticated Users:(M)` on a default
// profile. accounts.json holds upstream tokens in cleartext and the packaged
// first-run .env holds the API key + dashboard password, so "owner-only" has to
// be enforced explicitly there.
//
// Best effort by design: a failed icacls must never fail the write that just
// succeeded (the bytes are already durable), but it must be LOUD — a silent
// failure is the same inherited-ACE exposure with a false sense of safety.
// `/inheritance:r` drops inherited ACEs (which is what Users/Authenticated
// Users get); `/grant:r <user>:(F)` leaves exactly one explicit owner ACE.
// Group names are deliberately not referenced — they are localized, while
// `/inheritance:r` works on every Windows locale.
const ACL_WARNED = new Set();
function defaultWindowsUser() {
const username = process.env.USERNAME || userInfo().username;
const domain = process.env.USERDOMAIN;
return domain ? `${domain}\\${username}` : username;
}
export function restrictFileToOwnerSync(targetPath, {
platform = process.platform,
run = execFileSync,
warn = (message) => console.warn(message),
resolveUser = defaultWindowsUser,
} = {}) {
if (platform !== 'win32') return { applied: false, reason: 'not-windows' };
let user;
try {
user = resolveUser();
} catch (err) {
user = '';
warn(`[fs] cannot resolve the current Windows user for ${targetPath}: ${err?.message || err}`);
}
if (!user) return { applied: false, reason: 'no-user' };
try {
run('icacls', [targetPath, '/inheritance:r', '/grant:r', `${user}:(F)`], {
stdio: 'ignore',
windowsHide: true,
});
return { applied: true, user };
} catch (err) {
const message = `[fs] could not restrict permissions on ${targetPath} (${err?.code || err?.message}); inherited Users/Authenticated Users access may remain`;
// Saves repeat on a timer; one warning per path+reason is enough to be
// actionable without turning a read-only install into a log flood.
const key = `${targetPath}\0${err?.code || err?.message}`;
if (!ACL_WARNED.has(key)) {
ACL_WARNED.add(key);
warn(message);
}
return { applied: false, reason: 'icacls-failed', error: err?.message };
}
}