path-utils.ts

  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}