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; mode: number; mtime: Date; } interface VDirNode { kind: "dir"; children: Map; 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, 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 { return fromBuffer(await this.readFileBytes(path), getEncoding(options)); } async readFileBytes(path: string): Promise { 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 { this.insertContent(path, content, getEncoding(options)); } async writeFileBytes(path: string, content: Uint8Array): Promise { this.insertContent(path, content); } async appendFile(path: string, content: string | Uint8Array): Promise { 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 { 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 { 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 { 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 { this.mkdirSync(path, options); } async readdir(path: string): Promise { return (await this.readdirWithFileTypes(path)).map((d) => d.name); } async readdirWithFileTypes(path: string): Promise { 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 { 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 { 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 { await this.cp(src, dest, { recursive: true }); await this.rm(src, { recursive: true }); } async symlink(target: string, linkPath: string): Promise { 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 { 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 { 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 { 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 { const re = createGlobMatcher(pattern); const hits: string[] = []; this.gather(this.tree, "", re, hits); return sortPaths(hits); } async chmod(path: string, mode: number): Promise { 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 { 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, 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 { 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}'`); } }