path-utils.ts

  1import { accessSync, constants, realpathSync } from "node:fs";
  2import { dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
  3import { ToolInputError } from "../../util/errors.js";
  4
  5const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
  6
  7function normalizeAtPrefix(filePath: string): string {
  8	return filePath.startsWith("@") ? filePath.slice(1) : filePath;
  9}
 10
 11function fileExists(path: string): boolean {
 12	try {
 13		accessSync(path, constants.F_OK);
 14		return true;
 15	} catch {
 16		return false;
 17	}
 18}
 19
 20function tryMacOSScreenshotPath(filePath: string): string {
 21	return filePath.replace(/ (AM|PM)\./g, "\u202F$1.");
 22}
 23
 24function tryNFDVariant(filePath: string): string {
 25	return filePath.normalize("NFD");
 26}
 27
 28function tryCurlyQuoteVariant(filePath: string): string {
 29	return filePath.replace(/'/g, "\u2019");
 30}
 31
 32export function expandPath(filePath: string): string {
 33	let result = filePath.replace(UNICODE_SPACES, " ");
 34	result = normalizeAtPrefix(result);
 35
 36	// NOTE: tilde expansion is intentionally omitted.
 37	// In a workspace-sandboxed context, expanding ~ to the user's home
 38	// directory would bypass workspace containment. Tildes are treated
 39	// as literal path characters.
 40
 41	return result;
 42}
 43
 44export function resolveToCwd(filePath: string, cwd: string): string {
 45	const expanded = expandPath(filePath);
 46	if (isAbsolute(expanded)) {
 47		return expanded;
 48	}
 49	return resolvePath(cwd, expanded);
 50}
 51
 52export function resolveReadPath(filePath: string, cwd: string): string {
 53	const resolved = resolveToCwd(filePath, cwd);
 54
 55	if (fileExists(resolved)) {
 56		return resolved;
 57	}
 58
 59	const variants = [tryNFDVariant, tryMacOSScreenshotPath, tryCurlyQuoteVariant];
 60	for (const variant of variants) {
 61		const candidate = variant(resolved);
 62		if (candidate !== resolved && fileExists(candidate)) {
 63			return candidate;
 64		}
 65	}
 66
 67	return resolved;
 68}
 69
 70/**
 71 * Resolve the real path of `p`, following symlinks. If `p` does not exist,
 72 * walk up to the nearest existing ancestor, resolve *that*, and re-append
 73 * the remaining segments. This lets us validate write targets that don't
 74 * exist yet while still catching symlink escapes in any ancestor directory.
 75 */
 76function safeRealpath(p: string): string {
 77	try {
 78		return realpathSync(p);
 79	} catch (err: any) {
 80		if (err?.code === "ENOENT") {
 81			const parent = dirname(p);
 82			if (parent === p) {
 83				// filesystem root — nothing more to resolve
 84				return p;
 85			}
 86			const realParent = safeRealpath(parent);
 87			const tail = p.slice(parent.length);
 88			return realParent + tail;
 89		}
 90		throw err;
 91	}
 92}
 93
 94export function ensureWorkspacePath(workspacePath: string, targetPath: string): string {
 95	const resolved = resolvePath(workspacePath, targetPath);
 96
 97	// Quick textual check first (catches the common case cheaply)
 98	const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
 99	if (resolved !== workspacePath && !resolved.startsWith(root)) {
100		throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
101	}
102
103	// Resolve symlinks to catch symlink-based escapes
104	const realWorkspace = safeRealpath(workspacePath);
105	const realTarget = safeRealpath(resolved);
106	const realRoot = realWorkspace.endsWith(sep) ? realWorkspace : `${realWorkspace}${sep}`;
107
108	if (realTarget !== realWorkspace && !realTarget.startsWith(realRoot)) {
109		throw new ToolInputError(`Path escapes workspace via symlink: ${targetPath}`);
110	}
111
112	return resolved;
113}