refactor(config): extract the safe-write primitives cc-mutate guards with
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 `<name>.<ts>.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 };
|
||||
Reference in New Issue
Block a user