
# NFSv3

**A loopback NFS server: no `/dev/fuse`, no native code, and the only way to mount on macOS.**

`mountx/nfs` implements NFSv3 and MOUNTv3 from the RFCs — 1813 for the protocol, 5531 for ONC RPC, 4506 for XDR — over a plain TCP socket. Every struct is encoded _and_ decoded, so the whole protocol is testable by a JavaScript client built from the same codecs.

```ts
import { mountNfs } from "mountx/nfs";
import { createMemoryDriver } from "mountx/drivers/memory";

await using mounted = await mountNfs(createMemoryDriver(), "/mnt/point");
mounted.port; // the port both programs are on
```

Through `mountx/auto` this is what macOS gets automatically, and its options move under `nfs: {…}`.

## Where it mounts

| host      | root?  | notes                                           |
| --------- | ------ | ----------------------------------------------- |
| **macOS** | **no** | you must own the mountpoint                     |
| **Linux** | yes    | `mount(8)` has no setuid helper for NFS         |
| Windows   | —      | no NFS client worth the name, and no `mount(8)` |

**Root is a Linux requirement, not an NFS one.** `/sbin/mount_nfs` is not setuid and holds no entitlement: macOS is a BSD, and a BSD lets an ordinary user mount onto a directory that user owns. So an unprivileged process there uses the same `mount(8)` spawn root uses, and the kernel simply forces `MNT_NOSUID|MNT_NODEV` on the result.

That leaves one precondition the host probe cannot answer — _ownership of the mountpoint_, which is a fact about a path nobody has passed yet. `mountNfs()` checks it at mount time and refuses with a message naming the owner.

On Linux there is no equivalent: an unprivileged `mount(8)` would need an `fstab` entry marked `user`, which is not something a library can arrange.

### `nfsClientProbe(platform?)`

```ts
function nfsClientProbe(platform?: NodeJS.Platform): NfsClientProbe;

interface NfsClientProbe {
  usable: boolean; // can this host mount NFS at all?
  platform: "linux" | "darwin" | undefined;
  helper: string | undefined; // path of mount.nfs / mount_nfs, if found
  kernel: boolean; // does the kernel have an NFS client?
  root: boolean; // are we root — required on Linux only
  reason: string | undefined; // everything missing, in a sentence
}
```

Synchronous and cheap — it imports `node:fs` and nothing else, which is why `mountx/auto` can ask it before deciding what to load, a test can gate itself on it, and a mount can fail with a message naming the missing piece instead of `mount: wrong fs type`. `nfsPlatform(platform)` is the narrowing on its own.

::note
The kernel check is deliberately weak. On Linux, `nfs` appears in `/proc/filesystems` only once the module is loaded, and `mount.nfs` loads it on demand — so a host with the helper and a loadable module reports `kernel: false` and is still usable. What is _not_ usable is a host with neither.
::

## What it costs

NFSv3 is **stateless**. There is no `open`/`release`, so every request carries a file handle built from the driver's `(dev, ino)` identity rather than per-open state.

In practice that costs exactly one behaviour: **a file deleted while still open** stays readable over FUSE and answers `ESTALE` here. Everything else — reads, writes, directories, symlinks, permissions, `statfs` — behaves the same, which is what the shared conformance suite exists to demonstrate.

File handles also carry a **boot verifier**, random by default, so a server restart invalidates outstanding handles rather than answering for a tree that no longer exists.

## `mountNfs(driver, mountpoint, options?)`

```ts
function mountNfs(
  driver: FsDriver,
  mountpoint: string,
  options?: MountNfsOptions,
): Promise<NfsMount>;
```

Serves `driver` over NFSv3 and puts a kernel NFS client in front of it with `mount(8)`. **Linux (root) and macOS (no root, but you must own the mountpoint).** Options sit at the **top level** here — it is `mountx/auto` that nests them under `nfs: {…}`.

```ts
interface NfsMount extends AsyncDisposable {
  readonly mountpoint: string;
  readonly server: NfsServer;
  readonly port: number; // the port both programs are on
  readonly source: string; // e.g. "127.0.0.1:/"
  readonly active: boolean;
  unmount(): Promise<void>;
}
```

`unmount()` is idempotent, concurrency-safe and retryable after a failure. It always settles within `unmountTimeout`.

### `MountNfsOptions`

Extends [`NfsServerOptions`](#nfsserveroptions) (and through it [`NfsSessionOptions`](#nfssessionoptions)), so everything on this page is settable in one object.

| option             | default  |                                                                  |
| ------------------ | -------- | ---------------------------------------------------------------- |
| `server`           | —        | serve an existing `NfsServer`; its `listen()` is still called    |
| `exportPath`       | `"/"`    | directory of the driver to export                                |
| `readOnly`         | `false`  | mount `-o ro`                                                    |
| `hard`             | `false`  | retry forever instead of `soft` — see below                      |
| `timeo`            | `50`     | tenths of a second before a request is retried                   |
| `retrans`          | `2`      | retries before a `soft` mount gives up with `EIO`                |
| `nobrowse`         | `true`   | macOS only: `-o nobrowse`, hiding the volume from the GUI        |
| `mountOptions`     | none     | extra `-o` options, appended verbatim                            |
| `signals`          | `true`   | unmount on `SIGINT`/`SIGTERM`                                    |
| `unmountTimeout`   | `10_000` | ms teardown may spend; `0`/`Infinity` waits forever              |
| `onTransportError` | none     | `(error, peer)` for transport-level failures and forced teardown |

::note
**`nobrowse` on by default.** A visible volume is one Finder and Spotlight will crawl — for a JavaScript driver that means a burst of traffic and a scattering of `.DS_Store` writes nobody asked for. Set `false` if the point of the mount is for someone to open it in Finder.
::

### `nfsMountOptions(port, options?, platform?)`

```ts
function nfsMountOptions(port: number, options?: MountNfsOptions, platform?: NfsPlatform): string;
```

The exact `-o` string `mountNfs()` would pass, so you can see it, log it, or hand it to `mount(8)` yourself. Pure, and platform-parameterised — which is how the Linux/macOS spelling differences (`nolock` vs `nolocks`, no `hard` option on macOS, `nobrowse`) are tested from either host.

### `liveNfsMounts()` / `unmountAllNfs()`

Every NFS mount this process has up, in creation order; and unmount them all, never rejecting.

## How it mounts

Three decisions, all of them deliberate:

- **No portmapper.** Both programs sit on one port and the mount command is told so with `port=` _and_ `mountport=`, so `rpcbind` is never contacted.
- **No locking.** `nolock` (Linux) / `nolocks` (macOS) keeps `lockd`/`rpc.statd` out of it — NFSv3 file locking is a separate protocol (NLM) this server does not implement, and a client that tried to use it would hang waiting for a service that is not there. macOS's `locallocks`, which locks in the client's VFS layer instead, is a reasonable thing to add through `mountOptions` if something on the mount needs `flock` to succeed.
- **`soft` by default.** A hard mount retries forever, which is right when the server is a machine that may reboot and wrong when it is a JavaScript object in the process doing the mounting: a bug in your driver would produce unkillable `D`-state processes instead of an `EIO`. Set `hard: true` if you want the other trade.

## Serving without mounting

Serving needs no privileges at all, and no `mount(8)`. You can start just the server and put whatever NFSv3 client you like in front of it — including one on another machine, if you mean to:

```ts
import { createNfsServer } from "mountx/nfs";
import { createMemoryDriver } from "mountx/drivers/memory";

await using server = createNfsServer(createMemoryDriver());
await server.listen();
```

```ts
interface NfsServer extends AsyncDisposable {
  readonly session: NfsSession; // stats, handles, mount list
  readonly port: number; // 0 before listen(); ephemeral unless you set one
  readonly host: string; // "127.0.0.1" by default
  readonly connections: number;
  listen(): Promise<NfsServer>; // idempotent; resolves once bound
  close(): Promise<void>; // idempotent
}
```

`createNfsServer()` is the only file here that opens a socket, and it **runs anywhere Node does, including Windows** — only putting a _client_ in front of it is platform-specific.

Then mount it yourself:

```sh
# Linux
sudo mount -t nfs -o vers=3,tcp,port=<p>,mountport=<p>,nolock 127.0.0.1:/ /mnt

# macOS — `nolocks`, and no `hard` option (hard is the default there)
mount -t nfs -o vers=3,tcp,port=<p>,mountport=<p>,nolocks,nobrowse 127.0.0.1:/ /mnt
```

`<p>` is `server.port`, or `mounted.port` if you used `mountNfs()`; `nfsMountOptions()` above returns the exact `-o` string mountx would use, if you want to see it.

### `NfsServerOptions`

| option             | default       |                                                        |
| ------------------ | ------------- | ------------------------------------------------------ |
| `port`             | `0`           | `0` means an ephemeral port, which `port` then reports |
| `host`             | `"127.0.0.1"` | address to bind                                        |
| `allowRemote`      | `false`       | accept connections from non-loopback addresses         |
| `maxRecord`        | 8 MiB         | largest RPC record accepted                            |
| `onTransportError` | none          | `(error, peer)` — a bad record, a socket error         |

::caution
**NFSv3 has no authentication worth the name.** The server binds `127.0.0.1` and refuses non-loopback connections by default. Reaching it from another machine takes both `host` and `allowRemote: true`, and that exports your driver to anything that can reach the port.
::

A record larger than `maxRecord` cannot be a mistake — the biggest legal one is a `WRITE` of `wtmax` plus headers — so the connection is closed rather than the record skipped: there is no way to resynchronize a record-marked stream once a length is not to be believed.

## `NfsSession`

```ts
new NfsSession(driver: FsDriver, options?: NfsSessionOptions)
```

Bytes in, bytes out, with no socket anywhere: `handleCall(bytes)` → `Promise<Uint8Array | null>`, answering both the MOUNT and NFS programs. It never rejects — every call needing a reply gets exactly one.

```ts
session.driver; // the Loopback wrapping your driver
session.handles; // the FileHandleTable
session.stats; // { requests, replies, errors, dropped, procedures }
```

`stats.procedures` is a `Map` keyed `"NFS:LOOKUP"` / `"MOUNT:MNT"`.

### `NfsSessionOptions`

| option            | default |                                                                          |
| ----------------- | ------- | ------------------------------------------------------------------------ |
| `useDriverIno`    | `true`  | identify files by the driver's `(dev, ino)`, so hardlinks share a handle |
| `verifier`        | random  | boot verifier for file handles, so restarts invalidate them              |
| `rtmax` / `wtmax` | —       | largest `READ` answered / `WRITE` accepted                               |
| `snapshotCache`   | `64`    | directory snapshots kept for readdir cookies                             |
| `claimOwnership`  | `true`  | `chown` new entries to the request's `AUTH_SYS` uid/gid                  |
| `onError`         | none    | called for every request that ends in an error status                    |

`claimOwnership` solves the same problem the FUSE session solves the same way: the driver creates everything as the server process, while requests arrive from whoever mounted the share. It is quiet when the driver has no `lchown`, or when the server is not privileged enough to hand ownership away — a driver with no concept of ownership is not thereby broken.

## The macOS consent gate

macOS puts network volumes behind a sandbox approval that is **never prompted** for a command-line process. Without it, `umount` blocks and `umount -f` answers `EPERM` — with no dialog, ever.

mountx names that case specifically (`isConsentDenial()`/`consentAdvice()`) instead of blaming your driver, and says plainly that the mount survived. There is no escalation past it and nothing the library can paper over: the fix is a grant made in advance — Full Disk Access for whatever app the process is attributed to (the terminal, your IDE, the CI agent).

The escalation ladder differs for the same reason: macOS has `umount -f` but no `umount -l`, so the lazy step, and the advice to run it, are Linux-only.

## Teardown

The same discipline as [FUSE](/transports/fuse#teardown-and-what-it-cant-do): `umount(8)` first, the mount table as the truth rather than an exit status, forcing down on the deadline, idempotent and retryable throughout.

One difference worth knowing: macOS has no `/proc/self/mounts`, so the table comes from spawning `mount(8)` — which is why reading it is asynchronous, and why "is it mounted" is a **tri-state**. A table that could not be read is `undefined`, never `false`. Forcing down a mount that turns out to be gone is harmless; reporting a successful unmount on a guess shuts the server down under a live one.

`server.close()` does **not** wait for clients to go away politely: a mounted NFS client keeps its connection open forever, so a close that waited would never return.

## The layers below

Everything except `server.ts` and `mount.ts` runs on any OS with no privileges — which is what makes the protocol testable by a JS client built from the same codecs:

| module                         |                                                                           |
| ------------------------------ | ------------------------------------------------------------------------- |
| `xdr.ts`                       | XDR (RFC 4506): bounds-checked reader/writer, 64-bit fields as `bigint`   |
| `rpc.ts`                       | ONC RPC v2 (RFC 5531): call/reply, `AUTH_NONE`/`AUTH_SYS`, record marking |
| `constants.ts` + `protocol.ts` | RFC 1813, every struct both directions                                    |
| `handles.ts`                   | the file handle table and the readdir cookie scheme                       |
| `session.ts`                   | `NfsSession(driver, options)` — bytes in, bytes out, no socket            |
| `server.ts`                    | the socket. `mount.ts` — `mount(8)`                                       |

`mountx/nfs` re-exports the whole protocol layer by name:

- **`xdr.ts`** — `XdrReader`/`XdrWriter`: bounds-checked, big-endian, 64-bit fields as `bigint`, and they only ever throw `XdrError`.
- **`rpc.ts`** — call and reply, `AUTH_NONE`/`AUTH_SYS`, TCP record marking.
- **`constants.ts`** + **`protocol.ts`** — RFC 1813 transcribed; every NFSv3 and MOUNTv3 struct encoded _and_ decoded, plus `fattrOf`, `nfsStatusOf`, `errnoOfStatus`, `statusName` and the time conversions.
- **`handles.ts`** — `FileHandleTable` and the readdir cookie scheme: the state a stateless protocol still needs.

The sub-struct helpers the procedure codecs are built from (`readFattr`/`writeFattr`, `readSattr`/`writeSattr`, the `post_op_*` and `wcc_data` pairs) are deliberately **not** on the public surface — they are pieces, not something a consumer composes with.

## Next

- [FUSE](/transports/fuse) — the other transport.
- [Troubleshooting](/guide/troubleshooting) — `ESTALE`, `EPERM`, and hangs on macOS.
- [`mountx/auto` reference](/transports/auto) — the chooser that calls this.
