import type { NodeishFilesystem, NodeishStats } from "../interface.js" import { FilesystemError } from "../errors/FilesystemError.js" type Inode = Uint8Array | Set export function createMemoryFs(): NodeishFilesystem { // local state const fsMap: Map = new Map() const fsStats: Map = new Map() // initialize the root to an empty dir fsMap.set("/", new Set()) newStatEntry("/", fsStats, 1, 0o755) async function stat(path: Parameters[0]): Promise { path = normalPath(path) const stats: NodeishStats | undefined = fsStats.get(path) if (stats === undefined) throw new FilesystemError("ENOENT", path, "stat") if (stats.symlinkTarget) return stat(stats.symlinkTarget) return Object.assign({}, stats) } async function lstat(path: Parameters[0]) { path = normalPath(path) const stats: NodeishStats | undefined = fsStats.get(path) if (stats === undefined) throw new FilesystemError("ENOENT", path, "lstat") if (!stats.symlinkTarget) return stat(path) return Object.assign({}, stats) } return { writeFile: async function ( path: Parameters[0], data: Parameters[1], options?: Parameters[2], ) { const encoder = new TextEncoder() path = normalPath(path) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (!(parentDir instanceof Set)) throw new FilesystemError("ENOENT", path, "writeFile") if (typeof data === "string") { data = encoder.encode(data) } parentDir.add(getBasename(path)) newStatEntry(path, fsStats, 0, options?.mode ?? 0o644) fsMap.set(path, data) }, readFile: async function ( path: Parameters[0], options: Parameters[1], ) { const decoder = new TextDecoder() path = normalPath(path) const file: Inode | undefined = fsMap.get(path) if (file instanceof Set) throw new FilesystemError("EISDIR", path, "readFile") if (file === undefined) throw new FilesystemError("ENOENT", path, "readFile") if (!(options?.encoding || typeof options === "string")) return file return decoder.decode(file) }, readdir: async function (path: Parameters[0]) { path = normalPath(path) const dir: Inode | undefined = fsMap.get(path) if (dir instanceof Set) return [...dir.keys()] if (dir === undefined) throw new FilesystemError("ENOENT", path, "readdir") throw new FilesystemError("ENOTDIR", path, "readdir") }, mkdir: async function mkdir( path: Parameters[0], options: Parameters[1], ): Promise { path = normalPath(path) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (typeof parentDir === "string") throw new FilesystemError("ENOTDIR", path, "mkdir") if (parentDir && parentDir instanceof Set) { parentDir.add(getBasename(path)) newStatEntry(path, fsStats, 1, 0o755) fsMap.set(path, new Set()) return path } if (options?.recursive) { const firstPath = await mkdir(getDirname(path), options) await mkdir(path, options) return firstPath } throw new FilesystemError("ENOENT", path, "mkdir") }, rm: async function rm( path: Parameters[0], options: Parameters[1], ) { path = normalPath(path) const target: Inode | undefined = fsMap.get(path) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (parentDir === undefined || target === undefined) throw new FilesystemError("ENOENT", path, "rm") if (parentDir instanceof Uint8Array) throw new FilesystemError("ENOTDIR", path, "rm") if (target instanceof Uint8Array) { parentDir.delete(getBasename(path)) fsStats.delete(path) fsMap.delete(path) return } if (target instanceof Set && options?.recursive) { await Promise.all( [...target.keys()].map(async (child) => { await rm(`${path}/${child}`, { recursive: true }) }), ) parentDir.delete(getBasename(path)) fsStats.delete(path) fsMap.delete(path) return } throw new FilesystemError("EISDIR", path, "rm") }, rmdir: async function (path: Parameters[0]) { path = normalPath(path) const target: Inode | undefined = fsMap.get(path) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (parentDir === undefined || target === undefined) throw new FilesystemError("ENOENT", path, "rmdir") if (parentDir instanceof Uint8Array || target instanceof Uint8Array) throw new FilesystemError("ENOTDIR", path, "rmdir") if (target.size) throw new FilesystemError("ENOTEMPTY", path, "rmdir") parentDir.delete(getBasename(path)) fsStats.delete(path) fsMap.delete(path) }, symlink: async function ( target: Parameters[0], path: Parameters[1], ) { path = normalPath(path) target = target.startsWith("/") ? target : `${path}/../${target}` const targetInode: Inode | undefined = fsMap.get(normalPath(target)) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (fsMap.get(path)) throw new FilesystemError("EEXIST", path, "symlink", target) if (parentDir instanceof Uint8Array) throw new FilesystemError("ENOTDIR", path, "symlink", target) if (targetInode === undefined || parentDir === undefined) throw new FilesystemError("ENOENT", path, "symlink", target) parentDir.add(getBasename(path)) newStatEntry(path, fsStats, 2, 0o777, target) fsMap.set(path, targetInode) }, unlink: async function (path: Parameters[0]) { path = normalPath(path) const targetStats = fsStats.get(path) const target: Inode | undefined = fsMap.get(path) const parentDir: Inode | undefined = fsMap.get(getDirname(path)) if (parentDir === undefined || target === undefined) throw new FilesystemError("ENOENT", path, "unlink") if (parentDir instanceof Uint8Array) throw new FilesystemError("ENOTDIR", path, "unlink") if (targetStats?.isDirectory()) throw new FilesystemError("EISDIR", path, "unlink") parentDir.delete(getBasename(path)) fsStats.delete(path) fsMap.delete(path) }, readlink: async function (path: Parameters[0]) { path = normalPath(path) const linkStats = await lstat(path) if (linkStats === undefined) throw new FilesystemError("ENOENT", path, "readlink") if (linkStats.symlinkTarget === undefined) throw new FilesystemError("EINVAL", path, "readlink") return linkStats.symlinkTarget }, stat, lstat, } } /** * Creates a new stat entry. 'kind' refers to whether the entry is for a file, * directory or symlink: * 0 = File * 1 = Directory * 2 = Symlink */ function newStatEntry( path: string, stats: Map, kind: number, modeBits: number, target?: string, ) { const cdateMs: number = Date.now() stats.set(normalPath(path), { ctimeMs: cdateMs, mtimeMs: cdateMs, dev: 0, ino: stats.size + 1, mode: (!kind ? 0o100000 : kind === 1 ? 0o040000 : 0o120000) | modeBits, uid: 0, gid: 0, size: -1, isFile: () => kind === 0, isDirectory: () => kind === 1, isSymbolicLink: () => kind === 2, // symlinkTarget is only for symlinks, and is not normalized symlinkTarget: target, }) } function getDirname(path: string): string { return normalPath( path .split("/") .filter((x) => x) .slice(0, -1) .join("/") ?? path, ) } function getBasename(path: string): string { return ( path .split("/") .filter((x) => x) .at(-1) ?? path ) } /** * Removes extraneous dots and slashes, resolves relative paths and ensures the * path begins and ends with '/' */ function normalPath(path: string): string { const dots = /(\/|^)(\.\/)+/g const slashes = /\/+/g const upreference = /(?