/** * Shared validation helpers for MCP and Web UI. * * @module src/core/validation */ // node:fs/promises for realpath (no Bun equivalent) import { realpath } from "node:fs/promises"; // node:os for homedir (no Bun os utils) import { homedir } from "node:os"; // node:path for path utils (no Bun path utils) import { isAbsolute, join, posix as pathPosix, relative, sep } from "node:path"; import { toAbsolutePath } from "../config/paths"; const DANGEROUS_ROOT_PATTERNS = [ "/", homedir(), "/etc", "/usr", "/bin", "/var", "/System", "/Library", join(homedir(), ".config"), join(homedir(), ".local"), join(homedir(), ".ssh"), join(homedir(), ".gnupg"), ]; async function resolveRealPathSafe(path: string): Promise { try { return await realpath(path); } catch { return path; } } export function normalizeCollectionName(name: string): string { return name.trim().toLowerCase(); } export function validateRelPath(relPath: string): string { if (isAbsolute(relPath)) { throw new Error("relPath must be relative"); } if (relPath.includes("\0")) { throw new Error("relPath contains invalid characters"); } const normalizedInput = relPath.replaceAll("\\", "/"); const normalized = pathPosix.normalize(normalizedInput); const segments = normalized.split("/"); if (segments.includes("..")) { throw new Error("relPath cannot escape collection root"); } return normalized; } /** * Return whether candidate is equal to or nested beneath parent. * * Inputs must already be canonical absolute paths. `relative()` keeps this * segment-safe, unlike string-prefix checks (`/project` vs `/project-old`). */ export function isCanonicalPathContained( parent: string, candidate: string ): boolean { const relativePath = relative(parent, candidate); return ( relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) ); } export async function validateCollectionRoot( inputPath: string ): Promise { const absPath = toAbsolutePath(inputPath); const realPath = await resolveRealPathSafe(absPath); const dangerousRoots = await Promise.all( DANGEROUS_ROOT_PATTERNS.map((p) => resolveRealPathSafe(p)) ); if (dangerousRoots.includes(realPath)) { throw new Error(`Cannot add ${inputPath}: resolves to dangerous root`); } return realPath; }