feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 18:51:17 +02:00
commit a9b8039ef7
153 changed files with 34403 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"net"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// waitFor polls a condition until it holds or the deadline passes; the
// callback notifications travel on the receiver's callback worker, so
// they arrive a moment after the operation that triggered them.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("timed out waiting for " + what)
}
// startCBServer listens on the loopback and serves the handler; the
// returned shutdown closes the listener.
func startCBServer(t *testing.T, h *Handler) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
t.Cleanup(func() { ln.Close() })
return ln.Addr().String()
}
func TestDirDelegationNotify(t *testing.T) {
h := testTree(t)
addr := startCBServer(t, h)
// Client A takes the directory delegation, its back channel is where
// the change notifications travel.
ca, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer ca.Close()
if err := ca.Establish("dd-a"); err != nil {
t.Fatalf("establish a: %v", err)
}
sidA, _ := ca.SessionID()
// Client B will mutate the delegated directory.
cb, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer cb.Close()
if err := cb.Establish("dd-b"); err != nil {
t.Fatalf("establish b: %v", err)
}
sidB, _ := cb.SessionID()
body := mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "gdd", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetDirDelegationArgs(nil, false,
nfs4.OfBits(nfs4.NotifyAddEntry, nfs4.NotifyRemoveEntry),
nfs4.NfsTime{}, nfs4.NfsTime{}, nfs4.Bitmap{}, nfs4.Bitmap{}),
}))
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("get dir delegation: status %d, %v", res.Status, err)
}
gd := xdr.NewDecoder(bodies[2])
if st, gerr := gd.Uint32(); gerr != nil || st != nfs4.GddOK {
t.Fatalf("gdd status %d: %v", st, gerr)
}
if _, gerr := gd.Raw(8); gerr != nil { // cookie verifier
t.Fatal(gerr)
}
var ddSt nfs4.Stateid
raw, rerr := gd.Raw(16)
if rerr != nil {
t.Fatal(rerr)
}
copy(ddSt[:], raw)
if !bytes.HasPrefix(ddSt[4:], []byte("DDEL")) {
t.Fatalf("stateid other % x", ddSt[4:])
}
if h.dirDelegs().count() != 1 {
t.Fatalf("dir delegs %d, want 1", h.dirDelegs().count())
}
// A second client asking for the same directory is denied.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "gdd2", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetDirDelegationArgs(nil, false,
nfs4.OfBits(nfs4.NotifyAddEntry), nfs4.NfsTime{}, nfs4.NfsTime{},
nfs4.Bitmap{}, nfs4.Bitmap{}),
}))
res, _, err = nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatal(err)
}
if res.Status != nfs4.ErrDenied {
t.Fatalf("second grant: status %d, want DENIED", res.Status)
}
// B creates an entry: the ADD notification travels to A.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "mk", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, 2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
// Regular files ride OPEN; CREATE makes everything else, so the
// notified entry is a directory.
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "notified.d", "", 0, 0,
0o755),
}))
if res, _, _ = nfs4.DecodeCompoundResBodies(body); res.Status != nfs4.ErrOK {
t.Fatalf("create: status %d", res.Status)
}
var notes []nfs4.CBNotify
waitFor(t, "the change notification to reach client A", func() bool {
notes = ca.Notified()
return len(notes) == 1
})
if !bytes.Equal(notes[0].Stateid[:], ddSt[:]) {
t.Fatal("notification carries another stateid")
}
if len(notes[0].Changes) != 1 {
t.Fatalf("changes %d, want 1", len(notes[0].Changes))
}
change := notes[0].Changes[0]
if !change.Mask.Has(nfs4.NotifyAddEntry) {
t.Fatalf("mask %v", change.Mask)
}
if name := notifyNameOf(change.Vals); name != "notified.d" {
t.Fatalf("notified name %q", name)
}
// DESTROY_CLIENTID of A drops the delegation.
h.dirDelegs().dropClient(sidA.ClientIDOf())
if h.dirDelegs().count() != 0 {
t.Fatalf("dir delegs after drop %d, want 0", h.dirDelegs().count())
}
}
func TestCBNotifyLock(t *testing.T) {
h := testTree(t)
addr := startCBServer(t, h)
ca, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer ca.Close()
if err := ca.Establish("lock-a"); err != nil {
t.Fatalf("establish a: %v", err)
}
sidA, _ := ca.SessionID()
cb, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer cb.Close()
if err := cb.Establish("lock-b"); err != nil {
t.Fatalf("establish b: %v", err)
}
sidB, _ := cb.SessionID()
// A opens a.txt and locks the first bytes of it; the lock hangs from
// the open the way RFC 8881 section 18.10 requires.
body := mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sidA.ClientIDOf(), []byte("a-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
}))
res, openBodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open a: status %d, %v", res.Status, err)
}
var openSt nfs4.Stateid
copy(openSt[:], openBodies[2])
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "lock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openSt, sidA.ClientIDOf(), []byte("a-owner"),
nfs4.LockTypeWrite, false, 0, 5),
}))
res, lockBodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("lock a: status %d, %v", res.Status, err)
}
var lockSt nfs4.Stateid
copy(lockSt[:], lockBodies[3])
// B's conflicting lock is denied and recorded as a waiter. B locks
// through its own open: one client's open stateid never authorises
// another client's lock. A's write open carries a write delegation,
// so B's first open answers DELAY while the recall runs and the
// retry proceeds.
var openStB nfs4.Stateid
seqB := uint32(0)
for {
seqB++
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open-b", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, seqB, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sidB.ClientIDOf(), []byte("b-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
}))
var bBodies [][]byte
res, bBodies, err = nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatalf("open b: %v", err)
}
if res.Status == nfs4.ErrOK {
copy(openStB[:], bBodies[2])
break
}
if res.Status != nfs4.ErrDelay {
t.Fatalf("open b: status %d", res.Status)
}
time.Sleep(time.Millisecond)
}
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "lock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, seqB+1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openStB, sidB.ClientIDOf(), []byte("b-owner"),
nfs4.LockTypeWrite, false, 0, 5),
}))
res, _, err = nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrDenied {
t.Fatalf("lock b: status %d, want DENIED", res.Status)
}
// A releases: the CB_NOTIFY_LOCK travels to B's back channel on B's
// callback worker, so it arrives a moment after the unlock answers.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "unlock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 3, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockuArgs(nil, lockSt, 0, 5),
}))
_ = body
if res, _, _ = nfs4.DecodeCompoundResBodies(body); res.Status != nfs4.ErrOK {
t.Fatalf("unlock: status %d", res.Status)
}
var locks []nfs4.CBNotifyLock
waitFor(t, "the lock notification to reach client B", func() bool {
locks = cb.NotifiedLocks()
return len(locks) == 1
})
if locks[0].Clientid == 0 || string(locks[0].Owner) != "b-owner" {
t.Fatalf("notified owner %d/%q", locks[0].Clientid, locks[0].Owner)
}
if len(ca.NotifiedLocks()) != 0 {
t.Fatal("the holder was notified instead of the waiter")
}
}