@modelcontextprotocol/server-filesystem
Version:
MCP server for filesystem access
801 lines (800 loc) • 33.6 kB
JavaScript
// 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);
}
}
});
}