Atomic File

Atomic file I/O with integrity verification, retry on transient errors, and cross-process locking.

Every write goes through the same crash-safe sequence: 1. write payload to a sibling temp file (.tmp-) 2. fsync the file descriptor before close 3. fs.rename() the temp file over the destination — POSIX rename is atomic on the same filesystem; on Windows, fs.rename uses MoveFileEx with REPLACE_EXISTING for the same guarantee 4. fsync the parent directory so the rename itself is durable

Result: a partially-written file NEVER survives a crash to the caller. Either the new contents are fully on disk (rename succeeded) or the original (or absence) remains. No torn writes, no half-flushed pages.

fsync calls are best-effort across platforms — Windows rejects directory fsync, some FUSE filesystems no-op file fsync — but the rename remains atomic at the FS level regardless. The framework already uses this primitive internally for vault.key.sealed and audit.tip; this module exposes the same surface for any caller that needs durable write-replace semantics.

Optional computeHash: true returns SHA3-512 over the written bytes; passing the same digest as expectedHash on a later read gates retrieval on integrity. Transient FS errors (EBUSY / EAGAIN / ENFILE / EMFILE / EPERM) retry with exponential backoff via b.retry.withRetry — sync paths skip the loop because they can't usefully await a backoff.

b.atomicFile.fsync(fd) #

stable0.7.0

Best-effort fs.fsyncSync wrapper. Silently swallows errors because not every platform / fd type supports fsync (some FUSE mounts, some device fds). Use this when you want the durability hint but don't want a non-fsyncable target to crash the caller.

var fs = require("fs");
var fd = fs.openSync("/tmp/note.txt", "w");
fs.writeSync(fd, "hello\n");
b.atomicFile.fsync(fd);
fs.closeSync(fd);

b.atomicFile.fsyncDir(dirPath) #

stable0.7.0

Best-effort fsync of a directory inode. Required after a rename to make the directory entry itself durable on POSIX filesystems. Windows refuses directory fsync — the call is wrapped so the caller can run the same code on every platform without branching.

b.atomicFile.fsyncDir("/var/lib/blamejs/data");

b.atomicFile.ensureDir(dirPath, mode) #

stable0.7.0

Create dirPath (recursive) with a chosen mode. Default mode is 0o700 — owner-only — suitable for framework data directories holding sealed vaults, audit chains, or session state. Returns the dirPath unchanged so calls compose into path-building chains.

var dir = b.atomicFile.ensureDir("/var/lib/blamejs/audit", 0o700);
// → "/var/lib/blamejs/audit"

// Less-restricted dir for a public asset folder:
b.atomicFile.ensureDir("/var/www/uploads", 0o755);

b.atomicFile.copyDirRecursive(src, dest, opts) #

stable0.7.0
{
  overwrite: false,   // when true, overwrite files that already exist at dest
  dirMode:   0o700,   // mode for newly-created destination directories
}

Synchronous, file-by-file copy that mirrors the source directory structure. Skips symlinks, sockets, and devices — operators wanting symlink preservation should use a real archive tool. Refuses to overwrite existing files at dest by default; pass overwrite: true to replace. The dest tree is created with mode 0o700 by default (override with dirMode). Returns { fileCount, byteCount }.

var stats = b.atomicFile.copyDirRecursive(
  "/var/lib/blamejs/data",
  "/var/lib/blamejs/snapshot-2026-01-01",
  { overwrite: false, dirMode: 0o700 }
);
// → { fileCount: 42, byteCount: 1048576 }

b.atomicFile.pathTimestamp(date) #

stable0.7.0

Filesystem-safe ISO-8601 timestamp. Standard Date.toISOString() embeds ':' and '.' which Windows reserves for drive letters and extension separators; this helper substitutes both with '-' so the result is portable as a path segment. String sort still gives chronological order. Pass a Date to format a specific instant; omit it for new Date().

var stamp = b.atomicFile.pathTimestamp(new Date(0));
// → "1970-01-01T00-00-00-000Z"

var fixed = b.atomicFile.pathTimestamp(new Date(Date.UTC(2026, 0, 1)));
// → "2026-01-01T00-00-00-000Z"

b.atomicFile.conflictPath(originalPath, opts?) #

stable0.10.8
{
  tag:       string,     // default "conflict"; sandwiched between basename and timestamp
  timestamp: Date,       // default `new Date()`
  suffix:    string,     // optional extra disambiguator appended after timestamp
}

Build a filesystem-portable conflict-suffix path next to originalPath, e.g. notes.mdnotes.conflict-2026-05-17T19-30-00Z.md. Drop-in name for last-write-wins reconciliation in sync / backup / dual-control workflows. Preserves the original extension. Inserts a caller-supplied tag (default conflict) between the basename and the timestamp. The timestamp uses pathTimestamp so the result is portable across Windows (no : / .), macOS, and Linux. Same-second collision handling: pass opts.suffix (e.g. a per-row crypto-random hex) when multiple conflicts may land in the same second; otherwise the timestamp's millisecond field disambiguates.

var p = b.atomicFile.conflictPath("/srv/notes.md");
// → "/srv/notes.conflict-2026-05-17T20-30-00-123Z.md"

var withSuffix = b.atomicFile.conflictPath("/srv/notes.md", {
  tag: "merge", suffix: "abc123",
});
// → "/srv/notes.merge-2026-05-17T20-30-00-123Z.abc123.md"

b.atomicFile.writeSync(filepath, data, opts) #

stable0.7.0
{
  fileMode:    0o600,   // mode applied to the temp file (and thus the renamed final)
  computeHash: false,   // when true, return SHA3-512 of the written bytes
}

Synchronous atomic write — same temp + fsync + rename + dirfsync sequence as the async write, but without the retry loop (which requires awaits). Use from sync-only code paths: process exit handlers, module-load-time bootstraps, signal handlers. For everything else, prefer the async form. Either the rename completes (new contents fully visible) or the tmp file is removed — no half-written file ever appears at filepath.

var result = b.atomicFile.writeSync(
  "/var/lib/blamejs/state.bin",
  Buffer.from("payload"),
  { fileMode: 0o600, computeHash: true }
);
// → { bytesWritten: 7, hash: "" }

b.atomicFile.writeStream(filepath, source, opts?) #

stable0.15.14
{
  fileMode:  0o600,            // mode applied to the temp file (and thus the renamed final)
  maxBytes:  64 * 1024 * 1024, // refuse + clean up once the source exceeds this many bytes
  signal:    undefined,        // optional AbortSignal forwarded to the pipeline
}

Streaming sibling of writeSync for payloads too large to buffer in memory. Pipes a Readable source into a sibling temp file opened with O_EXCL | O_NOFOLLOW (the same exclusive, symlink-refusing create every atomic write uses), fsyncs, then atomically renames over filepath and fsyncs the parent directory. A plain fs.createWriteStream(filepath) instead follows a symlink an attacker pre-planted at filepath (CWE-59 arbitrary write) and leaves a half-written object at the canonical name if the source aborts mid-stream — this primitive does neither: the file appears at filepath only after the full stream has landed and synced.

Enforces a byte ceiling while streaming (maxBytes, default 64 MiB) so an unbounded source cannot fill the disk; the partial temp is removed on overflow or any pipeline error.

await b.atomicFile.writeStream(
  "/var/lib/blamejs/object",
  incomingRequestStream,
  { fileMode: 0o600, maxBytes: b.C.BYTES.gib(2) }
);
// → { bytesWritten: 12345 }

b.atomicFile.writeExclSync(filepath, data, opts?) #

stable0.15.14
{
  fileMode: 0o600,   // mode applied to the created file
}

Exclusive, symlink-refusing write to filepath WITHOUT the atomic rename — for staged "write → fsync → verify → rename" flows where the caller must re-read and validate the written bytes before committing them over the live file (the vault seal/unseal round-trip re-reads the staged file and confirms it decrypts before renaming it into place). Clears any stale leftover at filepath first (an aborted prior run, or a planted symlink — unlink removes the LINK, never its target), then creates the file with O_EXCL | O_NOFOLLOW, so a symlink re-planted in the race window fails the open closed instead of being followed (CWE-59 / CWE-377). fsyncs the data before returning. For an ordinary write-and-replace use writeSync, which renames atomically; reach for this only when a verify-before-commit step sits between the write and the rename.

b.atomicFile.writeExclSync(stagingPath, bytes, { fileMode: 0o600 });
// re-read + verify stagingPath, then:
b.atomicFile.renameWithRetry(stagingPath, finalPath);

b.atomicFile.cleanOrphans(filepath, opts) #

stable0.7.0
{
  olderThanMs: 300000,   // only prune temp files older than this many ms (default 5 minutes)
}

Sweep orphan temp files left behind by a previously-crashed process. Atomic writes use random temp names (.tmp-), so a crashed run leaves a file with a name the next boot can't predict — only glob-by-prefix and prune by age. Operators should call this at boot for every "important" filepath (vault.key.sealed, audit-sign.key.sealed, db.enc, ...) BEFORE the first atomic write to that path. Returns the number of orphans removed.

var removed = b.atomicFile.cleanOrphans(
  "/var/lib/blamejs/vault.key.sealed",
  { olderThanMs: 300000 }
);
// → 0   (no orphans found, or the count of files unlinked)

b.atomicFile.write(filepath, data, opts) #

stable0.7.0
{
  fileMode:      0o600,                   // mode applied to the renamed file
  computeHash:   false,                   // SHA3-512 the written bytes; included in result
  retryAttempts: 5,                       // attempts before giving up on transient FS errors
  retryBaseMs:   50,                      // base backoff
  retryMaxMs:    2000,                    // backoff ceiling
  signal:        AbortSignal | undefined, // abort the retry loop early
}

Crash-safe write-replace. Writes data to a sibling temp file, fsyncs the fd, atomically renames over filepath, then fsyncs the parent directory. On any failure path the temp is unlinked, so the destination is never seen as half-written. Transient FS errors (EBUSY / EAGAIN / ENFILE / EMFILE / EPERM) retry with exponential backoff. Returns { bytesWritten, hash } where hash is null unless computeHash: true.

async function persist() {
  var result = await b.atomicFile.write(
    "/var/lib/blamejs/state.bin",
    Buffer.from("payload"),
    { fileMode: 0o600, computeHash: true }
  );
  return result;   // → { bytesWritten: 7, hash: "" }
}

b.atomicFile.read(filepath, opts) #

stable0.7.0
{
  maxBytes:     67108864,             // ceiling on file size; reject anything larger
  encoding:     undefined,            // when set (e.g. "utf8"), return a decoded string
  expectedHash: undefined,            // SHA3-512 hex; when set, integrity-check the bytes
  retryAttempts: 5,                   // transient-error retry count
  retryBaseMs:   50,
  retryMaxMs:    2000,
  signal:        AbortSignal | undefined,
}

Read a file with size cap and optional integrity verification. maxBytes defaults to 64 MiB; values larger than the file's stat size throw atomic-file/too-large BEFORE the read happens (no memory-blow up on hostile inputs). When expectedHash is provided, the SHA3-512 of the bytes is compared and a mismatch throws atomic-file/integrity. Pass encoding to receive a decoded string instead of a Buffer. Retries on transient FS errors.

async function load() {
  var buf = await b.atomicFile.read(
    "/var/lib/blamejs/state.bin",
    { maxBytes: 1048576 }
  );
  return buf;   // →  (≤ 1 MiB)
}

// Integrity-checked read — pass the digest computed at write time:
async function loadVerified(digestHex) {
  return await b.atomicFile.read(
    "/var/lib/blamejs/state.bin",
    { expectedHash: digestHex, encoding: "utf8" }
  );
}

b.atomicFile.readSync(filepath, opts) #

stable0.7.0
{
  maxBytes:     67108864,
  encoding:     undefined,
  expectedHash: undefined,
}

Synchronous variant for callers in module-init / boot paths that can't await — vault unsealing, audit-sign init, DB rollback check. Same semantics as the async read: size cap via maxBytes, optional expectedHash integrity check, ENOENT translated to an AtomicFileError with code === "ENOENT". No retry loop — sync paths can't usefully back off.

var buf = b.atomicFile.readSync(
  "/var/lib/blamejs/vault.key.sealed",
  { maxBytes: 65536 }
);
// →  (≤ 64 KiB)

b.atomicFile.fdSafeReadSync(filepath, opts?) #

stable0.15.13
{
  mode:           number,    // open mode (default 0o600; inert under O_RDONLY)
  maxBytes:       number,    // refuse a file larger than this (default: no cap)
  refuseSymlink:  boolean,   // lstat + refuse a symlink source (default: false)
  inodeCheck:     boolean,   // refuse if the fd inode != the lstat inode (needs refuseSymlink)
  expectedHash:   string,    // SHA3-512 the content must match (default: none)
  encoding:       string,    // decode to a string (default: return a Buffer)
  allowShortRead: boolean,   // slice to the bytes read instead of throwing (default: false)
  withStat:       boolean,   // return { bytes, stat } — stat of the bound fd (mode/uid/gid/size/ino/nlink/mtimeMs), TOCTOU-free
  errorFor:       Function,  // (kind, detail) => Error|undefined; kinds: enoent / symlink / too-large / toctou / short-read / integrity
}

TOCTOU-safe synchronous file read (CWE-367 / js/file-system-race). Opens the path read-only, then binds every subsequent measurement — size, content, integrity — to the inode the fd holds open, so an attacker who swaps the file between stat and read can't change which bytes come back. The optional guards layer on top of that core: a byte cap (maxBytes), symlink refusal + inode-equality (refuseSymlink / inodeCheck — the strongest defense, for operator-writable source paths), an integrity hash (expectedHash, SHA3-512), and a short-read policy (throw, or slice when allowShortRead). Each caller maps a failure KIND to its own typed error via errorFor, so the message / code / audit posture stays per-domain; the default raises an AtomicFileError.

var cfg = b.atomicFile.fdSafeReadSync("/etc/app/config.json", {
  maxBytes: b.constants.BYTES.mib(1),
  encoding: "utf8",
});

// Assert mode + owner on the exact inode the bytes came from (no re-stat):
var r = b.atomicFile.fdSafeReadSync("/etc/app/secret", { withStat: true });
if ((r.stat.mode & 0o077) !== 0) throw new Error("secret is group/other-readable");
// r.bytes is the Buffer (or string under `encoding`)

b.atomicFile.writeJson(filepath, value, opts) #

stable0.7.0
{
  canonical:     false,    // when true, emit RFC 8785 JCS canonical bytes (suitable for signing)
  indent:        0,        // pretty-print indent for the non-canonical path
  fileMode:      0o600,
  computeHash:   false,
  retryAttempts: 5,
  retryBaseMs:   50,
  retryMaxMs:    2000,
}

Atomic JSON write. Serializes via b.safeJson (RFC 8785 canonical form when canonical: true, otherwise standard stringify with configurable indent) and routes through b.atomicFile.write for the same crash-safe semantics. Returns the same shape as write.

async function persist() {
  var result = await b.atomicFile.writeJson(
    "/var/lib/blamejs/manifest.json",
    { schema: 1, items: [] },
    { canonical: true, computeHash: true }
  );
  return result;   // → { bytesWritten: 24, hash: "" }
}

b.atomicFile.readJson(filepath, opts) #

stable0.7.0
{
  maxBytes:     67108864,
  expectedHash: undefined,
}

Atomic JSON read. Routes through b.atomicFile.read (size cap + optional integrity hash) then parses via b.safeJson.parse, which applies the framework's prototype-pollution / __proto__-key defenses. Throws atomic-file/too-large, atomic-file/integrity, or a JSON parse error from safeJson — never returns a partial object.

async function load() {
  var doc = await b.atomicFile.readJson(
    "/var/lib/blamejs/manifest.json",
    { maxBytes: 1048576 }
  );
  return doc;   // → { schema: 1, items: [] }
}

b.atomicFile.copy(src, dst, opts) #

stable0.7.0
{
  maxBytes:      67108864,
  fileMode:      0o600,
  computeHash:   false,
  expectedHash:  undefined,
  retryAttempts: 5,
}

Atomic file copy. Reads the source via b.atomicFile.read (so maxBytes and retry semantics apply), then writes the bytes through b.atomicFile.write (temp + fsync + rename). When expectedHash is set, the digest is checked against the WRITTEN bytes at dst — the source is not gated on it. Returns { bytesWritten, hash }.

async function snapshot() {
  var result = await b.atomicFile.copy(
    "/var/lib/blamejs/state.bin",
    "/var/lib/blamejs/state.bin.bak",
    { computeHash: true }
  );
  return result;   // → { bytesWritten: 4096, hash: "" }
}

b.atomicFile.exists(filepath) #

stable0.7.0

Synchronous existence check. Thin wrapper over fs.existsSync that normalises the answer for callers that already require this module — saves an additional require("fs") in modules that otherwise only need atomicFile.

if (b.atomicFile.exists("/var/lib/blamejs/state.bin")) {
  // → safe to read
}

b.atomicFile.lock(filepath, fn, opts) #

stable0.7.0
{
  lockTimeoutMs: 30000,                    // total time to wait before timing out
  lockPollMs:    50,                       // sleep between lock acquisition attempts
  fileMode:      0o600,                    // mode applied to the lock file
  signal:        AbortSignal | undefined,  // abort the wait early
}

Cross-process file mutex around a read-modify-write sequence. Acquires .lock via O_CREAT | O_EXCL (the POSIX atomic "create-or-fail" primitive — Node's "wx" flag), writes { pid, acquiredAt } into the lock for diagnostics, runs fn(), then unlinks the lock in a finally so a thrown handler still releases. Stale-lock detection: lock files older than 5 minutes are assumed crashed-holder and reclaimed. Returns whatever fn returns (or rejects with whatever it throws). Throws atomic-file/lock-timeout if the lock can't be acquired before lockTimeoutMs.

async function bumpCounter() {
  return await b.atomicFile.lock(
    "/var/lib/blamejs/counter.txt",
    async function () {
      var buf = await b.atomicFile.read("/var/lib/blamejs/counter.txt", { encoding: "utf8" });
      var next = (parseInt(buf, 10) || 0) + 1;
      await b.atomicFile.write("/var/lib/blamejs/counter.txt", String(next));
      return next;
    },
    { lockTimeoutMs: 5000 }
  );
}

b.atomicFile.listDir(dir, opts) #

stable0.7.0
{
  filter:      function (name) { return true; },  // name-only predicate; falsey skips entry
  includeStat: false,                             // when true, statSync each entry; one extra syscall per entry
  missingOk:   true,                              // when true (default), ENOENT returns []; when false, ENOENT throws
}

Single-directory listing with optional stat enrichment, name-only filter, and missing-dir tolerance. Returns an array of { name, fullPath } objects (plus mtimeMs, sizeBytes, isDirectory, isFile when includeStat: true). Entries that vanish between readdir and stat — concurrent cleanup by another process — are silently dropped. For recursive walks, callers compose per subdirectory so per-iteration limits, filters, and stop conditions stay explicit.

var entries = b.atomicFile.listDir(
  "/var/lib/blamejs/audit",
  {
    filter:      function (n) { return n.endsWith(".log"); },
    includeStat: true,
  }
);
// → [{ name: "audit-1.log", fullPath: "/var/lib/blamejs/audit/audit-1.log",
//      mtimeMs: 1700000000000, sizeBytes: 2048, isDirectory: false, isFile: true }, ...]

b.atomicFile.openNoFollowSync(filepath, mode?) #

stable0.15.14

Open a path read-only with O_NOFOLLOW so a symlink at the final path component is refused (ELOOP) instead of followed — the streaming-read counterpart to fdSafeReadSync for callers that must fs.createReadStream (range serving, SRI/ETag hashing, large-object download) and cannot buffer the whole file. Stream from the returned fd: fs.createReadStream(path, { fd }). Defends a post-confinement symlink swap (CWE-22 / CWE-367) on request-reachable static-serve and object-store read paths, where a lexical _assertInsideRoot check alone leaves a swap window between the check and the open. O_NOFOLLOW is POSIX-only; on platforms without it the flag is 0 (a plain O_RDONLY open) — Windows symlink semantics differ and are out of scope. Throws the raw openSync error (caller maps ELOOP / ENOENT).

var fd = b.atomicFile.openNoFollowSync(absPath);
var stream = fs.createReadStream(absPath, { fd: fd });   // autoClose closes fd

b.atomicFile.openAppendNoFollowSync(filepath, mode?) #

stable0.15.16

Open a path for append with O_NOFOLLOW so a symlink at the final path component is refused (ELOOP) instead of followed — the append-sink counterpart to openNoFollowSync (read) and _openExclTemp (exclusive create). For long-lived append targets a one-shot atomic write can't model: an active log file kept open across many appends and reopened on rotation. The flags are O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW — the file is created (mode-applied) if absent and appended to if it is a regular file, but a symlink at the path fails the open closed rather than redirecting writes to an attacker-chosen target (CWE-59). A bare openSync(path, "a") instead follows such a symlink, so a caller's pre-check-then-unlink defense still races a symlink re-planted before the sink's own open — this primitive makes the symlink refusal atomic with the open. O_NOFOLLOW is POSIX-only; on platforms without it the flag is 0 (a plain append open) — Windows symlink semantics differ and are out of scope. Throws the raw openSync error (caller maps ELOOP / ENOENT).

var fd = b.atomicFile.openAppendNoFollowSync(activeLogPath, 0o600);
fs.writeSync(fd, line);   // appends; a symlink at activeLogPath → ELOOP

Last updated 2026-08-08T16:39:49.652Z by seeder.