Releases: sirrobot01/facetfs
Release list
v0.7.0
This release adds facetcache, a caching FileSystem wrapper for slow backends. The package sits between a remote backend and any protocol server. Every protocol package benefits without change.
The facetcache package
facetcache.Cache wraps a backend and serves reads from local disk:
cache := &facetcache.Cache{Backend: backend, Dir: "/var/cache/facet"}
fsys, err := cache.FileSystem()
srv := &nfs4.Server{FileSystem: fsys}The wrapper keeps two caches:
- An attribute cache answers
StatandLstatfrom memory with a short TTL and negative entries. EveryReaddirbatch populates it for free. NFS costs several stats per read, so this cache matters more than the bytes against a high-latency backend. - A content cache stores file bytes in one sparse file per object, tracks exactly which byte ranges are present, fills misses from the backend with adaptive read-ahead, and parks concurrent readers of one region behind a single bounded fetch.
Content persists across restarts. A size and mtime fingerprint detects a backend object replaced behind the same name and discards its stale ranges. Metadata is written by atomic rename, and the data file is fdatasynced before any metadata publish that claims new ranges, so a crash can not turn holes into silently served zeros.
Writes go through: the backend must acknowledge a write before the cache stores it. There is no dirty state, so eviction never waits on uploads and a crash never loses writes.
A single janitor owns the disk budget with live accounting: TTL eviction, LRU eviction by size, and, on Linux, macOS, and Windows, hole punching behind the read head of open streams when whole-file eviction cannot reach the budget. The read path re-validates presence after every pread, so a racing punch causes a refetch, never zeros. On Windows, cache files are marked sparse at creation; a non-sparse NTFS file would physically zero-fill below far-offset writes.
The wrapper preserves the backend's capability set exactly. A generated matrix exposes SymlinkFS, LinkFS, RemoveFS, SetStatFS, and StatVFSFS only when the backend implements them, and cached files keep io.ReaderAt, io.WriterAt, and Sync under the same rule. The servers detect these by assertion; claiming or hiding one silently changes protocol behavior.
Performance
The warm path adds no measurable cost: a warm 64 KiB ReadAt matches a direct pread with zero allocations, a warm Stat is 69 ns with zero allocations, and the stateless-NFS-shaped OpenFile+Close is 180 ns with one allocation (Apple M1 Pro).
Against a backend charged 5 ms per call, through a real Linux kernel NFS client with attribute caching disabled: warm stats and reads improve 117-166x and match the fast-backend numbers, writes improve 1.8x, readdir improves 5x. Over a fast backend the cached server is 7-16% faster on reads and stats than the uncached one, because the attribute cache absorbs the client's stat storm.
Verification
- Formatting,
go vet, CGO-free tests and build - Full race detector suite, including concurrent open/close/eviction and download deduplication
- Property-based range-set tests against a bitmap model
- Crash-consistency tests: corrupt metadata, interrupted flushes, orphan files
- APFS hole punching verified by block count; punch-then-read refetch verified end to end
- Linux, Windows, and FreeBSD cross-compilation
- Real Linux kernel NFS client acceptance through the containerized benchmark harness
v0.6.0
This release makes NFS filehandles recoverable across restarts and gets the experimental SMB server through its real Linux CIFS acceptance path with required SMB 3.1.1 signing.
NFS filehandles across restarts
A fixed nfs4.Server.HandleKey already gives short filehandles a stable seal. This release makes that guarantee explicit and adds Server.ResolveLongHandle, an optional callback that maps the SHA-256 stored in a long filehandle back to its served path.
Long paths normally depend on a bounded in-memory lookup table, so their handles expire after a restart or table eviction. Applications with their own path index can now restore those handles without weakening validation: the server hashes the returned path and ignores a resolver result that does not match the sealed digest. Resolved paths are cached again so the callback does not remain on the hot path.
Tests cover short-handle restart stability, long-handle restart recovery, eviction recovery, caching, and rejection of mismatched resolver paths.
SMB 3.1.1 Linux interoperability
The new scripts/smb-smoke-linux.sh mounts the example server through a real Linux kernel CIFS client in Docker with signing required. It exercises listing, reads, an 8 MiB copy in both directions, rename and delete, nested directory operations, a 300-entry directory, and unmount/remount persistence.
That real-client path exposed and fixed several independent protocol issues:
- The SMB 3.1.1 SP 800-108 signing KDF now includes the null terminator required by the
SMBSigningKeylabel. A known-answer test pins the key derived by a real client. - Raw NTLM clients receive raw NTLM challenge and completion framing, while SPNEGO clients continue to receive SPNEGO wrappers.
- Advertising signing support no longer forces every request to be signed. Required sessions still reject unsigned requests, and any request carrying a signature is verified and receives a signed response.
- Directory entries and file-information replies now report the same stable file identity, avoiding Linux
ESTALEfailures before a read. - Multi-credit requests reserve their full consecutive message-ID range, so large transfers no longer leave artificial gaps that eventually close an otherwise valid connection.
The Linux client reports Dialect 0x311 signed and the complete smoke workflow exits with SMOKE OK. SMB remains experimental until the Windows and macOS acceptance criteria are also completed.
Verification
- Linux, macOS, and Windows package tests
- Formatting,
go vet, CGO-free tests and build - Full race detector suite
- XDR and NFS connection fuzz smoke
- Signed Linux CIFS end-to-end smoke test
v0.5.0
Adds the smb package: an embeddable SMB2/SMB3 server that makes a Go application mountable by Windows Explorer and the macOS Finder without an installed agent.
New: the smb package, experimental
smb.Server serves one disk share from a facetfs.FileSystem on listeners and connections the application binds. The package speaks dialects 2.1 and 3.1.1 over direct TCP and implements session setup, tree and handle lifetime, create/read/write/flush/close, share modes, byte-range locks, directory enumeration with DOS wildcards, metadata queries and changes, and FSCTL_VALIDATE_NEGOTIATE_INFO.
server := &smb.Server{
FileSystem: served,
Authenticator: credentials, // answers: what is the NT hash of this user
ShareName: "share",
}
log.Fatal(server.Serve(ctx, listener))Authentication keeps the credential split. No package in this module verifies a credential, but SMB derives the key that signs every message from the NTLM exchange, so the package cannot delegate the exchange. The split is drawn one level lower: the application owns the credential store and answers one question — the NT hash of a user — and the package owns the protocol: the challenge, the constant-time comparison, and the key derivation. smb.NTHash derives the stored value from a password.
Signing, not encryption. Every message of a signing session is signed on its own — HMAC-SHA256 for 2.1, AES-128-CMAC for 3.1.1 with the SP 800-108 key derivation over the pre-authentication integrity hash. Each message of a compound chain carries its own signature, as [MS-SMB2] requires. Encryption is not implemented and not advertised, so the transport is not confidential: serve it on a trusted network.
The SMB1 clients get one answer. Windows Explorer and the macOS Finder open every connection with an SMB1 multi-protocol negotiate by default. The server answers that one message in SMB2 with the wildcard revision, and the client negotiates again in SMB2. Nothing else of SMB1 exists in the package — the server reads one dialect list and refuses every other SMB1 frame.
Every client-controlled allocation is bounded. Frame sizes, chain lengths, credits, dialect lists, contexts, security buffers, paths, lock batches, and handles per session all have fixed limits. Every offset and length pair from the wire passes one validated accessor. Four fuzz targets drive the frame walk, the transport, the negotiation, and the NTLM and path decoders, and make fuzz runs them.
Why experimental: the protocol and race suites pass, but the acceptance matrix against real Windows, macOS, and Linux clients has not yet run. The notice drops when it has.
Benchmarks
make bench now measures the smb package, and a new make bench-protocols compares warm, end-to-end operations — one metadata lookup, one full-file read, one full-file write — through NFSv4, SFTP, and WebDAV against the same in-memory filesystem over loopback.
The SMB numbers include what a default modern client session actually costs: four MAC passes over every payload (both sides sign, both sides verify) and the open–transfer–close round trips of the protocol. The other packages exclude their transport crypto, because SSH and TLS belong to the application; SMB signing belongs to this package.
Also
examples/smbserves a directory with one user from environment variables.- Byte-range locks are advisory, as in every other package here: the contract cannot mediate local access, and the documentation says so.
v0.4.0
Drops the experimental notice from the NFS package and adds opt-in read delegations, which let a client serve repeated reads from its own cache without asking the server at all.
The nfs4 package is no longer experimental
The two acceptance criteria that were still open now pass against real clients:
- A client that loses its connection with work in flight. The Linux smoke test mounts through a proxy, starts a workload of writes and byte-range locks, and cuts every connection part way through. The client reconnects, retransmits, and every operation lands exactly once: 300 files, each with the content it was written with, none duplicated.
- Two hosts contending for byte-range locks. Two containers are two NFSv4 clients. A lock held on one host is refused to the other, and when the holder dies without unlocking — killed, no unmount — lease expiry frees its lock and the waiting host acquires it.
The trust model is unchanged and stays documented: AUTH_SYS identities on a trusted network, volatile state across restarts, advisory locks.
New: read delegations, off by default
A kernel NFS server hands out read delegations freely, so a client re-reading a file it already holds sends no requests. Until now this server answered every one. Measured against a real Linux client reading the same file again:
| RPCs for the re-read | |
|---|---|
| Without a delegation | 2 |
| With a delegation | 0 |
nfs4.Server{ReadDelegations: true} enables them. The server probes the client's callback path with CB_NULL when the client is confirmed, grants a delegation on a read-only open when the path answered and no other client could make the promise false, and recalls it with CB_RECALL before any conflicting operation proceeds — a write open, a write, a size or time change, a remove, a rename, a link. A client that does not return a recalled delegation within the lease loses it, and its later use of the stateid is answered with NFS4ERR_ADMIN_REVOKED.
A firewalled or NAT-ed callback path — the common case — costs nothing: the probe runs off the request path, the client is served normally, and it simply never receives a delegation.
Why off by default: a delegation is a promise that the file will not change without the client hearing first, and this server can only recall what it sees. A write through WebDAV or SFTP, or by the application itself, recalls nothing, and a delegated client would keep serving stale data. Enable delegations only when the NFS server is the only writer.
Also
examples/nfsgrew-delegationsand-leaseflags.- New real-client scripts:
scripts/nfs-deleg-linux.shmeasures what a delegation saves;scripts/nfs-two-clients-linux.shruns the two-host lock contention and lease-expiry case. - New fuzz targets cover the two decoders this release added: the callback universal-address parser and callback replies, which now arrive from outside.
v0.3.0
Makes the NFS package usable from a Linux client, which v0.2.0 was not, and roughly quadruples the speed of serving a real directory.
Fixed: a Linux client could not write at all
Linux creates an ordinary file with the EXCLUSIVE4 open mode, which v0.2.0 refused with NFS4ERR_NOTSUPP. Every write from a Linux client failed, so the export was effectively read-only. The mode is now served from a table of create verifiers, which is what tells a retransmitted create apart from a second create of the same name.
No in-process test found this. Mounting from a real client did.
New
facetfs.OpenDirreturns a*facetfs.Rootthat serves a directory tree from a handle it holds open.facetfs.Dirreopened the tree on every operation, which cost two extra system calls each time. PreferOpenDirin a server;Dirstill works and now delegates to the same implementation.facetfs.RemoveFSis an optional interface with a non-recursiveRemove. NFS REMOVE and SFTP remove and rmdir use it when the filesystem provides it, so refusing a directory that holds entries is the filesystem's own answer rather than an emptiness check in front of a recursive delete.DirandNewMemFSimplement it.
Faster
Serving a real directory over NFS, against v0.2.0:
| Operation | v0.2.0 | v0.3.0 |
|---|---|---|
| GETATTR | 172 us | 42 us |
| READ 64 KiB | 208 MB/s | 798 MB/s |
| READ 1 MiB | 1.7 GB/s | 3.3 GB/s |
| Listing 4000 entries | 21.8 ms | 3.0 ms |
| Lease sweep, 1000 clients | 12.9 us per request | 49 ns |
A READ copied its payload three times on the way out; the compound now encodes into the reply buffer and READ reads straight into it. Directory listings are served from a snapshot named by the cookie verifier, so a resume no longer re-reads the whole directory: the cost of a listing is now linear rather than quadratic, and one listing is stable while it runs. Reply buffers are reused between requests. The lease sweep no longer scans every client on every request.
Correctness
Found by review and by the client mounts, each with a regression test:
- A LOCK could name one owner through both stateid slots and make the server lock a single mutex twice, hanging that owner permanently.
- A LOCK that repeated one sequence id while advancing the other was answered from the replay cache, reporting a byte range the client never held.
- A WRITE at the advertised maxwrite exceeded the record cap and closed the connection, so no full-size write could succeed.
- RENAME applied no type rules, so a file could replace a directory and a directory could replace a file, destroying the target.
- COMMIT and stable WRITE failed on any backend whose
FilelacksSync, and COMMIT flushed a fresh handle rather than the file the client wrote through. - Lock and share state were keyed by path and did not follow a rename.
- A truncating open needed
SetStatFS, so a filesystem implementing only the core interface could not be truncated. - Conditions the io/fs sentinels cannot express, a full disk or a read-only export, all reached the client as a plain I/O error.
- The special stateids skipped the share-reservation check.
- READ and WRITE refused a transfer above the advertised size instead of doing a short one.
Hardening
Every variable-length decode already carried an explicit bound. The state a client can grow now does too: opens per client, which is what holds file descriptors, owners per client, lock ranges per owner, cached listings, and create verifiers. An RPC fragment is checked against the cap on its own as well as against the running total, which would overflow on a 32-bit platform.
CI gained a fuzz job over the XDR primitives and whole connections. make fuzz runs longer campaigns and make bench reports the numbers above.
Verified
Linux and macOS clients both mount and pass a file, directory, and listing workflow. Two processes contend for a byte-range lock through the server and observe the documented result. scripts/nfs-smoke-linux.sh needs only Docker; scripts/nfs-smoke-macos.sh needs root.
Status
The FileSystem contract is unchanged and remains frozen. webdav and sftp are unchanged apart from SFTP using RemoveFS.
nfs4 stays experimental. Two acceptance criteria are only partly met: a client that loses its connection with work in flight, and a lease that expires while another client waits on the lock it holds. Delegations are not implemented, so a client re-reading a file reaches the server every time.
v0.2.0
Adds an experimental NFSv4.0 package. Operating-system clients can now mount a facetfs.FileSystem directly, with no portmapper and no mountd.
nfs4 (experimental)
nfs4.Server serves NFSv4.0 (RFC 7530) on a listener you bind. The exported surface is one struct and two methods:
server := &nfs4.Server{FileSystem: facetfs.Dir("/srv/data")}
log.Fatal(server.Serve(ctx, listener))Implemented: ONC RPC with record marking and bounded XDR; the COMPOUND engine; HMAC-sealed volatile filehandles; the client-identity and lease machinery; open state with sequence-id replay, share reservations, and OPEN_CONFIRM; byte-range locks with split, merge, and conflict reporting; and the file, directory, and attribute operations. Delegations are never granted and reclaim is refused, since in-memory state leaves nothing to reclaim.
Optional interfaces unlock features: SymlinkFS for symlinks, SetStatFS for SETATTR, StatVFSFS for space attributes, LinkFS for LINK, and a Sync() error method on File for COMMIT and stable writes.
Root package
- New optional interface
LinkFS(Link), implemented byDir. TheFileSystemcontract itself is unchanged and remains frozen; optional interfaces may be added, existing ones do not change. - The optional
Sync() errormethod onFileis now documented.
Fixes found by review
Six defects were found and fixed before this release, each with a regression test:
- A LOCK could name one owner through both stateid slots and make the server lock a single mutex twice, hanging that owner permanently. A LOCK open_stateid now resolves only against live opens.
- A LOCK that repeated one sequence id while advancing the other was answered from the replay cache, reporting a byte range the client never held. Replay now requires both sequence ids to repeat.
- A WRITE at the advertised
maxwriteexceeded the record cap and closed the connection, so no full-size write could succeed. The cap is now derived frommaxwrite. - RENAME applied no type rules, so a file could replace a directory and a directory could replace a file, destroying the target.
- COMMIT and stable WRITE returned NFS4ERR_NOTSUPP on any backend whose
FilelacksSync, which left such a backend unable to complete a write. COMMIT also flushed a fresh handle rather than the file the client wrote through. - Lock and share state were keyed by path and did not follow a rename, so a second client could lock the same file under its new name.
Verification
Unit, state-machine, and in-process protocol tests; the race detector; and fuzz targets over both the XDR primitives and whole connections (4.4M executions, no crashes or hangs). Tested on Linux, macOS, and Windows.
Status
The nfs4 package is experimental and has not yet passed the real-client mount profile. AUTH_SYS carries an unverified identity, so serve it on a trusted network or behind your own authentication. Filehandles and protocol state do not survive a restart, and byte-range locks are advisory.
webdav and sftp are unchanged from v0.1.0. smb remains a stub.
v0.1.0
First tagged release. FacetFS is a set of thin, embeddable file-protocol packages for Go, in the style of golang.org/x/net/webdav: you implement one small path-based FileSystem interface and own the transport and authentication; each package speaks one protocol.
Highlights
- Root package: the
FileSystem/Filecontract (frozen as of this release), optional capability interfaces (SymlinkFS,SetStatFS,StatVFSFS),Dir(native directory tree via os.Root, symlink-confined), andNewMemFSfor tests. - webdav: an
http.Handlerimplementing RFC 4918 class 1 and 2 with exclusive Depth-0 write locks behind a pluggableLockSystem. Litmus: basic, copymove, and http suites pass 100% with zero warnings; the remaining deviations (no dead-property storage, exclusive Depth-0 locks only) are deliberate and documented in the package comment. - sftp: serves SFTP on an already-authenticated stream (typically an ssh.Channel for the "sftp" subsystem) from your own SSH server. Parallel positioned I/O, optional-interface gating, tested against the pkg/sftp client and OpenSSH.
- Examples: complete runnable WebDAV (basic auth + TLS) and SFTP (host key + authorized_keys) servers showing the application-owned side.
Stability
The FileSystem contract is frozen. Optional capability interfaces may be added in later releases; existing interfaces do not change.
nfs4 and smb are planned and currently package stubs. Pre-1.0: protocol package APIs may still evolve.