
# Writing a Driver

**Three methods — `stat`, `readdir`, `open` — and you have a filesystem. Everything past those is optional.**

There is nothing to extend, register or implement beyond writing the methods, and no base class to inherit: an object literal is a driver. Check the [built-in drivers](/guide/drivers/built-in) first — memory, node-fs and unstorage may already be what you need, or the thing you wrap. What follows is for when they are not.

## The minimum

Three methods are required. Everything else is optional, and **a missing method means the capability is absent** — the mount answers `ENOSYS`/`ENOTSUP` rather than pretending.

```ts
interface FsDriver {
  stat(path: string): Promise<StatsLike>;
  readdir(path: string, options: { withFileTypes: true }): Promise<DirentLike[]>;
  open(path: string, flags?: string | number, mode?: number): Promise<FileHandleLike>;
}
```

Every `path` your driver receives is **absolute, POSIX-style and already normalized** — the harness and the session layer guarantee that, and `..` clamps at the root, so you never have to defend against `/foo/../../etc/passwd`.

The optional methods are the rest of `fs/promises`: `lstat`, `statfs`, `mkdir`, `rmdir`, `unlink`, `rename`, `link`, `symlink`, `readlink`, `chmod`, `chown`, `lchown`, `truncate`, `utimes`, `lutimes`. Full signatures in the [reference](/reference/driver-interface#fsdriver).

## A complete filesystem

A read-only filesystem with one file in it:

```ts
import { createLoopback, fsError, S_IFDIR, S_IFMT, S_IFREG } from "mountx";
import type { DirentLike, FsDriver, StatsLike } from "mountx";

const content = new TextEncoder().encode("hello from JavaScript\n");

// `stat` returns the same fields as `fs.Stats`, so a helper keeps it short.
function stats(mode: number, size: number, ino: number): StatsLike {
  const now = Date.now();
  const is = (type: number) => () => (mode & S_IFMT) === type;
  return {
    dev: 1,
    ino,
    mode,
    nlink: 1,
    uid: 0,
    gid: 0,
    rdev: 0,
    size,
    blksize: 4096,
    blocks: Math.ceil(size / 512),
    atimeMs: now,
    mtimeMs: now,
    ctimeMs: now,
    birthtimeMs: now,
    isFile: is(S_IFREG),
    isDirectory: is(S_IFDIR),
    isSymbolicLink: () => false,
    isBlockDevice: () => false,
    isCharacterDevice: () => false,
    isFIFO: () => false,
    isSocket: () => false,
  };
}

// A directory entry is a name plus the same set of type questions.
const dirent = (name: string, mode: number): DirentLike => ({ name, ...stats(mode, 0, 0) });

const driver: FsDriver = {
  capabilities: { readOnly: true },

  async stat(path) {
    if (path === "/") return stats(S_IFDIR | 0o555, 0, 1);
    if (path === "/hello.txt") return stats(S_IFREG | 0o444, content.length, 2);
    throw fsError("ENOENT", { syscall: "stat", path });
  },

  async readdir(path) {
    if (path !== "/") throw fsError("ENOTDIR", { syscall: "scandir", path });
    return [dirent("hello.txt", S_IFREG | 0o444)];
  },

  async open(path) {
    if (path !== "/hello.txt") throw fsError("ENOENT", { syscall: "open", path });
    return {
      async read(buffer, offset, length, position) {
        const start = position ?? 0;
        const slice = content.subarray(start, start + (length ?? buffer.length));
        buffer.set(slice, offset ?? 0);
        return { bytesRead: slice.length, buffer };
      },
      async stat() {
        return stats(S_IFREG | 0o444, content.length, 2);
      },
      // Read-only, so the two writing methods just say so.
      async write(): Promise<never> {
        throw fsError("EROFS", { syscall: "write", path });
      },
      async truncate(): Promise<never> {
        throw fsError("EROFS", { syscall: "ftruncate", path });
      },
      async close() {},
    };
  },
};

const fs = createLoopback(driver);
console.log(new TextDecoder().decode(await fs.readFile("/hello.txt")));
// "hello from JavaScript"
```

Swap `createLoopback(driver)` for `mount(driver, "/mnt/point")` and the same code is a folder you can `cat`.

::note
`StatsLike`, `DirentLike` and `FileHandleLike` are the smallest structural shapes the transports need. Node's own `Stats`, `Dirent` and `FileHandle` satisfy them unmodified — so if you are wrapping real `fs` calls, you can return what `fs` gave you.
::

## File handles

`open()` returns a `FileHandleLike`: `read`, `write`, `stat`, `truncate`, `close`, and optionally `sync`/`datasync`. Both `read` and `write` take `(buffer, offset, length, position)` and resolve to `{ bytesRead | bytesWritten, buffer }`, exactly as `fs/promises` does.

Whether that handle is _real state_ is the `handles` capability, and it cannot be inferred:

- **`handles: true`** — your handle survives the path it came from. A file deleted while open stays readable, and the session keeps the handle for the lifetime of the open.
- **unset (the default)** — the session re-opens from the path for each operation. Correct, just chattier, and a deleted-but-open file is gone.

## Errors

Throw what `node:fs` throws: an error carrying a POSIX `code` and a negative libuv `errno`. `fsError()` builds one byte-for-byte identical to Node's, and it travels to the kernel untranslated:

```ts
import { fsError, isFsError } from "mountx";

throw fsError("EACCES", { syscall: "open", path });
```

If you are forwarding an error from a real `fs` call, just let it through — it is already the right shape. `isFsError(error, "ENOENT")` is the type guard for the other direction, and `errnoOf(error)` is what the transports use to turn anything thrown into a wire errno (unknown values become `EIO`).

The one hard rule the transports keep for you: **exactly one reply per request**. Whatever you throw, the kernel gets an answer.

::note
`ERRNO_CODES` in `mountx` is the Linux errno table, transcribed once. It is the numbers on the wire — not necessarily your host's, which is why `fsError` is worth using rather than hand-building an error.
::

## Capabilities

mountx works out what your driver supports from which methods exist. Two things cannot be guessed from shape, so declare them if they are true:

```ts
const driver = {
  capabilities: {
    handles: true, // open() returns real state that survives unlink
    atomicRename: true, // rename() replaces the target in one step
  },
  // ...
};
```

The full set, and how each is decided when you do not declare it:

| capability      | inferred from                    | meaning                                        |
| --------------- | -------------------------------- | ---------------------------------------------- |
| `handles`       | **never** — defaults `false`     | `open()` returns real per-open state           |
| `atomicRename`  | **never** — defaults `false`     | `rename()` replaces the destination atomically |
| `hardlinks`     | `link` exists                    | `link()` works and `nlink` is counted          |
| `symlinks`      | `symlink` + `readlink` + `lstat` | symlinks work and `lstat` differs from `stat`  |
| `permissions`   | `chmod` exists                   | mode and ownership bits are stored             |
| `times`         | `utimes` exists                  | timestamps are stored and returned             |
| `truncate`      | `truncate` exists                | truncation works                               |
| `caseSensitive` | defaults `true`                  | names differing only in case are distinct      |
| `statfs`        | `statfs` exists                  | `statfs()` returns meaningful numbers          |
| `readOnly`      | no `unlink`, `mkdir` or `rename` | every mutating operation answers `EROFS`       |

Unset means _infer it_, never _false_. And nothing is ever faked: an unmet capability answers `ENOSYS`/`ENOTSUP`, because a filesystem that silently pretends is one that passes `ls` and corrupts data under `git`.

### Extensions

`driver.mountx` is a small namespace for what `node:fs` genuinely does not cover — `utimens` (nanosecond timestamps), `mknod`, and the four `xattr` calls. It is **types only for now**: no transport implements them yet, and transports probe for each member and degrade without it. Declare `capabilities.extensions` if you want to be explicit; otherwise the keys of `driver.mountx` are the answer.

## Developing without mounting

`createLoopback(driver)` is the harness the whole project tests against:

```ts
import { createLoopback } from "mountx";

const fs = createLoopback(driver);

fs.capabilities; // every capability resolved
fs.driver; // the driver you passed
await fs.readFile("/a.txt"); // whole-file convenience, no handle dance
await fs.writeFile("/a.txt", "text"); // create + truncate
```

It normalizes every path before your driver sees it, fills in missing methods with `ENOSYS`, resolves capabilities, and adds `readFile`/`writeFile` on top of your `open()`. No mount, no privileges, every platform.

**Anything that works through `createLoopback()` works through a mount** — that is the design rule the conformance suite exists to hold. So the loopback is not a mock: it is the same normalization and capability layer a real session applies, minus the kernel.

## Testing your driver

`test/conformance.ts` in the repository is one suite written against the driver interface, and it is run three ways: through the loopback harness, through a real FUSE mount, and through a real NFSv3 socket. So a test that passes in one column and fails in another is a transport bug **by construction**, not a driver bug.

Point it at your driver, or write your own checks the same way. The current per-transport tables are in [`.agents/conformance-matrix.md`](https://github.com/pithings/mountx/blob/main/.agents/conformance-matrix.md).

The FUSE transport was also run against [**pjdfstest**](https://github.com/pjd/pjdfstest), the POSIX filesystem test suite, over a real mount: **59.1% passing** (5179/8770 assertions). Every one of the 45 remaining failing files tests `mkfifo`/`mknod`/UNIX-socket creation — which the driver interface has no way to express, since `node:fs/promises` cannot create special files either. Breakdown: [`.agents/pjdfstest-results.md`](https://github.com/pithings/mountx/blob/main/.agents/pjdfstest-results.md).

## Next

- [Mounting](/guide/mounting) — turning the driver into a folder.
- [Driver interface](/reference/driver-interface) — every type on this page in full, plus [capabilities](/reference/capabilities) and [errors](/reference/errors).
- [Built-in drivers](/guide/drivers/built-in) — memory, node-fs and unstorage, and what they are good for.
