78 lines
2.9 KiB
JavaScript
78 lines
2.9 KiB
JavaScript
/**
|
|
* safe-replace-dir.mjs — safely replace a directory within a safety-root boundary
|
|
*
|
|
* Exports:
|
|
* safeReplaceDir(source, target, safetyRoot) → Promise<void>
|
|
*
|
|
* 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<void>}
|
|
*/
|
|
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 });
|
|
}
|