branch:
in-memory-fs.ts
20938 bytesRaw
import { createGlobMatcher, sortPaths } from "../helpers";
import { fromBuffer, getEncoding, toBuffer } from "./encoding";
import type {
  BufferEncoding,
  CpOptions,
  FileContent,
  FileInit,
  FileSystem,
  FileSystemDirent,
  FileSystemEntryType,
  FsEntry,
  FsStat,
  InitialFiles,
  LazyFileProvider,
  MkdirOptions,
  ReadFileOptions,
  RmOptions,
  WriteFileOptions
} from "./interface";
import {
  DEFAULT_DIR_MODE,
  DEFAULT_FILE_MODE,
  MAX_SYMLINK_DEPTH,
  normalizePath,
  resolvePath,
  SYMLINK_MODE,
  validatePath
} from "./path-utils";

export type { FileContent, FsEntry, FsStat, FileSystem };

export interface FsData {
  [path: string]: FsEntry;
}

// ── Tree node types ──────────────────────────────────────────────────
//
// Storage is a rooted tree where each directory holds a Map of its
// children.  This gives O(children) directory listing and natural
// recursive operations instead of scanning every key in a flat map.

interface VFileNode {
  kind: "file";
  bytes: Uint8Array;
  mode: number;
  mtime: Date;
}

interface VLazyNode {
  kind: "lazy";
  provider: () => string | Uint8Array | Promise<string | Uint8Array>;
  mode: number;
  mtime: Date;
}

interface VDirNode {
  kind: "dir";
  children: Map<string, VNode>;
  mode: number;
  mtime: Date;
}

interface VSymlinkNode {
  kind: "symlink";
  target: string;
  mode: number;
  mtime: Date;
}

type VNode = VFileNode | VLazyNode | VDirNode | VSymlinkNode;

interface Located {
  node: VNode;
  parent: VDirNode;
  key: string;
}

const utf8 = new TextEncoder();

function split(normalized: string): string[] {
  return normalized === "/" ? [] : normalized.slice(1).split("/");
}

function freshDir(): VDirNode {
  return {
    kind: "dir",
    children: new Map(),
    mode: DEFAULT_DIR_MODE,
    mtime: new Date()
  };
}

function kindToType(node: VNode): FileSystemEntryType {
  if (node.kind === "file" || node.kind === "lazy") return "file";
  if (node.kind === "dir") return "directory";
  return "symlink";
}

function nodeSize(node: VNode): number {
  if (node.kind === "file") return node.bytes.length;
  if (node.kind === "symlink") return node.target.length;
  return 0;
}

function isInitObj(
  v: FileContent | FileInit | LazyFileProvider
): v is FileInit {
  return (
    typeof v === "object" &&
    v !== null &&
    !(v instanceof Uint8Array) &&
    "content" in v
  );
}

export class InMemoryFs implements FileSystem {
  private tree: VDirNode;

  constructor(initialFiles?: InitialFiles) {
    this.tree = freshDir();
    if (!initialFiles) return;
    for (const [p, v] of Object.entries(initialFiles)) {
      if (typeof v === "function") {
        this.insertLazy(p, v);
      } else if (isInitObj(v)) {
        this.insertContent(
          p,
          v.content,
          getEncoding(undefined),
          v.mode,
          v.mtime
        );
      } else {
        this.insertContent(p, v);
      }
    }
  }

  // ── Sync helpers (used by consumers and constructor) ────────────────

  writeFileSync(
    path: string,
    content: FileContent,
    options?: WriteFileOptions | BufferEncoding,
    metadata?: { mode?: number; mtime?: Date }
  ): void {
    this.insertContent(
      path,
      content,
      getEncoding(options),
      metadata?.mode,
      metadata?.mtime
    );
  }

  writeFileLazy(
    path: string,
    lazy: () => string | Uint8Array | Promise<string | Uint8Array>,
    metadata?: { mode?: number; mtime?: Date }
  ): void {
    this.insertLazy(path, lazy, metadata?.mode, metadata?.mtime);
  }

  mkdirSync(path: string, options?: MkdirOptions): void {
    validatePath(path, "mkdir");
    const norm = normalizePath(path);
    if (norm === "/") {
      if (!options?.recursive) {
        throw new Error(`EEXIST: directory already exists, mkdir '${path}'`);
      }
      return;
    }
    const segs = split(norm);
    let dir = this.tree;
    for (let i = 0; i < segs.length; i++) {
      const last = i === segs.length - 1;
      const child = dir.children.get(segs[i]);
      if (child) {
        if (child.kind === "dir") {
          if (last) {
            if (!options?.recursive) {
              throw new Error(
                `EEXIST: directory already exists, mkdir '${path}'`
              );
            }
            return;
          }
          dir = child;
        } else if (last) {
          throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
        } else if (options?.recursive) {
          const d = freshDir();
          dir.children.set(segs[i], d);
          dir = d;
        } else {
          throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
        }
      } else if (last) {
        dir.children.set(segs[i], freshDir());
      } else if (options?.recursive) {
        const d = freshDir();
        dir.children.set(segs[i], d);
        dir = d;
      } else {
        throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
      }
    }
  }

  // ── FileSystem interface ───────────────────────────────────────────

  async readFile(
    path: string,
    options?: ReadFileOptions | BufferEncoding
  ): Promise<string> {
    return fromBuffer(await this.readFileBytes(path), getEncoding(options));
  }

  async readFileBytes(path: string): Promise<Uint8Array> {
    validatePath(path, "open");
    if (normalizePath(path) === "/") {
      throw new Error(
        `EISDIR: illegal operation on a directory, read '${path}'`
      );
    }
    const loc = this.locate(path, true, "open");
    if (!loc) throw this.missing("open", path);
    if (loc.node.kind === "dir" || loc.node.kind === "symlink") {
      throw new Error(
        `EISDIR: illegal operation on a directory, read '${path}'`
      );
    }
    if (loc.node.kind === "lazy") return this.forceLazy(loc);
    return loc.node.bytes;
  }

  async writeFile(
    path: string,
    content: string,
    options?: WriteFileOptions | BufferEncoding
  ): Promise<void> {
    this.insertContent(path, content, getEncoding(options));
  }

  async writeFileBytes(path: string, content: Uint8Array): Promise<void> {
    this.insertContent(path, content);
  }

  async appendFile(path: string, content: string | Uint8Array): Promise<void> {
    validatePath(path, "append");
    const extra = typeof content === "string" ? utf8.encode(content) : content;
    const loc = this.locate(path, true, "append");

    if (loc?.node.kind === "dir") {
      throw new Error(
        `EISDIR: illegal operation on a directory, write '${path}'`
      );
    }

    if (!loc) {
      this.insertContent(path, content);
      return;
    }

    let existing: Uint8Array;
    if (loc.node.kind === "lazy") {
      existing = await this.forceLazy(loc);
    } else if (loc.node.kind === "file") {
      existing = loc.node.bytes;
    } else {
      this.insertContent(path, content);
      return;
    }

    const merged = new Uint8Array(existing.length + extra.length);
    merged.set(existing);
    merged.set(extra, existing.length);

    const fresh = loc.parent.children.get(loc.key);
    if (fresh && fresh.kind === "file") {
      fresh.bytes = merged;
      fresh.mtime = new Date();
    }
  }

  async exists(path: string): Promise<boolean> {
    if (path.includes("\0")) return false;
    try {
      if (normalizePath(path) === "/") return true;
      return this.locate(path, true, "access") !== null;
    } catch {
      return false;
    }
  }

  async stat(path: string): Promise<FsStat> {
    validatePath(path, "stat");
    if (normalizePath(path) === "/") {
      return {
        type: "directory",
        size: 0,
        mtime: this.tree.mtime,
        mode: this.tree.mode
      };
    }
    const loc = this.locate(path, true, "stat");
    if (!loc) throw this.missing("stat", path);
    if (loc.node.kind === "lazy") await this.forceLazy(loc);
    const n = loc.parent.children.get(loc.key);
    if (!n) throw this.missing("stat", path);
    return {
      type: kindToType(n),
      size: nodeSize(n),
      mtime: n.mtime,
      mode: n.mode
    };
  }

  async lstat(path: string): Promise<FsStat> {
    validatePath(path, "lstat");
    if (normalizePath(path) === "/") {
      return {
        type: "directory",
        size: 0,
        mtime: this.tree.mtime,
        mode: this.tree.mode
      };
    }
    const loc = this.locate(path, false, "lstat");
    if (!loc) throw this.missing("lstat", path);
    if (loc.node.kind === "symlink") {
      return {
        type: "symlink",
        size: loc.node.target.length,
        mtime: loc.node.mtime,
        mode: loc.node.mode
      };
    }
    if (loc.node.kind === "lazy") await this.forceLazy(loc);
    const n = loc.parent.children.get(loc.key);
    if (!n) throw this.missing("lstat", path);
    return {
      type: kindToType(n),
      size: nodeSize(n),
      mtime: n.mtime,
      mode: n.mode
    };
  }

  async mkdir(path: string, options?: MkdirOptions): Promise<void> {
    this.mkdirSync(path, options);
  }

  async readdir(path: string): Promise<string[]> {
    return (await this.readdirWithFileTypes(path)).map((d) => d.name);
  }

  async readdirWithFileTypes(path: string): Promise<FileSystemDirent[]> {
    validatePath(path, "scandir");
    const dir = this.resolveNode(path, true, "scandir");
    if (!dir) throw this.missing("scandir", path);
    if (dir.kind !== "dir") {
      throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
    }
    const out: FileSystemDirent[] = [];
    for (const [name, child] of dir.children) {
      out.push({ name, type: kindToType(child) });
    }
    return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
  }

  async rm(path: string, options?: RmOptions): Promise<void> {
    validatePath(path, "rm");
    const segs = split(normalizePath(path));
    if (segs.length === 0) {
      if (options?.force) return;
      throw new Error(`EPERM: cannot remove root, rm '${path}'`);
    }

    let dir = this.tree;
    for (let i = 0; i < segs.length - 1; i++) {
      const next = dir.children.get(segs[i]);
      if (!next || next.kind !== "dir") {
        if (options?.force) return;
        throw this.missing("rm", path);
      }
      dir = next;
    }

    const name = segs[segs.length - 1];
    const target = dir.children.get(name);
    if (!target) {
      if (options?.force) return;
      throw this.missing("rm", path);
    }
    if (
      target.kind === "dir" &&
      target.children.size > 0 &&
      !options?.recursive
    ) {
      throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`);
    }
    dir.children.delete(name);
  }

  async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
    validatePath(src, "cp");
    validatePath(dest, "cp");
    const srcNode = this.resolveNode(src, false, "cp");
    if (!srcNode) throw this.missing("cp", src);
    if (srcNode.kind === "dir" && !options?.recursive) {
      throw new Error(`EISDIR: is a directory, cp '${src}'`);
    }
    this.placeNode(normalizePath(dest), this.deepClone(srcNode));
  }

  async mv(src: string, dest: string): Promise<void> {
    await this.cp(src, dest, { recursive: true });
    await this.rm(src, { recursive: true });
  }

  async symlink(target: string, linkPath: string): Promise<void> {
    validatePath(linkPath, "symlink");
    const segs = split(normalizePath(linkPath));
    if (segs.length === 0) {
      throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`);
    }
    const parent = this.scaffold(segs);
    const name = segs[segs.length - 1];
    if (parent.children.has(name)) {
      throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`);
    }
    parent.children.set(name, {
      kind: "symlink",
      target,
      mode: SYMLINK_MODE,
      mtime: new Date()
    });
  }

  async link(existingPath: string, newPath: string): Promise<void> {
    validatePath(existingPath, "link");
    validatePath(newPath, "link");
    const srcLoc = this.locate(existingPath, true, "link");
    if (!srcLoc) throw this.missing("link", existingPath);
    if (srcLoc.node.kind !== "file" && srcLoc.node.kind !== "lazy") {
      throw new Error(`EPERM: operation not permitted, link '${existingPath}'`);
    }
    const segs = split(normalizePath(newPath));
    if (segs.length === 0) {
      throw new Error(`EEXIST: file already exists, link '${newPath}'`);
    }
    const parent = this.scaffold(segs);
    const name = segs[segs.length - 1];
    if (parent.children.has(name)) {
      throw new Error(`EEXIST: file already exists, link '${newPath}'`);
    }
    let bytes: Uint8Array;
    if (srcLoc.node.kind === "lazy") {
      bytes = await this.forceLazy(srcLoc);
    } else {
      bytes = srcLoc.node.bytes;
    }
    parent.children.set(name, {
      kind: "file",
      bytes,
      mode: srcLoc.node.mode,
      mtime: srcLoc.node.mtime
    });
  }

  async readlink(path: string): Promise<string> {
    validatePath(path, "readlink");
    const loc = this.locate(path, false, "readlink");
    if (!loc) throw this.missing("readlink", path);
    if (loc.node.kind !== "symlink") {
      throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
    }
    return loc.node.target;
  }

  async realpath(path: string): Promise<string> {
    validatePath(path, "realpath");
    const canon = this.canonicalize(path);
    if (canon === null) throw this.missing("realpath", path);
    return canon;
  }

  resolvePath(base: string, path: string): string {
    return resolvePath(base, path);
  }

  async glob(pattern: string): Promise<string[]> {
    const re = createGlobMatcher(pattern);
    const hits: string[] = [];
    this.gather(this.tree, "", re, hits);
    return sortPaths(hits);
  }

  async chmod(path: string, mode: number): Promise<void> {
    validatePath(path, "chmod");
    const node = this.resolveNode(path, true, "chmod");
    if (!node) throw this.missing("chmod", path);
    node.mode = mode;
  }

  async utimes(path: string, _atime: Date, mtime: Date): Promise<void> {
    validatePath(path, "utimes");
    const node = this.resolveNode(path, true, "utimes");
    if (!node) throw this.missing("utimes", path);
    node.mtime = mtime;
  }

  // ── Tree traversal ─────────────────────────────────────────────────
  //
  // Symlinks are resolved via a pending-segment stack: when a symlink
  // is encountered, its target segments replace the current position
  // and traversal restarts from root.  This avoids the string-key
  // lookups of a flat map and naturally handles chained symlinks.

  private resolveNode(
    rawPath: string,
    followLast: boolean,
    op: string
  ): VNode | null {
    if (normalizePath(rawPath) === "/") return this.tree;
    const loc = this.locate(rawPath, followLast, op);
    return loc ? loc.node : null;
  }

  private locate(
    rawPath: string,
    followLast: boolean,
    op: string
  ): Located | null {
    const norm = normalizePath(rawPath);
    if (norm === "/") return null;

    const pending = split(norm);
    const trail: string[] = [];
    let dir = this.tree;
    let budget = MAX_SYMLINK_DEPTH;

    while (pending.length > 0) {
      const seg = pending.shift()!;
      const child = dir.children.get(seg);
      if (!child) return null;

      const last = pending.length === 0;

      if (child.kind === "symlink" && (!last || followLast)) {
        if (--budget < 0) {
          throw new Error(
            `ELOOP: too many levels of symbolic links, ${op} '${rawPath}'`
          );
        }
        const base = trail.length > 0 ? "/" + trail.join("/") : "";
        const abs = child.target.startsWith("/")
          ? normalizePath(child.target)
          : normalizePath(base + "/" + child.target);
        pending.unshift(...split(abs));
        trail.length = 0;
        dir = this.tree;
        continue;
      }

      if (last) return { node: child, parent: dir, key: seg };
      if (child.kind !== "dir") return null;

      trail.push(seg);
      dir = child;
    }

    return null;
  }

  private canonicalize(rawPath: string): string | null {
    const norm = normalizePath(rawPath);
    if (norm === "/") return "/";

    const pending = split(norm);
    const resolved: string[] = [];
    let dir = this.tree;
    let budget = MAX_SYMLINK_DEPTH;

    while (pending.length > 0) {
      const seg = pending.shift()!;
      const child = dir.children.get(seg);
      if (!child) return null;

      if (child.kind === "symlink") {
        if (--budget < 0) {
          throw new Error(
            `ELOOP: too many levels of symbolic links, realpath '${rawPath}'`
          );
        }
        const base = resolved.length > 0 ? "/" + resolved.join("/") : "";
        const abs = child.target.startsWith("/")
          ? normalizePath(child.target)
          : normalizePath(base + "/" + child.target);
        pending.unshift(...split(abs));
        resolved.length = 0;
        dir = this.tree;
        continue;
      }

      resolved.push(seg);
      if (child.kind === "dir" && pending.length > 0) {
        dir = child;
      } else if (pending.length > 0) {
        return null;
      }
    }

    return "/" + resolved.join("/");
  }

  // ── Mutation helpers ───────────────────────────────────────────────

  private insertContent(
    rawPath: string,
    content: FileContent,
    encoding?: BufferEncoding,
    mode?: number,
    mtime?: Date
  ): void {
    validatePath(rawPath, "write");
    const segs = split(normalizePath(rawPath));
    if (segs.length === 0) {
      throw new Error(
        `EISDIR: illegal operation on a directory, write '${rawPath}'`
      );
    }
    const parent = this.scaffold(segs);
    parent.children.set(segs[segs.length - 1], {
      kind: "file",
      bytes: toBuffer(content, encoding),
      mode: mode ?? DEFAULT_FILE_MODE,
      mtime: mtime ?? new Date()
    });
  }

  private insertLazy(
    rawPath: string,
    provider: () => string | Uint8Array | Promise<string | Uint8Array>,
    mode?: number,
    mtime?: Date
  ): void {
    validatePath(rawPath, "write");
    const segs = split(normalizePath(rawPath));
    if (segs.length === 0) return;
    const parent = this.scaffold(segs);
    parent.children.set(segs[segs.length - 1], {
      kind: "lazy",
      provider,
      mode: mode ?? DEFAULT_FILE_MODE,
      mtime: mtime ?? new Date()
    });
  }

  private async forceLazy(loc: Located): Promise<Uint8Array> {
    const lazy = loc.node as VLazyNode;
    const raw = await lazy.provider();
    const bytes = typeof raw === "string" ? utf8.encode(raw) : raw;
    loc.parent.children.set(loc.key, {
      kind: "file",
      bytes,
      mode: lazy.mode,
      mtime: lazy.mtime
    });
    return bytes;
  }

  private scaffold(segs: string[]): VDirNode {
    let dir = this.tree;
    for (let i = 0; i < segs.length - 1; i++) {
      const child = dir.children.get(segs[i]);
      if (child && child.kind === "dir") {
        dir = child;
      } else {
        const d = freshDir();
        dir.children.set(segs[i], d);
        dir = d;
      }
    }
    return dir;
  }

  private placeNode(normalized: string, node: VNode): void {
    const segs = split(normalized);
    if (segs.length === 0) {
      throw new Error(
        `EISDIR: illegal operation on a directory, write '${normalized}'`
      );
    }
    const parent = this.scaffold(segs);
    parent.children.set(segs[segs.length - 1], node);
  }

  private deepClone(node: VNode): VNode {
    switch (node.kind) {
      case "file":
        return {
          kind: "file",
          bytes: new Uint8Array(node.bytes),
          mode: node.mode,
          mtime: node.mtime
        };
      case "lazy":
        return { ...node };
      case "symlink":
        return { ...node };
      case "dir": {
        const clone: VDirNode = {
          kind: "dir",
          children: new Map(),
          mode: node.mode,
          mtime: node.mtime
        };
        for (const [k, v] of node.children) {
          clone.children.set(k, this.deepClone(v));
        }
        return clone;
      }
    }
  }

  private gather(
    dir: VDirNode,
    prefix: string,
    re: RegExp,
    out: string[]
  ): void {
    for (const [name, child] of dir.children) {
      const full = prefix + "/" + name;
      if (re.test(full)) out.push(full);
      if (child.kind === "dir") this.gather(child, full, re, out);
    }
  }

  private missing(op: string, path: string): Error {
    return new Error(`ENOENT: no such file or directory, ${op} '${path}'`);
  }
}