ESC

Type to search the knowledge base.

In-Memory Filesystem

mkdir, writeFile, readFile on a tree of maps. Normalize paths, reject escape via .., and decide whether writeFile creates parents.

advanced7 min read
  • javascript
  • trees
  • interview
  • filesystem

The prompt is mkdir, writeFile, readFile. People split('/') and treat .. as a folder named two dots, or they pop the segment stack until they walk off the root into whatever happens to sit next to it. An in-memory filesystem is a tree of maps under one root. Path normalization is the API. .. must not escape that root.

This is not Node fs, not the File System Access API, and not a disk. No file descriptors, no Buffer encodings, no permissions, no cwd beyond the virtual root. If they want a UI on top, that is the file explorer tree. This page is the store.

Key takeaways

  • Directories are maps of names to nodes. Files are leaves with string content.
  • Normalize: split on /, drop empty and ., pop on .., throw if .. would leave the root.
  • mkdir can take { recursive }. Without it, the parent must already exist.
  • writeFile overwrites files and, in this implementation, does not create parent directories.
  • readFile throws on a missing path and on a directory. It does not return undefined.

mkdir, writeFile, readFile, and paths that must not escape root

Each node is either a directory (type: 'dir' plus a Map of children) or a file (type: 'file' plus a string content). A Map beats a plain object for names like __proto__ and "constructor". The root is a directory you never replace.

class MemoryFS {
  constructor() {
    this.root = { type: 'dir', children: new Map() };
  }

  normalize(path) {
    if (typeof path !== 'string' || path.length === 0) {
      throw new TypeError('path must be a non-empty string');
    }
    const parts = [];
    for (const part of path.split('/')) {
      if (part === '' || part === '.') continue;
      if (part === '..') {
        if (parts.length === 0) {
          throw new Error('EACCES: path escapes root');
        }
        parts.pop();
        continue;
      }
      parts.push(part);
    }
    return parts;
  }

  dirAt(parts, create) {
    let dir = this.root;
    for (const name of parts) {
      let next = dir.children.get(name);
      if (!next) {
        if (!create) throw new Error('ENOENT: ' + name);
        next = { type: 'dir', children: new Map() };
        dir.children.set(name, next);
      } else if (next.type !== 'dir') {
        throw new Error('ENOTDIR: ' + name);
      }
      dir = next;
    }
    return dir;
  }

  mkdir(path, { recursive = false } = {}) {
    const parts = this.normalize(path);
    if (parts.length === 0) return;
    if (recursive) {
      this.dirAt(parts, true);
      return;
    }
    const parent = this.dirAt(parts.slice(0, -1), false);
    const name = parts[parts.length - 1];
    const existing = parent.children.get(name);
    if (existing) {
      if (existing.type === 'dir') {
        throw new Error('EEXIST: ' + name);
      }
      throw new Error('EEXIST: file in the way');
    }
    parent.children.set(name, { type: 'dir', children: new Map() });
  }

  writeFile(path, content) {
    const parts = this.normalize(path);
    if (parts.length === 0) throw new Error('EISDIR: root');
    const parent = this.dirAt(parts.slice(0, -1), false);
    const name = parts[parts.length - 1];
    const existing = parent.children.get(name);
    if (existing && existing.type === 'dir') {
      throw new Error('EISDIR: ' + name);
    }
    parent.children.set(name, { type: 'file', content: String(content) });
  }

  readFile(path) {
    const parts = this.normalize(path);
    if (parts.length === 0) throw new Error('EISDIR: root');
    const parent = this.dirAt(parts.slice(0, -1), false);
    const node = parent.children.get(parts[parts.length - 1]);
    if (!node) throw new Error('ENOENT');
    if (node.type !== 'file') throw new Error('EISDIR');
    return node.content;
  }
}

The E* strings are documentation, not Node SystemError objects. Say that. Catch by message or, better, subclass Error with a code field if they want err.code === 'ENOENT' (error types).

Normalize, then walk

Input normalize Notes
/ [] The root. mkdir is a no-op. readFile / writeFile throw EISDIR.
/a/b ['a', 'b'] Absolute and a/b are the same: one root, no cwd.
/a/./b ['a', 'b'] . dropped.
/a/x/../b ['a', 'b'] .. pops x.
/../etc/passwd throw Would pop below root. This is the security question.
//a//b/ ['a', 'b'] Empty segments from repeated slashes dropped.
'' TypeError Not a path.

Numbered walk after normalize:

  1. Start at this.root.
  2. For each segment except you may stop before the last (parent lookup), Map.get the name.
  3. Missing + create === false: ENOENT.
  4. Missing + create === true: insert a new dir (mkdir recursive).
  5. Present but type === 'file' when you needed a dir: ENOTDIR.

That walk is the same shape as get/set nested objects, with maps instead of plain objects and an explicit “do not leave the root” rule .. does not have in dot paths.

writeFile does not create parents

Node’s fs.writeFile does not mkdir the parent. This implementation matches that: writeFile('/a/b.txt', 'hi') throws ENOENT unless /a already exists. State it in the first sentence of the method if they did not specify.

The other legal product is { createParents: true } that calls dirAt(parentParts, true) before writing. Pick one. Mixing “sometimes mkdir” without a flag is how callers lose files into surprise directories.

writeFile does overwrite an existing file. Second write replaces content. Writing where a directory sits is EISDIR, not a clobber.

mkdir('/a/b', { recursive: true }) creates both segments. mkdir('/a/b') without recursive requires /a and throws EEXIST if /a/b already exists as a directory (Node does this). Existing-dir-as-success is a fine variant if you say it; do not silently convert a file into a directory.

readFile throws

Missing path: throw. Directory: throw. Do not return undefined or null. Callers cannot tell “empty file” from “no file” if you return a falsey sentinel. Empty file is ''.

const fs = new MemoryFS();
fs.mkdir('/docs', { recursive: true });
fs.writeFile('/docs/readme.txt', 'hello');
fs.readFile('/docs/readme.txt'); // 'hello'
fs.writeFile('/docs/readme.txt', 'world');
fs.readFile('/docs/readme.txt'); // 'world'

fs.readFile('/docs'); // throws EISDIR
fs.readFile('/nope'); // throws ENOENT
fs.writeFile('/missing/x.txt', 'z'); // throws ENOENT (parent not created)
fs.mkdir('/../../etc'); // throws: escapes root

Relative .. inside the tree is allowed: /docs/../docs/readme.txt normalizes to /docs/readme.txt. Escape is only “pop when the stack is empty.”

What you are not building

Not Node fs: no open, no flags, no encoding: 'utf8' vs Buffer, no fs.promises, no umask. Not File System Access: no user prompt, no real disk. Not a trie of characters; names are whole path segments in a Map.

Out of scope unless they add a round: rm, readdir, stat, rename, copy, watches, binary files. readdir is [...dir.children.keys()] if they do. Flattening the tree into dot paths is a different prompt (flatten / unflatten).

Footguns of an in-memory FS

  1. .. as a literal key. If you skip normalize, children.get('..') grows a folder that looks like parent but is not. Escape bugs hide there.
  2. Popping past root into undefined. Then undefined.children throws, or worse, you create a second tree. Throw instead.
  3. Plain objects as directories. {'__proto__': file} is a prototype mess. Use Map.
  4. writeFile mkdir by accident. Decide. This page’s code does not create parents.
  5. readFile on a dir returning the Map. Leaks the store. Throw EISDIR.
  6. Treating '' or '/' as a file name. Root is a dir. Empty string is not a segment after normalize (leading slash). A file named '' would only appear if you refused to drop empty segments; don’t.
  7. Shared mutable content. readFile returns the string. Strings are immutable. If you later store objects as content, clone on read.

Windows backslashes are not /. This API is POSIX-shaped. Do not split(/[/\\]/) unless they ask.

Interview answer (out loud)

“I keep a directory tree where each dir is a Map of names to file or dir nodes. Every operation normalizes the path: split on slash, ignore empty and ., pop on .., and throw if .. would leave the root. mkdir has a recursive flag; without it the parent must exist. writeFile overwrites files and does not create parents in my implementation, so a missing /a on /a/b.txt is ENOENT. readFile throws on missing and on directories. This is not Node fs. I would not use a plain object as the directory, because keys like __proto__ are a mess.”

Further reading

Related guides