mountx logomountx

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.

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

hostroot?notes
macOSnoyou must own the mountpoint
Linuxyesmount(8) has no setuid helper for NFS
Windowsno 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?)

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?)

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: {…}.

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 (and through it NfsSessionOptions), so everything on this page is settable in one object.

optiondefault
serverserve an existing NfsServer; its listen() is still called
exportPath"/"directory of the driver to export
readOnlyfalsemount -o ro
hardfalseretry forever instead of soft — see below
timeo50tenths of a second before a request is retried
retrans2retries before a soft mount gives up with EIO
nobrowsetruemacOS only: -o nobrowse, hiding the volume from the GUI
mountOptionsnoneextra -o options, appended verbatim
signalstrueunmount on SIGINT/SIGTERM
unmountTimeout10_000ms teardown may spend; 0/Infinity waits forever
onTransportErrornone(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?)

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:

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

await using server = createNfsServer(createMemoryDriver());
await server.listen();
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:

# 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

optiondefault
port00 means an ephemeral port, which port then reports
host"127.0.0.1"address to bind
allowRemotefalseaccept connections from non-loopback addresses
maxRecord8 MiBlargest RPC record accepted
onTransportErrornone(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

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.

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

optiondefault
useDriverInotrueidentify files by the driver's (dev, ino), so hardlinks share a handle
verifierrandomboot verifier for file handles, so restarts invalidate them
rtmax / wtmaxlargest READ answered / WRITE accepted
snapshotCache64directory snapshots kept for readdir cookies
claimOwnershiptruechown new entries to the request's AUTH_SYS uid/gid
onErrornonecalled 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.

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: 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.tsXDR (RFC 4506): bounds-checked reader/writer, 64-bit fields as bigint
rpc.tsONC RPC v2 (RFC 5531): call/reply, AUTH_NONE/AUTH_SYS, record marking
constants.ts + protocol.tsRFC 1813, every struct both directions
handles.tsthe file handle table and the readdir cookie scheme
session.tsNfsSession(driver, options) — bytes in, bytes out, no socket
server.tsthe socket. mount.tsmount(8)

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

  • xdr.tsXdrReader/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.tsFileHandleTable 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

mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.