sigilOS can now mount and read NFS v3 shares. The client is written entirely in Sigil — no libtirpc, no portmapper C library, no libc at all. Two commits landed the full stack: the first built the XDR codec and ONC RPC transport with GETATTR/LOOKUP/READ/READDIR; the second wired the client into the VFS provider layer so the rest of the OS treats an NFS mount like any other filesystem. MANIFEST stands at 307 entries, 0 FAIL.
Why NFS and why now
The VFS layer (vfs_dev.sg) has always had a provider-dispatch model: a provider ID selects which backend handles a given path operation. Local filesystems occupy the lower IDs (FAT32, ext4, NTFS, devfs, and others). Provider ID 1 (vfs_nfs()) was reserved for NFS, ID 2 (vfs_smb()) for SMB. The reservation existed in code; the backends didn't. NFS v3 was the logical first to implement because its wire protocol — ONC RPC over UDP/TCP with XDR encoding — is well-specified and stateless. Stateless makes it safer to implement without a full session tracker.
XDR — the wire encoding
XDR (External Data Representation, RFC 4506) is the encoding NFS uses on the wire. Every field is big-endian, 4-byte-aligned, with no framing beyond the RPC record mark. sigilOS's XDR codec in nfs3/xdr.sg handles the operations the NFS v3 client needs:
| Function | XDR type | Notes |
|---|---|---|
xdr_u32 | uint32 | Big-endian read/write at offset |
xdr_u64 | uint64 | Split high/low 32-bit halves |
xdr_opaque | opaque<n> | Length-prefixed bytes, 4-byte-padded |
xdr_string | string<n> | Same as opaque; NUL not included on wire |
xdr_fh3 | nfs_fh3 | 64-byte file handle (opaque<64>) |
xdr_fattr3 | fattr3 | Type/mode/uid/gid/size/atime/mtime/ctime |
The codec operates on a flat byte buffer at a known base address — no heap allocation. The caller passes a base and a running offset; xdr_u32 and friends advance the offset and return the decoded value. This is the same pattern used throughout sigilOS: no allocator, no GC, pointer-free by construction.
ONC RPC — the transport
ONC RPC (RFC 5531) wraps every NFS v3 call in a Call message and every response in a Reply. The call header contains a transaction ID (xid), call type (0 = call), RPC version (2), program (NFS_PROG = 100003), version (NFS_V3 = 3), and procedure number. The reply header echoes the xid and carries an accept status (0 = SUCCESS).
The sigilOS RPC layer in nfs3/rpc.sg keeps this minimal:
rpc_build_call(buf, xid, proc) → fills the 28-byte call header
rpc_check_reply(buf, xid) → verifies xid match + accept_stat == 0
The transport itself uses the Kernel's TCP seam (syscalls 109–112). nfs3_send wraps net_conn_send; nfs3_recv wraps net_conn_recv. When send_fn == 0 (no live connection), all transport calls fail closed — returning -1 without touching any data. This is the design that makes the VFS root-path check safe: the client can answer path queries about the root without requiring a socket.
NFS v3 procedures
Four NFS v3 procedures are implemented:
| Proc | Number | What it does |
|---|---|---|
| GETATTR | 1 | Fetch file attributes (type, size, mode, times) for a file handle |
| LOOKUP | 3 | Resolve a name in a directory → file handle + attributes |
| READ | 6 | Read up to N bytes at offset from a file handle → data + EOF flag |
| READDIR | 16 | List directory entries → name/file-ID pairs (cookie-based) |
Each procedure is a thin wrapper: build the XDR call, hand it to rpc_send, parse the XDR reply. No state machine required — NFS v3 is stateless by design. The server maintains no session; every call carries its full context (file handle, offset, length).
The fhandle table
File handles in NFS v3 are opaque 64-byte blobs. sigilOS doesn't expose raw file handles to EL0 — instead, nfs3.sg maintains an in-kernel fhandle table (nfs3fh_base, nfs3fh_slots) that maps integer IDs to stored handles:
nfs3_fh_store(fh_ptr, len) → slot_id # copy handle into table, return ID
nfs3_fh_free(slot_id) # release slot
nfs3_vfs_mount(root_fh_ptr, len) # store root handle, record root_fh_id
The root file handle is stored at mount time. From that point, path resolution starts with the root handle and chains LOOKUP calls per path component. An EL0 process that calls vfsdev_resolve on an NFS path gets back an integer VFS node ID — the fhandle table slot. It never sees the 64-byte handle. The same cap-isolation pattern as SigDB and Cap<NetSession>: the broker owns the raw state, the caller holds a slot index.
VFS integration
The second commit (5683a26) wired everything into vfs_dev.sg. The four dispatch functions now branch on provider:
vfsdev_resolve(path, node_out):
if provider(path) == vfs_nfs():
nfs3_vfs_resolve(path, node_out)
else:
local_resolve(path, node_out)
vfsdev_readdir_path(path, buf, max):
if provider == vfs_nfs(): nfs3_vfs_readdir(...)
vfsdev_statfs_path(path, out):
if provider == vfs_nfs(): nfs3_vfs_statfs(...)
vfsdev_read(node, offset, buf, len):
if provider == vfs_nfs(): nfs3_vfs_read(...)
The SMB stub (provider ID 2) returns -2 on all calls — "not implemented" — pending smb.sg. That's the correct fail-closed behavior: a misconfigured mount that points to an SMB path gets a clear error, not a silent null read.
The VFS integration test (nfs3_vfs_test.sg) verifies five properties — fhandle store, fhandle free, root-resolve (without a live socket), fail-closed read, root-resolve uniformity — reported as the string FTRVU.
Test coverage across both commits
| Test string | What it covers | MANIFEST count |
|---|---|---|
GALR | GETATTR, LOOKUP, READ, READDIR (wire protocol) | 305 (first commit) |
FTRVU | fhandle table + VFS dispatch integration | 307 (second commit) |
Both sets run as part of the standard MANIFEST suite. 0 FAIL across all 307 entries.
What's next
The SMB 2.1 client landed the same day — provider ID 2 (vfs_smb()) is now wired. Together the two remote filesystem providers let sigilOS mount NFS shares (Linux/NAS) and Windows/Samba shares from the Lumen file browser, with the same cap-isolated fhandle model. The Network FS RFC is complete.