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}