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,85 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
|
||||
)
|
||||
|
||||
// The accessors answer copies of what the callback demux recorded.
|
||||
func TestNotifiedAccessors(t *testing.T) {
|
||||
c := &Client{}
|
||||
if got := c.Notified(); len(got) != 0 {
|
||||
t.Fatal("notified before any delivery")
|
||||
}
|
||||
if got := c.NotifiedLocks(); len(got) != 0 {
|
||||
t.Fatal("locks notified before any delivery")
|
||||
}
|
||||
c.recMu.Lock()
|
||||
c.notified = append(c.notified, nfs4.CBNotify{FH: []byte("d")})
|
||||
c.notifiedLocks = append(c.notifiedLocks, nfs4.CBNotifyLock{Clientid: 5})
|
||||
c.recMu.Unlock()
|
||||
if n := c.Notified(); len(n) != 1 || string(n[0].FH) != "d" {
|
||||
t.Fatalf("notified %+v", n)
|
||||
}
|
||||
if n := c.NotifiedLocks(); len(n) != 1 || n[0].Clientid != 5 {
|
||||
t.Fatalf("locks %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The callback demux answers CB_NOTIFY and CB_NOTIFY_LOCK and records
|
||||
// them for the caller.
|
||||
func TestCBCompoundNotifies(t *testing.T) {
|
||||
// The demux answers every callback on the wire; a pipe with a
|
||||
// draining side stands in for the connection.
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := b.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
c := &Client{conn: a, cbProgram: 0x40000000}
|
||||
st := nfs4.Stateid{1, 'D', 'D'}
|
||||
|
||||
notify := nfs4.AppendCBNotifyArgs(nil, st, []byte("dir"),
|
||||
[]nfs4.Notify4{{Mask: nfs4.OfBits(nfs4.NotifyAddEntry),
|
||||
Vals: nfs4.AppendNotifyAdd(nil, "new", 0, true)}})
|
||||
lock := nfs4.AppendCBNotifyLockArgs(nil, []byte("f"), 9, []byte("owner"))
|
||||
|
||||
c.handleCBCall(1, buildCBCall(t, [][]byte{notify, lock}))
|
||||
notes := c.Notified()
|
||||
if len(notes) != 1 || !notes[0].Changes[0].Mask.Has(nfs4.NotifyAddEntry) {
|
||||
t.Fatalf("notified %+v", notes)
|
||||
}
|
||||
if n := c.NotifiedLocks(); len(n) != 1 || n[0].Clientid != 9 {
|
||||
t.Fatalf("locks %+v", n)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// buildCBCall frames one CB_COMPOUND call record the way the server
|
||||
// sends it.
|
||||
func buildCBCall(t *testing.T, ops [][]byte) []byte {
|
||||
t.Helper()
|
||||
args := nfs4.AppendCBCompoundArgs(nil, "cb", nfs4.MinorVersion, 0, ops)
|
||||
record, err := rpc.AppendCall(nil, rpc.Call{
|
||||
XID: 1, Program: nfs4.CBDefaultProgram, Version: nfs4.Version,
|
||||
Procedure: 1,
|
||||
Cred: rpc.Auth{Flavor: rpc.FlavorNone},
|
||||
Verifier: rpc.Auth{Flavor: rpc.FlavorNone},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return append(record, args...)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/server"
|
||||
)
|
||||
|
||||
// The RPCSEC_GSS integrity and privacy levels of this client round trip
|
||||
// against the real server over TCP.
|
||||
func TestGSSRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "k.txt"), []byte("kerberos"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend, err := nfsfs.NewLocal(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 3)
|
||||
}
|
||||
h := &nfs4server.Handler{FS: backend, ServerKey: key, ServiceName: "nfs"}
|
||||
|
||||
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)
|
||||
defer ln.Close()
|
||||
|
||||
for _, svc := range []uint32{rpc.SvcIntegrity, rpc.SvcPrivacy} {
|
||||
cl, err := Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.Establish("gss-client"); err != nil {
|
||||
t.Fatalf("establish: %v", err)
|
||||
}
|
||||
if err := cl.EnableGSS(krb5.EtypeAES128, key, "EXAMPLE.ORG", "nfs",
|
||||
"petr@EXAMPLE.ORG", svc); err != nil {
|
||||
t.Fatalf("enable gss: %v", err)
|
||||
}
|
||||
res, _, err := cl.Compound("gss", [][]byte{
|
||||
nfs4.AppendPutRootfh(nil),
|
||||
nfs4.AppendLookup(nil, "k.txt"),
|
||||
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrSize)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compound: %v", err)
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
t.Fatalf("compound: status %d", res.Status)
|
||||
}
|
||||
if err := cl.DisableGSS(); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
cl.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
)
|
||||
|
||||
// The NFS error carries its status into text a caller can log.
|
||||
func TestNFSErrorText(t *testing.T) {
|
||||
err := &NFSError{Status: nfs4.ErrNoEnt}
|
||||
if !strings.Contains(err.Error(), "2") {
|
||||
t.Fatalf("error text %q", err.Error())
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Fatal("empty error text")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/server"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
|
||||
)
|
||||
|
||||
// The named attribute high-level API over a protected session: OPENATTR,
|
||||
// create, write and read of one named attribute on a real file.
|
||||
func TestNamedAttrAPI(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "n.txt"), []byte("obj"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend, err := nfsfs.NewLocal(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 11)
|
||||
}
|
||||
h := &nfs4server.Handler{FS: backend, ServerKey: key, ServiceName: "nfs"}
|
||||
|
||||
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)
|
||||
defer ln.Close()
|
||||
|
||||
cl, err := Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := cl.Establish("nattr"); err != nil {
|
||||
t.Fatalf("establish: %v", err)
|
||||
}
|
||||
if err := cl.EnableGSS(krb5.EtypeAES128, key, "EXAMPLE.ORG", "nfs",
|
||||
"petr@EXAMPLE.ORG", rpc.SvcIntegrity); err != nil {
|
||||
t.Fatalf("enable gss: %v", err)
|
||||
}
|
||||
|
||||
// Open the file and write the named attribute through the API.
|
||||
res, bodies, err := cl.Compound("open", [][]byte{
|
||||
nfs4.AppendPutRootfh(nil),
|
||||
nfs4.AppendLookup(nil, "n.txt"),
|
||||
nfs4.AppendGetfh(nil),
|
||||
})
|
||||
if err != nil || res.Status != nfs4.ErrOK {
|
||||
t.Fatalf("open: %d %v", res.Status, err)
|
||||
}
|
||||
fh, ferr := xdr.NewDecoder(bodies[2]).VarOpaque()
|
||||
if ferr != nil {
|
||||
t.Fatal(ferr)
|
||||
}
|
||||
if err := cl.SetXattrNamed(fh, "user.note", []byte("hello note")); err != nil {
|
||||
t.Fatalf("set xattr: %v", err)
|
||||
}
|
||||
|
||||
// Read it back through the OPENATTR path.
|
||||
got, err := cl.GetXattrNamed(fh, "user.note", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("get xattr: %v", err)
|
||||
}
|
||||
if string(got) != "hello note" {
|
||||
t.Fatalf("value %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
)
|
||||
|
||||
// A server that accepts and never answers: every call fails at its
|
||||
// deadline instead of hanging the process.
|
||||
func TestCallTimeout(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
cl, err := Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
cl.Timeout = 40 * time.Millisecond
|
||||
start := time.Now()
|
||||
if _, _, err := cl.call(nfs4.ProcNull, nil); err == nil {
|
||||
t.Fatal("a silent server answered")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("the call took %s, the deadline is 40ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// A connection that dies mid call: the waiting caller reports the
|
||||
// loss with the cause the reader saw.
|
||||
func TestConnectionLost(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
cl, err := Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
cl.Timeout = 5 * time.Second
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := cl.call(nfs4.ProcNull, nil)
|
||||
done <- err
|
||||
}()
|
||||
// The server side of the connection dies: the reader routes the
|
||||
// failure to the waiting call.
|
||||
var server net.Conn
|
||||
select {
|
||||
case server = <-accepted:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the server side never saw the connection")
|
||||
}
|
||||
server.Close()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("a lost connection answered")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the call never noticed the lost connection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfsclient
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/server"
|
||||
)
|
||||
|
||||
func testCert(t *testing.T) *tls.Certificate {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "nfs.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
|
||||
}
|
||||
|
||||
// The whole modern security stack of one client session: the AUTH_TLS
|
||||
// upgrade of RFC 9289, the RPCSEC_GSS context of RFC 2203 and the
|
||||
// version three child context with assertions of RFC 7861, all against
|
||||
// the real server over TCP at the privacy level.
|
||||
func TestTLSAndGSSv3Stack(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "s.txt"), []byte("stack"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend, err := nfsfs.NewLocal(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert := testCert(t)
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 5)
|
||||
}
|
||||
h := &nfs4server.Handler{FS: backend, ServerKey: key, ServiceName: "nfs",
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{*cert}}}
|
||||
|
||||
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)
|
||||
defer ln.Close()
|
||||
|
||||
cl, err := Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
// The TLS upgrade rides the AUTH_TLS probe.
|
||||
cfg := &tls.Config{ServerName: "localhost",
|
||||
RootCAs: func() *x509.CertPool {
|
||||
pool := x509.NewCertPool()
|
||||
leaf, lerr := x509.ParseCertificate(cert.Certificate[0])
|
||||
if lerr != nil {
|
||||
t.Fatal(lerr)
|
||||
}
|
||||
pool.AddCert(leaf)
|
||||
return pool
|
||||
}()}
|
||||
if err := cl.EnableTLS(cfg); err != nil {
|
||||
t.Fatalf("enable tls: %v", err)
|
||||
}
|
||||
|
||||
// The session and the parent GSS context establish on the encrypted
|
||||
// channel.
|
||||
if err := cl.Establish("stack"); err != nil {
|
||||
t.Fatalf("establish: %v", err)
|
||||
}
|
||||
if err := cl.EnableGSS(krb5.EtypeAES256, key, "EXAMPLE.ORG", "nfs",
|
||||
"petr@EXAMPLE.ORG", rpc.SvcPrivacy); err != nil {
|
||||
t.Fatalf("enable gss: %v", err)
|
||||
}
|
||||
|
||||
// CREATE binds a label assertion to a version three child handle.
|
||||
child, err := cl.CreateGSSChild([]rpc.Assertion{{
|
||||
Type: rpc.AssertionLabel,
|
||||
Label: rpc.Label{LfsId: 4, Bytes: []byte("top")},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// A compound under the child handle rides TLS plus the privacy
|
||||
// protected version three credential.
|
||||
res, _, err := cl.Compound("stack", [][]byte{
|
||||
nfs4.AppendPutRootfh(nil),
|
||||
nfs4.AppendLookup(nil, "s.txt"),
|
||||
})
|
||||
if err != nil || res.Status != nfs4.ErrOK {
|
||||
t.Fatalf("compound: status %d %v", res.Status, err)
|
||||
}
|
||||
|
||||
// The server bound the label to the child context.
|
||||
if lbl := h.LabelOf(child); lbl == nil || string(lbl.Bytes) != "top" {
|
||||
t.Fatalf("label not bound")
|
||||
}
|
||||
|
||||
// LIST answers the supported assertion types.
|
||||
types, err := cl.ListGSSAssertions()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(types) != 2 {
|
||||
t.Fatalf("types %v", types)
|
||||
}
|
||||
|
||||
// The destroy of the parent retires the child with it.
|
||||
if err := cl.DisableGSS(); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user