current commit
261022.9 · vfs: keep open memory handles in sync with file
'use strict';

const {
  DateNow,
  MathMax,
  MathMin,
  Number,
  Symbol,
  SymbolAsyncDispose,
  SymbolDispose,
} = primordials;

const { Buffer } = require('buffer');
const {
  codes: {
    ERR_INVALID_STATE,
    ERR_METHOD_NOT_IMPLEMENTED,
  },
} = require('internal/errors');
const {
  createEBADF,
  createEROFS,
} = require('internal/vfs/errors');
const { stringToFlags, toUnixTimestamp } = require('internal/fs/utils');
const { parseFileMode } = require('internal/validators');

// Private symbols
const kPath = Symbol('kPath');
const kFlags = Symbol('kFlags');
const kMode = Symbol('kMode');
const kPosition = Symbol('kPosition');
const kClosed = Symbol('kClosed');
const kAccess = Symbol('kAccess');

const {
  fs: { O_APPEND, O_CREAT, O_EXCL, O_RDONLY, O_RDWR, O_TRUNC, O_WRONLY },
} = internalBinding('constants');

/**
 * Decodes open flags into what they ask for. Both the string spellings and
 * the numeric `fs.constants` values are accepted. The bits decide, because
 * several numeric combinations (`O_WRONLY` alone, `O_RDONLY | O_CREAT`)
 * have no string spelling, and collapsing them to the nearest one changes
 * their meaning: a plain `O_WRONLY` must neither create nor truncate.
 * @param {string|number} flags
 * @returns {{ readable: boolean, writable: boolean, create: boolean,
 *   exclusive: boolean, truncate: boolean, append: boolean }}
 */
function decodeOpenFlags(flags) {
  const bits = typeof flags === 'number' ? flags : stringToFlags(flags);
  const access = bits & (O_RDONLY | O_WRONLY | O_RDWR);
  return {
    __proto__: null,
    readable: access !== O_WRONLY,
    writable: access !== O_RDONLY,
    create: (bits & O_CREAT) !== 0,
    exclusive: (bits & O_EXCL) !== 0,
    truncate: (bits & O_TRUNC) !== 0,
    append: (bits & O_APPEND) !== 0,
  };
}

/**
 * The string spelling closest to numeric flags, for `handle.flags`, which
 * has always been a string. Behaviour is never derived from it.
 * @param {string|number} flags
 * @returns {string}
 */
function flagsToString(flags) {
  if (typeof flags !== 'number') return flags;
  const { readable, writable, exclusive, truncate, append } = decodeOpenFlags(flags);
  const plus = readable && writable ? '+' : '';
  const x = exclusive ? 'x' : '';
  if (append) return `a${x}${plus}`;
  if (truncate) return `w${x}${plus}`;
  return writable ? 'r+' : 'r';
}

function isCurrentPosition(position) {
  return position === null || position === undefined || position === -1;
}

/**
 * Base class for virtual file handles.
 * Provides the interface that file handles must implement.
 */
class VirtualFileHandle {
  /**
   * @param {string} path The file path
   * @param {string} flags The open flags
   * @param {number} [mode] The file mode
   */
  constructor(path, flags, mode) {
    this[kPath] = path;
    this[kAccess] = decodeOpenFlags(flags);
    this[kFlags] = flagsToString(flags);
    this[kMode] = mode ?? 0o644;
    this[kPosition] = 0;
    this[kClosed] = false;
  }

  /**
   * Gets the file path.
   * @returns {string}
   */
  get path() {
    return this[kPath];
  }

  /**
   * Gets the open flags.
   * @returns {string}
   */
  get flags() {
    return this[kFlags];
  }

  /**
   * Gets the file mode.
   * @returns {number}
   */
  get mode() {
    return this[kMode];
  }

  /**
   * Gets the current position.
   * @returns {number}
   */
  get position() {
    return this[kPosition];
  }

  /**
   * Sets the current position.
   * @param {number} pos The new position
   */
  set position(pos) {
    this[kPosition] = pos;
  }

  /**
   * Returns true if the handle is closed.
   * @returns {boolean}
   */
  get closed() {
    return this[kClosed];
  }

  /**
   * Throws if the handle is closed.
   * @param {string} syscall The syscall name for the error
   */
  #checkClosed(syscall) {
    if (this[kClosed]) {
      throw createEBADF(syscall);
    }
  }

  /**
   * Reads data from the file.
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer to start writing
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position to read from (null uses current position)
   * @returns {Promise<{ bytesRead: number, buffer: Buffer }>}
   */
  async read(buffer, offset, length, position) {
    this.#checkClosed('read');
    throw new ERR_METHOD_NOT_IMPLEMENTED('read');
  }

  /**
   * Reads data from the file synchronously.
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer to start writing
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position to read from (null uses current position)
   * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
   */
  readSync(buffer, offset, length, position) {
    this.#checkClosed('read');
    throw new ERR_METHOD_NOT_IMPLEMENTED('readSync');
  }

  /**
   * Writes data to the file.
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer to start reading
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position to write to (null uses current position)
   * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>}
   */
  async write(buffer, offset, length, position) {
    this.#checkClosed('write');
    throw new ERR_METHOD_NOT_IMPLEMENTED('write');
  }

  /**
   * Writes data to the file synchronously.
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer to start reading
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position to write to (null uses current position)
   * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
   */
  writeSync(buffer, offset, length, position) {
    this.#checkClosed('write');
    throw new ERR_METHOD_NOT_IMPLEMENTED('writeSync');
  }

  /**
   * Reads the entire file.
   * @param {object|string} [options] Options or encoding
   * @returns {Promise<Buffer|string>}
   */
  async readFile(options) {
    this.#checkClosed('read');
    throw new ERR_METHOD_NOT_IMPLEMENTED('readFile');
  }

  /**
   * Reads the entire file synchronously.
   * @param {object|string} [options] Options or encoding
   * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
   */
  readFileSync(options) {
    this.#checkClosed('read');
    throw new ERR_METHOD_NOT_IMPLEMENTED('readFileSync');
  }

  /**
   * Writes data to the file (replacing content).
   * @param {Buffer|string} data The data to write
   * @param {object} [options] Options
   * @returns {Promise<void>}
   */
  async writeFile(data, options) {
    this.#checkClosed('write');
    throw new ERR_METHOD_NOT_IMPLEMENTED('writeFile');
  }

  /**
   * Writes data to the file synchronously (replacing content).
   * @param {Buffer|string} data The data to write
   * @param {object} [options] Options
   */
  writeFileSync(data, options) {
    this.#checkClosed('write');
    throw new ERR_METHOD_NOT_IMPLEMENTED('writeFileSync');
  }

  /**
   * Gets file stats.
   * @param {object} [options] Options
   * @returns {Promise<Stats>}
   */
  async stat(options) {
    this.#checkClosed('fstat');
    throw new ERR_METHOD_NOT_IMPLEMENTED('stat');
  }

  /**
   * Gets file stats synchronously.
   * @param {object} [options] Options
   * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
   */
  statSync(options) {
    this.#checkClosed('fstat');
    throw new ERR_METHOD_NOT_IMPLEMENTED('statSync');
  }

  /**
   * Truncates the file.
   * @param {number} [len] The new length
   * @returns {Promise<void>}
   */
  async truncate(len) {
    this.#checkClosed('ftruncate');
    throw new ERR_METHOD_NOT_IMPLEMENTED('truncate');
  }

  /**
   * Truncates the file synchronously.
   * @param {number} [len] The new length
   */
  truncateSync(len) {
    this.#checkClosed('ftruncate');
    throw new ERR_METHOD_NOT_IMPLEMENTED('truncateSync');
  }

  /**
   * @param {number} mode The new permission bits
   */
  chmodSync(mode) {}

  /**
   * @param {number} mode The new permission bits
   * @returns {Promise<void>}
   */
  async chmod(mode) {
    this.chmodSync(mode);
  }

  /**
   * No-op chown - VFS files don't have real ownership.
   * @returns {Promise<void>}
   */
  async chown() {}

  /**
   * @param {Date|number|string} atime The new access time
   * @param {Date|number|string} mtime The new modification time
   */
  utimesSync(atime, mtime) {}

  /**
   * @param {Date|number|string} atime The new access time
   * @param {Date|number|string} mtime The new modification time
   * @returns {Promise<void>}
   */
  async utimes(atime, mtime) {
    this.utimesSync(atime, mtime);
  }

  /**
   * No-op datasync - VFS is in-memory.
   * @returns {Promise<void>}
   */
  async datasync() {}

  /**
   * No-op sync - VFS is in-memory.
   * @returns {Promise<void>}
   */
  async sync() {}

  /**
   * Reads data from the file into multiple buffers.
   * @param {Buffer[]} buffers The buffers to read into
   * @param {number|null} [position] The position to read from
   * @returns {Promise<{ bytesRead: number, buffers: Buffer[] }>}
   */
  async readv(buffers, position) {
    this.#checkClosed('readv');
    let totalRead = 0;
    for (let i = 0; i < buffers.length; i++) {
      const buf = buffers[i];
      const pos = position != null ? position + totalRead : null;
      const { bytesRead } = await this.read(buf, 0, buf.byteLength, pos);
      totalRead += bytesRead;
      if (bytesRead < buf.byteLength) break;
    }
    return { __proto__: null, bytesRead: totalRead, buffers };
  }

  /**
   * Writes data from multiple buffers to the file.
   * @param {Buffer[]} buffers The buffers to write from
   * @param {number|null} [position] The position to write to
   * @returns {Promise<{ bytesWritten: number, buffers: Buffer[] }>}
   */
  async writev(buffers, position) {
    this.#checkClosed('writev');
    let totalWritten = 0;
    for (let i = 0; i < buffers.length; i++) {
      const buf = buffers[i];
      const pos = position != null ? position + totalWritten : null;
      const { bytesWritten } = await this.write(buf, 0, buf.byteLength, pos);
      totalWritten += bytesWritten;
      if (bytesWritten < buf.byteLength) break;
    }
    return { __proto__: null, bytesWritten: totalWritten, buffers };
  }

  /**
   * Appends data to the file.
   * @param {Buffer|string} data The data to append
   * @param {object} [options] Options
   * @returns {Promise<void>}
   */
  async appendFile(data, options) {
    this.#checkClosed('appendFile');
    const buffer = typeof data === 'string' ?
      Buffer.from(data, options?.encoding) : data;
    await this.write(buffer, 0, buffer.length, null);
  }

  readableWebStream() {
    throw new ERR_METHOD_NOT_IMPLEMENTED('readableWebStream');
  }

  readLines() {
    throw new ERR_METHOD_NOT_IMPLEMENTED('readLines');
  }

  createReadStream() {
    throw new ERR_METHOD_NOT_IMPLEMENTED('createReadStream');
  }

  createWriteStream() {
    throw new ERR_METHOD_NOT_IMPLEMENTED('createWriteStream');
  }

  /**
   * Closes the file handle.
   * @returns {Promise<void>}
   */
  async close() {
    this[kClosed] = true;
  }

  /**
   * Closes the file handle synchronously.
   */
  closeSync() {
    this[kClosed] = true;
  }
}

VirtualFileHandle.prototype[SymbolAsyncDispose] = VirtualFileHandle.prototype.close;
VirtualFileHandle.prototype[SymbolDispose] = VirtualFileHandle.prototype.closeSync;

/**
 * A file handle for in-memory file content.
 * Used by MemoryProvider and similar providers.
 */
class MemoryFileHandle extends VirtualFileHandle {
  #content;
  #size;
  #entry;
  #getStats;
  #isReadOnly;
  #published; // The entry content this handle last saw or wrote.

  #checkClosed(syscall) {
    if (this.closed) {
      throw createEBADF(syscall);
    }
  }

  /**
   * @param {string} path The file path
   * @param {string} flags The open flags
   * @param {number} [mode] The file mode
   * @param {Buffer} content The initial file content
   * @param {object} entry The entry object (for updating content)
   * @param {Function} getStats Function to get updated stats
   * @param {Function} [isReadOnly] Returns the provider's read-only state
   */
  constructor(path, flags, mode, content, entry, getStats, isReadOnly) {
    super(path, flags, mode);
    this.#content = content;
    this.#size = content.length;
    this.#entry = entry;
    this.#getStats = getStats;
    this.#isReadOnly = isReadOnly;
    this.#published = content;

    // O_TRUNC empties the file at open time. O_APPEND does not move the
    // read offset: it only forces writes to the end, so the position stays
    // at 0 and reads start from the beginning as they do on a real file.
    if (this[kAccess].truncate) {
      this.#content = Buffer.alloc(0);
      this.#size = 0;
      if (entry) {
        entry.content = this.#content;
        this.#published = this.#content;
      }
    }
  }

  /**
   * Adopts the entry's content if another handle replaced it, since an
   * open descriptor is a live view of the file, not a snapshot.
   */
  #sync() {
    const entry = this.#entry;
    if (entry && entry.content !== this.#published) {
      this.#content = entry.content;
      this.#size = entry.content.length;
      this.#published = entry.content;
    }
  }

  /**
   * Throws EBADF if the handle was not opened for writing, or EROFS if the
   * provider is read-only.
   * @param {string} syscall The system call name
   */
  #checkWritable(syscall) {
    if (!this[kAccess].writable) {
      throw createEBADF(syscall);
    }
    if (this.#isReadOnly?.()) {
      throw createEROFS(syscall, this.path);
    }
  }

  /**
   * Throws EBADF if the handle was not opened for reading.
   */
  #checkReadable() {
    if (!this[kAccess].readable) {
      throw createEBADF('read');
    }
  }

  /**
   * Returns true if this handle was opened in append mode.
   * @returns {boolean}
   */
  #isAppend() {
    return this[kAccess].append;
  }

  /**
   * Gets the current content synchronously.
   * For dynamic content providers, this gets fresh content from the entry.
   * @returns {Buffer}
   */
  get content() {
    // If entry has a dynamic content provider, get fresh content sync
    if (this.#entry?.isDynamic && this.#entry.isDynamic()) {
      return this.#entry.getContentSync();
    }
    this.#sync();
    return this.#content.subarray(0, this.#size);
  }

  /**
   * Gets the current content asynchronously.
   * For dynamic content providers, this gets fresh content from the entry.
   * @returns {Promise<Buffer>}
   */
  async getContentAsync() {
    // If entry has a dynamic content provider, get fresh content async
    if (this.#entry?.getContentAsync) {
      return this.#entry.getContentAsync();
    }
    return this.#content;
  }

  /**
   * Reads data from the file synchronously.
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer to start writing
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position to read from (null uses current position)
   * @returns {number} The number of bytes read
   */
  readSync(buffer, offset, length, position) {
    this.#checkClosed('read');
    this.#checkReadable();

    // Get content (resolves dynamic content providers)
    const content = this.content;
    const useCurrentPosition = isCurrentPosition(position);
    const readPos = useCurrentPosition ? this.position : Number(position);
    const available = content.length - readPos;

    if (available <= 0) {
      return 0;
    }

    const bytesToRead = MathMin(length, available);
    content.copy(buffer, offset, readPos, readPos + bytesToRead);

    // Update position if not using explicit position
    if (useCurrentPosition) {
      this.position = readPos + bytesToRead;
    }

    return bytesToRead;
  }

  /**
   * Reads data from the file.
   * @param {Buffer} buffer The buffer to read into
   * @param {number} offset The offset in the buffer to start writing
   * @param {number} length The number of bytes to read
   * @param {number|null} position The position to read from (null uses current position)
   * @returns {Promise<{ bytesRead: number, buffer: Buffer }>}
   */
  async read(buffer, offset, length, position) {
    const bytesRead = this.readSync(buffer, offset, length, position);
    return { __proto__: null, bytesRead, buffer };
  }

  /**
   * Writes data to the file synchronously.
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer to start reading
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position to write to (null uses current position)
   * @returns {number} The number of bytes written
   */
  writeSync(buffer, offset, length, position) {
    this.#checkClosed('write');
    this.#checkWritable('write');
    this.#sync();

    // In append mode, always write at the end
    const useCurrentPosition = isCurrentPosition(position);
    const writePos = this.#isAppend() ?
      this.#size :
      (useCurrentPosition ? this.position : Number(position));
    const data = buffer.subarray(offset, offset + length);

    // Expand buffer if needed (geometric doubling for amortized O(1) appends)
    const neededSize = writePos + length;
    if (neededSize > this.#content.length) {
      const newCapacity = MathMax(neededSize, this.#content.length * 2);
      const newContent = Buffer.alloc(newCapacity);
      this.#content.copy(newContent, 0, 0, this.#size);
      this.#content = newContent;
    }

    // Write the data
    data.copy(this.#content, writePos);

    // Update actual content size
    if (neededSize > this.#size) {
      this.#size = neededSize;
    }

    // Update the entry's content, mtime, and ctime
    if (this.#entry) {
      const now = DateNow();
      this.#entry.content = this.#content.subarray(0, this.#size);
      this.#entry.mtime = now;
      this.#entry.ctime = now;
      this.#published = this.#entry.content;
    }

    // Update position if not using explicit position
    if (useCurrentPosition) {
      this.position = writePos + length;
    }

    return length;
  }

  /**
   * Writes data to the file.
   * @param {Buffer} buffer The buffer to write from
   * @param {number} offset The offset in the buffer to start reading
   * @param {number} length The number of bytes to write
   * @param {number|null} position The position to write to (null uses current position)
   * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>}
   */
  async write(buffer, offset, length, position) {
    const bytesWritten = this.writeSync(buffer, offset, length, position);
    return { __proto__: null, bytesWritten, buffer };
  }

  /**
   * Reads the entire file synchronously.
   * @param {object|string} [options] Options or encoding
   * @returns {Buffer|string}
   */
  readFileSync(options) {
    this.#checkClosed('read');
    this.#checkReadable();

    // Get content (resolves dynamic content providers)
    const content = this.content;
    const encoding = typeof options === 'string' ? options : options?.encoding;
    if (encoding) {
      return content.toString(encoding);
    }
    return Buffer.from(content);
  }

  /**
   * Reads the entire file.
   * @param {object|string} [options] Options or encoding
   * @returns {Promise<Buffer|string>}
   */
  async readFile(options) {
    this.#checkClosed('read');
    this.#checkReadable();

    // Get content asynchronously (supports async content providers)
    const content = await this.getContentAsync();
    const encoding = typeof options === 'string' ? options : options?.encoding;
    if (encoding) {
      return content.toString(encoding);
    }
    return Buffer.from(content);
  }

  /**
   * Writes data to the file synchronously, from the current position (the
   * end, in append mode), the way `filehandle.writeFile()` does. Whether
   * earlier content is discarded was decided by the open flags: "w" has
   * already truncated, "r+" overwrites in place and keeps any tail.
   * @param {Buffer|string} data The data to write
   * @param {object} [options] Options
   */
  writeFileSync(data, options) {
    const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data;
    this.writeSync(buffer, 0, buffer.length, null);
  }

  /**
   * Writes data to the file (replacing content).
   * @param {Buffer|string} data The data to write
   * @param {object} [options] Options
   * @returns {Promise<void>}
   */
  async writeFile(data, options) {
    this.writeFileSync(data, options);
  }

  /**
   * Gets file stats synchronously.
   * @param {object} [options] Options
   * @returns {Stats}
   */
  statSync(options) {
    this.#checkClosed('fstat');
    if (this.#getStats) {
      this.#sync();
      return this.#getStats(this.#size);
    }
    throw new ERR_INVALID_STATE('stats not available');
  }

  /**
   * @param {number} mode The new permission bits
   */
  chmodSync(mode) {
    this.#checkClosed('fchmod');
    mode = parseFileMode(mode, 'mode');
    if (this.#entry) {
      this.#entry.mode = (this.#entry.mode & ~0o7777) | (mode & 0o7777);
      this.#entry.ctime = DateNow();
    }
  }

  /**
   * @param {Date|number|string} atime The new access time
   * @param {Date|number|string} mtime The new modification time
   */
  utimesSync(atime, mtime) {
    this.#checkClosed('futimes');
    const atimeMs = toUnixTimestamp(atime, 'atime') * 1000;
    const mtimeMs = toUnixTimestamp(mtime, 'mtime') * 1000;
    if (this.#entry) {
      this.#entry.atime = atimeMs;
      this.#entry.mtime = mtimeMs;
      this.#entry.ctime = DateNow();
    }
  }

  /**
   * Gets file stats.
   * @param {object} [options] Options
   * @returns {Promise<Stats>}
   */
  async stat(options) {
    return this.statSync(options);
  }

  /**
   * Truncates the file synchronously.
   * @param {number} [len] The new length
   */
  truncateSync(len = 0) {
    this.#checkClosed('ftruncate');
    this.#checkWritable('ftruncate');
    this.#sync();

    if (len < this.#size) {
      // Zero out truncated region to avoid stale data
      this.#content.fill(0, len, this.#size);
      this.#size = len;
    } else if (len > this.#size) {
      if (len > this.#content.length) {
        const newContent = Buffer.alloc(len);
        this.#content.copy(newContent, 0, 0, this.#size);
        this.#content = newContent;
      } else {
        // Buffer has enough capacity, just zero-fill the extension
        this.#content.fill(0, this.#size, len);
      }
      this.#size = len;
    }

    // Update the entry's content, mtime, and ctime
    if (this.#entry) {
      const now = DateNow();
      this.#entry.content = this.#content.subarray(0, this.#size);
      this.#entry.mtime = now;
      this.#entry.ctime = now;
      this.#published = this.#entry.content;
    }
  }

  /**
   * Truncates the file.
   * @param {number} [len] The new length
   * @returns {Promise<void>}
   */
  async truncate(len) {
    this.truncateSync(len);
  }
}

module.exports = {
  VirtualFileHandle,
  MemoryFileHandle,
  decodeOpenFlags,
  kAccess,
};