/ utils / path.ts
path.ts
  1  import { homedir } from 'os'
  2  import { dirname, isAbsolute, join, normalize, relative, resolve } from 'path'
  3  import { getCwd } from './cwd.js'
  4  import { getFsImplementation } from './fsOperations.js'
  5  import { getPlatform } from './platform.js'
  6  import { posixPathToWindowsPath } from './windowsPaths.js'
  7  
  8  /**
  9   * Expands a path that may contain tilde notation (~) to an absolute path.
 10   *
 11   * On Windows, POSIX-style paths (e.g., `/c/Users/...`) are automatically converted
 12   * to Windows format (e.g., `C:\Users\...`). The function always returns paths in
 13   * the native format for the current platform.
 14   *
 15   * @param path - The path to expand, may contain:
 16   *   - `~` - expands to user's home directory
 17   *   - `~/path` - expands to path within user's home directory
 18   *   - absolute paths - returned normalized
 19   *   - relative paths - resolved relative to baseDir
 20   *   - POSIX paths on Windows - converted to Windows format
 21   * @param baseDir - The base directory for resolving relative paths (defaults to current working directory)
 22   * @returns The expanded absolute path in the native format for the current platform
 23   *
 24   * @throws {Error} If path is invalid
 25   *
 26   * @example
 27   * expandPath('~') // '/home/user'
 28   * expandPath('~/Documents') // '/home/user/Documents'
 29   * expandPath('./src', '/project') // '/project/src'
 30   * expandPath('/absolute/path') // '/absolute/path'
 31   */
 32  export function expandPath(path: string, baseDir?: string): string {
 33    // Set default baseDir to getCwd() if not provided
 34    const actualBaseDir = baseDir ?? getCwd() ?? getFsImplementation().cwd()
 35  
 36    // Input validation
 37    if (typeof path !== 'string') {
 38      throw new TypeError(`Path must be a string, received ${typeof path}`)
 39    }
 40  
 41    if (typeof actualBaseDir !== 'string') {
 42      throw new TypeError(
 43        `Base directory must be a string, received ${typeof actualBaseDir}`,
 44      )
 45    }
 46  
 47    // Security: Check for null bytes
 48    if (path.includes('\0') || actualBaseDir.includes('\0')) {
 49      throw new Error('Path contains null bytes')
 50    }
 51  
 52    // Handle empty or whitespace-only paths
 53    const trimmedPath = path.trim()
 54    if (!trimmedPath) {
 55      return normalize(actualBaseDir).normalize('NFC')
 56    }
 57  
 58    // Handle home directory notation
 59    if (trimmedPath === '~') {
 60      return homedir().normalize('NFC')
 61    }
 62  
 63    if (trimmedPath.startsWith('~/')) {
 64      return join(homedir(), trimmedPath.slice(2)).normalize('NFC')
 65    }
 66  
 67    // On Windows, convert POSIX-style paths (e.g., /c/Users/...) to Windows format
 68    let processedPath = trimmedPath
 69    if (getPlatform() === 'windows' && trimmedPath.match(/^\/[a-z]\//i)) {
 70      try {
 71        processedPath = posixPathToWindowsPath(trimmedPath)
 72      } catch {
 73        // If conversion fails, use original path
 74        processedPath = trimmedPath
 75      }
 76    }
 77  
 78    // Handle absolute paths
 79    if (isAbsolute(processedPath)) {
 80      return normalize(processedPath).normalize('NFC')
 81    }
 82  
 83    // Handle relative paths
 84    return resolve(actualBaseDir, processedPath).normalize('NFC')
 85  }
 86  
 87  /**
 88   * Converts an absolute path to a relative path from cwd, to save tokens in
 89   * tool output. If the path is outside cwd (relative path would start with ..),
 90   * returns the absolute path unchanged so it stays unambiguous.
 91   *
 92   * @param absolutePath - The absolute path to relativize
 93   * @returns Relative path if under cwd, otherwise the original absolute path
 94   */
 95  export function toRelativePath(absolutePath: string): string {
 96    const relativePath = relative(getCwd(), absolutePath)
 97    // If the relative path would go outside cwd (starts with ..), keep absolute
 98    return relativePath.startsWith('..') ? absolutePath : relativePath
 99  }
100  
101  /**
102   * Gets the directory path for a given file or directory path.
103   * If the path is a directory, returns the path itself.
104   * If the path is a file or doesn't exist, returns the parent directory.
105   *
106   * @param path - The file or directory path
107   * @returns The directory path
108   */
109  export function getDirectoryForPath(path: string): string {
110    const absolutePath = expandPath(path)
111    // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
112    if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
113      return dirname(absolutePath)
114    }
115    try {
116      const stats = getFsImplementation().statSync(absolutePath)
117      if (stats.isDirectory()) {
118        return absolutePath
119      }
120    } catch {
121      // Path doesn't exist or can't be accessed
122    }
123    // If it's not a directory or doesn't exist, return the parent directory
124    return dirname(absolutePath)
125  }
126  
127  /**
128   * Checks if a path contains directory traversal patterns that navigate to parent directories.
129   *
130   * @param path - The path to check for traversal patterns
131   * @returns true if the path contains traversal (e.g., '../', '..\', or ends with '..')
132   */
133  export function containsPathTraversal(path: string): boolean {
134    return /(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path)
135  }
136  
137  // Re-export from the shared zero-dep source.
138  export { sanitizePath } from './sessionStoragePortable.js'
139  
140  /**
141   * Normalizes a path for use as a JSON config key.
142   * On Windows, paths can have inconsistent separators (C:\path vs C:/path)
143   * depending on whether they come from git, Node.js APIs, or user input.
144   * This normalizes to forward slashes for consistent JSON serialization.
145   *
146   * @param path - The path to normalize
147   * @returns The normalized path with consistent forward slashes
148   */
149  export function normalizePathForConfigKey(path: string): string {
150    // First use Node's normalize to resolve . and .. segments
151    const normalized = normalize(path)
152    // Then convert all backslashes to forward slashes for consistent JSON keys
153    // This is safe because forward slashes work in Windows paths for most operations
154    return normalized.replace(/\\/g, '/')
155  }