/ src / utils / windowsPaths.ts
windowsPaths.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import * as path from 'path'
  3  import * as pathWin32 from 'path/win32'
  4  import { getCwd } from './cwd.js'
  5  import { logForDebugging } from './debug.js'
  6  import { execSync_DEPRECATED } from './execSyncWrapper.js'
  7  import { memoizeWithLRU } from './memoize.js'
  8  import { getPlatform } from './platform.js'
  9  
 10  /**
 11   * Check if a file or directory exists on Windows using the dir command
 12   * @param path - The path to check
 13   * @returns true if the path exists, false otherwise
 14   */
 15  function checkPathExists(path: string): boolean {
 16    try {
 17      execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
 18      return true
 19    } catch {
 20      return false
 21    }
 22  }
 23  
 24  /**
 25   * Find an executable using where.exe on Windows
 26   * @param executable - The name of the executable to find
 27   * @returns The path to the executable or null if not found
 28   */
 29  function findExecutable(executable: string): string | null {
 30    // For git, check common installation locations first
 31    if (executable === 'git') {
 32      const defaultLocations = [
 33        // check 64 bit before 32 bit
 34        'C:\\Program Files\\Git\\cmd\\git.exe',
 35        'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
 36        // intentionally don't look for C:\Program Files\Git\mingw64\bin\git.exe
 37        // because that directory is the "raw" tools with no environment setup
 38      ]
 39  
 40      for (const location of defaultLocations) {
 41        if (checkPathExists(location)) {
 42          return location
 43        }
 44      }
 45    }
 46  
 47    // Fall back to where.exe
 48    try {
 49      const result = execSync_DEPRECATED(`where.exe ${executable}`, {
 50        stdio: 'pipe',
 51        encoding: 'utf8',
 52      }).trim()
 53  
 54      // SECURITY: Filter out any results from the current directory
 55      // to prevent executing malicious git.bat/cmd/exe files
 56      const paths = result.split('\r\n').filter(Boolean)
 57      const cwd = getCwd().toLowerCase()
 58  
 59      for (const candidatePath of paths) {
 60        // Normalize and compare paths to ensure we're not in current directory
 61        const normalizedPath = path.resolve(candidatePath).toLowerCase()
 62        const pathDir = path.dirname(normalizedPath).toLowerCase()
 63  
 64        // Skip if the executable is in the current working directory
 65        if (pathDir === cwd || normalizedPath.startsWith(cwd + path.sep)) {
 66          logForDebugging(
 67            `Skipping potentially malicious executable in current directory: ${candidatePath}`,
 68          )
 69          continue
 70        }
 71  
 72        // Return the first valid path that's not in the current directory
 73        return candidatePath
 74      }
 75  
 76      return null
 77    } catch {
 78      return null
 79    }
 80  }
 81  
 82  /**
 83   * If Windows, set the SHELL environment variable to git-bash path.
 84   * This is used by BashTool and Shell.ts for user shell commands.
 85   * COMSPEC is left unchanged for system process execution.
 86   */
 87  export function setShellIfWindows(): void {
 88    if (getPlatform() === 'windows') {
 89      const gitBashPath = findGitBashPath()
 90      process.env.SHELL = gitBashPath
 91      logForDebugging(`Using bash path: "${gitBashPath}"`)
 92    }
 93  }
 94  
 95  /**
 96   * Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
 97   */
 98  export const findGitBashPath = memoize((): string => {
 99    if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
100      if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
101        return process.env.CLAUDE_CODE_GIT_BASH_PATH
102      }
103      // biome-ignore lint/suspicious/noConsole:: intentional console output
104      console.error(
105        `Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
106      )
107      // eslint-disable-next-line custom-rules/no-process-exit
108      process.exit(1)
109    }
110  
111    const gitPath = findExecutable('git')
112    if (gitPath) {
113      const bashPath = pathWin32.join(gitPath, '..', '..', 'bin', 'bash.exe')
114      if (checkPathExists(bashPath)) {
115        return bashPath
116      }
117    }
118  
119    // biome-ignore lint/suspicious/noConsole:: intentional console output
120    console.error(
121      'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',
122    )
123    // eslint-disable-next-line custom-rules/no-process-exit
124    process.exit(1)
125  })
126  
127  /** Convert a Windows path to a POSIX path using pure JS. */
128  export const windowsPathToPosixPath = memoizeWithLRU(
129    (windowsPath: string): string => {
130      // Handle UNC paths: \\server\share -> //server/share
131      if (windowsPath.startsWith('\\\\')) {
132        return windowsPath.replace(/\\/g, '/')
133      }
134      // Handle drive letter paths: C:\Users\foo -> /c/Users/foo
135      const match = windowsPath.match(/^([A-Za-z]):[/\\]/)
136      if (match) {
137        const driveLetter = match[1]!.toLowerCase()
138        return '/' + driveLetter + windowsPath.slice(2).replace(/\\/g, '/')
139      }
140      // Already POSIX or relative — just flip slashes
141      return windowsPath.replace(/\\/g, '/')
142    },
143    (p: string) => p,
144    500,
145  )
146  
147  /** Convert a POSIX path to a Windows path using pure JS. */
148  export const posixPathToWindowsPath = memoizeWithLRU(
149    (posixPath: string): string => {
150      // Handle UNC paths: //server/share -> \\server\share
151      if (posixPath.startsWith('//')) {
152        return posixPath.replace(/\//g, '\\')
153      }
154      // Handle /cygdrive/c/... format
155      const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/)
156      if (cygdriveMatch) {
157        const driveLetter = cygdriveMatch[1]!.toUpperCase()
158        const rest = posixPath.slice(('/cygdrive/' + cygdriveMatch[1]).length)
159        return driveLetter + ':' + (rest || '\\').replace(/\//g, '\\')
160      }
161      // Handle /c/... format (MSYS2/Git Bash)
162      const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/)
163      if (driveMatch) {
164        const driveLetter = driveMatch[1]!.toUpperCase()
165        const rest = posixPath.slice(2)
166        return driveLetter + ':' + (rest || '\\').replace(/\//g, '\\')
167      }
168      // Already Windows or relative — just flip slashes
169      return posixPath.replace(/\//g, '\\')
170    },
171    (p: string) => p,
172    500,
173  )