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
354 lines
12 KiB
Go
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)
|
|
}
|
|
}
|