current commit
261019.2 · vfs: enforce readonly state on open handles
'use strict';

const {
  ArrayFrom,
  ArrayPrototypePop,
  ArrayPrototypePush,
  DateNow,
  SafeMap,
  SafeSet,
  StringPrototypeReplaceAll,
  StringPrototypeStartsWith,
  Symbol,
} = primordials;

const { Buffer } = require('buffer');
const { isPromise } = require('util/types');
const { posix: pathPosix } = require('path');
const { VirtualProvider } = require('internal/vfs/provider');
const { MemoryFileHandle, decodeOpenFlags } = require('internal/vfs/file_handle');
const {
  VFSWatcher,
  VFSStatWatcher,
  VFSWatchAsyncIterable,
} = require('internal/vfs/watcher');
const {
  codes: {
    ERR_INVALID_STATE,
  },
} = require('internal/errors');
const {
  createENOENT,
  createENOTDIR,
  createENOTEMPTY,
  createEISDIR,
  createEEXIST,
  createEINVAL,
  createELOOP,
  createEROFS,
} = require('internal/vfs/errors');
const {
  createFileStats,
  createDirectoryStats,
  createSymlinkStats,
} = require('internal/vfs/stats');
const { Dirent } = require('internal/fs/utils');
const { kEmptyObject } = require('internal/util');
const {
  fs: {
    UV_DIRENT_FILE,
    UV_DIRENT_DIR,
    UV_DIRENT_LINK,
  },
} = internalBinding('constants');

/**
 * Converts a time argument (Date, number, or string) to milliseconds.
 * Numbers are treated as seconds (matching Node.js utimes convention).
 * @param {Date|number|string} time The time value
 * @returns {number} Milliseconds since epoch
 */
function toMs(time) {
  if (typeof time === 'number') return time * 1000;
  if (typeof time === 'string') return DateNow(); // Fallback for string timestamps
  if (typeof time === 'object' && time !== null) return +time;
  return time;
}

// Private symbols
const kRoot = Symbol('kRoot');
const kReadonly = Symbol('kReadonly');
const kStatWatchers = Symbol('kStatWatchers');

// Entry types
const TYPE_FILE = 0;
const TYPE_DIR = 1;
const TYPE_SYMLINK = 2;

// Maximum symlink resolution depth
const kMaxSymlinkDepth = 40;

/**
 * Internal entry representation for MemoryProvider.
 */
class MemoryEntry {
  constructor(type, options = kEmptyObject) {
    this.type = type;
    this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644);
    this.content = null; // For files - static Buffer content
    this.contentProvider = null; // For files - dynamic content function
    this.target = null; // For symlinks
    this.children = null; // For directories
    this.populate = null; // For directories - lazy population callback
    this.populated = true; // For directories - has populate been called?
    this.nlink = 1;
    this.uid = 0;
    this.gid = 0;
    const now = DateNow();
    this.atime = now;
    this.mtime = now;
    this.ctime = now;
    this.birthtime = now;
  }

  /**
   * Gets the file content synchronously.
   * Throws if the content provider returns a Promise.
   * @returns {Buffer} The file content
   */
  getContentSync() {
    if (this.contentProvider !== null) {
      const result = this.contentProvider();
      if (isPromise(result)) {
        // It's a Promise - can't use sync API
        throw new ERR_INVALID_STATE('cannot use sync API with async content provider');
      }
      return typeof result === 'string' ? Buffer.from(result) : result;
    }
    return this.content;
  }

  /**
   * Gets the file content asynchronously.
   * @returns {Promise<Buffer>} The file content
   */
  async getContentAsync() {
    if (this.contentProvider !== null) {
      const result = await this.contentProvider();
      return typeof result === 'string' ? Buffer.from(result) : result;
    }
    return this.content;
  }

  /**
   * Returns true if this file has a dynamic content provider.
   * @returns {boolean}
   */
  isDynamic() {
    return this.contentProvider !== null;
  }

  isFile() {
    return this.type === TYPE_FILE;
  }

  isDirectory() {
    return this.type === TYPE_DIR;
  }

  isSymbolicLink() {
    return this.type === TYPE_SYMLINK;
  }
}

/**
 * In-memory filesystem provider.
 * Supports full read/write operations.
 */
class MemoryProvider extends VirtualProvider {
  constructor() {
    super();
    // Root directory
    this[kRoot] = new MemoryEntry(TYPE_DIR);
    this[kRoot].children = new SafeMap();
    this[kReadonly] = false;
    // Map of path -> VFSStatWatcher for watchFile
    this[kStatWatchers] = new SafeMap();
  }

  get readonly() {
    return this[kReadonly];
  }

  get supportsWatch() {
    return true;
  }

  /**
   * Sets the provider to read-only mode.
   * Once set to read-only, the provider cannot be changed back to writable.
   * This is useful for finalizing a VFS after initial population.
   */
  setReadOnly() {
    this[kReadonly] = true;
  }

  get supportsSymlinks() {
    return true;
  }

  /**
   * Normalizes a path to use forward slashes, removes trailing slash,
   * and resolves . and .. components.
   * @param {string} path The path to normalize
   * @returns {string} Normalized path
   */
  #normalizePath(path) {
    // Convert backslashes to forward slashes
    let normalized = StringPrototypeReplaceAll(path, '\\', '/');
    // Ensure absolute path
    if (normalized[0] !== '/') {
      normalized = '/' + normalized;
    }
    // Use path.posix.normalize to resolve . and ..
    return pathPosix.normalize(normalized);
  }

  /**
   * Splits a path into segments.
   * @param {string} path Normalized path
   * @returns {string[]} Path segments
   */
  #splitPath(path) {
    if (path === '/') {
      return [];
    }
    return path.slice(1).split('/');
  }


  /**
   * Resolves a symlink target to an absolute path.
   * @param {string} symlinkPath The path of the symlink
   * @param {string} target The symlink target
   * @returns {string} Resolved absolute path
   */
  #resolveSymlinkTarget(symlinkPath, target) {
    if (target.startsWith('/')) {
      return this.#normalizePath(target);
    }
    // Relative target: resolve against symlink's parent directory
    const parentPath = pathPosix.dirname(symlinkPath);
    return this.#normalizePath(pathPosix.join(parentPath, target));
  }

  /**
   * Looks up an entry by path, optionally following symlinks.
   * @param {string} path The path to look up
   * @param {boolean} followSymlinks Whether to follow symlinks
   * @param {number} depth Current symlink resolution depth
   * @returns {{ entry: MemoryEntry|null, resolvedPath: string|null, eloop?: boolean }}
   */
  #lookupEntry(path, followSymlinks = true, depth = 0) {
    const normalized = this.#normalizePath(path);

    if (normalized === '/') {
      return { entry: this[kRoot], resolvedPath: '/' };
    }

    const segments = this.#splitPath(normalized);
    let current = this[kRoot];
    let currentPath = '/';

    for (let i = 0; i < segments.length; i++) {
      const segment = segments[i];

      // Always follow symlinks for intermediate path components
      if (current.isSymbolicLink()) {
        if (depth >= kMaxSymlinkDepth) {
          return { entry: null, resolvedPath: null, eloop: true };
        }
        const targetPath = this.#resolveSymlinkTarget(currentPath, current.target);
        const result = this.#lookupEntry(targetPath, true, depth + 1);
        if (result.eloop) {
          return result;
        }
        if (!result.entry) {
          return { entry: null, resolvedPath: null };
        }
        current = result.entry;
        currentPath = result.resolvedPath;
      }

      if (!current.isDirectory()) {
        return { entry: null, resolvedPath: null };
      }

      // Ensure directory is populated before accessing children
      this.#ensurePopulated(current, currentPath);

      const entry = current.children.get(segment);
      if (!entry) {
        return { entry: null, resolvedPath: null };
      }

      currentPath = pathPosix.join(currentPath, segment);
      current = entry;
    }

    // Follow symlink at the end if requested
    if (current.isSymbolicLink() && followSymlinks) {
      if (depth >= kMaxSymlinkDepth) {
        return { entry: null, resolvedPath: null, eloop: true };
      }
      const targetPath = this.#resolveSymlinkTarget(currentPath, current.target);
      return this.#lookupEntry(targetPath, true, depth + 1);
    }

    return { entry: current, resolvedPath: currentPath };
  }

  /**
   * Gets an entry by path, throwing if not found.
   * @param {string} path The path
   * @param {string} syscall The syscall name for error
   * @param {boolean} followSymlinks Whether to follow symlinks
   * @returns {MemoryEntry}
   */
  #getEntry(path, syscall, followSymlinks = true) {
    const result = this.#lookupEntry(path, followSymlinks);
    if (result.eloop) {
      throw createELOOP(syscall, path);
    }
    if (!result.entry) {
      throw createENOENT(syscall, path);
    }
    return result.entry;
  }

  /**
   * Ensures parent directories exist, optionally creating them.
   * @param {string} path The full path
   * @param {boolean} create Whether to create missing directories
   * @param {string} syscall The syscall name for errors
   * @returns {MemoryEntry} The parent directory entry
   */
  #ensureParent(path, create, syscall) {
    if (path === '/') {
      return this[kRoot];
    }
    const parentPath = pathPosix.dirname(path);

    const segments = this.#splitPath(parentPath);
    let current = this[kRoot];

    for (let i = 0; i < segments.length; i++) {
      const segment = segments[i];
      const currentPath = pathPosix.join('/', ...segments.slice(0, i));

      // Follow symlinks in parent path
      if (current.isSymbolicLink()) {
        const targetPath = this.#resolveSymlinkTarget(currentPath, current.target);
        const result = this.#lookupEntry(targetPath, true, 0);
        if (!result.entry) {
          throw createENOENT(syscall, path);
        }
        current = result.entry;
      }

      if (!current.isDirectory()) {
        throw createENOTDIR(syscall, path);
      }

      // Ensure directory is populated before accessing children
      this.#ensurePopulated(current, currentPath);

      let entry = current.children.get(segment);
      if (!entry) {
        if (create) {
          entry = new MemoryEntry(TYPE_DIR);
          entry.children = new SafeMap();
          current.children.set(segment, entry);
        } else {
          throw createENOENT(syscall, path);
        }
      }
      current = entry;
    }

    // Follow symlinks on the final parent entry
    if (current.isSymbolicLink()) {
      const targetPath = this.#resolveSymlinkTarget(parentPath, current.target);
      const result = this.#lookupEntry(targetPath, true, 0);
      if (!result.entry) {
        throw createENOENT(syscall, path);
      }
      current = result.entry;
    }

    if (!current.isDirectory()) {
      throw createENOTDIR(syscall, path);
    }

    // Ensure final directory is populated
    this.#ensurePopulated(current, parentPath);

    return current;
  }

  /**
   * Creates stats for an entry.
   * @param {MemoryEntry} entry The entry
   * @param {number} [size] Override size for files
   * @returns {Stats}
   */
  #createStats(entry, size, bigint) {
    const options = {
      mode: entry.mode,
      nlink: entry.nlink,
      uid: entry.uid,
      gid: entry.gid,
      atimeMs: entry.atime,
      mtimeMs: entry.mtime,
      ctimeMs: entry.ctime,
      birthtimeMs: entry.birthtime,
      bigint,
    };

    if (entry.isFile()) {
      let fileSize = size;
      if (fileSize === undefined) {
        fileSize = entry.isDynamic() ?
          entry.getContentSync().length :
          entry.content.length;
      }
      return createFileStats(fileSize, options);
    } else if (entry.isDirectory()) {
      return createDirectoryStats(options);
    } else if (entry.isSymbolicLink()) {
      return createSymlinkStats(entry.target.length, options);
    }

    throw new ERR_INVALID_STATE('Unknown entry type');
  }

  /**
   * Ensures a directory is populated by calling its populate callback if needed.
   * @param {MemoryEntry} entry The directory entry
   * @param {string} path The directory path (for error messages and scoped VFS)
   */
  #ensurePopulated(entry, path) {
    if (entry.isDirectory() && !entry.populated && entry.populate) {
      // Create a scoped VFS for the populate callback
      const scopedVfs = {
        addFile: (name, content, opts) => {
          const fileEntry = new MemoryEntry(TYPE_FILE, opts);
          if (typeof content === 'function') {
            fileEntry.content = Buffer.alloc(0);
            fileEntry.contentProvider = content;
          } else {
            fileEntry.content = typeof content === 'string' ? Buffer.from(content) : content;
          }
          entry.children.set(name, fileEntry);
        },
        addDirectory: (name, populate, opts) => {
          const dirEntry = new MemoryEntry(TYPE_DIR, opts);
          dirEntry.children = new SafeMap();
          if (typeof populate === 'function') {
            dirEntry.populate = populate;
            dirEntry.populated = false;
          }
          entry.children.set(name, dirEntry);
        },
        addSymlink: (name, target, opts) => {
          const symlinkEntry = new MemoryEntry(TYPE_SYMLINK, opts);
          symlinkEntry.target = target;
          entry.children.set(name, symlinkEntry);
        },
      };
      entry.populate(scopedVfs);
      entry.populated = true;
    }
  }

  openSync(path, flags, mode) {
    const normalized = this.#normalizePath(path);
    const access = decodeOpenFlags(flags);

    // Creating a file is a write even when the handle itself is read-only.
    if (this.readonly && (access.writable || access.create)) {
      throw createEROFS('open', path);
    }

    let entry;
    try {
      entry = this.#getEntry(normalized, 'open');
      // O_EXCL only means anything together with O_CREAT: the file must
      // not exist yet.
      if (access.create && access.exclusive) {
        throw createEEXIST('open', path);
      }
    } catch (err) {
      if (err.code !== 'ENOENT' || !access.create) throw err;
      // Create the file
      const parent = this.#ensureParent(normalized, false, 'open');
      const name = pathPosix.basename(normalized);
      entry = new MemoryEntry(TYPE_FILE, { mode });
      entry.content = Buffer.alloc(0);
      parent.children.set(name, entry);
      const now = DateNow();
      parent.mtime = now;
      parent.ctime = now;
    }

    if (entry.isDirectory()) {
      throw createEISDIR('open', path);
    }

    if (entry.isSymbolicLink()) {
      // Should have been resolved already, but just in case
      throw createEINVAL('open', path);
    }

    const getStats = (size) => this.#createStats(entry, size);
    const isReadOnly = () => this.readonly;
    return new MemoryFileHandle(
      normalized,
      flags,
      mode ?? entry.mode,
      entry.content,
      entry,
      getStats,
      isReadOnly,
    );
  }

  async open(path, flags, mode) {
    return this.openSync(path, flags, mode);
  }

  statSync(path, options) {
    const entry = this.#getEntry(path, 'stat', true);
    return this.#createStats(entry, undefined, options?.bigint);
  }

  async stat(path, options) {
    return this.statSync(path, options);
  }

  lstatSync(path, options) {
    const entry = this.#getEntry(path, 'lstat', false);
    return this.#createStats(entry, undefined, options?.bigint);
  }

  async lstat(path, options) {
    return this.lstatSync(path, options);
  }

  readdirSync(path, options) {
    const entry = this.#getEntry(path, 'scandir', true);
    if (!entry.isDirectory()) {
      throw createENOTDIR('scandir', path);
    }

    // Ensure directory is populated (for lazy population)
    this.#ensurePopulated(entry, path);

    const normalized = this.#normalizePath(path);
    const withFileTypes = options?.withFileTypes === true;
    const recursive = options?.recursive === true;

    if (recursive) {
      return this.#readdirRecursive(entry, normalized, withFileTypes);
    }

    if (withFileTypes) {
      const dirents = [];
      for (const { 0: name, 1: childEntry } of entry.children) {
        let type;
        if (childEntry.isSymbolicLink()) {
          type = UV_DIRENT_LINK;
        } else if (childEntry.isDirectory()) {
          type = UV_DIRENT_DIR;
        } else {
          type = UV_DIRENT_FILE;
        }
        ArrayPrototypePush(dirents, new Dirent(name, type, normalized));
      }
      return dirents;
    }

    return ArrayFrom(entry.children.keys());
  }

  /**
   * Recursively reads directory contents.
   * @param {MemoryEntry} dirEntry The directory entry
   * @param {string} dirPath The normalized directory path
   * @param {boolean} withFileTypes Whether to return Dirent objects
   * @returns {string[]|Dirent[]}
   */
  #readdirRecursive(dirEntry, dirPath, withFileTypes) {
    const results = [];
    // Directories on the current traversal path. A directory reached again
    // through a symlink cycle is not descended into (but is still listed).
    const active = new SafeSet();

    // Traverse depth-first with an explicit stack instead of recursion, so a
    // deeply nested tree cannot exhaust the call stack. Each frame is a
    // directory being walked together with a snapshot of its children and the
    // index of the next child to visit.
    const enter = (entry, currentPath, relativePath) => {
      this.#ensurePopulated(entry, currentPath);
      active.add(entry);
      ArrayPrototypePush(stack, {
        entry,
        currentPath,
        relativePath,
        children: ArrayFrom(entry.children),
        index: 0,
      });
    };

    const stack = [];
    enter(dirEntry, dirPath, '');

    while (stack.length > 0) {
      const frame = stack[stack.length - 1];
      if (frame.index >= frame.children.length) {
        active.delete(frame.entry);
        ArrayPrototypePop(stack);
        continue;
      }

      const { 0: name, 1: childEntry } = frame.children[frame.index++];
      const childRelative = frame.relativePath ?
        frame.relativePath + '/' + name : name;

      if (withFileTypes) {
        let type;
        if (childEntry.isSymbolicLink()) {
          type = UV_DIRENT_LINK;
        } else if (childEntry.isDirectory()) {
          type = UV_DIRENT_DIR;
        } else {
          type = UV_DIRENT_FILE;
        }
        ArrayPrototypePush(results, new Dirent(childRelative, type, dirPath));
      } else {
        ArrayPrototypePush(results, childRelative);
      }

      // Follow symlinks to directories for recursive traversal, skipping any
      // directory already on the active path to avoid symlink cycles.
      let resolvedChild = childEntry;
      if (childEntry.isSymbolicLink()) {
        const targetPath = this.#resolveSymlinkTarget(
          pathPosix.join(frame.currentPath, name), childEntry.target,
        );
        const result = this.#lookupEntry(targetPath, true, 0);
        if (result.entry) {
          resolvedChild = result.entry;
        }
      }
      if (resolvedChild.isDirectory() && !active.has(resolvedChild)) {
        enter(resolvedChild, pathPosix.join(frame.currentPath, name),
              childRelative);
      }
    }

    return results;
  }

  async readdir(path, options) {
    return this.readdirSync(path, options);
  }

  mkdirSync(path, options) {
    if (this.readonly) {
      throw createEROFS('mkdir', path);
    }

    const normalized = this.#normalizePath(path);
    const recursive = options?.recursive === true;

    // Check if already exists
    const existing = this.#lookupEntry(normalized, true);
    if (existing.entry) {
      if (existing.entry.isDirectory() && recursive) {
        // Already exists, that's ok for recursive
        return undefined;
      }
      throw createEEXIST('mkdir', path);
    }

    if (recursive) {
      // Create all parent directories
      const segments = this.#splitPath(normalized);
      let current = this[kRoot];
      let currentPath = '/';
      let resolvedCurrentPath = '/';
      let firstCreated;

      for (const segment of segments) {
        currentPath = pathPosix.join(currentPath, segment);
        const resolvedPath = pathPosix.join(resolvedCurrentPath, segment);
        this.#ensurePopulated(current, resolvedCurrentPath);
        let entry = current.children.get(segment);
        if (!entry) {
          entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode });
          entry.children = new SafeMap();
          current.children.set(segment, entry);
          if (firstCreated === undefined) {
            firstCreated = currentPath;
          }
          resolvedCurrentPath = resolvedPath;
        } else if (entry.isSymbolicLink()) {
          const targetPath = this.#resolveSymlinkTarget(resolvedPath, entry.target);
          const result = this.#lookupEntry(targetPath, true, 0);
          if (result.eloop) {
            throw createELOOP('mkdir', path);
          }
          if (!result.entry) {
            throw createENOENT('mkdir', path);
          }
          entry = result.entry;
          resolvedCurrentPath = result.resolvedPath;
        } else {
          resolvedCurrentPath = resolvedPath;
        }

        if (!entry.isDirectory()) {
          throw createENOTDIR('mkdir', path);
        }
        current = entry;
      }
      return firstCreated;
    }

    const parent = this.#ensureParent(normalized, false, 'mkdir');
    const name = pathPosix.basename(normalized);
    const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode });
    entry.children = new SafeMap();
    parent.children.set(name, entry);
    const now = DateNow();
    parent.mtime = now;
    parent.ctime = now;
    return undefined;
  }

  async mkdir(path, options) {
    return this.mkdirSync(path, options);
  }

  rmdirSync(path) {
    if (this.readonly) {
      throw createEROFS('rmdir', path);
    }

    const normalized = this.#normalizePath(path);
    const entry = this.#getEntry(normalized, 'rmdir', false);

    if (!entry.isDirectory()) {
      throw createENOTDIR('rmdir', path);
    }

    if (entry.children.size > 0) {
      throw createENOTEMPTY('rmdir', path);
    }

    const parent = this.#ensureParent(normalized, false, 'rmdir');
    const name = pathPosix.basename(normalized);
    parent.children.delete(name);
    const now = DateNow();
    parent.mtime = now;
    parent.ctime = now;
  }

  async rmdir(path) {
    this.rmdirSync(path);
  }

  unlinkSync(path) {
    if (this.readonly) {
      throw createEROFS('unlink', path);
    }

    const normalized = this.#normalizePath(path);
    const entry = this.#getEntry(normalized, 'unlink', false);

    if (entry.isDirectory()) {
      throw createEISDIR('unlink', path);
    }

    const parent = this.#ensureParent(normalized, false, 'unlink');
    const name = pathPosix.basename(normalized);
    parent.children.delete(name);
    entry.nlink--;
    const now = DateNow();
    parent.mtime = now;
    parent.ctime = now;
  }

  async unlink(path) {
    this.unlinkSync(path);
  }

  renameSync(oldPath, newPath) {
    if (this.readonly) {
      throw createEROFS('rename', oldPath);
    }

    const normalizedOld = this.#normalizePath(oldPath);
    const normalizedNew = this.#normalizePath(newPath);

    // Get the entry (without following symlinks for the entry itself)
    const entry = this.#getEntry(normalizedOld, 'rename', false);

    // Validate destination parent exists (do not auto-create)
    const newParent = this.#ensureParent(normalizedNew, false, 'rename');
    const newName = pathPosix.basename(normalizedNew);

    if (entry.isDirectory()) {
      // Resolve both paths so symlinked parents cannot hide a descendant.
      const { resolvedPath: resolvedOld } = this.#lookupEntry(normalizedOld, false);
      const { resolvedPath: resolvedParent } = this.#lookupEntry(pathPosix.dirname(normalizedNew));
      if (resolvedParent === resolvedOld ||
          StringPrototypeStartsWith(resolvedParent, pathPosix.join(resolvedOld, '/'))) {
        throw createEINVAL('rename', oldPath);
      }
    }

    // Check if destination exists
    const existingDest = newParent.children.get(newName);
    if (existingDest === entry) {
      // Both names resolve to the same entry: rename does nothing
      return;
    }
    if (existingDest) {
      // Cannot overwrite a directory with a non-directory
      if (existingDest.isDirectory() && !entry.isDirectory()) {
        throw createEISDIR('rename', newPath);
      }
      // Cannot overwrite a non-directory with a directory
      if (!existingDest.isDirectory() && entry.isDirectory()) {
        throw createENOTDIR('rename', newPath);
      }
      if (existingDest.isDirectory()) {
        this.#ensurePopulated(existingDest, normalizedNew);
        // Cannot overwrite a non-empty directory
        if (existingDest.children.size > 0) {
          throw createENOTEMPTY('rename', newPath);
        }
      } else {
        existingDest.nlink--;
      }
    }

    // Remove from old location (after destination validation)
    const oldParent = this.#ensureParent(normalizedOld, false, 'rename');
    const oldName = pathPosix.basename(normalizedOld);
    oldParent.children.delete(oldName);

    // Add to new location
    newParent.children.set(newName, entry);

    const now = DateNow();
    oldParent.mtime = now;
    oldParent.ctime = now;
    if (newParent !== oldParent) {
      newParent.mtime = now;
      newParent.ctime = now;
    }
  }

  async rename(oldPath, newPath) {
    this.renameSync(oldPath, newPath);
  }

  linkSync(existingPath, newPath) {
    if (this.readonly) {
      throw createEROFS('link', newPath);
    }

    const normalizedExisting = this.#normalizePath(existingPath);
    const normalizedNew = this.#normalizePath(newPath);

    const entry = this.#getEntry(normalizedExisting, 'link', true);
    if (!entry.isFile()) {
      // Hard links to directories are not supported
      throw createEINVAL('link', existingPath);
    }

    // Check if new path already exists
    const existing = this.#lookupEntry(normalizedNew, false);
    if (existing.entry) {
      throw createEEXIST('link', newPath);
    }

    const parent = this.#ensureParent(normalizedNew, false, 'link');
    const name = pathPosix.basename(normalizedNew);
    // Hard link: same entry object referenced by both names
    parent.children.set(name, entry);
    entry.nlink++;
    const now = DateNow();
    parent.mtime = now;
    parent.ctime = now;
  }

  async link(existingPath, newPath) {
    this.linkSync(existingPath, newPath);
  }

  readlinkSync(path, options) {
    const normalized = this.#normalizePath(path);
    const entry = this.#getEntry(normalized, 'readlink', false);

    if (!entry.isSymbolicLink()) {
      throw createEINVAL('readlink', path);
    }

    return entry.target;
  }

  async readlink(path, options) {
    return this.readlinkSync(path, options);
  }

  symlinkSync(target, path, type) {
    if (this.readonly) {
      throw createEROFS('symlink', path);
    }

    const normalized = this.#normalizePath(path);

    // Check if already exists
    const existing = this.#lookupEntry(normalized, false);
    if (existing.entry) {
      throw createEEXIST('symlink', path);
    }

    const parent = this.#ensureParent(normalized, false, 'symlink');
    const name = pathPosix.basename(normalized);
    const entry = new MemoryEntry(TYPE_SYMLINK);
    entry.target = target;
    parent.children.set(name, entry);
    const now = DateNow();
    parent.mtime = now;
    parent.ctime = now;
  }

  async symlink(target, path, type) {
    this.symlinkSync(target, path, type);
  }

  realpathSync(path, options) {
    const result = this.#lookupEntry(path, true, 0);
    if (result.eloop) {
      throw createELOOP('realpath', path);
    }
    if (!result.entry) {
      throw createENOENT('realpath', path);
    }
    return result.resolvedPath;
  }

  async realpath(path, options) {
    return this.realpathSync(path, options);
  }

  // === METADATA OPERATIONS ===

  chmodSync(path, mode) {
    const entry = this.#getEntry(path, 'chmod', true);
    // Preserve file type bits, update permission bits
    entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777);
    entry.ctime = DateNow();
  }

  lchmodSync(path, mode) {
    const entry = this.#getEntry(path, 'chmod', false);
    // Preserve file type bits, update permission bits
    entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777);
    entry.ctime = DateNow();
  }

  chownSync(path, uid, gid) {
    const entry = this.#getEntry(path, 'chown', true);
    if (uid >= 0) entry.uid = uid;
    if (gid >= 0) entry.gid = gid;
    entry.ctime = DateNow();
  }

  lchownSync(path, uid, gid) {
    const entry = this.#getEntry(path, 'chown', false);
    if (uid >= 0) entry.uid = uid;
    if (gid >= 0) entry.gid = gid;
    entry.ctime = DateNow();
  }

  utimesSync(path, atime, mtime) {
    const entry = this.#getEntry(path, 'utime', true);
    entry.atime = toMs(atime);
    entry.mtime = toMs(mtime);
    entry.ctime = DateNow();
  }

  lutimesSync(path, atime, mtime) {
    const entry = this.#getEntry(path, 'utime', false);
    entry.atime = toMs(atime);
    entry.mtime = toMs(mtime);
    entry.ctime = DateNow();
  }

  // === WATCH OPERATIONS ===

  /**
   * Watches a file or directory for changes.
   * @param {string} path The path to watch
   * @param {object} [options] Watch options
   * @returns {VFSWatcher}
   */
  watch(path, options) {
    const normalized = this.#normalizePath(path);
    return new VFSWatcher(this, normalized, options);
  }

  /**
   * Watches a file or directory for changes (async iterable version).
   * Used by fs.promises.watch().
   * @param {string} path The path to watch
   * @param {object} [options] Watch options
   * @returns {VFSWatchAsyncIterable}
   */
  watchAsync(path, options) {
    const normalized = this.#normalizePath(path);
    return new VFSWatchAsyncIterable(this, normalized, options);
  }

  /**
   * Watches a file for changes using stat polling.
   * @param {string} path The path to watch
   * @param {object} [options] Watch options
   * @param {Function} [listener] Change listener
   * @returns {VFSStatWatcher}
   */
  watchFile(path, options, listener) {
    const normalized = this.#normalizePath(path);

    // Reuse existing watcher for the same path
    let watcher = this[kStatWatchers].get(normalized);
    if (!watcher) {
      watcher = new VFSStatWatcher(this, normalized, options);
      this[kStatWatchers].set(normalized, watcher);
    }

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

    return watcher;
  }

  /**
   * Stops watching a file for changes.
   * @param {string} path The path to stop watching
   * @param {Function} [listener] Optional listener to remove
   */
  unwatchFile(path, listener) {
    const normalized = this.#normalizePath(path);
    const watcher = this[kStatWatchers].get(normalized);

    if (!watcher) {
      return;
    }

    if (listener) {
      watcher.removeListener('change', listener);
    } else {
      // Remove all listeners
      watcher.removeAllListeners('change');
    }

    // If no more listeners, stop and remove the watcher
    if (watcher.hasNoListeners()) {
      watcher.stop();
      this[kStatWatchers].delete(normalized);
    }
  }
}

module.exports = {
  MemoryProvider,
};