import * as fs from 'fs' import { mkdir as mkdirPromise, open, readdir as readdirPromise, readFile as readFilePromise, rename as renamePromise, rmdir as rmdirPromise, rm as rmPromise, stat as statPromise, unlink as unlinkPromise, } from 'fs/promises' import { homedir } from 'os' import * as nodePath from 'path' import { getErrnoCode } from './errors.js' import { slowLogging } from './slowOperations.js' /** * Simplified filesystem operations interface based on Node.js fs module. * Provides a subset of commonly used sync operations with type safety. * Allows abstraction for alternative implementations (e.g., mock, virtual). */ export type FsOperations = { // File access and information operations /** Gets the current working directory */ cwd(): string /** Checks if a file or directory exists */ existsSync(path: string): boolean /** Gets file stats asynchronously */ stat(path: string): Promise /** Lists directory contents with file type information asynchronously */ readdir(path: string): Promise /** Deletes file asynchronously */ unlink(path: string): Promise /** Removes an empty directory asynchronously */ rmdir(path: string): Promise /** Removes files and directories asynchronously (with recursive option) */ rm( path: string, options?: { recursive?: boolean; force?: boolean }, ): Promise /** Creates directory recursively asynchronously. */ mkdir(path: string, options?: { mode?: number }): Promise /** Reads file content as string asynchronously */ readFile(path: string, options: { encoding: BufferEncoding }): Promise /** Renames/moves file asynchronously */ rename(oldPath: string, newPath: string): Promise /** Gets file stats */ statSync(path: string): fs.Stats /** Gets file stats without following symlinks */ lstatSync(path: string): fs.Stats // File content operations /** Reads file content as string with specified encoding */ readFileSync( path: string, options: { encoding: BufferEncoding }, ): string /** Reads raw file bytes as Buffer */ readFileBytesSync(path: string): Buffer /** Reads specified number of bytes from file start */ readSync( path: string, options: { length: number }, ): { buffer: Buffer bytesRead: number } /** Appends string to file */ appendFileSync(path: string, data: string, options?: { mode?: number }): void /** Copies file from source to destination */ copyFileSync(src: string, dest: string): void /** Deletes file */ unlinkSync(path: string): void /** Renames/moves file */ renameSync(oldPath: string, newPath: string): void /** Creates hard link */ linkSync(target: string, path: string): void /** Creates symbolic link */ symlinkSync( target: string, path: string, type?: 'dir' | 'file' | 'junction', ): void /** Reads symbolic link */ readlinkSync(path: string): string /** Resolves symbolic links and returns the canonical pathname */ realpathSync(path: string): string // Directory operations /** Creates directory recursively. Mode defaults to 0o777 & ~umask if not specified. */ mkdirSync( path: string, options?: { mode?: number }, ): void /** Lists directory contents with file type information */ readdirSync(path: string): fs.Dirent[] /** Lists directory contents as strings */ readdirStringSync(path: string): string[] /** Checks if the directory is empty */ isDirEmptySync(path: string): boolean /** Removes an empty directory */ rmdirSync(path: string): void /** Removes files and directories (with recursive option) */ rmSync( path: string, options?: { recursive?: boolean force?: boolean }, ): void /** Create a writable stream for writing data to a file. */ createWriteStream(path: string): fs.WriteStream /** Reads raw file bytes as Buffer asynchronously. * When maxBytes is set, only reads up to that many bytes. */ readFileBytes(path: string, maxBytes?: number): Promise } /** * Safely resolves a file path, handling symlinks and errors gracefully. * * Error handling strategy: * - If the file doesn't exist, returns the original path (allows for file creation) * - If symlink resolution fails (broken symlink, permission denied, circular links), * returns the original path and marks it as not a symlink * - This ensures operations can continue with the original path rather than failing * * @param fs The filesystem implementation to use * @param filePath The path to resolve * @returns Object containing the resolved path and whether it was a symlink */ export function safeResolvePath( fs: FsOperations, filePath: string, ): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } { // Block UNC paths before any filesystem access to prevent network // requests (DNS/SMB) during validation on Windows if (filePath.startsWith('//') || filePath.startsWith('\\\\')) { return { resolvedPath: filePath, isSymlink: false, isCanonical: false } } try { // Check for special file types (FIFOs, sockets, devices) before calling realpathSync. // realpathSync can block on FIFOs waiting for a writer, causing hangs. // If the file doesn't exist, lstatSync throws ENOENT which the catch // below handles by returning the original path (allows file creation). const stats = fs.lstatSync(filePath) if ( stats.isFIFO() || stats.isSocket() || stats.isCharacterDevice() || stats.isBlockDevice() ) { return { resolvedPath: filePath, isSymlink: false, isCanonical: false } } const resolvedPath = fs.realpathSync(filePath) return { resolvedPath, isSymlink: resolvedPath !== filePath, // realpathSync returned: resolvedPath is canonical (all symlinks in // all path components resolved). Callers can skip further symlink // resolution on this path. isCanonical: true, } } catch (_error) { // If lstat/realpath fails for any reason (ENOENT, broken symlink, // EACCES, ELOOP, etc.), return the original path to allow operations // to proceed return { resolvedPath: filePath, isSymlink: false, isCanonical: false } } } /** * Check if a file path is a duplicate and should be skipped. * Resolves symlinks to detect duplicates pointing to the same file. * If not a duplicate, adds the resolved path to loadedPaths. * * @returns true if the file should be skipped (is duplicate) */ export function isDuplicatePath( fs: FsOperations, filePath: string, loadedPaths: Set, ): boolean { const { resolvedPath } = safeResolvePath(fs, filePath) if (loadedPaths.has(resolvedPath)) { return true } loadedPaths.add(resolvedPath) return false } /** * Resolve the deepest existing ancestor of a path via realpathSync, walking * up until it succeeds. Detects dangling symlinks (link entry exists, target * doesn't) via lstat and resolves them via readlink. * * Use when the input path may not exist (new file writes) and you need to * know where the write would ACTUALLY land after the OS follows symlinks. * * Returns the resolved absolute path with non-existent tail segments * rejoined, or undefined if no symlink was found in any existing ancestor * (the path's existing ancestors all resolve to themselves). * * Handles: live parent symlinks, dangling file symlinks, dangling parent * symlinks. Same core algorithm as teamMemPaths.ts:realpathDeepestExisting. */ export function resolveDeepestExistingAncestorSync( fs: FsOperations, absolutePath: string, ): string | undefined { let dir = absolutePath const segments: string[] = [] // Walk up using lstat (cheap, O(1)) to find the first existing component. // lstat does not follow symlinks, so dangling symlinks are detected here. // Only call realpathSync (expensive, O(depth)) once at the end. while (dir !== nodePath.dirname(dir)) { let st: fs.Stats try { st = fs.lstatSync(dir) } catch { // lstat failed: truly non-existent. Walk up. segments.unshift(nodePath.basename(dir)) dir = nodePath.dirname(dir) continue } if (st.isSymbolicLink()) { // Found a symlink (live or dangling). Try realpath first (resolves // chained symlinks); fall back to readlink for dangling symlinks. try { const resolved = fs.realpathSync(dir) return segments.length === 0 ? resolved : nodePath.join(resolved, ...segments) } catch { // Dangling: realpath failed but lstat saw the link entry. const target = fs.readlinkSync(dir) const absTarget = nodePath.isAbsolute(target) ? target : nodePath.resolve(nodePath.dirname(dir), target) return segments.length === 0 ? absTarget : nodePath.join(absTarget, ...segments) } } // Existing non-symlink component. One realpath call resolves any // symlinks in its ancestors. If none, return undefined (no symlink). try { const resolved = fs.realpathSync(dir) if (resolved !== dir) { return segments.length === 0 ? resolved : nodePath.join(resolved, ...segments) } } catch { // realpath can still fail (e.g. EACCES in ancestors). Return // undefined — we can't resolve, and the logical path is already // in pathSet for the caller. } return undefined } return undefined } /** * Gets all paths that should be checked for permissions. * This includes the original path, all intermediate symlink targets in the chain, * and the final resolved path. * * For example, if test.txt -> /etc/passwd -> /private/etc/passwd: * - test.txt (original path) * - /etc/passwd (intermediate symlink target) * - /private/etc/passwd (final resolved path) * * This is important for security: a deny rule for /etc/passwd should block * access even if the file is actually at /private/etc/passwd (as on macOS). * * @param path - The path to check (will be converted to absolute) * @returns An array of absolute paths to check permissions for */ export function getPathsForPermissionCheck(inputPath: string): string[] { // Expand tilde notation defensively - tools should do this in getPath(), // but we normalize here as defense in depth for permission checking let path = inputPath if (path === '~') { path = homedir().normalize('NFC') } else if (path.startsWith('~/')) { path = nodePath.join(homedir().normalize('NFC'), path.slice(2)) } const pathSet = new Set() const fsImpl = getFsImplementation() // Always check the original path pathSet.add(path) // Block UNC paths before any filesystem access to prevent network // requests (DNS/SMB) during validation on Windows if (path.startsWith('//') || path.startsWith('\\\\')) { return Array.from(pathSet) } // Follow the symlink chain, collecting ALL intermediate targets // This handles cases like: test.txt -> /etc/passwd -> /private/etc/passwd // We want to check all three paths, not just test.txt and /private/etc/passwd try { let currentPath = path const visited = new Set() const maxDepth = 40 // Prevent runaway loops, matches typical SYMLOOP_MAX for (let depth = 0; depth < maxDepth; depth++) { // Prevent infinite loops from circular symlinks if (visited.has(currentPath)) { break } visited.add(currentPath) if (!fsImpl.existsSync(currentPath)) { // Path doesn't exist (new file case). existsSync follows symlinks, // so this is also reached for DANGLING symlinks (link entry exists, // target doesn't). Resolve symlinks in the path and its ancestors // so permission checks see the real destination. Without this, // `./data -> /etc/cron.d/` (live parent symlink) or // `./evil.txt -> ~/.ssh/authorized_keys2` (dangling file symlink) // would allow writes that escape the working directory. if (currentPath === path) { const resolved = resolveDeepestExistingAncestorSync(fsImpl, path) if (resolved !== undefined) { pathSet.add(resolved) } } break } const stats = fsImpl.lstatSync(currentPath) // Skip special file types that can cause issues if ( stats.isFIFO() || stats.isSocket() || stats.isCharacterDevice() || stats.isBlockDevice() ) { break } if (!stats.isSymbolicLink()) { break } // Get the immediate symlink target const target = fsImpl.readlinkSync(currentPath) // If target is relative, resolve it relative to the symlink's directory const absoluteTarget = nodePath.isAbsolute(target) ? target : nodePath.resolve(nodePath.dirname(currentPath), target) // Add this intermediate target to the set pathSet.add(absoluteTarget) currentPath = absoluteTarget } } catch { // If anything fails during chain traversal, continue with what we have } // Also add the final resolved path using realpathSync for completeness // This handles any remaining symlinks in directory components const { resolvedPath, isSymlink } = safeResolvePath(fsImpl, path) if (isSymlink && resolvedPath !== path) { pathSet.add(resolvedPath) } return Array.from(pathSet) } export const NodeFsOperations: FsOperations = { cwd() { return process.cwd() }, existsSync(fsPath) { using _ = slowLogging`fs.existsSync(${fsPath})` return fs.existsSync(fsPath) }, async stat(fsPath) { return statPromise(fsPath) }, async readdir(fsPath) { return readdirPromise(fsPath, { withFileTypes: true }) }, async unlink(fsPath) { return unlinkPromise(fsPath) }, async rmdir(fsPath) { return rmdirPromise(fsPath) }, async rm(fsPath, options) { return rmPromise(fsPath, options) }, async mkdir(dirPath, options) { try { await mkdirPromise(dirPath, { recursive: true, ...options }) } catch (e) { // Bun/Windows: recursive:true throws EEXIST on directories with the // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. // https://github.com/anthropics/claude-code/issues/30924 if (getErrnoCode(e) !== 'EEXIST') throw e } }, async readFile(fsPath, options) { return readFilePromise(fsPath, { encoding: options.encoding }) }, async rename(oldPath, newPath) { return renamePromise(oldPath, newPath) }, statSync(fsPath) { using _ = slowLogging`fs.statSync(${fsPath})` return fs.statSync(fsPath) }, lstatSync(fsPath) { using _ = slowLogging`fs.lstatSync(${fsPath})` return fs.lstatSync(fsPath) }, readFileSync(fsPath, options) { using _ = slowLogging`fs.readFileSync(${fsPath})` return fs.readFileSync(fsPath, { encoding: options.encoding }) }, readFileBytesSync(fsPath) { using _ = slowLogging`fs.readFileBytesSync(${fsPath})` return fs.readFileSync(fsPath) }, readSync(fsPath, options) { using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)` let fd: number | undefined = undefined try { fd = fs.openSync(fsPath, 'r') const buffer = Buffer.alloc(options.length) const bytesRead = fs.readSync(fd, buffer, 0, options.length, 0) return { buffer, bytesRead } } finally { if (fd) fs.closeSync(fd) } }, appendFileSync(path, data, options) { using _ = slowLogging`fs.appendFileSync(${path}, ${data.length} chars)` // For new files with explicit mode, use 'ax' (atomic create-with-mode) to avoid // TOCTOU race between existence check and open. Fall back to normal append if exists. if (options?.mode !== undefined) { try { const fd = fs.openSync(path, 'ax', options.mode) try { fs.appendFileSync(fd, data) } finally { fs.closeSync(fd) } return } catch (e) { if (getErrnoCode(e) !== 'EEXIST') throw e // File exists — fall through to normal append } } fs.appendFileSync(path, data) }, copyFileSync(src, dest) { using _ = slowLogging`fs.copyFileSync(${src} → ${dest})` fs.copyFileSync(src, dest) }, unlinkSync(path: string) { using _ = slowLogging`fs.unlinkSync(${path})` fs.unlinkSync(path) }, renameSync(oldPath: string, newPath: string) { using _ = slowLogging`fs.renameSync(${oldPath} → ${newPath})` fs.renameSync(oldPath, newPath) }, linkSync(target: string, path: string) { using _ = slowLogging`fs.linkSync(${target} → ${path})` fs.linkSync(target, path) }, symlinkSync( target: string, path: string, type?: 'dir' | 'file' | 'junction', ) { using _ = slowLogging`fs.symlinkSync(${target} → ${path})` fs.symlinkSync(target, path, type) }, readlinkSync(path: string) { using _ = slowLogging`fs.readlinkSync(${path})` return fs.readlinkSync(path) }, realpathSync(path: string) { using _ = slowLogging`fs.realpathSync(${path})` return fs.realpathSync(path).normalize('NFC') }, mkdirSync(dirPath, options) { using _ = slowLogging`fs.mkdirSync(${dirPath})` const mkdirOptions: { recursive: boolean; mode?: number } = { recursive: true, } if (options?.mode !== undefined) { mkdirOptions.mode = options.mode } try { fs.mkdirSync(dirPath, mkdirOptions) } catch (e) { // Bun/Windows: recursive:true throws EEXIST on directories with the // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. // https://github.com/anthropics/claude-code/issues/30924 if (getErrnoCode(e) !== 'EEXIST') throw e } }, readdirSync(dirPath) { using _ = slowLogging`fs.readdirSync(${dirPath})` return fs.readdirSync(dirPath, { withFileTypes: true }) }, readdirStringSync(dirPath) { using _ = slowLogging`fs.readdirStringSync(${dirPath})` return fs.readdirSync(dirPath) }, isDirEmptySync(dirPath) { using _ = slowLogging`fs.isDirEmptySync(${dirPath})` const files = this.readdirSync(dirPath) return files.length === 0 }, rmdirSync(dirPath) { using _ = slowLogging`fs.rmdirSync(${dirPath})` fs.rmdirSync(dirPath) }, rmSync(path, options) { using _ = slowLogging`fs.rmSync(${path})` fs.rmSync(path, options) }, createWriteStream(path: string) { return fs.createWriteStream(path) }, async readFileBytes(fsPath: string, maxBytes?: number) { if (maxBytes === undefined) { return readFilePromise(fsPath) } const handle = await open(fsPath, 'r') try { const { size } = await handle.stat() const readSize = Math.min(size, maxBytes) const buffer = Buffer.allocUnsafe(readSize) let offset = 0 while (offset < readSize) { const { bytesRead } = await handle.read( buffer, offset, readSize - offset, offset, ) if (bytesRead === 0) break offset += bytesRead } return offset < readSize ? buffer.subarray(0, offset) : buffer } finally { await handle.close() } }, } // The currently active filesystem implementation let activeFs: FsOperations = NodeFsOperations /** * Overrides the filesystem implementation. Note: This function does not * automatically update cwd. * @param implementation The filesystem implementation to use */ export function setFsImplementation(implementation: FsOperations): void { activeFs = implementation } /** * Gets the currently active filesystem implementation * @returns The currently active filesystem implementation */ export function getFsImplementation(): FsOperations { return activeFs } /** * Resets the filesystem implementation to the default Node.js implementation. * Note: This function does not automatically update cwd. */ export function setOriginalFsImplementation(): void { activeFs = NodeFsOperations } export type ReadFileRangeResult = { content: string bytesRead: number bytesTotal: number } /** * Read up to `maxBytes` from a file starting at `offset`. * Returns a flat string from Buffer — no sliced string references to a * larger parent. Returns null if the file is smaller than the offset. */ export async function readFileRange( path: string, offset: number, maxBytes: number, ): Promise { await using fh = await open(path, 'r') const size = (await fh.stat()).size if (size <= offset) { return null } const bytesToRead = Math.min(size - offset, maxBytes) const buffer = Buffer.allocUnsafe(bytesToRead) let totalRead = 0 while (totalRead < bytesToRead) { const { bytesRead } = await fh.read( buffer, totalRead, bytesToRead - totalRead, offset + totalRead, ) if (bytesRead === 0) { break } totalRead += bytesRead } return { content: buffer.toString('utf8', 0, totalRead), bytesRead: totalRead, bytesTotal: size, } } /** * Read the last `maxBytes` of a file. * Returns the whole file if it's smaller than maxBytes. */ export async function tailFile( path: string, maxBytes: number, ): Promise { await using fh = await open(path, 'r') const size = (await fh.stat()).size if (size === 0) { return { content: '', bytesRead: 0, bytesTotal: 0 } } const offset = Math.max(0, size - maxBytes) const bytesToRead = size - offset const buffer = Buffer.allocUnsafe(bytesToRead) let totalRead = 0 while (totalRead < bytesToRead) { const { bytesRead } = await fh.read( buffer, totalRead, bytesToRead - totalRead, offset + totalRead, ) if (bytesRead === 0) { break } totalRead += bytesRead } return { content: buffer.toString('utf8', 0, totalRead), bytesRead: totalRead, bytesTotal: size, } } /** * Async generator that yields lines from a file in reverse order. * Reads the file backwards in chunks to avoid loading the entire file into memory. * @param path - The path to the file to read * @returns An async generator that yields lines in reverse order */ export async function* readLinesReverse( path: string, ): AsyncGenerator { const CHUNK_SIZE = 1024 * 4 const fileHandle = await open(path, 'r') try { const stats = await fileHandle.stat() let position = stats.size // Carry raw bytes (not a decoded string) across chunk boundaries so that // multi-byte UTF-8 sequences split by the 4KB boundary are not corrupted. // Decoding per-chunk would turn a split sequence into U+FFFD on both sides, // which for history.jsonl means JSON.parse throws and the entry is dropped. let remainder = Buffer.alloc(0) const buffer = Buffer.alloc(CHUNK_SIZE) while (position > 0) { const currentChunkSize = Math.min(CHUNK_SIZE, position) position -= currentChunkSize await fileHandle.read(buffer, 0, currentChunkSize, position) const combined = Buffer.concat([ buffer.subarray(0, currentChunkSize), remainder, ]) const firstNewline = combined.indexOf(0x0a) if (firstNewline === -1) { remainder = combined continue } remainder = Buffer.from(combined.subarray(0, firstNewline)) const lines = combined.toString('utf8', firstNewline + 1).split('\n') for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]! if (line) { yield line } } } if (remainder.length > 0) { yield remainder.toString('utf8') } } finally { await fileHandle.close() } }