current commit
261025.7 · vfs: fix readlink, realpath and mkdtemp encoding
'use strict';

const {
  MathRandom,
  ObjectFreeze,
  StringPrototypeLastIndexOf,
  StringPrototypeSlice,
  StringPrototypeStartsWith,
  Symbol,
  SymbolDispose,
} = primordials;

const {
  codes: {
    ERR_INVALID_STATE,
  },
} = require('internal/errors');
const { MemoryProvider } = require('internal/vfs/providers/memory');
const path = require('path');
const { posix: pathPosix, resolve: resolvePath, sep, toNamespacedPath } = path;
const { join: joinPath } = pathPosix;
const {
  getLayerRoot,
  getRelativePath,
  getVfsRoot,
} = require('internal/vfs/router');
const {
  openVirtualFd,
  getVirtualFd,
  closeVirtualFd,
} = require('internal/vfs/fd');
const {
  createENOENT,
  createEBADF,
  createEBUSY,
  createEISDIR,
} = require('internal/vfs/errors');
const { encodeRealpathResult, getOptions } = require('internal/fs/utils');
const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams');
const { VirtualDir } = require('internal/vfs/dir');
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
const { validateBoolean } = require('internal/validators');
let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => {
  debug = fn;
});

const kProvider = Symbol('kProvider');
const kMountPoint = Symbol('kMountPoint');
// Cached normalizeMountedPath(kMountPoint) so the hot dispatch path
// does not recompute toNamespacedPath(resolve(...)) on every fs call.
const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
const kMounted = Symbol('kMounted');
const kPromises = Symbol('kPromises');
const kLayerId = Symbol('kLayerId');
const kReservedRoot = Symbol('kReservedRoot');
const kLoadLayer = Symbol('kLoadLayer');

// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
// at the same mount point in every thread whatever else a thread mounts, and a
// path into it stays valid in a worker. Everything else is numbered from 1, in
// the order it is mounted.
const kLoadLayerId = 0;
let nextLayerId = kLoadLayerId + 1;

/**
 * Normalizes paths for mount-point comparisons. On Windows, `path.resolve('/x')`
 * yields `C:\x`, while loader internals often pass namespaced paths like
 * `\\?\C:\x`. Compare using a single canonical representation so POSIX-style
 * rooted test paths, drive-absolute paths, and namespaced paths all route to the
 * same VFS mount.
 * @param {string} inputPath
 * @returns {string}
 */
function normalizeMountedPath(inputPath) {
  return toNamespacedPath(resolvePath(inputPath));
}

const kTempChars =
  'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

function randomSuffix() {
  let suffix = '';
  for (let i = 0; i < 6; i++) {
    suffix += kTempChars[(MathRandom() * kTempChars.length) | 0];
  }
  return suffix;
}

// The root of a file system is its mount point, and like any mount point it
// cannot be removed or renamed, nor replaced by a rename.
function checkNotRoot(providerPath, syscall, path) {
  if (providerPath === '/') throw createEBUSY(syscall, path);
}

let registerVFS;
let deregisterVFS;

function loadVfsSetup() {
  if (!registerVFS) {
    const setup = require('internal/vfs/setup');
    registerVFS = setup.registerVFS;
    deregisterVFS = setup.deregisterVFS;
  }
}

/**
 * Virtual File System implementation using Provider architecture.
 * Wraps a Provider and exposes an fs-like API operating on
 * provider-relative paths.
 */
class VirtualFileSystem {
  /**
   * @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options
   * @param {object} [options] Configuration options
   */
  constructor(providerOrOptions, options = kEmptyObject) {
    let provider = null;
    if (providerOrOptions !== undefined && providerOrOptions !== null) {
      if (typeof providerOrOptions.openSync === 'function') {
        provider = providerOrOptions;
      } else if (typeof providerOrOptions === 'object') {
        options = providerOrOptions;
        provider = null;
      }
    }

    if (options.emitExperimentalWarning !== undefined) {
      validateBoolean(options.emitExperimentalWarning, 'options.emitExperimentalWarning');
    }

    if (options.emitExperimentalWarning !== false) {
      emitExperimentalWarning('VirtualFileSystem');
    }

    this[kProvider] = provider ?? new MemoryProvider();
    this[kPromises] = null;
    if (options[kReservedRoot] === true) {
      // Serves the reserved root directory itself. It is not a layer, so it
      // takes no layer id and leaves the numbering of real mounts alone.
      const root = getVfsRoot();
      this[kMountPoint] = root;
      this[kNormalizedMountPoint] = normalizeMountedPath(root);
      this[kMounted] = true;
      this[kLayerId] = -1;
      return;
    }
    this[kMountPoint] = null;
    this[kNormalizedMountPoint] = null;
    this[kMounted] = false;
    this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
  }

  /**
   * Gets the underlying provider.
   * @returns {VirtualProvider}
   */
  get provider() {
    return this[kProvider];
  }

  /**
   * Gets the mount point path, or null if not mounted.
   * @returns {string|null}
   */
  get mountPoint() {
    return this[kMountPoint];
  }

  /**
   * Gets the mount point as a `file:` URL string, or null if not
   * mounted.
   * @returns {string|null}
   */
  get mountPointURL() {
    if (this[kMountPoint] === null) return null;
    const { pathToFileURL } = require('internal/url');
    return pathToFileURL(this[kMountPoint]).href;
  }

  /**
   * Returns true if VFS is mounted.
   * @returns {boolean}
   */
  get mounted() {
    return this[kMounted];
  }

  /**
   * Returns true if the provider is read-only.
   * @returns {boolean}
   */
  get readonly() {
    return this[kProvider].readonly;
  }

  /**
   * Mounts the VFS at its reserved mount point and returns that
   * mount point. The mount point is `${os.devNull}/vfs/<layerId>` -
   * a location that cannot exist on the real file system (`os.devNull`
   * is a character device on POSIX and a device-namespace path on
   * Windows; neither can have child filesystem entries) - so virtual
   * paths never conflate with real paths, and the owning layer of any
   * path is decidable from the path alone.
   * @returns {string} The absolute mount point
   */
  mount() {
    if (this[kMounted]) {
      throw new ERR_INVALID_STATE('VFS is already mounted');
    }
    const mountPoint = getLayerRoot(this[kLayerId]);
    this[kMountPoint] = mountPoint;
    this[kNormalizedMountPoint] = normalizeMountedPath(mountPoint);
    this[kMounted] = true;
    debug('mount %s', mountPoint);
    loadVfsSetup();
    registerVFS(this);
    return mountPoint;
  }

  /**
   * Unmounts the VFS.
   */
  unmount() {
    debug('unmount %s', this[kMountPoint]);
    loadVfsSetup();
    deregisterVFS(this);
    this[kMountPoint] = null;
    this[kNormalizedMountPoint] = null;
    this[kMounted] = false;
  }

  /**
   * Disposes of the VFS by unmounting it.
   * Supports the Explicit Resource Management proposal (using declaration).
   */
  [SymbolDispose]() {
    if (this[kMounted]) {
      this.unmount();
    }
  }

  /**
   * Returns true when `normalizedInput` falls inside this VFS's
   * reserved mount point. The caller must have already passed the
   * input through `normalizeMountedPath`; the dispatch loop in
   * `internal/vfs/setup.js` normalizes once and hands the same value
   * to every candidate layer.
   * @param {string} normalizedInput
   * @returns {boolean}
   */
  shouldHandleNormalized(normalizedInput) {
    const mountPoint = this[kNormalizedMountPoint];
    if (mountPoint === null) return false;
    if (normalizedInput === mountPoint) return true;
    return StringPrototypeStartsWith(normalizedInput, mountPoint + sep);
  }

  /**
   * Converts an absolute mounted path to a provider-relative POSIX path.
   * If not mounted, treats the path as already provider-relative.
   * @param {string} inputPath The path to convert
   * @returns {string}
   */
  #toProviderPath(inputPath) {
    const mountPoint = this[kNormalizedMountPoint];
    if (mountPoint !== null) {
      const resolved = normalizeMountedPath(inputPath);
      if (!this.shouldHandleNormalized(resolved)) {
        throw createENOENT('open', inputPath);
      }
      return getRelativePath(resolved, mountPoint);
    }
    return pathPosix.normalize(inputPath);
  }

  /**
   * Converts a provider-relative path back to a mounted path.
   * If not mounted, returns the path as-is.
   * @param {string} providerPath The provider-relative path
   * @returns {string} The mounted path
   */
  #toMountedPath(providerPath) {
    if (this[kMounted] && this[kMountPoint]) {
      // path.join() would keep the trailing separator of the provider root.
      if (providerPath === '/') return this[kMountPoint];
      return path.join(this[kMountPoint], providerPath);
    }
    return providerPath;
  }

  /**
   * Checks if a path exists synchronously.
   * @param {string} filePath The path to check
   * @returns {boolean}
   */
  existsSync(filePath) {
    try {
      const providerPath = this.#toProviderPath(filePath);
      return this[kProvider].existsSync(providerPath);
    } catch {
      return false;
    }
  }

  /**
   * Gets stats for a path synchronously.
   * @param {string} filePath The path to stat
   * @param {object} [options] Options
   * @returns {Stats}
   */
  statSync(filePath, options) {
    const providerPath = this.#toProviderPath(filePath);
    return this[kProvider].statSync(providerPath, options);
  }

  /**
   * Gets stats for a path synchronously without following symlinks.
   * @param {string} filePath The path to stat
   * @param {object} [options] Options
   * @returns {Stats}
   */
  lstatSync(filePath, options) {
    const providerPath = this.#toProviderPath(filePath);
    return this[kProvider].lstatSync(providerPath, options);
  }

  /**
   * Reads a file synchronously.
   * @param {string} filePath The path to read
   * @param {object|string} [options] Options or encoding
   * @returns {Buffer|string}
   */
  readFileSync(filePath, options) {
    const providerPath = this.#toProviderPath(filePath);
    return this[kProvider].readFileSync(providerPath, options);
  }

  /**
   * Writes a file synchronously.
   * @param {string} filePath The path to write
   * @param {Buffer|string} data The data to write
   * @param {object} [options] Options
   */
  writeFileSync(filePath, data, options) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].writeFileSync(providerPath, data, options);
  }

  /**
   * Appends to a file synchronously.
   * @param {string} filePath The path to append to
   * @param {Buffer|string} data The data to append
   * @param {object} [options] Options
   */
  appendFileSync(filePath, data, options) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].appendFileSync(providerPath, data, options);
  }

  /**
   * Reads directory contents synchronously.
   * @param {string} dirPath The directory path
   * @param {object} [options] Options
   * @returns {string[]|Dirent[]}
   */
  readdirSync(dirPath, options) {
    const providerPath = this.#toProviderPath(dirPath);
    const result = this[kProvider].readdirSync(providerPath, options);
    return this.#toMountedDirents(dirPath, result, options);
  }

  /**
   * Rewrites the Dirents of a listing of `dirPath` from provider-relative to
   * VFS paths, so that each `parentPath` is the directory the entry is in.
   * @param {string} dirPath The listed directory, as given by the caller
   * @param {string[]|Dirent[]} result The provider's listing
   * @param {object} [options] The readdir options
   * @returns {string[]|Dirent[]}
   */
  #toMountedDirents(dirPath, result, options) {
    if (options?.withFileTypes !== true) return result;
    const recursive = options?.recursive === true;
    // A mounted VFS is addressed by host paths, so, like fs, join with the
    // host's separator; an unmounted one uses POSIX paths throughout.
    const join = this[kMounted] ? path.join : joinPath;
    for (let i = 0; i < result.length; i++) {
      const dirent = result[i];
      dirent.parentPath = dirPath;
      if (recursive) {
        // In recursive mode, name may contain slashes (e.g. 'a/b.txt').
        const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
        if (slashIdx !== -1) {
          const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
          dirent.parentPath = join(dirPath, subdir);
          dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
        }
      }
    }
    return result;
  }

  /**
   * Creates a directory synchronously.
   * @param {string} dirPath The directory path
   * @param {object} [options] Options
   * @returns {string|undefined}
   */
  mkdirSync(dirPath, options) {
    const providerPath = this.#toProviderPath(dirPath);
    const created = this[kProvider].mkdirSync(providerPath, options);
    return created === undefined ? undefined : this.#toMountedPath(created);
  }

  /**
   * Removes a directory synchronously.
   * @param {string} dirPath The directory path
   */
  rmdirSync(dirPath) {
    const providerPath = this.#toProviderPath(dirPath);
    checkNotRoot(providerPath, 'rmdir', dirPath);
    this[kProvider].rmdirSync(providerPath);
  }

  /**
   * Removes a file synchronously.
   * @param {string} filePath The file path
   */
  unlinkSync(filePath) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].unlinkSync(providerPath);
  }

  /**
   * Renames a file or directory synchronously.
   * @param {string} oldPath The old path
   * @param {string} newPath The new path
   */
  renameSync(oldPath, newPath) {
    const oldProviderPath = this.#toProviderPath(oldPath);
    const newProviderPath = this.#toProviderPath(newPath);
    checkNotRoot(oldProviderPath, 'rename', oldPath);
    checkNotRoot(newProviderPath, 'rename', newPath);
    this[kProvider].renameSync(oldProviderPath, newProviderPath);
  }

  /**
   * Copies a file synchronously.
   * @param {string} src Source path
   * @param {string} dest Destination path
   * @param {number} [mode] Copy mode flags
   */
  copyFileSync(src, dest, mode) {
    const srcProviderPath = this.#toProviderPath(src);
    const destProviderPath = this.#toProviderPath(dest);
    this[kProvider].copyFileSync(srcProviderPath, destProviderPath, mode);
  }

  /**
   * Gets the real path by resolving all symlinks.
   * @param {string} filePath The path
   * @param {object|string} [options] Options or encoding
   * @returns {string|Buffer}
   */
  realpathSync(filePath, options) {
    options = getOptions(options);
    const providerPath = this.#toProviderPath(filePath);
    const realProviderPath = this[kProvider].realpathSync(providerPath);
    return encodeRealpathResult(this.#toMountedPath(realProviderPath), options);
  }

  /**
   * Reads the target of a symbolic link.
   * @param {string} linkPath The symlink path
   * @param {object|string} [options] Options or encoding
   * @returns {string|Buffer}
   */
  readlinkSync(linkPath, options) {
    options = getOptions(options);
    const providerPath = this.#toProviderPath(linkPath);
    return encodeRealpathResult(this[kProvider].readlinkSync(providerPath), options);
  }

  /**
   * Creates a symbolic link.
   * @param {string} target The symlink target
   * @param {string} path The symlink path
   * @param {string} [type] The symlink type
   */
  symlinkSync(target, path, type) {
    const providerPath = this.#toProviderPath(path);
    this[kProvider].symlinkSync(target, providerPath, type);
  }

  /**
   * Checks file accessibility synchronously.
   * @param {string} filePath The path to check
   * @param {number} [mode] Access mode
   */
  accessSync(filePath, mode) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].accessSync(providerPath, mode);
  }

  /**
   * Removes a file or directory synchronously.
   * @param {string} filePath The path to remove
   * @param {object} [options] Options
   * @param {boolean} [options.recursive] If true, remove directories recursively
   * @param {boolean} [options.force] If true, ignore ENOENT errors
   */
  rmSync(filePath, options) {
    const recursive = options?.recursive === true;
    const force = options?.force === true;

    let stats;
    try {
      stats = this.lstatSync(filePath);
    } catch (err) {
      if (force && err?.code === 'ENOENT') return;
      throw err;
    }

    // Symlinks should be unlinked directly, never recursed into
    if (stats.isSymbolicLink()) {
      this.unlinkSync(filePath);
      return;
    }

    if (stats.isDirectory()) {
      if (!recursive) {
        throw createEISDIR('rm', filePath);
      }
      const entries = this.readdirSync(filePath);
      for (let i = 0; i < entries.length; i++) {
        this.rmSync(joinPath(filePath, entries[i]), options);
      }
      this.rmdirSync(filePath);
    } else {
      this.unlinkSync(filePath);
    }
  }

  /**
   * Truncates a file synchronously.
   * @param {string} filePath The file path
   * @param {number} [len] The new length
   */
  truncateSync(filePath, len = 0) {
    if (len < 0) len = 0;
    const providerPath = this.#toProviderPath(filePath);
    const handle = this[kProvider].openSync(providerPath, 'r+');
    try {
      handle.truncateSync(len);
    } finally {
      handle.closeSync();
    }
  }

  /**
   * Truncates a file descriptor synchronously.
   * @param {number} fd The file descriptor
   * @param {number} [len] The new length
   */
  ftruncateSync(fd, len = 0) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      throw createEBADF('ftruncate');
    }
    vfd.entry.truncateSync(len);
  }

  /**
   * Creates a hard link synchronously.
   * @param {string} existingPath The existing file path
   * @param {string} newPath The new link path
   */
  linkSync(existingPath, newPath) {
    const existingProviderPath = this.#toProviderPath(existingPath);
    const newProviderPath = this.#toProviderPath(newPath);
    this[kProvider].linkSync(existingProviderPath, newProviderPath);
  }

  chmodSync(filePath, mode) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].chmodSync(providerPath, mode);
  }

  chownSync(filePath, uid, gid) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].chownSync(providerPath, uid, gid);
  }

  lchownSync(filePath, uid, gid) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].lchownSync(providerPath, uid, gid);
  }

  utimesSync(filePath, atime, mtime) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].utimesSync(providerPath, atime, mtime);
  }

  lutimesSync(filePath, atime, mtime) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].lutimesSync(providerPath, atime, mtime);
  }

  /**
   * Creates a unique temporary directory synchronously.
   * @param {string} prefix The prefix for the temp directory
   * @returns {string} The full path of the created directory
   */
  mkdtempSync(prefix) {
    const dirPath = this.#toProviderPrefix(prefix) + randomSuffix();
    this[kProvider].mkdirSync(dirPath);
    return this.#toMountedPath(dirPath);
  }

  /**
   * Creates and opens a unique temporary file synchronously.
   * @param {string} prefix The prefix for the temp file
   * @returns {{ path: string, fd: number }} The full path and a file descriptor
   */
  mkstempSync(prefix) {
    const filePath = this.#toProviderPrefix(prefix) + randomSuffix();
    const handle = this[kProvider].openSync(filePath, 'wx+', 0o600);
    return { path: this.#toMountedPath(filePath), fd: openVirtualFd(handle) };
  }

  /**
   * Converts a mkdtemp or mkstemp prefix to a provider-relative one, keeping
   * a trailing separator.
   * @param {string} prefix The mounted prefix
   * @returns {string}
   */
  #toProviderPrefix(prefix) {
    const last = prefix[prefix.length - 1];
    const trailing = last === '/' || last === sep;
    const providerPrefix = this.#toProviderPath(prefix);
    if (!trailing) return providerPrefix;
    return providerPrefix === '/' ? '/' : `${providerPrefix}/`;
  }

  /**
   * Opens a directory synchronously.
   * @param {string} dirPath The directory path
   * @param {object} [options] Options
   * @returns {VirtualDir} A directory handle
   */
  opendirSync(dirPath, options) {
    const entries = this.readdirSync(dirPath, {
      withFileTypes: true,
      recursive: options?.recursive,
    });
    return new VirtualDir(dirPath, entries);
  }

  /**
   * Opens a file as a Blob.
   * @param {string} filePath The file path
   * @param {object} [options] Options
   * @returns {Blob} The file content as a Blob
   */
  openAsBlob(filePath, options) {
    const { Blob } = require('buffer');
    const providerPath = this.#toProviderPath(filePath);
    const content = this[kProvider].readFileSync(providerPath);
    const type = options?.type || '';
    return new Blob([content], { type });
  }

  /**
   * Opens a file synchronously and returns a file descriptor.
   * @param {string} filePath The path to open
   * @param {string} [flags] Open flags
   * @param {number} [mode] File mode
   * @returns {number} The file descriptor
   */
  openSync(filePath, flags = 'r', mode) {
    const providerPath = this.#toProviderPath(filePath);
    const handle = this[kProvider].openSync(providerPath, flags, mode);
    return openVirtualFd(handle);
  }

  /**
   * Closes a file descriptor synchronously.
   * @param {number} fd The file descriptor
   */
  closeSync(fd) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      throw createEBADF('close');
    }
    vfd.entry.closeSync();
    closeVirtualFd(fd);
  }

  /**
   * Reads from a file descriptor synchronously.
   * @param {number} fd The file descriptor
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position in the file
   * @returns {number} The number of bytes read
   */
  readSync(fd, buffer, offset, length, position) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      throw createEBADF('read');
    }
    return vfd.entry.readSync(buffer, offset, length, position);
  }

  /**
   * Writes to a file descriptor synchronously.
   * @param {number} fd The file descriptor
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position in the file
   * @returns {number} The number of bytes written
   */
  writeSync(fd, buffer, offset, length, position) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      throw createEBADF('write');
    }
    return vfd.entry.writeSync(buffer, offset, length, position);
  }

  /**
   * Gets file stats from a file descriptor synchronously.
   * @param {number} fd The file descriptor
   * @param {object} [options] Options
   * @returns {Stats}
   */
  fstatSync(fd, options) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      throw createEBADF('fstat');
    }
    return vfd.entry.statSync(options);
  }

  /**
   * Reads a file asynchronously.
   * @param {string} filePath The path to read
   * @param {object|string|Function} [options] Options, encoding, or callback
   * @param {Function} [callback] Callback (err, data)
   */
  readFile(filePath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    this[kProvider].readFile(this.#toProviderPath(filePath), options)
      .then((data) => callback(null, data), (err) => callback(err));
  }

  /**
   * Writes a file asynchronously.
   * @param {string} filePath The path to write
   * @param {Buffer|string} data The data to write
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err)
   */
  writeFile(filePath, data, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    this[kProvider].writeFile(this.#toProviderPath(filePath), data, options)
      .then(() => callback(null), (err) => callback(err));
  }

  /**
   * Gets stats for a path asynchronously.
   * @param {string} filePath The path to stat
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, stats)
   */
  stat(filePath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    this[kProvider].stat(this.#toProviderPath(filePath), options)
      .then((stats) => callback(null, stats), (err) => callback(err));
  }

  /**
   * Gets stats without following symlinks asynchronously.
   * @param {string} filePath The path to stat
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, stats)
   */
  lstat(filePath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    this[kProvider].lstat(this.#toProviderPath(filePath), options)
      .then((stats) => callback(null, stats), (err) => callback(err));
  }

  /**
   * Reads directory contents asynchronously.
   * @param {string} dirPath The directory path
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, entries)
   */
  readdir(dirPath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    this[kProvider].readdir(this.#toProviderPath(dirPath), options)
      .then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
            (err) => callback(err));
  }

  /**
   * Gets the real path asynchronously.
   * @param {string} filePath The path
   * @param {object|string|Function} [options] Options, encoding, or callback
   * @param {Function} [callback] Callback (err, resolvedPath)
   */
  realpath(filePath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    options = getOptions(options);

    this[kProvider].realpath(this.#toProviderPath(filePath))
      .then((realPath) => callback(null, encodeRealpathResult(this.#toMountedPath(realPath), options)),
            (err) => callback(err));
  }

  /**
   * Reads symlink target asynchronously.
   * @param {string} linkPath The symlink path
   * @param {object|string|Function} [options] Options, encoding, or callback
   * @param {Function} [callback] Callback (err, target)
   */
  readlink(linkPath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    options = getOptions(options);

    this[kProvider].readlink(this.#toProviderPath(linkPath))
      .then((target) => callback(null, encodeRealpathResult(target, options)), (err) => callback(err));
  }

  /**
   * Checks file accessibility asynchronously.
   * @param {string} filePath The path to check
   * @param {number|Function} [mode] Access mode or callback
   * @param {Function} [callback] Callback (err)
   */
  access(filePath, mode, callback) {
    if (typeof mode === 'function') {
      callback = mode;
      mode = undefined;
    }

    this[kProvider].access(this.#toProviderPath(filePath), mode)
      .then(() => callback(null), (err) => callback(err));
  }

  /**
   * Opens a file asynchronously.
   * @param {string} filePath The path to open
   * @param {string|Function} [flags] Open flags or callback
   * @param {number|Function} [mode] File mode or callback
   * @param {Function} [callback] Callback (err, fd)
   */
  open(filePath, flags, mode, callback) {
    if (typeof flags === 'function') {
      callback = flags;
      flags = 'r';
      mode = undefined;
    } else if (typeof mode === 'function') {
      callback = mode;
      mode = undefined;
    }

    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].open(providerPath, flags, mode)
      .then((handle) => {
        const fd = openVirtualFd(handle);
        callback(null, fd);
      }, (err) => callback(err));
  }

  /**
   * Closes a file descriptor asynchronously.
   * @param {number} fd The file descriptor
   * @param {Function} callback Callback (err)
   */
  close(fd, callback) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      process.nextTick(callback, createEBADF('close'));
      return;
    }

    vfd.entry.close()
      .then(() => {
        closeVirtualFd(fd);
        callback(null);
      }, (err) => callback(err));
  }

  /**
   * Reads from a file descriptor asynchronously.
   * @param {number} fd The file descriptor
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position in the file
   * @param {Function} callback Callback (err, bytesRead, buffer)
   */
  read(fd, buffer, offset, length, position, callback) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      process.nextTick(callback, createEBADF('read'));
      return;
    }

    vfd.entry.read(buffer, offset, length, position)
      .then(({ bytesRead }) => callback(null, bytesRead, buffer), (err) => callback(err));
  }

  /**
   * Writes to a file descriptor asynchronously.
   * @param {number} fd The file descriptor
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position in the file
   * @param {Function} callback Callback (err, bytesWritten, buffer)
   */
  write(fd, buffer, offset, length, position, callback) {
    const vfd = getVirtualFd(fd);
    if (!vfd) {
      process.nextTick(callback, createEBADF('write'));
      return;
    }

    vfd.entry.write(buffer, offset, length, position)
      .then(({ bytesWritten }) => callback(null, bytesWritten, buffer), (err) => callback(err));
  }

  /**
   * Removes a file or directory asynchronously.
   * @param {string} filePath The path to remove
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err)
   */
  rm(filePath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    try {
      this.rmSync(filePath, options);
      process.nextTick(callback, null);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Gets file stats from a file descriptor asynchronously.
   * @param {number} fd The file descriptor
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, stats)
   */
  fstat(fd, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    const vfd = getVirtualFd(fd);
    if (!vfd) {
      process.nextTick(callback, createEBADF('fstat'));
      return;
    }

    vfd.entry.stat(options)
      .then((stats) => callback(null, stats), (err) => callback(err));
  }

  /**
   * Truncates a file asynchronously.
   * @param {string} filePath The file path
   * @param {number|Function} [len] The new length or callback
   * @param {Function} [callback] Callback (err)
   */
  truncate(filePath, len, callback) {
    if (typeof len === 'function') {
      callback = len;
      len = 0;
    }
    try {
      this.truncateSync(filePath, len);
      process.nextTick(callback, null);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Truncates a file descriptor asynchronously.
   * @param {number} fd The file descriptor
   * @param {number|Function} [len] The new length or callback
   * @param {Function} [callback] Callback (err)
   */
  ftruncate(fd, len, callback) {
    if (typeof len === 'function') {
      callback = len;
      len = 0;
    }
    try {
      this.ftruncateSync(fd, len);
      process.nextTick(callback, null);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Creates a hard link asynchronously.
   * @param {string} existingPath The existing file path
   * @param {string} newPath The new link path
   * @param {Function} callback Callback (err)
   */
  link(existingPath, newPath, callback) {
    try {
      this.linkSync(existingPath, newPath);
      process.nextTick(callback, null);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Creates a unique temporary directory asynchronously.
   * @param {string} prefix The prefix for the temp directory
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, dirPath)
   */
  mkdtemp(prefix, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    try {
      const dirPath = this.mkdtempSync(prefix);
      process.nextTick(callback, null, dirPath);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Creates and opens a unique temporary file asynchronously.
   * @param {string} prefix The prefix for the temp file
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, { path, fd })
   */
  mkstemp(prefix, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    try {
      const file = this.mkstempSync(prefix);
      process.nextTick(callback, null, file);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Opens a directory asynchronously.
   * @param {string} dirPath The directory path
   * @param {object|Function} [options] Options or callback
   * @param {Function} [callback] Callback (err, dir)
   */
  opendir(dirPath, options, callback) {
    if (typeof options === 'function') {
      callback = options;
      options = undefined;
    }
    try {
      const dir = this.opendirSync(dirPath, options);
      process.nextTick(callback, null, dir);
    } catch (err) {
      process.nextTick(callback, err);
    }
  }

  /**
   * Creates a readable stream for a virtual file.
   * @param {string} filePath The path to the file
   * @param {object} [options] Stream options
   * @returns {ReadStream}
   */
  createReadStream(filePath, options) {
    return new VirtualReadStream(this, filePath, options);
  }

  /**
   * Creates a writable stream for a virtual file.
   * @param {string} filePath The path to the file
   * @param {object} [options] Stream options
   * @returns {WriteStream}
   */
  createWriteStream(filePath, options) {
    return new VirtualWriteStream(this, filePath, options);
  }

  /**
   * Watches a file or directory for changes.
   * @param {string} filePath The path to watch
   * @param {object|Function} [options] Watch options or listener
   * @param {Function} [listener] Change listener
   * @returns {EventEmitter} A watcher that emits 'change' events
   */
  watch(filePath, options, listener) {
    if (typeof options === 'function') {
      listener = options;
      options = {};
    }

    const providerPath = this.#toProviderPath(filePath);
    const watcher = this[kProvider].watch(providerPath, options);

    if (listener) {
      watcher.on('change', listener);
    }

    return watcher;
  }

  /**
   * Watches a file for changes using stat polling.
   * @param {string} filePath The path to watch
   * @param {object|Function} [options] Watch options or listener
   * @param {Function} [listener] Change listener
   * @returns {EventEmitter} A stat watcher that emits 'change' events
   */
  watchFile(filePath, options, listener) {
    if (typeof options === 'function') {
      listener = options;
      options = {};
    }

    const providerPath = this.#toProviderPath(filePath);
    return this[kProvider].watchFile(providerPath, options, listener);
  }

  /**
   * Stops watching a file for changes.
   * @param {string} filePath The path to stop watching
   * @param {Function} [listener] Optional listener to remove
   */
  unwatchFile(filePath, listener) {
    const providerPath = this.#toProviderPath(filePath);
    this[kProvider].unwatchFile(providerPath, listener);
  }

  /**
   * Gets the promises API for this VFS instance.
   * @returns {object} Promise-based fs methods
   */
  get promises() {
    if (this[kPromises] === null) {
      this[kPromises] = this.#createPromisesAPI();
    }
    return this[kPromises];
  }

  /**
   * Creates the promises API object for this VFS instance.
   * @returns {object} Promise-based fs methods
   */
  #createPromisesAPI() {
    const provider = this[kProvider];

    // Arrow functions capture `this` for private method access.
    const toProviderPath = (p) => this.#toProviderPath(p);
    const toProviderPrefix = (p) => this.#toProviderPrefix(p);
    const toMountedPath = (p) => this.#toMountedPath(p);
    const toMountedDirents = (p, result, options) =>
      this.#toMountedDirents(p, result, options);

    return ObjectFreeze({
      async readFile(filePath, options) {
        const providerPath = toProviderPath(filePath);
        return provider.readFile(providerPath, options);
      },

      async writeFile(filePath, data, options) {
        const providerPath = toProviderPath(filePath);
        return provider.writeFile(providerPath, data, options);
      },

      async appendFile(filePath, data, options) {
        const providerPath = toProviderPath(filePath);
        return provider.appendFile(providerPath, data, options);
      },

      async stat(filePath, options) {
        const providerPath = toProviderPath(filePath);
        return provider.stat(providerPath, options);
      },

      async lstat(filePath, options) {
        const providerPath = toProviderPath(filePath);
        return provider.lstat(providerPath, options);
      },

      async readdir(dirPath, options) {
        const providerPath = toProviderPath(dirPath);
        const result = await provider.readdir(providerPath, options);
        return toMountedDirents(dirPath, result, options);
      },

      async mkdir(dirPath, options) {
        const providerPath = toProviderPath(dirPath);
        const created = await provider.mkdir(providerPath, options);
        return created === undefined ? undefined : toMountedPath(created);
      },

      async rmdir(dirPath) {
        const providerPath = toProviderPath(dirPath);
        checkNotRoot(providerPath, 'rmdir', dirPath);
        return provider.rmdir(providerPath);
      },

      async unlink(filePath) {
        const providerPath = toProviderPath(filePath);
        return provider.unlink(providerPath);
      },

      async rename(oldPath, newPath) {
        const oldProviderPath = toProviderPath(oldPath);
        const newProviderPath = toProviderPath(newPath);
        checkNotRoot(oldProviderPath, 'rename', oldPath);
        checkNotRoot(newProviderPath, 'rename', newPath);
        return provider.rename(oldProviderPath, newProviderPath);
      },

      async copyFile(src, dest, mode) {
        const srcProviderPath = toProviderPath(src);
        const destProviderPath = toProviderPath(dest);
        return provider.copyFile(srcProviderPath, destProviderPath, mode);
      },

      async realpath(filePath, options) {
        options = getOptions(options);
        const providerPath = toProviderPath(filePath);
        return encodeRealpathResult(toMountedPath(await provider.realpath(providerPath)), options);
      },

      async readlink(linkPath, options) {
        options = getOptions(options);
        const providerPath = toProviderPath(linkPath);
        return encodeRealpathResult(await provider.readlink(providerPath), options);
      },

      async symlink(target, path, type) {
        const providerPath = toProviderPath(path);
        return provider.symlink(target, providerPath, type);
      },

      async access(filePath, mode) {
        const providerPath = toProviderPath(filePath);
        return provider.access(providerPath, mode);
      },

      async rm(filePath, options) {
        const recursive = options?.recursive === true;
        const force = options?.force === true;

        let stats;
        try {
          stats = await provider.lstat(toProviderPath(filePath));
        } catch (err) {
          if (force && err?.code === 'ENOENT') return;
          throw err;
        }

        // Symlinks should be unlinked directly, never recursed into
        if (stats.isSymbolicLink()) {
          await provider.unlink(toProviderPath(filePath));
          return;
        }

        if (stats.isDirectory()) {
          if (!recursive) {
            throw createEISDIR('rm', filePath);
          }
          const entries = await provider.readdir(toProviderPath(filePath));
          for (let i = 0; i < entries.length; i++) {
            await this.rm(joinPath(filePath, entries[i]), options);
          }
          await this.rmdir(filePath);
        } else {
          await provider.unlink(toProviderPath(filePath));
        }
      },

      async truncate(filePath, len = 0) {
        const providerPath = toProviderPath(filePath);
        const handle = await provider.open(providerPath, 'r+');
        try {
          await handle.truncate(len);
        } finally {
          await handle.close();
        }
      },

      async link(existingPath, newPath) {
        const existingProviderPath = toProviderPath(existingPath);
        const newProviderPath = toProviderPath(newPath);
        return provider.link(existingProviderPath, newProviderPath);
      },

      async mkdtemp(prefix) {
        const dirPath = toProviderPrefix(prefix) + randomSuffix();
        await provider.mkdir(dirPath);
        return toMountedPath(dirPath);
      },

      async mkstemp(prefix) {
        const filePath = toProviderPrefix(prefix) + randomSuffix();
        const handle = provider.openSync(filePath, 'wx+', 0o600);
        return {
          __proto__: null,
          path: toMountedPath(filePath),
          fd: openVirtualFd(handle),
        };
      },

      async chmod(filePath, mode) {
        const providerPath = toProviderPath(filePath);
        provider.chmodSync(providerPath, mode);
      },

      async chown(filePath, uid, gid) {
        const providerPath = toProviderPath(filePath);
        provider.chownSync(providerPath, uid, gid);
      },

      async lchown(filePath, uid, gid) {
        const providerPath = toProviderPath(filePath);
        provider.lchownSync(providerPath, uid, gid);
      },

      async utimes(filePath, atime, mtime) {
        const providerPath = toProviderPath(filePath);
        provider.utimesSync(providerPath, atime, mtime);
      },

      async lutimes(filePath, atime, mtime) {
        const providerPath = toProviderPath(filePath);
        provider.lutimesSync(providerPath, atime, mtime);
      },

      async open(filePath, flags, mode) {
        const providerPath = toProviderPath(filePath);
        const handle = provider.openSync(providerPath, flags, mode);
        return openVirtualFd(handle);
      },

      async lchmod(filePath, mode) {
        const providerPath = toProviderPath(filePath);
        provider.lchmodSync(providerPath, mode);
      },

      watch(filePath, options) {
        const providerPath = toProviderPath(filePath);
        return provider.watchAsync(providerPath, options);
      },
    });
  }
}

module.exports = {
  VirtualFileSystem,
  kLayerId,
  kLoadLayer,
  kReservedRoot,
  normalizeMountedPath,
};