path-utils.ts

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: GPL-3.0-or-later
 4
 5import { accessSync, constants } from "node:fs";
 6import { 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 = [tryNFDVariant, tryMacOSScreenshotPath, tryCurlyQuoteVariant];
64	for (const variant of variants) {
65		const candidate = variant(resolved);
66		if (candidate !== resolved && fileExists(candidate)) {
67			return candidate;
68		}
69	}
70
71	return resolved;
72}
73
74export function ensureWorkspacePath(workspacePath: string, targetPath: string): string {
75	const resolved = resolvePath(workspacePath, targetPath);
76	const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
77
78	if (resolved === workspacePath || resolved.startsWith(root)) {
79		return resolved;
80	}
81
82	throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
83}