Files
petrbalvin a9b8039ef7
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
feat: full NFSv4.2 server and client in pure Go
Assisted-by: GLM 5.3 Flash
2026-09-21 18:51:17 +02:00

448 lines
14 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsclient_test
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// startServer runs the full stack, the TCP listener, the connection
// skeleton, the dispatcher and the local backend, on an ephemeral port.
func startServer(t *testing.T) *nfsclient.Client {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello over the wire"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("backend: %v", err)
}
handler := &nfs4server.Handler{FS: backend}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &server.Server{Handle: handler.HandleConn}
go srv.Serve(t.Context(), listener)
c, err := nfsclient.Dial(listener.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
if err := c.Establish("probe-client"); err != nil {
t.Fatalf("establish: %v", err)
}
t.Cleanup(func() { c.Close(); listener.Close() })
return c
}
func TestNullPingOverTCP(t *testing.T) {
c := startServer(t)
if err := c.Null(); err != nil {
t.Fatalf("null: %v", err)
}
}
func TestCompoundRoundTripOverTCP(t *testing.T) {
c := startServer(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendGetfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode)),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 1024),
}
res, bodies, err := c.Compound("wire", ops)
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 5 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(bodies[3])
if err != nil {
t.Fatalf("getattr: %v", err)
}
if attrs.Type != nfs4.NF4Reg || attrs.Size != 19 {
t.Fatalf("attrs: type %d size %d", attrs.Type, attrs.Size)
}
eof, data, err := nfs4.DecodeReadBody(bodies[4])
if err != nil || !eof || string(data) != "hello over the wire" {
t.Fatalf("read: %q eof %v, %v", data, eof, err)
}
}
func TestUnknownProgramAndProcedure(t *testing.T) {
c := startServer(t)
// An unknown procedure is answered at the RPC layer with PROC_UNAVAIL.
status, err := c.Procedure(999)
if err != nil {
t.Fatalf("procedure 999: %v", err)
}
if status != rpc.AcceptProcUnavail {
t.Fatalf("procedure 999 answered with status %d", status)
}
}
func TestRejectedOperationOverTCP(t *testing.T) {
c := startServer(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "nope"),
}
res, _, err := c.Compound("miss", ops)
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt {
t.Fatalf("status %d, want NFS4ERR_NOENT", res.Status)
}
}
func TestCreateAndWriteOverTCP(t *testing.T) {
c := startServer(t)
// Make a directory over the wire, then a file inside it would come
// from OPEN, so the test writes into the file the fixture seeded by
// creating one directly in the tree the backend serves.
res, _, err := c.Compound("made-over-the-wire", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "made-over-the-wire", "", 0, 0,
0o755),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrMode)),
})
if err != nil {
t.Fatalf("create compound: %v", err)
}
if res.Status != nfs4.ErrOK {
t.Fatalf("create status %d", res.Status)
}
_, attrs, err := nfs4.DecodeGetattrBody(res.Ops[2].Body)
if err != nil || attrs.Type != nfs4.NF4Dir || attrs.Mode != 0o755 {
t.Fatalf("created dir: %+v, %v", attrs, err)
}
// Write into the seeded file and read back through a fresh session of
// COMPOUNDs.
res, _, err = c.Compound("write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendWriteArgs(nil, nfs4.AllZero, 0, nfs4.StableUnstable, []byte("WRITTEN OVER TCP")),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write compound: status %d, %v", res.Status, err)
}
count, committed, verf, err := nfs4.DecodeWriteRes(res.Ops[2].Body)
if err != nil || count != 16 || committed != nfs4.StableFileSync || verf == ([8]byte{}) {
t.Fatalf("write res: %d %d %x, %v", count, committed, verf, err)
}
res, _, err = c.Compound("read", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 1024),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("read compound: status %d, %v", res.Status, err)
}
eof, data, err := nfs4.DecodeReadBody(res.Ops[2].Body)
// The write replaced the head of the seeded file; its tail survives.
if err != nil || !eof || string(data) != "WRITTEN OVER TCPire" {
t.Fatalf("read after write: %q eof %v, %v", data, eof, err)
}
}
func TestRemoveAndRenameOverTCP(t *testing.T) {
c := startServer(t)
// Rename over the wire with the SAVEFH setup: the source directory in
// the saved handle, the target directory as the current one.
res, _, err := c.Compound("rename", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSavefh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendRenameArgs(nil, "hello.txt", "moved.txt"),
})
if err != nil {
t.Fatalf("rename compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 4 {
t.Fatalf("rename status %d ops %d", res.Status, len(res.Ops))
}
// The old name is gone and the new one answers.
res, _, err = c.Compound("old-name", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
})
if err != nil {
t.Fatalf("old name compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt {
t.Fatalf("the old name survived: status %d", res.Status)
}
res, _, err = c.Compound("present", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendLookup(nil, "moved.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("the moved file is not under dir: status %d, %v", res.Status, err)
}
// REMOVE clears the entry from its directory.
res, _, err = c.Compound("remove", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendRemoveArgs(nil, "moved.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("remove: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("gone", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendLookup(nil, "moved.txt"),
})
if err != nil {
t.Fatalf("gone compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt || len(res.Ops) != 3 {
t.Fatalf("gone: status %d ops %d", res.Status, len(res.Ops))
}
if res.Ops[2].Status != nfs4.ErrNoEnt {
t.Fatalf("lookup after remove status %d", res.Ops[2].Status)
}
}
func TestSetattrAndLinkOverTCP(t *testing.T) {
c := startServer(t)
// SETATTR mode and size over the wire, then read back through the
// same handle the server kept current.
res, _, err := c.Compound("setattr", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendGetfh(nil),
nfs4.AppendSetattrArgs(nil, nfs4.AllZero,
nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize),
nfs4.Attrs{Mode: 0o600, Size: 5}),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize)),
})
if err != nil {
t.Fatalf("setattr compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 5 {
t.Fatalf("setattr: status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(res.Ops[4].Body)
if err != nil || attrs.Mode != 0o600 || attrs.Size != 5 {
t.Fatalf("after setattr: %+v, %v", attrs, err)
}
// LINK with the saved handle pointing at the file: the link lands in
// the directory that is current.
res, _, err = c.Compound("link", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLinkArgs(nil, "hard.txt"),
nfs4.AppendRestorefh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrNumlinks)),
})
if err != nil {
t.Fatalf("link compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 7 {
t.Fatalf("link: status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err = nfs4.DecodeGetattrBody(res.Ops[6].Body)
if err != nil || attrs.Numlinks != 2 {
t.Fatalf("link count: %d, %v", attrs.Numlinks, err)
}
}
func TestReadlinkAndCommitOverTCP(t *testing.T) {
c := startServer(t)
// Create a symlink over the wire, then read it back.
res, _, err := c.Compound("mklink", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Lnk, "wire-link", "hello.txt", 0, 0,
0o644),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("create symlink: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("readlink", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "wire-link"),
nfs4.AppendReadlinkArgs(nil),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("readlink: status %d, %v", res.Status, err)
}
target, err := xdr.NewDecoder(res.Ops[2].Body).String()
if err != nil || target != "hello.txt" {
t.Fatalf("readlink target: %q, %v", target, err)
}
// COMMIT of the written file answers with the server verifier.
res, _, err = c.Compound("commit", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendCommitArgs(nil, 0, 0, [8]byte{}),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("commit: status %d, %v", res.Status, err)
}
verf, err := xdr.NewDecoder(res.Ops[2].Body).Raw(8)
if err != nil {
t.Fatalf("commit body: %v", err)
}
if string(verf) == string(make([]byte, 8)) {
t.Fatal("the commit verifier is zero")
}
}
func TestSecinfoNoNameOverTCP(t *testing.T) {
c := startServer(t)
// The shape a Linux client uses: LOOKUP of the component it wants to
// mount, then SECINFO_NO_NAME for the current component.
res, _, err := c.Compound("secinfo", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendSecinfoNoNameArgs(nil, nfs4.StyleCurrentFH),
})
if err != nil {
t.Fatalf("secinfo compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("secinfo: status %d ops %d", res.Status, len(res.Ops))
}
entries, err := nfs4.DecodeSecinfoRes(res.Ops[2].Body)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(entries) != 1 || entries[0].Flavor != nfs4.SecFlavorSys {
t.Fatalf("secinfo entries: %+v", entries)
}
}
func TestOpenWriteCloseOverTCP(t *testing.T) {
c := startServer(t)
if err := c.Establish("integration-client"); err != nil {
t.Fatalf("establish: %v", err)
}
// OPEN with create, WRITE under the open stateid, CLOSE.
res, _, err := c.Compound("open", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x7777, []byte("owner"), nfs4.ShareAccessBoth, 0,
true, 0o644, "opened-over-tcp.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
var stateid nfs4.Stateid
copy(stateid[:], res.Ops[1].Body)
res, _, err = c.Compound("write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("stateful!")),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("close", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendCloseArgs(nil, stateid),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("close: status %d, %v", res.Status, err)
}
// A write after the close is rejected as an old stateid.
res, _, err = c.Compound("late-write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("x")),
})
if err != nil || res.Status != nfs4.ErrOldStateid {
t.Fatalf("write after close: status %d, %v", res.Status, err)
}
}
// TestServesCallbackCalls drives a raw CB_COMPOUND from the server side
// of the connection to the client's callback dispatcher and checks the
// recall recording.
func TestServesCallbackCalls(t *testing.T) {
root := t.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
handler := &nfs4server.Handler{FS: backend}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: handler.HandleConn}
go srv.Serve(t.Context(), ln)
c, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Establish("cb-client"); err != nil {
t.Fatalf("establish: %v", err)
}
sid, ok := c.SessionID()
if !ok {
t.Fatal("no session")
}
// The peer delivers a CB_COMPOUND: CB_SEQUENCE then CB_RECALL of a
// fake delegation stateid, through the raw send the back channel uses.
var fake nfs4.Stateid
fake[0] = 0xde
probe := [][]byte{
nfs4.AppendCBRecallArgs(nil, fake, false, []byte{1, 2, 3}),
}
if _, _, err := handler.SendCB(sid, "recall", probe); err != nil {
t.Fatalf("sendCB: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rec := c.Recalled(); len(rec) == 1 && rec[0] == fake {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the recall never reached the client")
}