// Secure filesystem adapter (T025)
// Responsibilities:
// - Resolve and validate paths under a configured root (RUNBOOK_ROOT)
// - List markdown files recursively (uses fast-glob if available)
// - Read file content safely
// - Prevent path traversal: any resolved path must stay within root
// - Expose small surface for testability
import { promises as fs } from 'fs';
import path from 'path';
import fg from 'fast-glob';
import { ValidationError, SecurityError, NotFoundError } from '../utils/errors.mjs';
function assertInside(root, target) {
const rel = path.relative(root, target);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new SecurityError(`Path escapes root: ${target}`);
}
}
export function createFsAdapter(rootDir) {
if (!rootDir) throw new ValidationError('rootDir required');
const root = path.resolve(rootDir);
async function listMarkdown(options = {}) {
const pattern = options.pattern || '**/*.md';
const entries = await fg(pattern, { cwd: root, dot: false, onlyFiles: true, unique: true });
return entries.map(e => ({
relative: e,
absolute: path.join(root, e)
}));
}
async function read(relativePath) {
if (!relativePath) throw new ValidationError('relativePath required');
const abs = path.resolve(root, relativePath);
assertInside(root, abs);
let data;
try {
data = await fs.readFile(abs, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') throw new NotFoundError(`File not found: ${relativePath}`);
throw err;
}
return { path: relativePath, content: data };
}
async function stat(relativePath) {
const abs = path.resolve(root, relativePath);
assertInside(root, abs);
return fs.stat(abs);
}
return Object.freeze({ root, listMarkdown, read, stat });
}
export default { createFsAdapter };