BMAD-METHOD/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs

136 lines
5.2 KiB
JavaScript

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
// Resolve `declared` as a path inside `rootAbs`. Rejects absolute paths,
// `..` segments, and symlink escapes. Returns the absolute path or null.
export function safePathInsideRoot(rootAbs, declared) {
if (typeof declared !== 'string' || declared === '') return null;
if (path.isAbsolute(declared)) return null;
if (declared.split(/[\\/]/).includes('..')) return null;
const resolved = path.resolve(rootAbs, declared);
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) return null;
if (fs.existsSync(resolved)) {
try {
const real = fs.realpathSync(resolved);
const realRoot = fs.realpathSync(rootAbs);
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
} catch {
return null;
}
}
return resolved;
}
// SHA-256 of a file's bytes, returned as a hex string. Returns null on
// I/O failure — callers should treat a null hash as "file unreadable".
export async function sha256File(filePath) {
try {
const buf = await fsp.readFile(filePath);
return crypto.createHash('sha256').update(buf).digest('hex');
} catch {
return null;
}
}
// Recursively copy `srcDir` into `destDir`, creating destDir first.
// Returns an array of relative file paths (POSIX-style) actually copied.
// Skips entries matched by `shouldSkip(relPath)` which receives a POSIX
// path relative to srcDir.
export async function copyDir(srcDir, destDir, shouldSkip = () => false) {
await fsp.mkdir(destDir, { recursive: true });
const copied = [];
async function walk(rel) {
const absSrc = path.join(srcDir, rel);
const entries = await fsp.readdir(absSrc, { withFileTypes: true });
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (shouldSkip(childRel)) continue;
const childSrc = path.join(srcDir, childRel);
const childDest = path.join(destDir, childRel);
if (entry.isDirectory()) {
await fsp.mkdir(childDest, { recursive: true });
await walk(childRel);
} else if (entry.isFile()) {
await fsp.mkdir(path.dirname(childDest), { recursive: true });
await fsp.copyFile(childSrc, childDest);
copied.push(childRel);
}
// Symlinks are skipped — install staging never preserves them.
}
}
await walk('');
return copied;
}
// Stage a copy plan into `destDir`: each plan entry copies one file from
// `srcRoot/srcRel` to `destDir/destRel`. `extras` is an optional map of
// `destRel → string content` for synthesized files (e.g. a rewritten plugin.json)
// that have no source-tree counterpart. Returns the union of destRels written.
export async function stageCopyPlan(srcRoot, destDir, plan, extras = {}) {
await fsp.mkdir(destDir, { recursive: true });
const written = [];
for (const { srcRel, destRel } of plan) {
const absSrc = path.join(srcRoot, srcRel);
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.copyFile(absSrc, absDest);
written.push(destRel);
}
for (const [destRel, content] of Object.entries(extras)) {
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.writeFile(absDest, content, 'utf8');
written.push(destRel);
}
return written;
}
// Atomically replace `targetDir` with `stagedDir` contents. Best effort —
// not truly atomic, but minimizes the inconsistent window.
//
// `stagedDir` usually lives under the OS temp dir, which is frequently a
// separate filesystem (e.g. tmpfs on /tmp) from the target. rename() cannot
// move across filesystems and throws EXDEV there, so we first land the staged
// tree onto the target's own filesystem as a sibling — by rename when they
// already share a filesystem, by copy when they don't — and then rename that
// sibling into place, which is always an intra-filesystem atomic swap.
export async function atomicSwapDir(stagedDir, targetDir) {
const parent = path.dirname(targetDir);
await fsp.mkdir(parent, { recursive: true });
const sibling = path.join(parent, `.${path.basename(targetDir)}.bmad-tmp-${crypto.randomBytes(6).toString('hex')}`);
try {
try {
await fsp.rename(stagedDir, sibling);
} catch (e) {
if (e.code !== 'EXDEV') throw e;
await copyDir(stagedDir, sibling);
await fsp.rm(stagedDir, { recursive: true, force: true });
}
await fsp.rm(targetDir, { recursive: true, force: true });
await fsp.rename(sibling, targetDir);
} catch (e) {
await fsp.rm(sibling, { recursive: true, force: true });
throw e;
}
}
// Remove empty parent directories upward until a non-empty one is hit,
// stopping at `stopAt` (exclusive). Used after file deletion.
export async function pruneEmptyDirs(startDir, stopAt) {
let dir = path.resolve(startDir);
const stop = path.resolve(stopAt);
while (dir !== stop && dir.startsWith(stop + path.sep)) {
let entries;
try {
entries = await fsp.readdir(dir);
} catch {
return;
}
if (entries.length > 0) return;
await fsp.rmdir(dir);
dir = path.dirname(dir);
}
}