Ctrl K
ComunidadGuías…

src/fs-atomic.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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 };
  }
}