// Copyright (c) 2026 Petr BalvĂ­n (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") } }