/** * safe-replace-dir.mjs — safely replace a directory within a safety-root boundary * * Exports: * safeReplaceDir(source, target, safetyRoot) → Promise * * Usage: * import { safeReplaceDir } from "./lib/safe-replace-dir.mjs"; * await safeReplaceDir("/path/to/source", "/safe/root/target", "/safe/root"); * * Safety contract: * - `target` must be a strict descendant of `safetyRoot` (not equal to it). * - `target` must be a non-empty path. * - Throws with a descriptive message if either constraint is violated. * * Behaviour: * - Removes any existing content at `target` (rm -rf equivalent). * - Creates `target` (and any missing parent directories). * - Copies all files from `source` into `target`. */ import { cp, mkdir, realpath, rm } from "node:fs/promises"; import path from "node:path"; /** * Safely replace `target` with the contents of `source`, enforcing that * `target` is a strict descendant of `safetyRoot`. * * @param {string} source - Directory to copy from. * @param {string} target - Directory to replace (will be removed then recreated). * @param {string} safetyRoot - Ancestor boundary; `target` must be inside this. * @returns {Promise} */ export async function safeReplaceDir(source, target, safetyRoot) { if (!target || target === "") { throw new Error(`Refusing to replace unsafe target: (empty string)`); } const resolvedSafety = path.resolve(safetyRoot); const resolvedTarget = path.resolve(target); // Lexical check: target must be a strict descendant of safetyRoot. const relative = path.relative(resolvedSafety, resolvedTarget); if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative === "") { throw new Error(`Refusing to replace target outside safety root: ${target}`); } // Real-path check: resolve the deepest existing ancestor of target's parent // and verify it lies inside the real (symlink-resolved) safety root. // This blocks a symlinked parent directory from redirecting outside the boundary. const realSafety = await realpath(resolvedSafety); let checkPath = path.dirname(resolvedTarget); for (;;) { try { const realAncestor = await realpath(checkPath); const realRel = path.relative(realSafety, realAncestor); if (realRel.startsWith("..") || path.isAbsolute(realRel)) { throw new Error(`Refusing to replace target outside safety root: ${target}`); } break; // validation passed } catch (err) { if (err.code === "ENOENT") { const parent = path.dirname(checkPath); if (parent === checkPath) { throw new Error(`Refusing to replace target outside safety root: ${target}`, { cause: err }); } checkPath = parent; continue; } throw err; } } await rm(resolvedTarget, { recursive: true, force: true }); await mkdir(resolvedTarget, { recursive: true }); await cp(source, resolvedTarget, { recursive: true, force: true }); }