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