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