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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user