UNPKG

@modelcontextprotocol/server-filesystem

Version:
984 lines (983 loc) • 48 kB
import fs from "fs/promises"; import path from "path"; import { StringDecoder } from "string_decoder"; import { createTwoFilesPatch } from "diff"; import { minimatch } from "minimatch"; import { normalizePathFormat, expandHome } from "./path-utils.js"; import { isPathWithinAllowedDirectories } from "./path-validation.js"; import { assertWritable } from "./read-only-directories.js"; import { appendFileInDirectory, createDirectoryInPlace, createFileInDirectory, moveFileAcrossDevices, copyFileInDirectory, openFileForReading, overwriteFileInDirectory, readFileInDirectory, renameIntoDirectory, statInDirectory, } from "./pinned-directory.js"; // Bounds on the filesystem calls a request can wait on, and on the recursive // walks (#4162). A path on a lazily materialized provider (a macOS // CloudStorage or iCloud Drive folder, a slow network mount) can make // realpath or readdir block for minutes while the provider fetches metadata, // and a recursive walk over a large tree had no upper bound, so one request // could stall the server past the client's own timeout. // // They are constants, not options: 15 s is far beyond a local call (which // takes milliseconds), is the bound the issue's reporter ran for months against // Google Drive and iCloud Drive trees without a misfire, and still answers // before the 60 s default request timeout of the MCP SDK clients. 100,000 entries is far more than a // result a model can use, and a walk that reaches it is told to narrow the path // or add excludePatterns. // // A timeout only stops waiting. Node cannot cancel an fs call already in // flight, so the stalled call keeps one of libuv's threadpool threads (4 by // default) until the provider answers, and four stalled calls at once delay // every other fs call in the process until one returns. The timeout still // answers the request that hit it with an error instead of hanging. export const FS_CALL_TIMEOUT_MS = 15_000; export const MAX_WALK_ENTRIES = 100_000; /** A filesystem call that did not settle within FS_CALL_TIMEOUT_MS. */ export class FsTimeoutError extends Error { constructor(operation, target) { super(`Filesystem call timed out after ${FS_CALL_TIMEOUT_MS} ms: ${operation} ${target}. ` + "The path may be on a cloud-storage or network provider that fetches it on demand; " + "try a narrower path, or exclude it with excludePatterns."); this.name = "FsTimeoutError"; } } /** A recursive walk that reached MAX_WALK_ENTRIES before it finished. */ export class WalkLimitError extends Error { constructor(operation, root, limit) { super(`${operation} stopped after visiting ${limit} entries under ${root}, without finishing. ` + "Narrow the path, or add excludePatterns for large subtrees."); this.name = "WalkLimitError"; } } // Races an fs call against FS_CALL_TIMEOUT_MS. The losing call is not // cancelled (see above); Promise.race keeps a handler on it, so a late // rejection is still handled. async function withFsTimeout(call, operation, target) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new FsTimeoutError(operation, target)), FS_CALL_TIMEOUT_MS); }); try { return await Promise.race([call, timeout]); } finally { clearTimeout(timer); } } /** * fs.realpath, bounded by FS_CALL_TIMEOUT_MS. A timeout rejects with * FsTimeoutError, which path validation treats as a refusal: a path whose real * location could not be read in time is never admitted. */ export function realpathWithTimeout(target) { return withFsTimeout(fs.realpath(target), "realpath", target); } /** fs.readdir with file types, bounded by FS_CALL_TIMEOUT_MS. */ export function readdirWithTimeout(target) { return withFsTimeout(fs.readdir(target, { withFileTypes: true }), "readdir", target); } /** * A counter for a recursive walk: each call records one visited entry and * throws WalkLimitError once the walk has visited more than MAX_WALK_ENTRIES. */ export function createEntryBudget(operation, root) { let visited = 0; return () => { visited++; if (visited > MAX_WALK_ENTRIES) { throw new WalkLimitError(operation, root, MAX_WALK_ENTRIES); } }; } // Pure Utility Functions export function formatSize(bytes) { const units = ["B", "KB", "MB", "GB", "TB"]; if (bytes <= 0) return "0 B"; const i = Math.floor(Math.log(bytes) / Math.log(1024)); if (i < 0 || i === 0) return `${bytes} ${units[0]}`; const unitIndex = Math.min(i, units.length - 1); return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`; } export function normalizeLineEndings(text) { return text.replace(/\r\n/g, "\n"); } export function createUnifiedDiff(originalContent, newContent, filepath = "file") { // Ensure consistent line endings for diff const normalizedOriginal = normalizeLineEndings(originalContent); const normalizedNew = normalizeLineEndings(newContent); return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, "original", "modified"); } // Helper function to resolve relative paths against allowed directories function resolveRelativePathAgainstAllowedDirectories(relativePath, allowedDirectories) { if (allowedDirectories.length === 0) { // Fallback to process.cwd() if no allowed directories are set return path.resolve(process.cwd(), relativePath); } // Try to resolve relative path against each allowed directory for (const allowedDir of allowedDirectories) { const candidate = path.resolve(allowedDir, relativePath); const normalizedCandidate = normalizePathFormat(candidate); // Check if the resulting path lies within any allowed directory if (isPathWithinAllowedDirectories(normalizedCandidate, allowedDirectories)) { return candidate; } } // If no valid resolution found, use the first allowed directory as base // This provides a consistent fallback behavior return path.resolve(allowedDirectories[0], relativePath); } // Security & Validation Functions // The form two path components are compared in when the exact spelling is not // on disk (#1970): canonically equivalent spellings (NFC/NFD, as macOS stores // "é" or Japanese dakuten) match, and so do the Unicode space separators // (U+00A0, the U+202F macOS puts before "PM" in a screenshot's name, ...), // which a client typing the name back almost always sends as U+0020. function unicodeMatchKey(component) { return component.normalize("NFC").replace(/\p{Zs}/gu, " "); } // An allowed directory whose own name is spelled differently on disk (NFD) // from the request (NFC) fails the allow-list check, which compares strings // (#1970). Map such a request onto the allowed directory's own spelling, so // the result is inside that directory by construction and every later check // still runs against the real allow-list. A request that does not spell an // allowed directory is returned unchanged and is refused as before. function respellAllowedDirectoryPrefix(absolutePath, allowedDirectories) { const requestedParts = absolutePath.split(path.sep).filter(Boolean); const candidates = allowedDirectories .map((directory) => path.resolve(directory)) .sort((left, right) => right.length - left.length); for (const directory of candidates) { const directoryParts = directory.split(path.sep).filter(Boolean); if (directoryParts.length <= requestedParts.length && directoryParts.every((part, index) => unicodeMatchKey(part) === unicodeMatchKey(requestedParts[index]))) { return path.join(directory, ...requestedParts.slice(directoryParts.length)); } } return absolutePath; } // The form two components are compared in when only their letter case may // differ (#470). Folding alone proves nothing: whether two spellings name one // entry is the file system's call, and namesOneEntry asks it. function caseMatchKey(component) { return unicodeMatchKey(component).toLowerCase(); } // Whether `requested` names the same entry of `parent` as `configured`, an // allowed directory's own component. Asked of the file system rather than // folded, because NTFS and APFS directories can be case-sensitive, and there a // name differing only in case is another entry, or none (#470). Both // conditions must hold: `parent` holds exactly one entry matching the request // case-insensitively, and the two spellings lstat to the same device and // inode. Any error, a missing name included, answers no, so the request is // refused as before. async function namesOneEntry(parent, requested, configured) { try { const key = caseMatchKey(requested); const entries = (await withFsTimeout(fs.readdir(parent), "readdir", parent)) ?? []; if (entries.filter((entry) => caseMatchKey(entry) === key).length !== 1) { return false; } // Bounded like every request-path fs call (#4162); a timeout lands in the // catch below and answers no, so the request is refused. const requestedPath = path.join(parent, requested); const configuredPath = path.join(parent, configured); const requestedStats = await withFsTimeout(fs.lstat(requestedPath, { bigint: true }), "lstat", requestedPath); const configuredStats = await withFsTimeout(fs.lstat(configuredPath, { bigint: true }), "lstat", configuredPath); return (requestedStats.dev === configuredStats.dev && requestedStats.ino === configuredStats.ino); } catch { return false; } } // An allowed directory whose own name the request spells in another letter // case, matching neither the configured nor the on-disk spelling, fails the // allow-list check, which compares strings (#470). Like the Unicode respelling // above, map such a request onto the allowed directory's own spelling, but // only where every component that differs in case is confirmed, segment by // segment, by namesOneEntry. The result is inside that directory by // construction, and every later check still runs against the real allow-list. async function respellAllowedDirectoryCase(absolutePath, allowedDirectories) { const requestedRoot = path.parse(absolutePath).root; const requestedParts = absolutePath .slice(requestedRoot.length) .split(path.sep) .filter(Boolean); const candidates = allowedDirectories .map((directory) => path.resolve(directory)) .sort((left, right) => right.length - left.length); for (const directory of candidates) { const root = path.parse(directory).root; const directoryParts = directory .slice(root.length) .split(path.sep) .filter(Boolean); // A root is "/" off Windows; on Windows its drive letter or UNC server // and share are case-insensitive by definition, so folding it is safe. if (caseMatchKey(root) !== caseMatchKey(requestedRoot) || directoryParts.length > requestedParts.length || !directoryParts.every((part, index) => caseMatchKey(part) === caseMatchKey(requestedParts[index]))) { continue; } let parent = root; let confirmed = true; for (let index = 0; index < directoryParts.length && confirmed; index++) { const part = directoryParts[index]; const requestedPart = requestedParts[index]; confirmed = unicodeMatchKey(part) === unicodeMatchKey(requestedPart) || (await namesOneEntry(parent, requestedPart, part)); parent = path.join(parent, part); } if (confirmed) { return path.join(directory, ...requestedParts.slice(directoryParts.length)); } } return absolutePath; } // Whether a directory entry of this name exists, without following it. async function entryExists(entryPath) { try { await fs.lstat(entryPath); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } } async function resolveUnicodeEquivalentPath(absolutePath, allowedDirectories) { const allowedDirectory = [...allowedDirectories] .sort((left, right) => right.length - left.length) .find((directory) => isPathWithinAllowedDirectories(normalizePathFormat(absolutePath), [ directory, ])); /* v8 ignore next -- validatePath calls this only after the same allow-list check passed, so a containing directory always exists */ if (!allowedDirectory) { return absolutePath; } let currentPath = await realpathWithTimeout(allowedDirectory); // An allowed directory can be a symlink (the advertised form of a Root or a // command-line directory), retargeted since it was allowed, so its realpath // is checked like every component below it (#5183). if (!isPathWithinAllowedDirectories(normalizePathFormat(currentPath), allowedDirectories)) { throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(", ")}`); } const relativeParts = path .relative(allowedDirectory, absolutePath) .split(path.sep) .filter(Boolean); for (let index = 0; index < relativeParts.length; index++) { const requestedPart = relativeParts[index]; const entries = (await withFsTimeout(fs.readdir(currentPath), "readdir", currentPath)) ?? []; const exactMatch = entries.find((entry) => entry === requestedPart); const equivalentMatches = exactMatch ? [exactMatch] : entries.filter((entry) => unicodeMatchKey(entry) === unicodeMatchKey(requestedPart)); if (equivalentMatches.length > 1) { throw new Error(`Ambiguous Unicode path component: ${requestedPart}`); } // When no entry matches by spelling, the requested name still goes to the // OS as it is: on a case-insensitive file system (macOS and Windows by // default) it can name an existing entry spelled with other letter case, // and that entry can be a symlink that leaves the allowed directories. So // it is resolved and checked like a match, and only a name that does not // exist at all (lstat, so a dangling symlink still counts as existing) // starts the missing tail. const candidate = path.join(currentPath, equivalentMatches[0] ?? requestedPart); if (equivalentMatches.length === 0 && !(await entryExists(candidate))) { // Nothing below this point exists yet, so there are no symlinks left to // resolve. currentPath is already realpath'd and inside an allowed // directory; append the missing tail so create_directory can mkdir -p it. return path.join(currentPath, ...relativeParts.slice(index)); } try { currentPath = await realpathWithTimeout(candidate); } catch (error) { // The entry exists (lstat found it just above) but realpath cannot // follow it, so it is a symlink whose target is missing. Name it: a bare // ENOENT reaches validatePath's catch, which would report its parent, // a directory that plainly exists, as missing (#5191). if (error.code === "ENOENT") { throw new Error(`Broken symlink: ${candidate} points to a target that does not exist`, { cause: error }); } throw error; } if (!isPathWithinAllowedDirectories(normalizePathFormat(currentPath), allowedDirectories)) { throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(", ")}`); } } return currentPath; } export async function validatePath(requestedPath, allowedDirectories, access, readOnlyDirectories = []) { const resolved = await resolveAllowedPath(requestedPath, allowedDirectories); if (access !== "read") { assertWritable(resolved, readOnlyDirectories, access); } return resolved; } // The allow-list half of validatePath: the requested path resolved to its real // location and checked to be inside the allowed directories. async function resolveAllowedPath(requestedPath, allowedDirectories) { const expandedPath = expandHome(requestedPath); // Do not silently reinterpret a Windows drive path as a relative POSIX path. // This would create a literal filename such as `C:\\Users\\...` inside the // allowed root and report success for the wrong location. if (process.platform !== "win32" && /^(?:[A-Za-z]:)(?:[\\/]|$)/.test(expandedPath)) { throw new Error(`Access denied - Windows-style path received on a POSIX host: ${requestedPath}`); } let absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : resolveRelativePathAgainstAllowedDirectories(expandedPath, allowedDirectories); if (!isPathWithinAllowedDirectories(normalizePathFormat(absolute), allowedDirectories)) { absolute = respellAllowedDirectoryPrefix(absolute, allowedDirectories); if (!isPathWithinAllowedDirectories(normalizePathFormat(absolute), allowedDirectories)) { absolute = await respellAllowedDirectoryCase(absolute, allowedDirectories); } } const normalizedRequested = normalizePathFormat(absolute); // Security: Check if path is within allowed directories before any file operations const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories); if (!isAllowed) { throw new Error(allowedDirectories.length === 0 ? `Access denied - no allowed directories: the client exposes no valid roots (${absolute})` : `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(", ")}`); } // Security: Handle symlinks by checking their real path to prevent symlink attacks // This prevents attackers from creating symlinks that point outside allowed directories try { const realPath = await realpathWithTimeout(absolute); const normalizedReal = normalizePathFormat(realPath); if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) { throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(", ")}`); } return realPath; } catch (error) { // Security: For new files that don't exist yet, verify parent directory // This ensures we can't create files in unauthorized locations if (error.code === "ENOENT") { try { return await resolveUnicodeEquivalentPath(absolute, allowedDirectories); } catch (resolutionError) { if (resolutionError.code === "ENOENT") { throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`, { cause: resolutionError }); } throw resolutionError; } } throw error; } } // File Operations // // Every read below, of content or of metadata, goes through // pinned-directory.ts, as every write does: the path was validated before it // runs, and a directory on it swapped for a symlink since then must not // redirect the read outside the allowed directories (TOCTOU). export async function getFileStats(filePath) { const stats = statInDirectory(filePath); return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), // The link count: more than 1 means a file has other names, possibly // outside the allowed directories, that a write here also changes. hardLinks: stats.nlink, }; } export async function readFileContent(filePath, encoding = "utf-8") { return (await readFileInDirectory(filePath)).toString(encoding); } export async function writeFileContent(filePath, content) { try { // Security: exclusive creation fails if a file or symlink exists, so a // pre-existing symlink is never written through; the directory is pinned // (see pinned-directory.ts), so a swapped one is never written through. await createFileInDirectory(filePath, content); } catch (error) { if (error.code === "EEXIST") { await overwriteInPlace(filePath, content); } else { throw error; } } } /** * Replace an existing file's content in place, through a single handle. * * Writing through the existing file, rather than renaming a temp file over it, * keeps its identity: the inode, creation time (birthtime), hard links, * permission bits and anything watching it by inode all survive (#4512). It * also never renames over the target, which on Windows fails with EPERM when * another process holds the file open without FILE_SHARE_DELETE (#3199). * * Security: the path was validated before this runs, so something swapped in * since then must not be written through (TOCTOU): * - lstat refuses anything at the path that is not a regular file, a symlink * included; * - the open happens inside the pinned directory (pinned-directory.ts), so a * directory on the path swapped for a symlink cannot redirect it; * - O_NOFOLLOW makes the open itself fail with ELOOP on a symlink (POSIX * only), and the opened handle must be the very file lstat saw (same device * and inode, compared as BigInts so a 64-bit file id cannot round; where * Windows reports no device for the path, same inode, size and mtime: see * isSameEntry in pinned-directory.ts, #5154), which catches a swap between * the lstat and the open. Once the handle is verified, every write goes * through it. * * The trade-off is crash-atomicity: a crash mid-write leaves a partly written * file, where the rename left either the old content or the new. */ async function overwriteInPlace(filePath, content) { const expected = await fs.lstat(filePath, { bigint: true }); if (!expected.isFile()) { throw new Error(`Refusing to write: not a regular file: ${filePath}`); } await overwriteFileInDirectory(filePath, content, expected); } /** * Append `content` to the file at `filePath`, exactly as given: no newline is * added before or after it (#1208). A file that does not exist yet is created * with `content`, the way write_file creates one. * * Security: the same path as writeFileContent. Exclusive creation never * follows a symlink; an existing entry must be a regular file, and is appended * to through a handle checked to be that very file (see appendFileInDirectory * in pinned-directory.ts), so nothing swapped in after validation is written * through. */ export async function appendFileContent(filePath, content) { try { // O_APPEND here too: another append can reach the file between its // creation and this write, and must not be overwritten. await createFileInDirectory(filePath, content, true); return; } catch (error) { if (error.code !== "EEXIST") { throw error; } } const expected = await fs.lstat(filePath, { bigint: true }); if (!expected.isFile()) { throw new Error(`Refusing to append: not a regular file: ${filePath}`); } await appendFileInDirectory(filePath, content, expected); } /** Create a directory and any missing parents (see pinned-directory.ts). */ export async function createDirectory(directoryPath) { createDirectoryInPlace(directoryPath); } export async function moveFile(sourcePath, destinationPath) { // The move_file tool contract (and README) state the operation fails if the // destination already exists. fs.rename would silently overwrite it, which is // a data-loss bug, so reject up front when anything - file, directory, or // symlink - occupies the target. lstat is used so an existing symlink at the // destination is detected rather than followed. try { await fs.lstat(destinationPath); } catch (error) { if (error.code === "ENOENT") { // The destination's directory is pinned (see pinned-directory.ts). try { renameIntoDirectory(sourcePath, destinationPath); } catch (renameError) { // rename cannot cross a device boundary: copy, then remove (#5188). if (renameError.code !== "EXDEV") { throw renameError; } await moveFileAcrossDevices(sourcePath, destinationPath); } return; } throw error; } throw new Error(`Destination already exists: ${destinationPath}`); } /** * Copy the regular file at `sourcePath` to `destinationPath` (#519). Like * move_file, it never overwrites: anything at the destination (a file, a * directory or a symlink, which lstat sees rather than follows) fails it, and * the exclusive create in copyFileInDirectory refuses one that appears in * between. Directories are not copied. */ export async function copyFile(sourcePath, destinationPath) { try { await fs.lstat(destinationPath); } catch (error) { if (error.code === "ENOENT") { await copyFileInDirectory(sourcePath, destinationPath); return; } throw error; } throw new Error(`Destination already exists: ${destinationPath}`); } /** * The line ending a file predominantly uses: CRLF when more of its line breaks * are CRLF than bare LF, otherwise LF. A file with mixed endings is written * back with its predominant one. */ export function detectLineEnding(text) { const crlf = text.match(/\r\n/g)?.length ?? 0; const lf = (text.match(/\n/g)?.length ?? 0) - crlf; return crlf > lf ? "\r\n" : "\n"; } function leadingWhitespace(line) { return line.slice(0, line.length - line.trimStart().length); } /** * The indentation unit of some lines: a tab when any indented line starts * with one, otherwise the narrowest run of leading spaces, or "" when no line * is indented. */ function indentUnit(lines) { let narrowest = Infinity; for (const line of lines) { const indent = leadingWhitespace(line); if (indent === "" || line.trim() === "") continue; if (indent.includes("\t")) return "\t"; narrowest = Math.min(narrowest, indent.length); } return narrowest === Infinity ? "" : " ".repeat(narrowest); } /** * Reindents one replacement line for the whitespace-tolerant matcher: it gets * the indentation of `fileLine` (the file line it replaces), plus or minus the * difference between its own indentation and `oldLine`'s (the oldText line * that matched `fileLine`). A dedent never goes below no indentation. A blank * line is kept as given. * * When the edit and the file indent with different characters (tabs in one, * spaces in the other), the difference is counted in indent levels of the * edit's unit and applied in the file's unit, so four spaces of extra * indent in the edit become one tab in a tab-indented file. */ function reindentLine(line, fileLine, oldLine, editUnit, fileUnit) { const body = line.trimStart(); if (body === "") return line; const fileIndent = leadingWhitespace(fileLine); const newIndent = leadingWhitespace(line); const delta = newIndent.length - leadingWhitespace(oldLine).length; if (editUnit && fileUnit && (editUnit === "\t") !== (fileUnit === "\t")) { const levels = Math.trunc(delta / editUnit.length); const indent = levels >= 0 ? fileIndent + fileUnit.repeat(levels) : fileIndent.slice(0, Math.max(0, fileIndent.length + levels * fileUnit.length)); return indent + body; } const indent = delta >= 0 ? fileIndent + newIndent.slice(newIndent.length - delta) : fileIndent.slice(0, Math.max(0, fileIndent.length + delta)); return indent + body; } // What the no-match error shows (#2034). A model that sent oldText which // differs from the file in a way the matcher does not tolerate (whitespace // inside a line, a typo, stale content) needs the file's real text to retry // with, so the error quotes the closest run of lines with their line // numbers. Finding it compares oldText against the lines at every position // of the file, so it is skipped past a fixed budget of bigram lookups rather // than stalling the error on a huge file or a huge oldText. const MISMATCH_WORK_LIMIT = 10_000_000; const MISMATCH_CONTEXT_LINES = 2; const MISMATCH_MAX_REGION_LINES = 30; // A quoted file line is cut at this many characters, so a long line (minified // code, one-line JSON) cannot blow the error up to the size of the file. const MISMATCH_MAX_LINE_CHARS = 500; /** A line's character bigrams with all whitespace removed, for similarity. */ function lineGrams(line) { const text = line.replace(/\s+/g, ""); const grams = new Map(); if (text.length === 1) grams.set(text, 1); for (let i = 0; i < text.length - 1; i++) { const gram = text.slice(i, i + 2); grams.set(gram, (grams.get(gram) ?? 0) + 1); } let size = 0; for (const count of grams.values()) size += count; return { grams, size }; } /** Dice similarity of two lines' bigrams: 1 for two blank lines, 0 for nothing shared. */ function lineSimilarity(a, b) { if (a.size + b.size === 0) return 1; let shared = 0; for (const [gram, count] of a.grams) { shared += Math.min(count, b.grams.get(gram) ?? 0); } return (2 * shared) / (a.size + b.size); } /** * The error message for an edit that matched nowhere in `content` (the file * as the earlier edits in the same call left it). It names the edit, says * what the matcher tolerates, and quotes the run of file lines most similar * to oldText, numbered, with the first line that differs shown quoted so * whitespace is visible. It only reports: it never changes what matches. */ export function describeEditMismatch(content, oldText, editIndex, editCount) { const oldLines = normalizeLineEndings(oldText).split("\n"); // The lines as the matcher sees them, so a terminal newline in oldText // lines up with the file's. The empty element after a terminal newline is // not a line of the file, so it is left out of what is counted and shown. const fileLines = content.split("\n"); const lineCount = content.endsWith("\n") ? fileLines.length - 1 : content === "" ? 0 : fileLines.length; const parts = [ `Could not find exact match for edit ${editIndex + 1} of ${editCount}. ` + "oldText must match the file's text exactly; only line endings, and " + "leading and trailing whitespace on whole lines, may differ. " + "No edits were applied.", `oldText:\n${oldText}`, ]; if (lineCount === 0) { parts.push("The file is empty."); return parts.join("\n\n"); } const oldGrams = oldLines.map(lineGrams); let oldWork = 0; for (const line of oldGrams) oldWork += line.grams.size + 1; if (fileLines.length * oldWork > MISMATCH_WORK_LIMIT) { parts.push(`The file has ${lineCount} lines, too many to search for the closest ` + "text to this oldText. Read the file and copy oldText from it exactly."); return parts.join("\n\n"); } // Every starting line is scored, including those near the end where fewer // lines than oldText has remain, so a near match at the end of the file is // still found. The earliest start wins a tie. A start is always a real // line, never the empty element after a terminal newline. const fileGrams = fileLines.map(lineGrams); let best = 0; let bestScore = 0; for (let i = 0; i < lineCount; i++) { const width = Math.min(oldLines.length, fileLines.length - i); let score = 0; for (let j = 0; j < width; j++) { score += lineSimilarity(oldGrams[j], fileGrams[i + j]); } if (score > bestScore) { best = i; bestScore = score; } } if (bestScore === 0) { parts.push("No line of the file resembles oldText. Read the file and copy " + "oldText from it exactly."); return parts.join("\n\n"); } const start = Math.min(Math.max(0, best - MISMATCH_CONTEXT_LINES), lineCount - 1); const end = Math.min(lineCount, best + oldLines.length + MISMATCH_CONTEXT_LINES, start + MISMATCH_MAX_REGION_LINES); let clipped = false; const clip = (line) => { if (line.length <= MISMATCH_MAX_LINE_CHARS) return line; clipped = true; return (line.slice(0, MISMATCH_MAX_LINE_CHARS) + ` [... cut: the line has ${line.length} characters]`); }; const numbered = fileLines .slice(start, end) .map((line, k) => `${start + k + 1}: ${clip(line)}`); const after = editIndex > 0 ? ", after the earlier edits in this call" : ""; parts.push(`Closest text in the file (lines ${start + 1}-${end} of ` + `${lineCount}${after}):\n${numbered.join("\n")}`); // Some line differs beyond leading and trailing whitespace, or oldText runs // past the file's end: otherwise the line-by-line matcher would have // matched here. Math.max only keeps the index a valid one. const differs = Math.max(0, oldLines.findIndex((line, j) => best + j >= lineCount || line.trim() !== fileLines[best + j].trim())); parts.push(best + differs >= lineCount ? `oldText line ${differs + 1} (${JSON.stringify(clip(oldLines[differs]))}) ` + `runs past the end of the file, which ends at line ${lineCount}.` : `First difference, oldText line ${differs + 1} against file line ` + `${best + differs + 1}:\n` + ` oldText: ${JSON.stringify(clip(oldLines[differs]))}\n` + ` file: ${JSON.stringify(clip(fileLines[best + differs]))}`); parts.push(clipped ? "Some lines above were cut. Read the file to copy oldText from it " + "exactly." : "Retry with oldText copied exactly from the file, without the " + "line-number prefixes."); return parts.join("\n\n"); } export async function applyFileEdits(filePath, edits, dryRun = false) { // Read file content and normalize line endings, remembering which ending // the file uses so the edited file is written back with it (#4991). const rawContent = await readFileContent(filePath); const eol = detectLineEnding(rawContent); const content = normalizeLineEndings(rawContent); // Apply edits sequentially let modifiedContent = content; for (const [index, edit] of edits.entries()) { const normalizedOld = normalizeLineEndings(edit.oldText); const normalizedNew = normalizeLineEndings(edit.newText); // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) { modifiedContent = modifiedContent.replace(normalizedOld, () => normalizedNew); continue; } // Otherwise, try line-by-line matching with flexibility for whitespace. // A trailing newline ends oldText's last line rather than adding an // empty line after it (#5431), so it is dropped from the lines compared, // and the match must instead be followed by a newline in the file, as // exact matching would require. newText's trailing newline is handled to // match: the newline after the matched lines stays when newText also // ends with one; otherwise newText's last line joins the line after the // match, as an exact replacement would leave it. const oldEndsLine = normalizedOld.endsWith("\n"); const newEndsLine = normalizedNew.endsWith("\n"); const oldLines = (oldEndsLine ? normalizedOld.slice(0, -1) : normalizedOld).split("\n"); const replacementLines = (oldEndsLine && newEndsLine ? normalizedNew.slice(0, -1) : normalizedNew).split("\n"); const contentLines = modifiedContent.split("\n"); // With a trailing newline, the last matched line needs an element after // it in contentLines: that is what shows the file's newline is there. const lastStart = contentLines.length - oldLines.length - (oldEndsLine ? 1 : 0); let matchFound = false; for (let i = 0; i <= lastStart; i++) { const potentialMatch = contentLines.slice(i, i + oldLines.length); // Compare lines with normalized whitespace const isMatch = oldLines.every((oldLine, j) => { const contentLine = potentialMatch[j]; return oldLine.trim() === contentLine.trim(); }); if (isMatch) { // Each replacement line takes the indentation of the file line it // replaces, shifted by however much the edit indents or dedents it // relative to the matching oldText line (#4990). Lines beyond // oldText's length are placed relative to its last line. const editUnit = indentUnit([...oldLines, ...replacementLines]); const fileUnit = indentUnit(contentLines); const newLines = replacementLines.map((line, j) => { const k = Math.min(j, oldLines.length - 1); return reindentLine(line, potentialMatch[k], oldLines[k], editUnit, fileUnit); }); if (oldEndsLine && !newEndsLine) { // oldText took the newline after the matched lines and newText // does not put one back, so newText's last line runs into the line // that followed the match. An empty newText just removes the lines. newLines[newLines.length - 1] += contentLines[i + oldLines.length]; contentLines.splice(i, oldLines.length + 1, ...newLines); } else { contentLines.splice(i, oldLines.length, ...newLines); } modifiedContent = contentLines.join("\n"); matchFound = true; break; } } if (!matchFound) { throw new Error(describeEditMismatch(modifiedContent, edit.oldText, index, edits.length)); } } // Create unified diff const diff = createUnifiedDiff(content, modifiedContent, filePath); // Write the file back with the line ending it was read with (#4991). if (eol === "\r\n") { modifiedContent = modifiedContent.replace(/\n/g, "\r\n"); } // Format diff with appropriate number of backticks let numBackticks = 3; while (diff.includes("`".repeat(numBackticks))) { numBackticks++; } const formattedDiff = `${"`".repeat(numBackticks)}diff\n${diff}${"`".repeat(numBackticks)}\n\n`; if (!dryRun) { await overwriteInPlace(filePath, modifiedContent); } return formattedDiff; } // Memory-efficient implementation to get the last N lines of a file export async function tailFile(filePath, numLines) { const CHUNK_SIZE = 1024; // Read 1KB at a time // Open file for reading; its size is the opened file's. const fileHandle = await openFileForReading(filePath); try { const fileSize = fileHandle.size; // Zero lines is an empty result; slice(-0) below would return every line. if (fileSize === 0 || numLines === 0) return ""; const chunks = []; let position = fileSize; const chunk = Buffer.alloc(CHUNK_SIZE); let newlinesFound = 0; // A final newline ends the last line rather than starting an empty one, // so it is not one of the N line breaks the loop needs to see. Set from // the file's last byte on the first read (CRLF ends in 0x0a too). let newlinesNeeded = numLines; // Read chunks from the end of the file until the start of the first of // the last N lines is in hand while (position > 0 && newlinesFound < newlinesNeeded) { const size = Math.min(CHUNK_SIZE, position); const atEnd = position === fileSize; position -= size; const { bytesRead } = await fileHandle.read(chunk, 0, size, position); if (!bytesRead) break; const readData = Buffer.from(chunk.subarray(0, bytesRead)); if (atEnd && readData[bytesRead - 1] === 0x0a) newlinesNeeded++; chunks.unshift(readData); for (const byte of readData) { if (byte === 0x0a) newlinesFound++; } } // Drop one trailing newline so it does not count as an empty last line, // and so the result ends the way headFile's does: without one. const text = normalizeLineEndings(Buffer.concat(chunks).toString("utf-8")).replace(/\n$/, ""); return text.split("\n").slice(-numLines).join("\n"); } finally { await fileHandle.close(); } } // New function to get the first N lines of a file export async function headFile(filePath, numLines) { const fileHandle = await openFileForReading(filePath); try { const lines = []; let buffer = ""; let bytesRead = 0; const chunk = Buffer.alloc(1024); // 1KB buffer const decoder = new StringDecoder("utf-8"); // Read chunks and count lines until we have enough or reach EOF while (lines.length < numLines) { const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); if (result.bytesRead === 0) break; // End of file bytesRead += result.bytesRead; buffer += decoder.write(chunk.subarray(0, result.bytesRead)); const newLineIndex = buffer.lastIndexOf("\n"); if (newLineIndex !== -1) { const completeLines = buffer.slice(0, newLineIndex).split("\n"); buffer = buffer.slice(newLineIndex + 1); for (const line of completeLines) { // Each of these lines ended in "\n"; drop the "\r" of a CRLF ending // so head normalizes line endings the way tailFile does. A "\r" // whose "\n" is still in the next chunk stays in the buffer until // that "\n" arrives, so a pair split across chunks is handled too. lines.push(line.endsWith("\r") ? line.slice(0, -1) : line); if (lines.length >= numLines) break; } } } buffer += decoder.end(); // If there is leftover content and we still need lines, add it if (buffer.length > 0 && lines.length < numLines) { lines.push(buffer); } return lines.join("\n"); } finally { await fileHandle.close(); } } // Whether an entry is excluded by an `excludePatterns` list, given its path // relative to the root of the walk. `directory_tree` and `search_files` take the // same argument, so they share this one predicate and read it the same way // (#5216). A pattern containing `*` is a glob matched against the relative path // as written. A bare name also matches an entry of that name at any depth // (`**/<name>`) and everything below it (`**/<name>/**`), so `["node_modules"]` // prunes every `node_modules` directory, not only one at the root. Names are // matched whole: `node_modules` does not exclude `node_modules_old`. // // A wildcard pattern ending in `/**` also excludes the directory it names // (#5445): minimatch matches `**/node_modules/**` only against paths below a // `node_modules`, never the directory entry itself, so without this the walk // would still resolve and read the directory and only drop its children. The // pattern is matched a second time with that suffix removed (`**/node_modules`), // for directory entries only: a file is not a directory a pattern can name, so a // file called `build` stays under `**/build/**`, and `index.js` under `*/**`. // // Callers check it before validating an entry, so an excluded subtree is never // realpath'd or read (#4162). export function isExcludedPath(relativePath, excludePatterns, isDirectory = false) { return excludePatterns.some((pattern) => { if (pattern.includes("*")) { return (minimatch(relativePath, pattern, { dot: true }) || (isDirectory && pattern.endsWith("/**") && minimatch(relativePath, pattern.slice(0, -"/**".length), { dot: true, }))); } return (minimatch(relativePath, pattern, { dot: true }) || minimatch(relativePath, `**/${pattern}`, { dot: true }) || minimatch(relativePath, `**/${pattern}/**`, { dot: true })); }); } export async function searchFilesWithValidation(rootPath, pattern, allowedDirectories, options = {}) { const { excludePatterns = [], onUnreadable } = options; const results = []; const visit = createEntryBudget("search_files", rootPath); async function search(currentPath) { let entries; try { entries = await readdirWithTimeout(currentPath); } catch (error) { // The starting path failing is the request failing, and a timeout ends // the whole search (see FsTimeoutError). A subdirectory that cannot be // read is skipped, but reported to the caller, so the result never reads // as complete when it is not (#5189). if (currentPath === rootPath || error instanceof FsTimeoutError) { throw error; } onUnreadable?.(currentPath, error); return; } for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); // Excludes are matched on the name before the entry is validated, so an // excluded subtree is never realpath'd or read (#4162): excluding a slow // provider folder keeps the walk from touching it at all. const relativePath = path.relative(rootPath, fullPath); if (isExcludedPath(relativePath, excludePatterns, entry.isDirectory())) continue; visit(); // Only validation is guarded: an entry refused by validatePath (outside // the allowed directories, a dangling link) is skipped silently. A // timeout still ends the whole search rather than returning partial // results as if they were complete. try { await validatePath(fullPath, allowedDirectories, "read"); } catch (error) { if (error instanceof FsTimeoutError) { throw error; } continue; } // Use glob matching for the search pattern if (minimatch(relativePath, pattern, { dot: true })) { results.push(fullPath); } if (entry.isDirectory()) { await search(fullPath); } } } await search(rootPath); return results; }