/** * @file config-write.js * @description Shared safe-write primitives for config writers (cc-mutate.js * for Claude Code, and any Codex equivalent): path containment, timestamped * backup-before-mutate, and atomic file write. Extracted out of cc-mutate.js * so both writers share one copy of this security-sensitive code instead of * diverging copies. * * Hard constraints (do not relax without a follow-up review): * - Backups happen BEFORE the mutation and land outside the tree being * backed up (caller passes `backupRoot`). * - Writes are atomic via temp file + fs.renameSync. Tmp is removed on * any failure path. * - `assertUnder` is the single containment check callers must run on any * resolved path before touching disk. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { isUnder } = require("./cc-discovery"); function timestamp() { return new Date().toISOString().replace(/[:]/g, "-"); } function copyDirSync(src, dst) { fs.mkdirSync(dst, { recursive: true }); for (const ent of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, ent.name); const d = path.join(dst, ent.name); if (ent.isDirectory()) copyDirSync(s, d); else if (ent.isFile()) fs.copyFileSync(s, d); // symlinks/sockets/etc skipped intentionally — these surfaces are // text-file-only by spec } } /** * Always-on backup. Copies `source` (file or directory tree) into * `backupRoot` as `..bak`. Returns the backup path, or null if * `source` does not exist (e.g. brand-new file/skill). */ function createBackup({ source, backupRoot, type, name }) { if (!fs.existsSync(source)) return null; fs.mkdirSync(backupRoot, { recursive: true }); const stamp = timestamp(); const dst = path.join(backupRoot, `${name}.${stamp}.bak`); if (fs.statSync(source).isDirectory()) { copyDirSync(source, dst); } else { fs.copyFileSync(source, dst); } return dst; } /** * Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked * on any failure path. Caller is responsible for ensuring parent dir exists. */ function atomicWriteFile(filePath, content) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); let fd; try { fd = fs.openSync(tmp, "wx"); fs.writeSync(fd, content); try { fs.fsyncSync(fd); } catch { // fsync may fail on some filesystems / tmpfs — non-fatal } fs.closeSync(fd); fd = null; fs.renameSync(tmp, filePath); } catch (err) { try { if (fd != null) fs.closeSync(fd); } catch { /* ignore */ } try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* ignore */ } throw err; } } /** Throw EOUTOFROOT unless `target` resolves to `root` or a descendant. */ function assertUnder(root, target) { if (!isUnder(root, target)) { const err = new Error("resolved path is outside containment root"); err.code = "EOUTOFROOT"; throw err; } } module.exports = { timestamp, createBackup, atomicWriteFile, assertUnder };