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,150 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nfs4server_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"io/fs"
|
||||
"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/server"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
|
||||
)
|
||||
|
||||
// serveSquash starts a handler over a loopback listener with the export
|
||||
// holding one file, and answers whether root is squashed.
|
||||
func serveSquash(t *testing.T, squash bool) (addr, file string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "x.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
backend, err := nfsfs.NewLocal(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
handler := &nfs4server.Handler{FS: backend, RootSquash: squash}
|
||||
go func() { _ = (&server.Server{Handle: handler.HandleConn}).Serve(t.Context(), ln) }()
|
||||
|
||||
cl, err := nfsclient.Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := cl.Establish("squash-test"); err != nil {
|
||||
t.Fatalf("Establish: %v", err)
|
||||
}
|
||||
res, _, err := cl.Compound("remove", [][]byte{
|
||||
nfs4.AppendPutRootfh(nil),
|
||||
nfs4.AppendRemoveArgs(nil, "x.txt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("remove compound: %v", err)
|
||||
}
|
||||
// A removal inside the export root needs the modify right on the
|
||||
// directory: the superuser grant answers it, the anonymous identity
|
||||
// does not.
|
||||
status := res.Status
|
||||
if squash && status != nfs4.ErrAccess {
|
||||
t.Fatalf("a squashed root removed a file it must not: status %d", status)
|
||||
}
|
||||
if !squash && status != nfs4.ErrOK {
|
||||
t.Fatalf("root lost its grant without squash: status %d", status)
|
||||
}
|
||||
return ln.Addr().String(), filepath.Join(root, "x.txt")
|
||||
}
|
||||
|
||||
// TestRootSquashRemovesRootGrant covers the core of the feature: the
|
||||
// squashed credential has no superuser grant, so a removal root may not
|
||||
// make is refused with NFS4ERR_ACCESS.
|
||||
func TestRootSquashRemovesRootGrant(t *testing.T) {
|
||||
serveSquash(t, true)
|
||||
}
|
||||
|
||||
// TestNoSquashKeepsRootGrant covers the default: without the switch the
|
||||
// root claim keeps everything AUTH_SYS grants it.
|
||||
func TestNoSquashKeepsRootGrant(t *testing.T) {
|
||||
addr, file := serveSquash(t, false)
|
||||
if _, err := os.Stat(file); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("the removal did not land: %v", err)
|
||||
}
|
||||
_ = addr
|
||||
}
|
||||
|
||||
// TestRootSquashOwnerUnderRoot covers the owner attribution on a server
|
||||
// that can chown: a file root creates carries nobody. A server without
|
||||
// the privilege keeps its own identity on the objects it makes, the
|
||||
// documented fallback, so the assertion runs only where it is
|
||||
// guaranteed.
|
||||
func TestRootSquashOwnerUnderRoot(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("the owner attribution needs the chown privilege; run as root")
|
||||
}
|
||||
root := t.TempDir()
|
||||
backend, err := nfsfs.NewLocal(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
handler := &nfs4server.Handler{FS: backend, RootSquash: true}
|
||||
go func() { _ = (&server.Server{Handle: handler.HandleConn}).Serve(t.Context(), ln) }()
|
||||
|
||||
cl, err := nfsclient.Dial(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := cl.Establish("squash-owner"); err != nil {
|
||||
t.Fatalf("Establish: %v", err)
|
||||
}
|
||||
res, bodies, err := cl.Compound("create", [][]byte{
|
||||
nfs4.AppendPutRootfh(nil),
|
||||
nfs4.AppendOpenArgs(nil, 0, []byte("root"), nfs4.ShareAccessBoth, 0,
|
||||
true, 0o644, "made.txt"),
|
||||
nfs4.AppendGetfh(nil),
|
||||
})
|
||||
if err != nil || res.Status != nfs4.ErrOK {
|
||||
t.Fatalf("open: status %d, %v", res.Status, err)
|
||||
}
|
||||
var st nfs4.Stateid
|
||||
copy(st[:], bodies[len(bodies)-2])
|
||||
fh, err := xdr.NewDecoder(bodies[len(bodies)-1]).VarOpaque()
|
||||
if err != nil {
|
||||
t.Fatalf("decode fh: %v", err)
|
||||
}
|
||||
if _, _, err := cl.Compound("close", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendCloseArgs(nil, st),
|
||||
}); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, "made.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
sys, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
t.Skip("the raw stat is unavailable on this platform")
|
||||
}
|
||||
if sys.Uid != 65534 {
|
||||
t.Fatalf("a root created file carries uid %d, want 65534", sys.Uid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user