Files
nfs/internal/nfs4server/guard_test.go
T
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

354 lines
12 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// The permission gate: the identity a call carries decides what the
// data operations may touch. Root passes everything; an identity that
// holds no rights on the object is refused before the backend runs.
func TestPermissionGate(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "secret.txt"), []byte("s"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "public.txt"), []byte("p"), 0o644); err != nil {
t.Fatal(err)
}
h := testTree(t)
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
h.FS = backend
sid, seq := newSession(t, h)
other := cred{uid: 65534, gid: 65534}
run := func(c cred, s uint32, ops [][]byte) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "perm", nfs4.MinorVersion, all), c)
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
// Another identity cannot read a 0600 file, but the public one it
// can; the ACCESS operation agrees with both answers.
if res := run(other, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "secret.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrAccess {
t.Fatalf("read of a private file as another identity: %d, want ACCESS", res.Status)
}
seq++
if res := run(other, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "public.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrOK {
t.Fatalf("read of a public file as another identity: %d", res.Status)
}
seq++
// Root passes the gate.
if res := run(cred{uid: 0, gid: 0}, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "secret.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrOK {
t.Fatalf("read of a private file as root: %d", res.Status)
}
}
// The wire bounds: a CLONE or WRITE_SAME whose sizes overflow or exceed
// the limits is refused with INVAL, never used to size an allocation.
func TestCloneAndWriteSameBounds(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "bounds", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
// A CLONE whose source offset and count wrap the size guard. The
// saved handle is the source, the current one the destination.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "sub"),
nfs4.AppendLookup(nil, "b.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendLookupp(nil),
nfs4.AppendLookupp(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendCloneArgs(nil, nfs4.Stateid{}, nfs4.Stateid{},
1<<63, 0, (1<<63)+8),
}); res.Status != nfs4.ErrInval {
t.Fatalf("wrapping clone: %d, want INVAL", res.Status)
}
seq++
// A WRITE_SAME with a block size beyond the write limit.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendWriteSameArgs(nil, nfs4.Stateid{}, nfs4.StableFileSync,
0, 1<<50, 1, 0, 0, 0, []byte("x")),
}); res.Status != nfs4.ErrInval {
t.Fatalf("oversized write same: %d, want INVAL", res.Status)
}
seq++
// A READ past the signed offset range is refused the same way.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 1<<63, 4),
}); res.Status != nfs4.ErrInval {
t.Fatalf("read at an impossible offset: %d, want INVAL", res.Status)
}
}
// The CURRENT_STATEID: after an OPEN in the same session, the special
// form names the caller's own open of the file.
func TestCurrentStateid(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
current := nfs4.Stateid{0, 0, 0, 1}
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid.ClientIDOf(), []byte("cur"),
nfs4.ShareAccessBoth, 0, true, 0o644, "cur.txt"),
nfs4.AppendWriteArgs(nil, current, 0, nfs4.StableFileSync, []byte("data")))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "cur", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write through the current stateid: status %d, %v", res.Status, err)
}
}
// A data operation may travel on the delegation stateid of the file,
// RFC 8881 section 10.3: the Linux client reads through the delegation
// it was granted. The delegation stateid belongs to its holder; a
// foreign client presenting it is refused.
func TestReadThroughDelegationStateid(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid.ClientIDOf(), []byte("deleg"),
nfs4.ShareAccessRead, 0, false, 0, "a.txt"),
nfs4.AppendGetfh(nil))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "deleg", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
_, delegType, delegSt, err := nfs4.DecodeOpenResDeleg(bodies[2])
if err != nil || delegType != nfs4.OpenDelegRead {
t.Fatalf("delegation %d: %v", delegType, err)
}
// The holder reads through the delegation stateid.
fh, err := xdr.NewDecoder(bodies[3]).VarOpaque()
if err != nil {
t.Fatal(err)
}
seq++
read := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutfh(nil, fh),
nfs4.AppendReadArgs(nil, delegSt, 0, 64))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "read", nfs4.MinorVersion, read), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err = nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("read through the delegation stateid: status %d, %v", res.Status, err)
}
// A foreign session presenting the same stateid is refused.
sid2, seq2 := newSession(t, h)
foreign := append([][]byte{nfs4.AppendSequenceArgs(nil, sid2, seq2, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadArgs(nil, delegSt, 0, 64))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "foreign", nfs4.MinorVersion, foreign), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err = nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrBadStateid {
t.Fatalf("foreign read through the delegation: status %d, %v", res.Status, err)
}
}
// EXCLUSIVE4_1 creates like its guarded equivalent, with the replay
// semantics of the exclusive forms: a retry with the same verifier
// replays into success, a create over an existing name with a
// different verifier answers EXIST, RFC 8881 section 18.16.
func TestOpenExclusive41(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, verf byte, name string) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgsExclusive41(nil, sid.ClientIDOf(), []byte("ex"),
[8]byte{verf, 2, 3, 4, 5, 6, 7, 8}, 0o644, name))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "ex41", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
if res := run(seq, 1, "ex41.txt"); res.Status != nfs4.ErrOK {
t.Fatalf("exclusive 4.1 create: %d", res.Status)
}
// The same verifier replays the lost reply into success.
if res := run(seq+1, 1, "ex41.txt"); res.Status != nfs4.ErrOK {
t.Fatalf("exclusive 4.1 replay: %d, want OK", res.Status)
}
// A different verifier over the existing name is EXIST.
if res := run(seq+2, 2, "ex41.txt"); res.Status != nfs4.ErrExist {
t.Fatalf("exclusive 4.1 over an existing name: %d, want EXIST", res.Status)
}
}
// A lease that lapsed while the client was gone frees its state and
// ends its identity: the next SEQUENCE answers EXPIRED and the client
// establishes a new one, while nothing of the old life denies anybody.
func TestLeaseExpiryReleasesState(t *testing.T) {
h := testTree(t)
h.LeasePeriod = 20 * time.Millisecond
sid, seq := newSession(t, h)
clientid := sid.ClientIDOf()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, clientid, []byte("lease"),
nfs4.ShareAccessBoth, nfs4.ShareDenyBoth, true, 0o644, "lease.txt"))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "lease", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
time.Sleep(60 * time.Millisecond)
expired := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq+1, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "after", nfs4.MinorVersion, expired), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrExpired {
t.Fatalf("sequence after the lease lapsed: status %d, %v", res.Status, err)
}
// A second client takes the name the expired client held open with
// deny bits: nothing of the old life denies it anymore.
sid2, seq2 := newSession(t, h)
all2 := append([][]byte{nfs4.AppendSequenceArgs(nil, sid2, seq2, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid2.ClientIDOf(), []byte("fresh"),
nfs4.ShareAccessBoth, 0, true, 0o644, "lease.txt"))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "fresh", nfs4.MinorVersion, all2), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open after the expired client: status %d, %v", res.Status, err)
}
}
// The seek answers the virtual hole at the end of a dense file with the
// size and the eof flag, RFC 7862 section 15.11.
func TestSeekVirtualHoleOverWire(t *testing.T) {
h := testTree(t)
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 := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.Establish("seek"); err != nil {
t.Fatal(err)
}
res, bodies, err := cl.Compound("seek", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, nfs4.Stateid{}, 0, nfs4.ContentHole),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("seek hole in a dense file: status %d, %v", res.Status, err)
}
d := xdr.NewDecoder(bodies[2])
eof, err := d.Bool()
if err != nil {
t.Fatal(err)
}
offset, err := d.Uint64()
if err != nil {
t.Fatal(err)
}
if !eof || offset != 9 {
t.Fatalf("seek hole: eof %v offset %d, want the size 9 with eof", eof, offset)
}
// A seek past the end is NXIO.
res, _, err = cl.Compound("seek", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, nfs4.Stateid{}, 1<<40, nfs4.ContentData),
})
if err != nil || res.Status != nfs4.ErrNXIO {
t.Fatalf("seek past the end: status %d, want NXIO, %v", res.Status, err)
}
}