path-utils.ts

 1import { accessSync, constants } from "node:fs";
 2import { 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
70export function ensureWorkspacePath(workspacePath: string, targetPath: string): string {
71	const resolved = resolvePath(workspacePath, targetPath);
72	const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
73
74	if (resolved === workspacePath || resolved.startsWith(root)) {
75		return resolved;
76	}
77
78	throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
79}