UNPKG

@modelcontextprotocol/server-filesystem

Version:
801 lines (800 loc) • 33.6 kB
// Filesystem operations anchored to their directory, so a directory swapped // out from under them cannot redirect them: every write, and every read that // returns a file's content or metadata. // // validatePath() checks a path and returns its canonical form, but the write // or read that follows resolves that path again. A directory on it swapped // for a symlink in between would redirect it outside the allowed directories, // because the kernel follows the symlink at use. Checking again afterwards // cannot close that (the racer swaps back in time), and openat() is not in // Node. The substitute here: chdir() into the directory, read back where the // process really is (getcwd() reports the directory the kernel entered, not // the string it was given), refuse unless that is the validated directory, // and only then act on the final component relative to it. The working // directory holds the directory's inode, so swapping the path afterwards // cannot move the operation. A read opens the file that way and then reads // only through the descriptor, which stays bound to the file it opened, so // the read is anchored however long it takes. Every check happens before a // byte is written or read, so nothing ever needs rolling back, except in two // moves: the cross-device move copies, and so removes its copy if a later // step fails; the same-device move can anchor only one side of each rename // (rename takes two paths), so it first moves its source to a fresh random // name inside the anchored source directory, renames only that name across, // checks it arrived by identity, and puts the source back if anything fails. // // On Windows the working directory is a string and relative paths resolve // against it, so reading it back proves nothing; there the leaf is lstat'd // relative to the anchor and must be the file the descriptor opened, which // narrows the race without closing it. import { randomBytes } from "crypto"; import fs from "fs"; import path from "path"; import { isWindowsUncRoot } from "./path-validation.js"; // Descriptor I/O, looked up on fs at call time so tests can stand in for it. function ftruncate(fd) { return new Promise((resolve, reject) => fs.ftruncate(fd, 0, (error) => (error ? reject(error) : resolve()))); } function writeAll(fd, content) { return new Promise((resolve, reject) => fs.writeFile(fd, content, "utf-8", (error) => error ? reject(error) : resolve())); } function close(fd) { return new Promise((resolve, reject) => fs.close(fd, (error) => (error ? reject(error) : resolve()))); } // Read at call time, so tests can stand in for another platform. function onWindows() { return process.platform === "win32"; } // O_NOFOLLOW is undefined on Windows; the lstat checks below stand in there. function noFollow() { return fs.constants.O_NOFOLLOW ?? 0; } function directoryChangedError(directory) { return new Error(`Access denied - a directory on the path changed during the operation: ${directory}`); } /** * Whether `pathStat` (an lstat of the path) and `handleStat` (an fstat of the * opened descriptor) describe the same file (#5154). * * Both are BigInt stats, so `ino` compares exactly: a number stat's `ino` is * a double, and NTFS file ids above 2^53 round in it, so two different files * could compare equal. * * Normally the file is the same when device and inode both match. On Windows * with libuv < 1.51.0 (the Node 23 line), a path-based stat reports `dev` 0 * while the handle reports the volume serial, so comparing devices would * refuse every overwrite. There a 0n device on the path stat is treated as * unknown rather than as a match: the file must then agree on inode, size and * modification time (in nanoseconds), and any difference refuses. It never * skips the check; a stale or changed size or time fails closed. */ function isSameEntry(pathStat, handleStat) { if (pathStat.ino !== handleStat.ino) { return false; } if (onWindows() && pathStat.dev === 0n) { return (pathStat.size === handleStat.size && pathStat.mtimeNs === handleStat.mtimeNs); } return pathStat.dev === handleStat.dev; } // The macOS Data volume, which firmlinks /Users, /private and the rest into // the root: realpath keeps a /System/Volumes/Data/... spelling it is given, // while getcwd reports the firmlinked one. const DATA_VOLUME = "/System/Volumes/Data"; function withoutDataVolume(directory) { return directory.startsWith(`${DATA_VOLUME}/`) ? directory.slice(DATA_VOLUME.length) : directory; } /** * Whether `cwd`, as getcwd() reports it, is `directory`, the validated * directory in its on-disk spelling (validatePath builds it from realpath and * readdir results). * * Exact, byte for byte, on every platform: a filesystem that compares names * byte for byte can hold an NFC and an NFD twin side by side (Linux, and an * NFS mount of one on macOS, where mount_nfs folds only when asked to), so * any normalization fold would accept the twin. The one fold is macOS's Data * volume: /System/Volumes/Data/<x> and the firmlinked /<x> name the same * directory, and the root of the sealed, read-only system volume cannot hold * a directory of the attacker's making, so folding that prefix away cannot * admit one. */ export function isSameDirectory(cwd, directory, platform = process.platform) { if (platform !== "darwin") { return cwd === directory; } return withoutDataVolume(cwd) === withoutDataVolume(directory); } /** * Whether `cwd` is the directory `name` inside `parent`, entered by a * relative chdir(name) from `parent`, the anchor already verified. The parent * compares exactly (isSameDirectory); the last component compares in NFC, * because a volume may store the name mkdir was given decomposed (HFS+). A * relative chdir resolves against the verified anchor, so the most a fold * there can admit is a twin inside that same directory. */ export function isChildDirectory(cwd, parent, name, platform = process.platform) { return (isSameDirectory(path.dirname(cwd), parent, platform) && path.basename(cwd).normalize("NFC") === name.normalize("NFC")); } /** * Check that the process is in `directory`. getcwd() is computed from the * directory actually entered, so this holds only if no directory on the path * was swapped before the chdir. */ function verifyWorkingDirectory(directory) { if (!onWindows() && !isSameDirectory(process.cwd(), directory)) { throw directoryChangedError(directory); } } /** * The error for a directory the process could not enter (#5432). Node's chdir * error names the working directory it left, which is wherever the server was * launched from and none of the client's business, and names chdir, an * implementation detail of the anchor. A missing directory (ENOENT, or * ENOTDIR for a file in its place) is the parent of the entry being written, * so it gets validatePath's message for a missing parent; anything else keeps * its code and names only the directory. The code stays on the error, so a * caller that branches on it (createDirectoryInPlace) still can. */ function cannotEnterError(directory, error) { const code = error.code; const message = code === "ENOENT" || code === "ENOTDIR" ? `Parent directory does not exist: ${directory}` : `${code ?? "Error"}: cannot enter directory: ${directory}`; return Object.assign(new Error(message), { code }); } function enterDirectory(directory) { try { process.chdir(directory); } catch (error) { throw cannotEnterError(directory, error); } verifyWorkingDirectory(directory); } /** * Run `action` with the working directory anchored at `directory`, and * restore the previous working directory afterwards. * * ⚠️ The cwd invariant, load-bearing: `action` must be synchronous, and so is * everything here between the chdir and the chdir back. The working directory * is process-wide; because no await happens while it is borrowed, no other * JavaScript in the process (another request, a path.resolve() of a relative * path) ever runs while it is, so no lock is needed and nothing else can see * it. Only what is done to an anchor-relative path inside `action` uses it; * every other path the server hands to fs is absolute. */ function inDirectory(directory, action) { let previous; try { previous = process.cwd(); } catch { // The working directory was removed; there is nothing to return to. } try { enterDirectory(directory); return action(); } finally { try { process.chdir(previous ?? path.parse(directory).root); } catch { process.chdir(path.parse(directory).root); } } } /** * Open the final component of `filePath` relative to its anchored directory. * Afterwards the anchor must still hold, at that name, the file the descriptor * opened (the check that matters on Windows, where `refuseLink` also refuses a * symlink or reparse point there before the open). */ function openInDirectory(filePath, flags, refuseLink, mode = 0o666) { const name = path.basename(filePath); return inDirectory(path.dirname(filePath), () => { if (refuseLink && fs.lstatSync(name).isSymbolicLink()) { throw new Error(`Refusing to write: not a regular file: ${filePath}`); } const fd = fs.openSync(name, flags, mode); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!isSameEntry(fs.lstatSync(name, { bigint: true }), opened)) { throw directoryChangedError(path.dirname(filePath)); } return { fd, opened }; } catch (error) { fs.closeSync(fd); throw error; } }); } /** * Create `filePath`, which must not exist, and write `content` to it. With * `append`, the descriptor is opened O_APPEND, so an append that lands on the * new file before this write does is kept rather than overwritten (#1208). */ export async function createFileInDirectory(filePath, content, append = false) { // O_EXCL: an existing file or symlink fails with EEXIST, never followed. const { fd } = openInDirectory(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (append ? fs.constants.O_APPEND : 0) | noFollow(), false); try { await writeAll(fd, content); } finally { await close(fd); } } /** * Replace the content of the regular file at `filePath`, which must be the * file `expected` (its BigInt lstat) describes, through a single descriptor. */ export async function overwriteFileInDirectory(filePath, content, expected) { // O_RDWR rather than O_WRONLY: opening a FIFO swapped in for writing only // would block until a reader arrives. O_NOFOLLOW fails the open with ELOOP // on a symlink; no O_TRUNC, so nothing changes before the checks pass. const { fd, opened } = openInDirectory(filePath, fs.constants.O_RDWR | noFollow(), onWindows()); try { if (!opened.isFile() || !isSameEntry(expected, opened)) { throw new Error(`Refusing to write: ${filePath} was replaced while it was being opened`); } await ftruncate(fd); await writeAll(fd, content); } finally { await close(fd); } } /** * Append `content` to the end of the regular file at `filePath`, which must be * the file `expected` (its BigInt lstat) describes, through a single * descriptor (#1208). The same checks as overwriteFileInDirectory guard the * open; O_APPEND makes every write land at the file's end as it is at that * moment, so a concurrent append is never overwritten. */ export async function appendFileInDirectory(filePath, content, expected) { // O_WRONLY, so a write-only file (a 0222 log) can be appended to: append // never reads. O_NONBLOCK keeps a FIFO swapped in from blocking the open // (it fails with ENXIO without a reader; a regular file ignores the flag; // it is undefined on Windows, where the lstat identity checks stand in). const { fd, opened } = openInDirectory(filePath, fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NONBLOCK ?? 0) | noFollow(), onWindows()); try { if (!opened.isFile() || !isSameEntry(expected, opened)) { throw new Error(`Refusing to append: ${filePath} was replaced while it was being opened`); } await writeAll(fd, content); } finally { await close(fd); } } /** * A fresh name to stage a rename under. It is random, so nothing at it can * have been prepared in advance, and its length is fixed (30 bytes), so it * fits a filesystem's name limit whatever the entry's own name is. */ function stagingName() { return `.mcp-${randomBytes(8).toString("hex")}.moving`; } /** * Rename `staged` to `name` without replacing anything at `name`, and say * whether it did. A hard link refuses an existing name; a directory, which * cannot be linked, is renamed only while nothing is at `name`. */ function returnStaged(staged, name) { try { fs.linkSync(staged, name); fs.unlinkSync(staged); return true; } catch { // Not linkable (a directory), or something is at `name` now. } try { fs.lstatSync(name); return false; } catch (error) { if (error.code !== "ENOENT") { return false; } } try { fs.renameSync(staged, name); return true; } catch { return false; } } /** * Rename `sourcePath` to `destinationPath`, keeping both inside their * validated directories. rename() takes two paths and Node has no * renameat(), so each rename can resolve only one side relative to an * anchor. The other side always names a fresh random name (`stagingName`), * so a directory swapped on that side's path leads to a name nothing holds, * and the rename fails, rather than to an entry that was waiting there: * * 1. Anchored at the source's directory, the source moves to a fresh name, * and its identity is read. * 2. Anchored at the destination's directory, that name moves to another * fresh name there, which must be the entry read in step 1. * 3. Still anchored, it takes the destination's name. * * If a step fails, the source goes back under its own name, and the error is * the step's own (EXDEV still sends moveFile to the cross-device copy). If it * cannot go back (something took its name in the meantime, or its directory * no longer resolves to the one validated, which is never entered), the error * says where it was left. */ export function renameIntoDirectory(sourcePath, destinationPath) { const sourceDirectory = path.dirname(sourcePath); const name = path.basename(sourcePath); const destinationDirectory = path.dirname(destinationPath); const held = stagingName(); const expected = inDirectory(sourceDirectory, () => { try { fs.renameSync(name, held); } catch (error) { throw namingPath(error, name, sourcePath); } return fs.lstatSync(held, { bigint: true }); }); const landing = stagingName(); let landed = false; try { inDirectory(destinationDirectory, () => { try { fs.renameSync(path.join(sourceDirectory, held), landing); } catch (error) { // The name was made in step 1, so a path that no longer reaches it // leads somewhere else: the source's directory was swapped. if (error.code === "ENOENT") { throw directoryChangedError(sourceDirectory); } throw error; } if (!isSameEntry(fs.lstatSync(landing, { bigint: true }), expected)) { // An entry another process put under the fresh name, after seeing // it, at the end of a swapped path: send it back the way it came. // The source is still where step 1 put it. returnStaged(landing, path.join(sourceDirectory, held)); throw directoryChangedError(sourceDirectory); } landed = true; fs.renameSync(landing, path.basename(destinationPath)); }); } catch (error) { // Named by its directory as validated: if that directory was swapped, // the entry is in the directory that was moved away, under this name. let left = landed ? `${landing} in ${destinationDirectory}` : `${held} in ${sourceDirectory}`; let restored = false; try { restored = inDirectory(sourceDirectory, () => { let staged = held; if (landed) { // The destination's directory is reached by path here, and could // have been swapped since step 3 failed, with an entry planted // under the landing name it showed. So the entry comes back under // a fresh name and must be the source before it takes the source's // name; anything else goes back the way it came. staged = stagingName(); try { fs.renameSync(path.join(destinationDirectory, landing), staged); } catch { return false; } if (!isSameEntry(fs.lstatSync(staged, { bigint: true }), expected)) { returnStaged(staged, path.join(destinationDirectory, landing)); return false; } left = `${staged} in ${sourceDirectory}`; } return returnStaged(staged, name); }); } catch { // The source's directory cannot be entered as validated. } if (restored) { throw error; } // EXDEV is dropped: it sends moveFile to copy whatever is at sourcePath // now, which is not the source. The step's error stays as the cause. const code = error.code; throw Object.assign(new Error(`${error.message}. The entry being moved could not be ` + `put back at ${sourcePath} and was left as ${left}`, { cause: error }), { code: code === "EXDEV" ? undefined : code }); } } // The cross-device move below (#5188). rename() cannot cross a device // boundary, so a move between allowed directories on different mounts copies // the file and then removes the source. Every name it touches is opened, // created or removed relative to its anchored directory, like the writes // above, and checked to be the entry it expects. // Read without following a symlink, and without blocking on a FIFO swapped // in after the lstat (a regular file ignores O_NONBLOCK). Both are undefined // on Windows, where the lstat identity checks stand in. function readFlags() { return fs.constants.O_RDONLY | noFollow() | (fs.constants.O_NONBLOCK ?? 0); } function readChunk(fd, buffer) { return new Promise((resolve, reject) => fs.read(fd, buffer, 0, buffer.length, null, (error, bytesRead) => error ? reject(error) : resolve(bytesRead))); } function writeChunk(fd, chunk) { return new Promise((resolve, reject) => fs.writeFile(fd, chunk, (error) => (error ? reject(error) : resolve()))); } // A numeric string, since fs.futimes takes a negative number to mean "now", // which would lose a time before 1970; a string it parses as given. function seconds(nanoseconds) { return String(Number(nanoseconds) / 1e9); } function keepModeAndTimes(fd, source) { return new Promise((resolve, reject) => fs.fchmod(fd, Number(source.mode & 511n), (chmodError) => chmodError ? reject(chmodError) : // In seconds from the nanosecond fields: a Date keeps only // milliseconds. fs.futimes(fd, seconds(source.atimeNs), seconds(source.mtimeNs), (timesError) => (timesError ? reject(timesError) : resolve())))); } function directoryRefusal(sourcePath, operation) { switch (operation) { case "move": return new Error(`Cannot move a directory across filesystems: ${sourcePath}. ` + "Create the directory at the destination and move its files one at a time."); case "copy": return new Error(`Cannot copy a directory: ${sourcePath}. copy_file copies one regular file; ` + "create the directory at the destination and copy its files one at a time."); case "read": // The code a read of a directory has always failed with, which callers // (resources/read) branch on. return Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${sourcePath}'`), { code: "EISDIR" }); } } function notRegularRefusal(sourcePath, operation) { switch (operation) { case "move": return new Error(`Cannot move across filesystems: not a regular file: ${sourcePath}`); case "copy": return new Error(`Cannot copy: not a regular file: ${sourcePath}`); case "read": return new Error(`Cannot read: not a regular file: ${sourcePath}`); } } /** * An errno error from a call on the anchor-relative `name` names that bare * name; name `filePath` instead, as the call on the absolute path would have. */ function namingPath(error, name, filePath) { const errno = error; if (error instanceof Error && errno.path === name) { error.message = error.message.replace(`'${name}'`, `'${filePath}'`); errno.path = filePath; } return error; } /** * Open the regular file at `sourcePath` for reading, relative to its anchored * directory. A directory, a symlink or anything else that is not a regular * file is refused: a symlink is not followed or recreated, since its target * could name anything, a directory is not copied recursively, and a FIFO or * device is never read, so a read cannot block on one. `operation` names the * caller in those refusals: a cross-device move, copy_file, or a read. */ function openSourceInDirectory(sourcePath, operation) { const name = path.basename(sourcePath); return inDirectory(path.dirname(sourcePath), () => { let found; let fd; try { found = fs.lstatSync(name, { bigint: true }); if (found.isDirectory()) { throw directoryRefusal(sourcePath, operation); } if (!found.isFile()) { throw notRegularRefusal(sourcePath, operation); } fd = fs.openSync(name, readFlags()); } catch (error) { throw namingPath(error, name, sourcePath); } try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || !isSameEntry(found, opened)) { throw new Error(`Refusing to ${operation}: ${sourcePath} was replaced while it was being opened`); } return { fd, opened }; } catch (error) { fs.closeSync(fd); throw error; } }); } /** * Check that `filePath`, relative to its anchored directory, is still the * entry `expected` describes, and fail with `message` if it is not. */ function confirmInDirectory(filePath, expected, message) { const name = path.basename(filePath); inDirectory(path.dirname(filePath), () => { let found; try { found = fs.lstatSync(name, { bigint: true }); } catch (error) { if (error.code !== "ENOENT") { throw error; } } if (!found || !isSameEntry(found, expected)) { throw new Error(message); } }); } /** * Remove `filePath`, relative to its anchored directory, only while it is * still the entry `expected` describes. */ function removeFromDirectory(filePath, expected, snapshot) { const name = path.basename(filePath); inDirectory(path.dirname(filePath), () => { const found = fs.lstatSync(name, { bigint: true }); if (!isSameEntry(found, expected)) { throw new Error(`Refusing to remove: ${filePath} was replaced during the move`); } // The same file, written to in place after it was copied: removing it // would lose what was written, so the copy is the one that goes. if (snapshot && (found.size !== snapshot.size || found.mtimeNs !== snapshot.mtimeNs || found.ctimeNs !== snapshot.ctimeNs)) { throw new Error(`Refusing to remove: ${filePath} changed during the move`); } fs.unlinkSync(name); }); } /** Remove a copy this move created, keeping the error that made it necessary. */ function discardCopy(destinationPath, created) { try { removeFromDirectory(destinationPath, created); } catch { // Left in place; the error that made the move fail is the one to report. } } /** * Create `destinationPath`, which must not exist, as a copy of the file open * at `sourceFd`, with its mode and times. A copy that fails is removed. */ async function copyIntoNewFile(sourceFd, source, destinationPath) { // O_EXCL: an existing file or symlink fails with EEXIST, never followed. const { fd, opened } = openInDirectory(destinationPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow(), false, // Owner-only until the copy is complete and takes the source's mode, so // a private file is never readable at the destination in between. 0o600); // Where isSameEntry falls back to size and modification time (see there), // the identity from the create goes stale as the copy is written, so it is // read again from the descriptor before that closes. let identity = opened; try { try { const buffer = Buffer.alloc(64 * 1024); for (;;) { const bytesRead = await readChunk(sourceFd, buffer); if (bytesRead === 0) { break; } await writeChunk(fd, buffer.subarray(0, bytesRead)); } await keepModeAndTimes(fd, source); } finally { try { identity = fs.fstatSync(fd, { bigint: true }); } catch { // Keep the identity from the create; the close reports the fault. } await close(fd); } } catch (error) { discardCopy(destinationPath, identity); throw error; } return identity; } /** * Move the regular file at `sourcePath` to `destinationPath`, which must not * exist, where the two are on different devices: copy it into a newly created * file, then remove the source. If any step fails, the copy is removed and the * source is left where it was. */ export async function moveFileAcrossDevices(sourcePath, destinationPath) { const { fd, opened: source } = openSourceInDirectory(sourcePath, "move"); // Set once the copy is complete; until then copyIntoNewFile cleans up. let created; try { try { created = await copyIntoNewFile(fd, source, destinationPath); } finally { await close(fd); } // The source goes only while the copy is still where the move put it. confirmInDirectory(destinationPath, created, `Refusing to remove the source: ${destinationPath} was removed or replaced during the move`); removeFromDirectory(sourcePath, source, source); } catch (error) { if (created) { discardCopy(destinationPath, created); } throw error; } } /** * Copy the regular file at `sourcePath` to `destinationPath`, which must not * exist (#519): the copy half of the cross-device move above, with the same * checks. The source is opened relative to its anchored directory and must be * a regular file; the destination is created exclusively relative to its own, * owner-only until it is complete, and then takes the source's permissions and * times. A copy that fails is removed. The source is never changed. */ export async function copyFileInDirectory(sourcePath, destinationPath) { const { fd, opened: source } = openSourceInDirectory(sourcePath, "copy"); // Set once the copy is complete; until then copyIntoNewFile cleans up. let created; try { try { created = await copyIntoNewFile(fd, source, destinationPath); } finally { await close(fd); } } catch (error) { // A failure after the copy completed (closing the source) still fails // the call, so the copy goes too, as the move's does. if (created) { discardCopy(destinationPath, created); } throw error; } } // Reads. The file is opened as the copy source above // is, and everything read from it is read through that one descriptor. function readWhole(fd) { return new Promise((resolve, reject) => fs.readFile(fd, (error, data) => (error ? reject(error) : resolve(data)))); } function readAt(fd, buffer, offset, length, position) { return new Promise((resolve, reject) => fs.read(fd, buffer, offset, length, position, (error, bytesRead) => error ? reject(error) : resolve({ bytesRead }))); } /** * Open the regular file at `filePath` for reading, relative to its anchored * directory, refusing anything else (see openSourceInDirectory). The caller * reads through the result and closes it. */ export async function openFileForReading(filePath) { const { fd, opened } = openSourceInDirectory(filePath, "read"); return { size: Number(opened.size), read: (buffer, offset, length, position) => readAt(fd, buffer, offset, length, position), close: () => close(fd), }; } /** * Read the whole of the regular file at `filePath`, opened relative to its * anchored directory. */ export async function readFileInDirectory(filePath) { const { fd } = openSourceInDirectory(filePath, "read"); try { return await readWhole(fd); } finally { await close(fd); } } /** * The lstat of `filePath`, taken relative to its anchored directory, so it * describes the entry validation resolved rather than whatever a swapped * directory now leads to. validatePath returns a resolved path, so its last * component is not a symlink, and this reports what stat() would. A * filesystem root has no directory above it to swap, and is lstat'd as is. */ export function statInDirectory(filePath) { const name = path.basename(filePath); // A root (`/`, `C:\`, a UNC share) has no directory above it to anchor at. if (name === "" || (onWindows() && isWindowsUncRoot(filePath))) { return fs.lstatSync(filePath); } return inDirectory(path.dirname(filePath), () => { try { return fs.lstatSync(name); } catch (error) { throw namingPath(error, name, filePath); } }); } /** * Create `directory` and any missing parents, like mkdir -p: anchor at the * deepest ancestor that exists, then step down one component at a time, * creating it relative to the anchor, entering it relative to the anchor, and * checking where the process landed. Anything other than a directory in the * way fails with EEXIST, as mkdir -p reports it. */ export function createDirectoryInPlace(directory) { const root = path.parse(directory).root; inDirectory(root, () => { const missing = []; let current = directory; for (;;) { try { enterDirectory(current); break; } catch (error) { const code = error.code; if (code !== "ENOENT" && code !== "ENOTDIR") { throw error; } } missing.unshift(path.basename(current)); current = path.dirname(current); } for (const name of missing) { let inTheWay; try { fs.mkdirSync(name); } catch (error) { if (error.code !== "EEXIST") { throw error; } inTheWay = error; } try { process.chdir(name); } catch (error) { // A file in the way: POSIX reports ENOTDIR, Windows ENOENT. const code = error.code; if (inTheWay && (code === "ENOTDIR" || code === "ENOENT")) { throw inTheWay; } throw error; } if (!onWindows()) { const entered = process.cwd(); if (!isChildDirectory(entered, current, name)) { throw directoryChangedError(path.join(current, name)); } // The next step's parent, in the spelling the volume stored. current = entered; } else { current = path.join(current, name); } } }); }