/** * Canonical atomic file writer (DC-099). * * Write discipline: same-directory temp file → write → fsync → close → rename. * rename() is atomic on POSIX, so a reader (or a crash) can only ever see the * complete old file or the complete new file — never a truncated mix. fsync * before rename pins the bytes so a post-rename power loss doesn't leave an * empty/short file behind (the failure mode plain writeFileSync has). * * This is the ONE shared implementation. It replaces the three private * `_atomicWriteJSON` copies (invite-store, user-store, share-store) as they * are touched, and mirrors the DC-098 redact tool's write path. Do not add a * fourth copy — require this module instead. */ 'use strict'; const fs = require('fs'); const path = require('path'); // Monotonic counter guarantees unique tmp names even for back-to-back writes // of the same target within one process tick. let writeCounter = 0; function tmpPathFor(filePath) { writeCounter += 1; const base = path.basename(filePath); const dir = path.dirname(filePath); return path.join(dir, `.${base}.tmp-${process.pid}-${Date.now().toString(36)}-${writeCounter}`); } /** * Best-effort durability for the rename itself: fsync the parent directory so * the swap survives a post-rename power loss. POSIX guarantees the rename is * atomic *visibly*, but without a dir fsync a crash can leave the old entry — * a stale-but-complete file, never a torn one, so failure here is not fatal. */ function fsyncDir(dirPath) { let dfd = null; try { dfd = fs.openSync(dirPath, 'r'); fs.fsyncSync(dfd); } catch (_) { // Some platforms/filesystems reject fsync on directory fds; the payload // is already durable via the file-level fsync above. } finally { if (dfd !== null) { try { fs.closeSync(dfd); } catch (_) { /* fd already closed */ } } } } /** * Atomically replace `filePath` with `contents`. * * @param {string} filePath - destination (parent dir must exist) * @param {string} contents - full file contents * @param {object} [opts] * @param {number} [opts.mode=0o600] - mode for a newly created file * @returns {string} the final path (filePath) * @throws whatever fs throws (ENOSPC, EACCES, …); on failure the destination * is untouched and the temp file is removed best-effort. */ function atomicWriteFile(filePath, contents, opts = {}) { const mode = typeof opts.mode === 'number' ? opts.mode : 0o600; const tmp = tmpPathFor(filePath); let fd = null; try { // 'wx' — fail loudly if the tmp name somehow exists rather than clobber. fd = fs.openSync(tmp, 'wx', mode); fs.writeSync(fd, contents, null, 'utf8'); fs.fsyncSync(fd); fs.closeSync(fd); fd = null; fs.renameSync(tmp, filePath); fsyncDir(path.dirname(filePath)); return filePath; } catch (err) { if (fd !== null) { try { fs.closeSync(fd); } catch (_) { /* fd already closed or broken */ } } try { fs.unlinkSync(tmp); } catch (_) { /* nothing to clean up */ } throw err; } } /** * Atomically write `data` as JSON. Single serializer for the whole codebase: * 2-space indent, no trailing newline (matches the notification config's * byte-for-byte idempotence check in _persistCanonicalForm). * * @param {string} filePath * @param {*} data - JSON.stringify-able value * @param {object} [opts] - passed through to atomicWriteFile * @returns {string} the final path (filePath) */ function atomicWriteJSON(filePath, data, opts = {}) { return atomicWriteFile(filePath, JSON.stringify(data, null, 2), opts); } module.exports = { atomicWriteFile, atomicWriteJSON, tmpPathFor };