Files

2167 lines
69 KiB
Go
Raw Permalink Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"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"
)
func testTree(t *testing.T) *Handler {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello nfs"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("inner"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.Symlink("a.txt", filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("backend: %v", err)
}
return &Handler{FS: backend}
}
func compoundOps(t *testing.T, h *Handler, minor uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
if minor == nfs4.MinorVersion {
sid, seq := newSession(t, h)
ops = append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, ops...)
}
payload := nfs4.AppendCompoundArgs(nil, "test", minor, ops)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("well formed arguments were treated as garbage")
}
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatalf("decode result: %v", err)
}
if minor == nfs4.MinorVersion {
res.Ops = res.Ops[1:]
bodies = bodies[1:]
}
return res, bodies
}
// newSession runs EXCHANGE_ID and CREATE_SESSION against the handler,
// each in its own COMPOUND with a fresh identity, and returns the session
// id and the sequence the next SEQUENCE must carry.
func newSession(t *testing.T, h *Handler) (nfs4.SessionID, uint32) {
t.Helper()
n := h.probeCount()
verifier := [8]byte{byte(n), 3, 3, 7, 7, 7, 7, 7}
owner := []byte{byte(n), 'o', 'w', 'n'}
ex := nfs4.AppendExchangeIDArgs(nil, verifier, owner, 0)
payload := nfs4.AppendCompoundArgs(nil, "ex", nfs4.MinorVersion, [][]byte{ex})
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("exchange id treated as garbage")
}
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("exchange id: status %d, %v", res.Status, err)
}
clientid, err := xdr.NewDecoder(bodies[0]).Uint64()
if err != nil {
t.Fatalf("clientid: %v", err)
}
cs := nfs4.AppendCreateSessionArgs(nil, clientid, 1, 0,
nfs4.DefaultForeChannel, nfs4.DefaultBackChannel, 0)
payload = nfs4.AppendCompoundArgs(nil, "cs", nfs4.MinorVersion, [][]byte{cs})
body, ok = h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("create session treated as garbage")
}
res, bodies, err = nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("create session: status %d, %v", res.Status, err)
}
var sid nfs4.SessionID
copy(sid[:], bodies[0])
return sid, 1
}
func wantStatus(t *testing.T, where string, got, want uint32) {
t.Helper()
if got != want {
t.Fatalf("%s: status %d, want %d", where, got, want)
}
}
func TestMountLikeSession(t *testing.T) {
h := testTree(t)
var ops [][]byte
ops = append(ops, nfs4.AppendPutRootfh(nil))
ops = append(ops, nfs4.AppendGetfh(nil))
ops = append(ops, nfs4.AppendGetattr(nil, nfs4.OfBits(
nfs4.AttrType, nfs4.AttrSize, nfs4.AttrFSID, nfs4.AttrMode,
nfs4.AttrChange, nfs4.AttrMountedOnFileID, nfs4.AttrNumlinks)))
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
fh, err := xdr.NewDecoder(bodies[1]).VarOpaque()
if err != nil || len(fh) == 0 {
t.Fatalf("root handle: %x, %v", fh, err)
}
response, attrs, err := nfs4.DecodeGetattrBody(bodies[2])
if err != nil {
t.Fatalf("getattr body: %v", err)
}
if !response.Has(nfs4.AttrType) || attrs.Type != nfs4.NF4Dir {
t.Fatalf("root is not a directory: type %d", attrs.Type)
}
if attrs.FSID[0] == 0 {
t.Fatalf("fsid major is zero: %+v", attrs.FSID)
}
}
func TestLookupReadFile(t *testing.T) {
h := testTree(t)
var ops [][]byte
ops = append(ops, nfs4.AppendPutRootfh(nil))
ops = append(ops, nfs4.AppendLookup(nil, "a.txt"))
ops = append(ops, nfs4.AppendGetfh(nil))
ops = append(ops, nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize)))
ops = append(ops, nfs4.AppendRead(nil, nfs4.AllZero, 6, 64))
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
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 || attrs.Type != nfs4.NF4Reg || attrs.Size != 9 {
t.Fatalf("a.txt attrs: %+v, %v", attrs, err)
}
eof, data, err := nfs4.DecodeReadBody(bodies[4])
if err != nil || string(data) != "nfs" {
t.Fatalf("read tail: %q, eof %v, %v", data, eof, err)
}
// A read from the start that fills the whole file reports EOF.
ops = append(ops[:4], nfs4.AppendRead(nil, nfs4.AllZero, 0, 64))
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK {
t.Fatalf("full read status %d", res.Status)
}
eof, data, err = nfs4.DecodeReadBody(bodies[4])
if err != nil || !eof || string(data) != "hello nfs" {
t.Fatalf("full read: %q eof %v, %v", data, eof, err)
}
}
func TestLookupMissingFailsTheCompound(t *testing.T) {
h := testTree(t)
var ops [][]byte
ops = append(ops, nfs4.AppendPutRootfh(nil))
ops = append(ops, nfs4.AppendLookup(nil, "missing"))
ops = append(ops, nfs4.AppendGetfh(nil))
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "compound", res.Status, nfs4.ErrNoEnt)
if len(res.Ops) != 2 {
t.Fatalf("%d results, want 2: the third operation never ran", len(res.Ops))
}
wantStatus(t, "lookup", res.Ops[1].Status, nfs4.ErrNoEnt)
if len(bodies) > 2 && len(bodies[2]) != 0 {
t.Fatalf("the skipped operation produced a body")
}
}
func TestMinorVersionMismatch(t *testing.T) {
h := testTree(t)
res, _ := compoundOps(t, h, 1, [][]byte{nfs4.AppendPutRootfh(nil)})
wantStatus(t, "compound", res.Status, nfs4.ErrMinorVersMismatch)
if len(res.Ops) != 0 {
t.Fatalf("a mismatch carries %d results", len(res.Ops))
}
}
func TestUnsupportedOperation(t *testing.T) {
h := testTree(t)
var ops [][]byte
ops = append(ops, nfs4.AppendPutRootfh(nil))
ops = append(ops, nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)))
// SET_SSV carries no meaning without the SSV mechanism and is
// answered NOT_SUPP.
ops[len(ops)-1] = []byte{0, 0, 0, byte(nfs4.OpSetSsv)}
res, _ := compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "compound", res.Status, nfs4.ErrNotSupp)
wantStatus(t, "ssv", res.Ops[1].Status, nfs4.ErrNotSupp)
}
func TestGetfhWithoutCurrent(t *testing.T) {
h := testTree(t)
res, _ := compoundOps(t, h, nfs4.MinorVersion, [][]byte{nfs4.AppendGetfh(nil)})
wantStatus(t, "getfh", res.Ops[0].Status, nfs4.ErrNoFileHandle)
}
func TestSaveRestore(t *testing.T) {
h := testTree(t)
var ops [][]byte
ops = append(ops, nfs4.AppendPutRootfh(nil))
ops = append(ops, nfs4.AppendLookup(nil, "sub"))
ops = append(ops, nfs4.AppendSavefh(nil))
ops = append(ops, nfs4.AppendLookup(nil, "b.txt"))
ops = append(ops, nfs4.AppendRestorefh(nil))
ops = append(ops, nfs4.AppendGetfh(nil))
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 6 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
fh, err := xdr.NewDecoder(bodies[5]).VarOpaque()
if err != nil || len(fh) == 0 {
t.Fatalf("restored handle: %v", err)
}
info, err := h.FS.Getattr(fh)
if err != nil || !info.IsDir() {
t.Fatalf("the restored handle is not sub: %v, %v", info, err)
}
}
func TestAccessAsRootAndOther(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendAccess(nil, nfs4.AccessRead|nfs4.AccessModify|nfs4.AccessExec),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK {
t.Fatalf("status %d", res.Status)
}
d := xdr.NewDecoder(bodies[2])
supported, err := d.Uint32()
granted, err := d.Uint32()
if err != nil || supported != supportedAccess {
t.Fatalf("supported mask %x, %v", supported, err)
}
if granted&(nfs4.AccessRead|nfs4.AccessModify) == 0 {
t.Fatalf("root was denied read or modify: %x", granted)
}
// The same call under an unrelated identity loses modify on 0644.
// The compound runs in its own session, built for the other identity.
sid, seq := newSession(t, h)
otherOps := append([][]byte{
nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true),
}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, otherOps)
body, ok := h.compound(payload, cred{uid: 60000, gid: 60000})
if !ok {
t.Fatal("garbage")
}
res, bodies, err = nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("other: %d, %v", res.Status, err)
}
// The results carry the SEQUENCE first; the ACCESS body is the fourth.
d = xdr.NewDecoder(bodies[3])
_, _ = d.Uint32()
granted, err = d.Uint32()
if err != nil {
t.Fatal(err)
}
if granted&nfs4.AccessModify != 0 || granted&nfs4.AccessRead == 0 {
t.Fatalf("an other access of %x on a 0644 file", granted)
}
}
func TestReaddirRoot(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<16, 1<<16,
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize)),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK {
t.Fatalf("status %d", res.Status)
}
_, entries, eof, err := nfs4.DecodeReadDirBody(bodies[1])
if err != nil || !eof {
t.Fatalf("listing: eof %v, %v", eof, err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name)
}
want := []string{"a.txt", "link", "sub"}
if len(names) != len(want) {
t.Fatalf("listing %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Fatalf("listing %v, want %v", names, want)
}
}
if entries[0].Attrs.Type != nfs4.NF4Reg || entries[1].Attrs.Type != nfs4.NF4Lnk ||
entries[2].Attrs.Type != nfs4.NF4Dir {
t.Fatalf("entry types %d %d %d", entries[0].Attrs.Type,
entries[1].Attrs.Type, entries[2].Attrs.Type)
}
}
func TestReaddirSeesFifo(t *testing.T) {
root := t.TempDir()
if err := syscall.Mkfifo(filepath.Join(root, "pipe"), 0o644); err != nil {
t.Skipf("mkfifo: %v", err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
h := &Handler{FS: backend}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 0, 1<<16, nfs4.OfBits(nfs4.AttrType)),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("status %d", res.Status)
}
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[1])
if err != nil || len(entries) != 1 || entries[0].Attrs.Type != nfs4.NF4Fifo {
t.Fatalf("fifo entry: %+v, %v", entries, err)
}
}
func TestGarbageArguments(t *testing.T) {
h := testTree(t)
// A tag, a minor version, a count of two and one operation: the second
// operation cannot be read.
payload := nfs4.AppendCompoundArgs(nil, "x", nfs4.MinorVersion, [][]byte{nfs4.AppendPutRootfh(nil)})
payload = payload[:len(payload)-4] // the count already says two
if _, ok := h.compound(payload, cred{}); ok {
t.Fatal("truncated arguments were not treated as garbage")
}
}
func TestWriteOverCompound(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendWriteArgs(nil, nfs4.AllZero, 6, nfs4.StableUnstable, []byte("NFS")),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
count, committed, verf, err := nfs4.DecodeWriteRes(bodies[2])
if err != nil || count != 3 || committed != nfs4.StableFileSync {
t.Fatalf("write res: %d %d, %v", count, committed, err)
}
if verf == ([8]byte{}) {
t.Fatal("the write verifier is zero")
}
// The data landed on the disk of the backend, not in some buffer.
got, err := h.FS.Read(mustLookup(t, h, "a.txt"), 0, 64)
if err != nil || string(got) != "hello NFS" {
t.Fatalf("after write: %q, %v", got, err)
}
// The boot verifier survives across calls and COMPOUNDs.
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
_, _, verf2, err := nfs4.DecodeWriteRes(bodies[2])
if err != nil || verf2 != verf {
t.Fatalf("the verifier changed between calls: %x vs %x", verf, verf2)
}
}
// a readOnlyFS hides the Writer of the backend behind the read only
// interface, the way a backend that cannot write would.
type readOnlyFS struct {
nfsfs.FS
}
func TestWriteAndCreateOnReadOnlyBackend(t *testing.T) {
h := testTree(t)
h2 := &Handler{FS: readOnlyFS{FS: h.FS}}
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendWriteArgs(nil, nfs4.AllZero, 0, nfs4.StableFileSync, []byte("x")),
}
res, _ := compoundOps(t, h2, nfs4.MinorVersion, ops)
wantStatus(t, "write on read only", res.Status, nfs4.ErrROFS)
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "d", "", 0, 0,
0o755),
}
res, _ = compoundOps(t, h2, nfs4.MinorVersion, ops)
wantStatus(t, "create on read only", res.Status, nfs4.ErrROFS)
}
func TestCreateOverCompound(t *testing.T) {
h := testTree(t)
// CREATE leaves the new object as the current handle, so the attributes
// that follow the creation are its own.
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "newdir", "", 0, 0,
0o750),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrMode)),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(bodies[2])
if err != nil || attrs.Type != nfs4.NF4Dir || attrs.Mode != 0o750 {
t.Fatalf("the created dir: %+v, %v", attrs, err)
}
// The object is reachable from the root under its name.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "newdir"),
nfs4.AppendGetfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lookup of the created dir: status %d", res.Status)
}
// A symlink carries its target and reports as NF4LNK.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Lnk, "zlink", "a.txt", 0, 0,
0o644),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK {
t.Fatalf("symlink status %d", res.Status)
}
_, attrs, err = nfs4.DecodeGetattrBody(bodies[2])
if err != nil || attrs.Type != nfs4.NF4Lnk {
t.Fatalf("the created symlink: %+v, %v", attrs, err)
}
// An existing target is NFS4ERR_EXIST.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "newdir", "", 0, 0,
0o755),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "create existing", res.Status, nfs4.ErrExist)
// CREATE leaves the new object as the current handle, checked from the
// outside with a fifo of a known type.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Fifo, "probe.fifo", "", 0, 0,
0o644),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK {
t.Fatalf("fifo status %d", res.Status)
}
_, attrs, err = nfs4.DecodeGetattrBody(bodies[2])
if err != nil || attrs.Type != nfs4.NF4Fifo {
t.Fatalf("the created fifo: %+v, %v", attrs, err)
}
}
func mustLookup(t *testing.T, h *Handler, name string) nfsfs.Handle {
t.Helper()
root, err := h.FS.Root()
if err != nil {
t.Fatalf("root: %v", err)
}
child, _, err := h.FS.Lookup(root, name)
if err != nil {
t.Fatalf("lookup %s: %v", name, err)
}
return child
}
func TestUnknownBackendErrorMapsToServerFault(t *testing.T) {
if got := mapErr(bytes.ErrTooLarge); got != nfs4.ErrServerFault {
t.Fatalf("an unknown error mapped to %d", got)
}
for _, tc := range []struct {
err error
want uint32
}{
{nfsfs.ErrNoEnt, nfs4.ErrNoEnt},
{nfsfs.ErrNotDir, nfs4.ErrNotDir},
{nfsfs.ErrIsDir, nfs4.ErrIsDir},
{nfsfs.ErrStale, nfs4.ErrStale},
{nfsfs.ErrNameTooLong, nfs4.ErrNameTooLong},
{nfsfs.ErrBadName, nfs4.ErrBadName},
{nfsfs.ErrPermission, nfs4.ErrAccess},
{nfsfs.ErrIO, nfs4.ErrIO},
} {
if got := mapErr(tc.err); got != tc.want {
t.Fatalf("%v mapped to %d, want %d", tc.err, got, tc.want)
}
}
}
func TestHandleConnOverPipe(t *testing.T) {
h := testTree(t)
serverConn, clientConn := net.Pipe()
done := make(chan struct{})
go func() {
h.HandleConn(serverConn)
close(done)
}()
credBody, err := (rpc.AuthSys{Machine: "probe", UID: 0, GID: 0}).Body()
if err != nil {
t.Fatal(err)
}
sysCall := rpc.Call{Program: nfs4.Program, Version: nfs4.Version,
Cred: rpc.Auth{Flavor: rpc.FlavorSys, Body: credBody}}
// NULL: answered with success and the connection stays.
sysCall.XID, sysCall.Procedure = 1, nfs4.ProcNull
rec, err := rpc.AppendCall(nil, sysCall)
if err != nil {
t.Fatal(err)
}
if err := rpc.WriteRecord(clientConn, rec); err != nil {
t.Fatal(err)
}
rep, err := rpc.ReadRecord(clientConn, 1<<20)
if err != nil {
t.Fatalf("null: %v", err)
}
reply, err := rpc.DecodeReply(rep)
if err != nil || reply.Status != rpc.AcceptSuccess || reply.XID != 1 {
t.Fatalf("null reply: %d, %v", reply.Status, err)
}
// An unknown program is answered PROG_UNAVAIL and the session lives.
sysCall.XID, sysCall.Program = 2, 99999
rec, _ = rpc.AppendCall(nil, sysCall)
if err := rpc.WriteRecord(clientConn, rec); err != nil {
t.Fatal(err)
}
rep, _ = rpc.ReadRecord(clientConn, 1<<20)
reply, err = rpc.DecodeReply(rep)
if err != nil || reply.Status != rpc.AcceptProgUnavail {
t.Fatalf("unknown program: %d, %v", reply.Status, err)
}
// An unknown procedure is answered PROC_UNAVAIL.
sysCall.XID, sysCall.Program, sysCall.Procedure = 3, nfs4.Program, 999
rec, _ = rpc.AppendCall(nil, sysCall)
if err := rpc.WriteRecord(clientConn, rec); err != nil {
t.Fatal(err)
}
rep, _ = rpc.ReadRecord(clientConn, 1<<20)
reply, err = rpc.DecodeReply(rep)
if err != nil || reply.Status != rpc.AcceptProcUnavail {
t.Fatalf("unknown procedure: %d, %v", reply.Status, err)
}
// A record that is no ONC RPC call ends the session.
if err := rpc.WriteRecord(clientConn, []byte{0xde, 0xad}); err != nil {
t.Fatal(err)
}
if _, err := rpc.ReadRecord(clientConn, 1<<20); err == nil {
t.Fatal("the connection survived a malformed call")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("HandleConn did not return")
}
}
func TestReadDirTooSmallAndWrongVerifier(t *testing.T) {
h := testTree(t)
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<16, 512,
nfs4.OfBits(nfs4.AttrType)),
})
wantStatus(t, "too small", res.Status, nfs4.ErrTooSmall)
if len(bodies[1]) != 0 {
t.Fatal("a failed readdir carried a body")
}
// A first page earns the verifier a later call must echo. The root
// verifier is the directory mtime in nanoseconds, never zero here.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, 1, [8]byte{}, 1<<16, 1<<16,
nfs4.OfBits(nfs4.AttrType)),
})
wantStatus(t, "wrong verifier", res.Status, nfs4.ErrNotSame)
}
func TestReaddirPagingUnderBudget(t *testing.T) {
// Ten entries and a maxcount that takes only some of them: the page
// ends early, reports that, and the client resumes from the last
// cookie with the verifier it earned.
root := t.TempDir()
for i := range 100 {
if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("f%03d", i)), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
h := &Handler{FS: backend}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 0, 1024,
nfs4.OfBits(nfs4.AttrType)),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("first page status %d", res.Status)
}
verifier, first, eof, err := nfs4.DecodeReadDirBody(bodies[1])
if err != nil {
t.Fatalf("decode: %v", err)
}
if eof || len(first) == 0 || len(first) == 100 {
t.Fatalf("first page: %d entries, eof %v", len(first), eof)
}
// Resume from the cookie of the last entry; the same verifier is
// accepted and the rest arrives.
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendReaddir(nil, first[len(first)-1].Cookie, verifier, 0, 1<<16,
nfs4.OfBits(nfs4.AttrType)),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("second page status %d", res.Status)
}
_, second, eof2, err := nfs4.DecodeReadDirBody(bodies[1])
if err != nil || !eof2 {
t.Fatalf("second page: eof %v, %v", eof2, err)
}
if len(first)+len(second) != 100 {
t.Fatalf("%d entries over two pages, want 100", len(first)+len(second))
}
}
func TestReadOnDirectory(t *testing.T) {
h := testTree(t)
res, _ := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "sub"),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 64),
})
wantStatus(t, "read a directory", res.Status, nfs4.ErrIsDir)
}
func TestDecodeCred(t *testing.T) {
// A well formed AUTH_SYS claim arrives as the identity it carries.
body, err := (rpc.AuthSys{UID: 5, GID: 6, GIDs: []uint32{6, 7}}).Body()
if err != nil {
t.Fatal(err)
}
if got := decodeCred(rpc.Auth{Flavor: rpc.FlavorSys, Body: body}); got.uid != 5 || got.gid != 6 || len(got.groups) != 2 {
t.Fatalf("sys cred: %+v", got)
}
// A broken claim and a flavourless credential both become nobody.
if got := decodeCred(rpc.Auth{Flavor: rpc.FlavorSys, Body: []byte{1}}); got.uid != 0xffffffff {
t.Fatalf("a broken sys cred became %+v", got)
}
if got := decodeCred(rpc.AuthNull); got.uid != 0xffffffff || got.gid != 0xffffffff {
t.Fatalf("a null cred became %+v", got)
}
}
func TestWriteAndCreateGarbageAndErrors(t *testing.T) {
h := testTree(t)
// A WRITE whose stateid is cut short is garbage, not a status. The
// compound runs inside a session, so the WRITE is reached at all.
sid, seq := newSession(t, h)
ops := [][]byte{
nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
append(append([]byte{}, nfs4.AppendWriteArgs(nil, nfs4.AllZero, 0, 0, nil)[:8]...), 0, 0),
}
payload := nfs4.AppendCompoundArgs(nil, "x", nfs4.MinorVersion, ops)
if _, ok := h.compound(payload, cred{}); ok {
t.Fatal("a truncated WRITE was not garbage")
}
// A truncated CREATE attribute list is garbage, not a status.
sid, seq2 := newSession(t, h)
create := nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "d", "", 0, 0, 0o755)
ops = [][]byte{
nfs4.AppendSequenceArgs(nil, sid, seq2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
create[:len(create)-1],
}
payload = nfs4.AppendCompoundArgs(nil, "x", nfs4.MinorVersion, ops)
if _, ok := h.compound(payload, cred{}); ok {
t.Fatal("a truncated CREATE was not garbage")
}
// A CREATE over a fresh name succeeds; the same name again answers
// EXIST.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Fifo, "ex.fifo", "", 0, 0, 0o644),
}
res, _ := compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "create", res.Status, nfs4.ErrOK)
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "create again", res.Status, nfs4.ErrExist)
// A block device needs privileges this process may not have; the
// failure is permission or success, never a crash.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Blk, "dev", "", 1, 3,
0o644),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK && res.Status != nfs4.ErrAccess && res.Status != nfs4.ErrPerm && res.Status != nfs4.ErrIO {
t.Fatalf("block device create: status %d", res.Status)
}
}
func TestRemoveOverCompound(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendRemoveArgs(nil, "a.txt"),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 2 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
if len(bodies[1]) != 20 {
t.Fatalf("the change info carries %d bytes, want 20", len(bodies[1]))
}
// The name is gone: the next LOOKUP reports it.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
})
wantStatus(t, "lookup after remove", res.Status, nfs4.ErrNoEnt)
}
func TestRenameOverCompoundWithSavefh(t *testing.T) {
h := testTree(t)
// The canonical shape: the source directory goes into the saved
// handle, the target directory into the current one.
ops := [][]byte{
nfs4.AppendPutRootfh(nil), // current = root
nfs4.AppendSavefh(nil), // saved = root
nfs4.AppendLookup(nil, "sub"), // current = sub
nfs4.AppendRenameArgs(nil, "a.txt", "moved.txt"),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 4 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
if len(bodies[3]) != 40 {
t.Fatalf("two change infos carry %d bytes, want 40", len(bodies[3]))
}
// The file lives under the new directory and under no other.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
})
wantStatus(t, "old name", res.Status, nfs4.ErrNoEnt)
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "sub"),
nfs4.AppendLookup(nil, "moved.txt"),
nfs4.AppendGetfh(nil),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 64),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("new name status %d", res.Status)
}
eof, data, err := nfs4.DecodeReadBody(bodies[4])
if err != nil || !eof || string(data) != "hello nfs" {
t.Fatalf("moved content: %q, %v", data, err)
}
// Without SAVEFH there is no saved handle and RENAME refuses.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendRenameArgs(nil, "a.txt", "b.txt"),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "rename without savefh", res.Ops[1].Status, nfs4.ErrNoFileHandle)
// A rename of a missing source is NFS4ERR_NOENT.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSavefh(nil),
nfs4.AppendRenameArgs(nil, "missing", "x"),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "rename missing", res.Status, nfs4.ErrNoEnt)
// REMOVE refuses to take a full directory.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendRemoveArgs(nil, "sub"),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "remove full dir", res.Status, nfs4.ErrNotEmpty)
}
func TestSetattrOverCompound(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSetattrArgs(nil, nfs4.AllZero,
nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize),
nfs4.Attrs{Mode: 0o600, Size: 4}),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
attrsset, err := nfs4.ReadBitmap(xdr.NewDecoder(bodies[2]))
if err != nil {
t.Fatalf("attrsset: %v", err)
}
if !attrsset.Has(nfs4.AttrMode) || !attrsset.Has(nfs4.AttrSize) {
t.Fatalf("attrsset misses the changes: %v", attrsset.Words())
}
// The backend carries the result.
info, err := h.FS.Getattr(mustLookup(t, h, "a.txt"))
if err != nil || info.Size != 4 || info.Mode.Perm() != 0o600 {
t.Fatalf("after setattr: %d bytes, mode %o, %v", info.Size, info.Mode.Perm(), err)
}
// A SETATTR naming an attribute the server does not set is
// NFS4ERR_ATTRNOTSUPP, not a silent half answer. RAWDEV carries no
// value in Attrs, so it is built by hand: a bitmap naming it and an
// eight byte specdata value inside the attribute list.
attrlist := append(xdr.AppendUint32(nil, 0), xdr.AppendUint32(nil, 0)...)
fattr := xdr.AppendVarOpaque(nfs4.OfBits(nfs4.AttrRawDev).AppendTo(nil), attrlist)
rawdevArg := append(xdr.AppendUint32(nil, nfs4.OpSetattr), nfs4.AllZero[:]...)
rawdevArg = append(rawdevArg, fattr...)
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
rawdevArg,
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "setattr rawdev", res.Status, nfs4.ErrAttrNotSupp)
}
func TestLinkOverCompound(t *testing.T) {
h := testTree(t)
// The saved handle is the object, the current one the directory.
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLinkArgs(nil, "linked.txt"),
nfs4.AppendRestorefh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrNumlinks)),
}
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 7 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(bodies[6])
if err != nil || attrs.Numlinks != 2 {
t.Fatalf("link count: %d, %v", attrs.Numlinks, err)
}
// The link reads the same content from the root directory.
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "linked.txt"),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 64),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("read of the link: status %d", res.Status)
}
_, data, err := nfs4.DecodeReadBody(bodies[2])
if err != nil || string(data) != "hello nfs" {
t.Fatalf("through the link: %q, %v", data, err)
}
// Without SAVEFH the LINK refuses.
ops = [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLinkArgs(nil, "x"),
}
res, _ = compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "link without savefh", res.Ops[1].Status, nfs4.ErrNoFileHandle)
}
func TestSetattrTimesOverCompound(t *testing.T) {
h := testTree(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSetattrArgs(nil, nfs4.AllZero,
nfs4.OfBits(nfs4.AttrTimeAccessSet, nfs4.AttrTimeModifySet),
nfs4.Attrs{}),
}
// The Attrs shape cannot carry the settime4 encoding, so the argop
// comes from the structured encoder: access = server time, modify =
// explicit time 1e9.
ops[2] = nfs4.AppendSetattrArgsUpdates(nil, nfs4.AllZero, nfs4.SetAttrUpdates{
Atime: &nfs4.NfsTimeSet{Server: true},
Mtime: &nfs4.NfsTimeSet{Time: nfs4.NfsTime{Seconds: 1_000_000_000, Nseconds: 1}},
})
res, bodies := compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
attrsset, err := nfs4.ReadBitmap(xdr.NewDecoder(bodies[2]))
if err != nil || !attrsset.Has(nfs4.AttrTimeAccessSet) || !attrsset.Has(nfs4.AttrTimeModifySet) {
t.Fatalf("attrsset misses the times: %v, %v", attrsset.Words(), err)
}
}
func TestReadlinkAndCommitOverCompound(t *testing.T) {
h := testTree(t)
// READLINK on the symlink of the fixture reports its target.
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "link"),
nfs4.AppendReadlinkArgs(nil),
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
target, err := xdr.NewDecoder(bodies[2]).String()
if err != nil || target != "a.txt" {
t.Fatalf("readlink: %q, %v", target, err)
}
// READLINK on a regular file is NFS4ERR_INVAL.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadlinkArgs(nil),
})
wantStatus(t, "readlink of a file", res.Status, nfs4.ErrInval)
// COMMIT on a file answers with the boot verifier, and the verifier is
// stable across calls.
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendCommitArgs(nil, 0, 0, [8]byte{}),
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("commit: status %d ops %d", res.Status, len(res.Ops))
}
verf1, err := xdr.NewDecoder(bodies[2]).Raw(8)
if err != nil {
t.Fatalf("commit verifier: %v", err)
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, ops)
verf2, err := xdr.NewDecoder(bodies[2]).Raw(8)
if err != nil || string(verf1) != string(verf2) {
t.Fatalf("the commit verifier changed: %x vs %x", verf1, verf2)
}
// COMMIT on a read only backend is NFS4ERR_ROFS.
h2 := &Handler{FS: readOnlyFS{FS: h.FS}}
res, _ = compoundOps(t, h2, nfs4.MinorVersion, ops)
wantStatus(t, "commit on read only", res.Status, nfs4.ErrROFS)
}
func TestSecinfoOverCompound(t *testing.T) {
h := testTree(t)
// SECINFO for an explicit name answers AUTH_SYS.
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSecinfoArgs(nil, "a.txt"),
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 2 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
entries, err := nfs4.DecodeSecinfoRes(bodies[1])
if err != nil || len(entries) != 1 || entries[0].Flavor != nfs4.SecFlavorSys {
t.Fatalf("secinfo: %+v, %v", entries, err)
}
// Even a name that does not exist is answered: probing security is the
// purpose of the operation.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSecinfoArgs(nil, "missing"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("secinfo of a missing name: status %d", res.Status)
}
// SECINFO_NO_NAME with the current file handle: no LOOKUP needed,
// the argument is the style enum alone, RFC 8881 section 18.44.
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSecinfoNoNameArgs(nil, nfs4.StyleCurrentFH),
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 2 {
t.Fatalf("current handle: status %d ops %d", res.Status, len(res.Ops))
}
entries, err = nfs4.DecodeSecinfoRes(bodies[1])
if err != nil || len(entries) != 1 || entries[0].Flavor != nfs4.SecFlavorSys {
t.Fatalf("current handle secinfo: %+v, %v", entries, err)
}
// The parent style names the parent of the current handle; no name
// rides the wire there either.
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSecinfoNoNameArgs(nil, nfs4.StyleParent),
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("parent style: status %d ops %d", res.Status, len(res.Ops))
}
entries, err = nfs4.DecodeSecinfoRes(bodies[2])
if err != nil || len(entries) != 1 {
t.Fatalf("parent secinfo: %+v, %v", entries, err)
}
// An unknown style is NFS4ERR_INVAL.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSecinfoNoNameArgs(nil, 99),
})
wantStatus(t, "unknown style", res.Status, nfs4.ErrInval)
}
func TestSessionReplayAndMisorder(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
mk := func(s uint32) []byte {
ops := [][]byte{
nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
}
return nfs4.AppendCompoundArgs(nil, "replay", nfs4.MinorVersion, ops)
}
// The first run executes and its answer is cached.
first, ok := h.compound(mk(seq), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("first run was garbage")
}
// The same sequence replays the very same bytes.
second, ok := h.compound(mk(seq), cred{uid: 0, gid: 0})
if !ok || string(first) != string(second) {
t.Fatal("a repeated sequence did not replay the cached answer")
}
// A skipped sequence is misordered.
skipped, ok := h.compound(mk(seq+7), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("misordered treated as garbage")
}
res, _, err := nfs4.DecodeCompoundResBodies(skipped)
if err != nil || res.Status != nfs4.ErrSeqMisordered {
t.Fatalf("a skipped sequence: status %d, %v", res.Status, err)
}
// DESTROY_SESSION closes the session; SEQUENCE on it is BADSESSION.
destroy := nfs4.AppendCompoundArgs(nil, "destroy", nfs4.MinorVersion,
[][]byte{nfs4.AppendDestroySessionArgs(nil, sid)})
res, _, err = nfs4.DecodeCompoundResBodies(mustCompound(t, h, destroy))
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("destroy: status %d, %v", res.Status, err)
}
dead, ok := h.compound(mk(seq+1), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("dead session treated as garbage")
}
res, _, err = nfs4.DecodeCompoundResBodies(dead)
if err != nil || res.Status != nfs4.ErrBadSession {
t.Fatalf("sequence on a destroyed session: status %d, %v", res.Status, err)
}
}
func TestOpsWithoutSessionAreRefused(t *testing.T) {
h := testTree(t)
payload := nfs4.AppendCompoundArgs(nil, "nosess", nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
})
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOpNotInSession {
t.Fatalf("a stateless call inside 4.2: status %d, %v", res.Status, err)
}
}
func mustCompound(t *testing.T, h *Handler, payload []byte) []byte {
t.Helper()
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
return body
}
func TestExchangeIDClientReboot(t *testing.T) {
h := testTree(t)
mk := func(v byte) []byte {
return nfs4.AppendExchangeIDArgs(nil, [8]byte{v, 1, 1, 1, 1, 1, 1, 1},
[]byte("same-owner"), 0)
}
// The first identity is assigned and confirmed on reuse.
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{mk(1)})
clientid1, err := xdr.NewDecoder(bodies[0]).Uint64()
if err != nil {
t.Fatal(err)
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{mk(1)})
rd := xdr.NewDecoder(bodies[0])
_, _ = rd.Uint64()
_, _ = rd.Uint32()
flags, err := rd.Uint32()
if err != nil || flags&nfs4.ExchgIDConfirmedR == 0 {
t.Fatalf("reuse without the confirmed flag: %x, %v", flags, err)
}
_ = res
// A new verifier for the same owner is a reboot: a new client id and
// no confirmed flag, and the sessions of the old id are gone.
sid, _, csStatus := h.sessions().createSession(clientid1, 0, 0)
if csStatus != nfs4.ErrOK {
t.Fatalf("create session for the confirmed client: %d", csStatus)
}
res, bodies = compoundOps(t, h, nfs4.MinorVersion, [][]byte{mk(2)})
clientid2, err := xdr.NewDecoder(bodies[0]).Uint64()
if err != nil {
t.Fatal(err)
}
if clientid2 == clientid1 {
t.Fatal("a rebooted client kept its id")
}
rd = xdr.NewDecoder(bodies[0])
_, _ = rd.Uint64()
_, _ = rd.Uint32()
flags, err = rd.Uint32()
if err != nil || flags&nfs4.ExchgIDConfirmedR != 0 {
t.Fatalf("a new client id carries the confirmed flag: %x, %v", flags, err)
}
if _, _, status := h.sessions().sequence(sid, 1, 0); status != nfs4.ErrBadSession {
t.Fatalf("a session of a rebooted client: status %d", status)
}
}
func TestOpenCloseOverCompound(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
// Every COMPOUND opens with its own SEQUENCE over the same session.
run := func(t *testing.T, s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("well formed arguments were treated as garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// OPEN with create: the file springs into existence with a stateid.
open := nfs4.AppendOpenArgs(nil, 0x1234, []byte("owner-1"),
nfs4.ShareAccessBoth, 0, true, 0o640, "created.txt")
res, bodies := run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
open,
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("open: status %d ops %d", res.Status, len(res.Ops))
}
st, err := xdr.NewDecoder(bodies[2]).Raw(16)
if err != nil || st[0] != 0 || st[3] != 1 {
t.Fatalf("open stateid: %x, %v", st, err)
}
var stateid nfs4.Stateid
copy(stateid[:], st)
// WRITE under the open stateid lands on the disk.
res, _ = run(t, seq+1, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "created.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("opened!")),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("write: status %d", res.Status)
}
// A conflicting OPEN is NFS4ERR_SHARE_DENIED: the first open asked
// both access modes with no deny, so a deny-write open collides.
conflict := nfs4.AppendOpenArgs(nil, 0x1234, []byte("owner-2"),
nfs4.ShareAccessRead, nfs4.ShareDenyWrite, false, 0, "created.txt")
res, _ = run(t, seq+2, [][]byte{
nfs4.AppendPutRootfh(nil),
conflict,
})
wantStatus(t, "share conflict", res.Status, nfs4.ErrShareDenied)
// CLOSE ends the state; the answer carries the dead stateid.
res, bodies = run(t, seq+3, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "created.txt"),
nfs4.AppendCloseArgs(nil, stateid),
})
if res.Status != nfs4.ErrOK || len(res.Ops) != 4 {
t.Fatalf("close: status %d ops %d", res.Status, len(res.Ops))
}
closed, cerr := xdr.NewDecoder(bodies[3]).Raw(16)
if cerr != nil || closed[3] != stateid[3]+1 {
t.Fatalf("closed stateid: %x, %v", closed, cerr)
}
// The dead stateid is OLD, not merely bad, when reused for a write.
res, _ = run(t, seq+4, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "created.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("x")),
})
wantStatus(t, "write after close", res.Status, nfs4.ErrOldStateid)
}
func TestOpenUnknownStateid(t *testing.T) {
h := testTree(t)
// A WRITE with a stateid nobody issued, on a file nobody opened.
var bogus nfs4.Stateid
bogus[0] = 1
bogus[4] = 0xaa
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendWriteArgs(nil, bogus, 0, nfs4.StableFileSync, []byte("x")),
}
res, _ := compoundOps(t, h, nfs4.MinorVersion, ops)
wantStatus(t, "bogus stateid", res.Status, nfs4.ErrBadStateid)
}
func TestCreateSessionReplay(t *testing.T) {
h := testTree(t)
clientid, _, _, _ := h.sessions().exchangeID([8]byte{9}, []byte("replay-owner"), time.Now())
id, _, status := h.sessions().createSession(clientid, 0, 0)
if status != nfs4.ErrOK {
t.Fatalf("first create: %d", status)
}
// The same sequence replays: the same session comes back.
id2, replay, status := h.sessions().createSession(clientid, 0, 0)
if status != nfs4.ErrOK || !replay || id2 != id {
t.Fatalf("replay: id %d replay %v status %d", id2, replay, status)
}
// A higher sequence replaces the slot table fresh.
_, replay, status = h.sessions().createSession(clientid, 1, 0)
if status != nfs4.ErrOK || replay {
t.Fatalf("second create: replay %v status %d", replay, status)
}
// A sequence behind the last one is misordered.
_, _, status = h.sessions().createSession(clientid, 0, 0)
if status != nfs4.ErrSeqMisordered {
t.Fatalf("old create sequence: %d", status)
}
// An unknown client id is stale.
if _, _, status = h.sessions().createSession(0xdead, 0, 0); status != nfs4.ErrStaleClientID {
t.Fatalf("unknown client: %d", status)
}
}
func TestLockLocktLockuOverCompound(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(t *testing.T, s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "lock", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("well formed arguments were treated as garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// Owner A opens the file and takes a write lock over the whole file.
open := nfs4.AppendOpenArgs(nil, 0x1111, []byte("owner-a"),
nfs4.ShareAccessBoth, 0, true, 0o644, "locked.txt")
res, bodies := run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
open,
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d", res.Status)
}
var openStateid nfs4.Stateid
copy(openStateid[:], bodies[2])
seq++
lock := nfs4.AppendLockArgsNew(nil, openStateid, 0x1111, []byte("locker-a"),
nfs4.LockTypeWrite, false, 0, 0)
res, bodies = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
lock,
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lock: status %d", res.Status)
}
var lockStateid nfs4.Stateid
copy(lockStateid[:], bodies[3])
// Owner B probes the same range: LOCKT reports the write lock denied,
// naming owner A's client.
seq++
probe := nfs4.AppendLocktArgs(nil, nfs4.LockTypeWrite, 0, 100, 0x2222, []byte("locker-b"))
res, bodies = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
probe,
})
if res.Status != nfs4.ErrDenied {
t.Fatalf("lockt: status %d", res.Status)
}
denied, err := nfs4.DecodeLocktResBody(res.Status, bodies[3])
// The lock is registered under the client of the session that took
// it, never under a clientid the wire alone claimed.
if err != nil || denied.ClientID != sid.ClientIDOf() || denied.LockType != nfs4.LockTypeWrite {
t.Fatalf("denied: %+v, %v", denied, err)
}
// A read probe is denied just the same against a write lock.
seq++
probe = nfs4.AppendLocktArgs(nil, nfs4.LockTypeRead, 0, 100, 0x2222, []byte("locker-b"))
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
probe,
})
if res.Status != nfs4.ErrDenied {
t.Fatalf("read probe: status %d", res.Status)
}
// Owner B's actual LOCK over the same range is denied too.
seq++
lockB := nfs4.AppendLockArgsNew(nil, openStateid, 0x2222, []byte("locker-b"),
nfs4.LockTypeWrite, false, 0, 100)
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
lockB,
})
if res.Status != nfs4.ErrDenied || len(res.Ops) != 4 {
t.Fatalf("lock of owner b: status %d ops %d", res.Status, len(res.Ops))
}
// Owner A unlocks the first hundred bytes; a lock by B over that range
// then succeeds, while the tail stays held.
seq++
unlock := nfs4.AppendLockuArgs(nil, lockStateid, 0, 100)
res, bodies = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
unlock,
})
if res.Status != nfs4.ErrOK {
t.Fatalf("unlock: status %d", res.Status)
}
var unlockedStateid nfs4.Stateid
copy(unlockedStateid[:], bodies[3])
if unlockedStateid[3] != lockStateid[3]+1 {
t.Fatalf("unlock seqid %d, want %d", unlockedStateid[3], lockStateid[3]+1)
}
seq++
lockB = nfs4.AppendLockArgsNew(nil, openStateid, 0x2222, []byte("locker-b"),
nfs4.LockTypeWrite, false, 0, 100)
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
lockB,
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lock into the freed range: status %d", res.Status)
}
// CLOSE with locks still held on the tail is NFS4ERR_LOCKS_HELD.
seq++
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "locked.txt"),
nfs4.AppendCloseArgs(nil, openStateid),
})
wantStatus(t, "close with locks", res.Status, nfs4.ErrLocksHeld)
}
func TestOpenDowngradeOverCompound(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(t *testing.T, s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// Open read-write, downgrade to read-only.
res, bodies := run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x1111, []byte("owner-a"),
nfs4.ShareAccessBoth, 0, true, 0o644, "down.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d", res.Status)
}
var st nfs4.Stateid
copy(st[:], bodies[2])
seq++
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "down.txt"),
nfs4.AppendOpenDowngradeArgs(nil, st, nfs4.ShareAccessRead, 0),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("downgrade: status %d", res.Status)
}
// The old stateid is now OLD; the write under it is refused.
seq++
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "down.txt"),
nfs4.AppendWriteArgs(nil, st, 0, nfs4.StableFileSync, []byte("x")),
})
wantStatus(t, "write with old stateid", res.Status, nfs4.ErrOldStateid)
// A new open of the same file with deny-write now succeeds, because
// the live open only holds read access.
seq++
res, _ = run(t, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x2222, []byte("owner-b"),
nfs4.ShareAccessWrite, nfs4.ShareDenyWrite, false, 0, "down.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open after downgrade: status %d", res.Status)
}
}
func TestDestroyClientIDOverCompound(t *testing.T) {
h := testTree(t)
sid, _ := newSession(t, h)
clientid := sid.ClientIDOf()
// OPEN first, then DESTROY_CLIENTID: the state goes with the client.
res, _, err := nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open",
nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sid, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, clientid, []byte("o"),
nfs4.ShareAccessBoth, 0, true, 0o644, "f.txt"),
})))
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
res, _, err = nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "destroy",
nfs4.MinorVersion, [][]byte{nfs4.AppendDestroyClientIDArgs(nil, clientid)})))
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("destroy: status %d, %v", res.Status, err)
}
// SEQUENCE on the destroyed session is BADSESSION.
res, _, err = nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "after",
nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sid, 2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
})))
if err != nil || res.Status != nfs4.ErrBadSession {
t.Fatalf("sequence after destroy: status %d, %v", res.Status, err)
}
// DESTROY of an unknown client id is STALE_CLIENTID.
res, _, err = nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "destroy2",
nfs4.MinorVersion, [][]byte{nfs4.AppendDestroyClientIDArgs(nil, 0xbeef)})))
if err != nil || res.Status != nfs4.ErrStaleClientID {
t.Fatalf("destroy unknown: status %d, %v", res.Status, err)
}
}
func TestReclaimComplete(t *testing.T) {
h := testTree(t)
sid, _ := newSession(t, h)
seq := uint32(1)
run := func(ops [][]byte) (nfs4.CompoundRes, [][]byte) {
r, bodies, derr := nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "rc",
nfs4.MinorVersion, append([][]byte{
nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true),
}, ops...))))
if derr != nil {
t.Fatalf("decode: %v", derr)
}
seq++
return r, bodies
}
res, _ := run([][]byte{nfs4.AppendReclaimCompleteArgs(nil, false)})
if res.Status != nfs4.ErrOK {
t.Fatalf("reclaim complete: status %d", res.Status)
}
res, _ = run([][]byte{nfs4.AppendReclaimCompleteArgs(nil, false)})
if res.Status != nfs4.ErrCompleteAlready {
t.Fatalf("second reclaim complete: status %d", res.Status)
}
}
func TestLeaseExpiry(t *testing.T) {
s := newSessionStore([4]byte{})
v := [8]byte{1}
clientid, _, _, _ := s.exchangeID(v, []byte("lease-owner"), time.Now())
// A fresh client with a renewed lease is not expired.
now := time.Now()
s.renew(clientid, now)
if s.leaseExpired(clientid, 40*time.Millisecond, now.Add(20*time.Millisecond)) {
t.Fatal("a fresh lease expired")
}
// Past the period it is.
if !s.leaseExpired(clientid, 40*time.Millisecond, now.Add(60*time.Millisecond)) {
t.Fatal("a lapsed lease did not expire")
}
// A zero period disables enforcement entirely.
if s.leaseExpired(clientid, 0, now.Add(time.Hour)) {
t.Fatal("enforcement was not disabled")
}
}
func TestBackChannelProbe(t *testing.T) {
h := testTree(t)
root := t.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
h.FS = backend
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)
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.Establish("cb-probe"); err != nil {
t.Fatalf("establish: %v", err)
}
sid, ok := cl.SessionID()
if !ok {
t.Fatal("no session after establish")
}
// The server delivers a CB_COMPOUND over the same wire and reads the
// answer the client's callback server produced. sendCB opens with its
// own CB_SEQUENCE.
probe := [][]byte{
nfs4.AppendCBRecallArgs(nil, nfs4.AllZero, false, []byte{1, 2, 3}),
}
res, bodies, err := h.SendCB(sid, "probe", probe)
if err != nil {
t.Fatalf("sendCB: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 2 {
t.Fatalf("cb res: status %d ops %d", res.Status, len(res.Ops))
}
for i, op := range res.Ops {
if op.Status != nfs4.ErrOK {
t.Fatalf("cb op %d status %d", i, op.Status)
}
}
if len(bodies[1]) != 0 {
t.Fatalf("cb recall body: %x", bodies[1])
}
// A session created without a connection context has no back channel
// and sendCB refuses it.
otherID, _ := newSession(t, h)
if _, _, err := h.SendCB(otherID, "probe", probe); err == nil {
t.Fatal("a session without a back channel accepted a CB call")
}
}
func TestDestroyClientIDLocksAndSessions(t *testing.T) {
h := testTree(t)
h.LeasePeriod = time.Second
sid, _ := newSession(t, h)
clientid := sid.ClientIDOf()
// A second client on the same file: it survives the destroy and
// carries the proof that the state is really gone.
sid2, _ := newSession(t, h)
seq := uint32(1)
run := func(session nfs4.SessionID, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, session, seq, 0, defaultSlots-1, true)}, ops...)
seq++
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// OPEN plus a lock: state the destroy has to take with the client.
res, bodies := run(sid, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, clientid, []byte("o"),
nfs4.ShareAccessBoth, 0, true, 0o644, "held.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d", res.Status)
}
var openStateid nfs4.Stateid
copy(openStateid[:], bodies[2])
res, _ = run(sid, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "held.txt"),
nfs4.AppendLockArgsNew(nil, openStateid, clientid, []byte("lk"),
nfs4.LockTypeWrite, false, 0, 0),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lock: status %d", res.Status)
}
// DESTROY_CLIENTID clears the sessions and the state.
t.Logf("pre-destroy deleg count=%d", len(h.delegs().byKey))
res, _ = run(sid, [][]byte{nfs4.AppendDestroyClientIDArgs(nil, clientid)})
if res.Status != nfs4.ErrOK {
t.Fatalf("destroy: status %d", res.Status)
}
t.Logf("post-destroy deleg count=%d byID=%d", len(h.delegs().byKey), len(h.sessions().byID))
// SEQUENCE on the destroyed session is BADSESSION.
res, _ = run(sid, [][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)})
wantStatus(t, "old session", res.Status, nfs4.ErrBadSession)
// A WRITE with the dead open stateid, carried by the surviving
// session, is BAD_STATEID: the state went with the client.
res, _ = run(sid2, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "held.txt"),
nfs4.AppendWriteArgs(nil, openStateid, 0, nfs4.StableFileSync, []byte("x")),
})
wantStatus(t, "write after destroy", res.Status, nfs4.ErrBadStateid)
}
func TestDelegationGrantAndRecall(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)
// Two clients: A opens read-write and is granted a write delegation.
ca, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer ca.Close()
if err := ca.Establish("client-a"); err != nil {
t.Fatalf("establish a: %v", err)
}
sidA, _ := ca.SessionID()
res, bodies, err := nfs4.DecodeCompoundResBodies(mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open-a",
nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0xaaaa, []byte("a"),
nfs4.ShareAccessWrite, 0, true, 0o644, "deleg.txt"),
})))
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open a: status %d, %v", res.Status, err)
}
openSt, delegType, delegSt, err := nfs4.DecodeOpenResDeleg(bodies[2])
if err != nil {
t.Fatalf("deleg decode: %v", err)
}
if delegType != nfs4.OpenDelegWrite {
t.Fatalf("delegation %d, want write", delegType)
}
_ = openSt
// Client B opens for read-write: the recall of A's delegation travels
// over A's back channel on A's callback worker, and the conflicting
// open answers NFS4ERR_DELAY while it runs, RFC 8881 section 18.16.
// The retry after the recall completes proceeds.
cb, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cb.Close()
if err := cb.Establish("client-b"); err != nil {
t.Fatalf("establish b: %v", err)
}
sidB, _ := cb.SessionID()
openB := func(seq uint32) (nfs4.CompoundRes, error) {
body := mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open-b",
nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, seq, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0xbbbb, []byte("b"),
nfs4.ShareAccessWrite, 0, true, 0o644, "deleg.txt"),
}))
res, _, derr := nfs4.DecodeCompoundResBodies(body)
return res, derr
}
res, err = openB(1)
if err != nil || res.Status != nfs4.ErrDelay {
t.Fatalf("open b while the recall runs: status %d, want DELAY, %v", res.Status, err)
}
// A's back channel receives the recall of the delegation stateid, and
// B's retries proceed once it completed.
deadline := time.Now().Add(3 * time.Second)
for seq := uint32(2); time.Now().Before(deadline); seq++ {
if rec := ca.Recalled(); len(rec) != 1 || rec[0] != delegSt {
time.Sleep(time.Millisecond)
seq--
continue
}
res, err = openB(seq)
if err != nil {
t.Fatalf("open b after the recall: %v", err)
}
if res.Status == nfs4.ErrOK {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the delegation recall never completed for client A")
}
func TestDestroyDropsLocksAndDelegations(t *testing.T) {
h := testTree(t)
sid, seq0 := newSession(t, h)
clientid := sid.ClientIDOf()
seq := seq0
run := func(ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, ops...)
seq++
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// OPEN with a write delegation plus a byte range lock.
res, bodies := run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, clientid, []byte("o"),
nfs4.ShareAccessWrite, 0, true, 0o644, "held.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d", res.Status)
}
var openStateid nfs4.Stateid
copy(openStateid[:], bodies[2])
root, rerr := h.FS.Root()
if rerr != nil {
t.Fatal(rerr)
}
fileFH, _, lerr := h.FS.Lookup(root, "held.txt")
if lerr != nil {
t.Fatal(lerr)
}
if _, ok := h.delegs().holder(fileKey(fileFH)); !ok {
t.Fatal("the delegation was not granted")
}
res, _ = run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "held.txt"),
nfs4.AppendLockArgsNew(nil, openStateid, clientid, []byte("lk"),
nfs4.LockTypeWrite, false, 0, 0),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lock: status %d", res.Status)
}
// DESTROY_CLIENTID drops the locks and the delegation with the client.
res, _ = run([][]byte{
nfs4.AppendDestroyClientIDArgs(nil, clientid),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("destroy: status %d", res.Status)
}
if h.locks().locksHeldOn(fileFH) {
t.Fatal("locks survived the destroy")
}
if _, ok := h.delegs().holder(fileKey(fileFH)); ok {
t.Fatal("the delegation survived the destroy")
}
}
func TestGraceWindowAndCompletion(t *testing.T) {
h := testTree(t)
h.GracePeriod = time.Hour
g := h.graced()
if !g.active(time.Now()) {
t.Fatal("a fresh grace window is not active")
}
// RECLAIM_COMPLETE is answered once per client within the window.
res, bodies := compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendReclaimCompleteArgs(nil, false),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("reclaim complete: status %d", res.Status)
}
_ = bodies
// A LOCK reclaim inside the window re-registers the lock (the server
// recovers no state, so the reclaim starts from scratch) and succeeds.
res, _ = compoundOps(t, h, nfs4.MinorVersion, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLockArgsNew(nil, nfs4.AllZero, 0x4242, []byte("lk"),
nfs4.LockTypeWrite, true, 0, 0),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("reclaim lock: status %d", res.Status)
}
}
func TestLockReclaimRefusedAfterGrace(t *testing.T) {
h := testTree(t)
h.GracePeriod = -time.Nanosecond
sid, _ := newSession(t, h)
seq := uint32(1)
run := func(ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, ops...)
seq++
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
res, _ := run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, nfs4.AllZero, 0x4242, []byte("lk"),
nfs4.LockTypeWrite, true, 0, 0),
})
wantStatus(t, "reclaim after grace", res.Status, nfs4.ErrNoGrace)
}
func TestDestroyUnderLoad(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)
const workers = 4
const rounds = 25
var wg sync.WaitGroup
errCh := make(chan error, workers)
for w := range workers {
wg.Add(1)
go func(worker int) {
defer wg.Done()
c, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
errCh <- err
return
}
defer c.Close()
if err := c.Establish(fmt.Sprintf("load-%d", worker)); err != nil {
errCh <- err
return
}
for i := range rounds {
res, _, cerr := c.Compound("load", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrSize)),
})
if cerr != nil || res.Status != nfs4.ErrOK {
errCh <- fmt.Errorf("worker %d round %d: status %d err %v", worker, i, res.Status, cerr)
return
}
}
}(w)
wg.Go(func() {
// A hammering destroyer: unknown and known client ids alike.
dc, derr := nfsclient.Dial(ln.Addr().String())
if derr != nil {
return
}
defer dc.Close()
if err := dc.Establish("destroyer"); err != nil {
return
}
for range rounds {
dc.Procedure(0) // keep the connection active
}
})
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
func TestOpenDowngradeConflict(t *testing.T) {
h := testTree(t)
sid, seq0 := newSession(t, h)
seq := seq0
run := func(ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, ops...)
seq++
payload := nfs4.AppendCompoundArgs(nil, "test", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies
}
// A opens read-write with no deny, B opens read with a write deny.
res, bodies := run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x1111, []byte("owner-a"),
nfs4.ShareAccessBoth, 0, true, 0o644, "down.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open a: status %d", res.Status)
}
var stA nfs4.Stateid
copy(stA[:], bodies[2])
res, _ = run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x2222, []byte("owner-b"),
nfs4.ShareAccessRead, 0, false, 0, "down.txt"),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open b: status %d", res.Status)
}
// A narrows to write-only access with deny-write: that collides with
// B's read access, so the downgrade is refused.
// A narrows to write-only with deny-read: that collides with B's
// read access, so the downgrade is refused.
res, _ = run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "down.txt"),
nfs4.AppendOpenDowngradeArgs(nil, stA, nfs4.ShareAccessWrite, nfs4.ShareDenyRead),
})
wantStatus(t, "downgrade conflict", res.Status, nfs4.ErrShareDenied)
// Narrowing without a deny succeeds.
res, _ = run([][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "down.txt"),
nfs4.AppendOpenDowngradeArgs(nil, stA, nfs4.ShareAccessWrite, 0),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("downgrade without deny: status %d", res.Status)
}
}
func TestRestartRecovery(t *testing.T) {
dir := t.TempDir()
root := t.TempDir()
// First life: a server with its own backend and state dir.
backend1, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
if err := backend1.LoadPersistedHandles(dir); err != nil {
t.Fatal(err)
}
h1 := &Handler{FS: backend1, StateDir: dir}
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv1 := &server.Server{Handle: h1.HandleConn}
go srv1.Serve(t.Context(), ln1)
c1, err := nfsclient.Dial(ln1.Addr().String())
if err != nil {
t.Fatal(err)
}
if err := c1.Establish("restart-a"); err != nil {
t.Fatal(err)
}
sidA, _ := c1.SessionID()
res, bodies, err := c1.Compound("open", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x1111, []byte("owner-a"),
nfs4.ShareAccessBoth, 0, true, 0o644, "kept.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
var openSt nfs4.Stateid
copy(openSt[:], bodies[1])
// Restart: the connections drop and a fresh server reads the persisted
// state from the same directory.
c1.Close()
ln1.Close()
ln2, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
backend2, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
if err := backend2.LoadPersistedHandles(dir); err != nil {
t.Fatal(err)
}
h2 := &Handler{FS: backend2, StateDir: dir}
srv2 := &server.Server{Handle: h2.HandleConn}
go srv2.Serve(t.Context(), ln2)
c2, err := nfsclient.Dial(ln2.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c2.Close()
if err := c2.Establish("restart-b"); err != nil {
t.Fatalf("establish: %v", err)
}
sidB, _ := c2.SessionID()
// The client reclaims its open with CLAIM_PREVIOUS over the old
// handle bytes; the recovered state answers.
res, bodies, err = c2.Compound("reclaim", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "kept.txt"),
nfs4.AppendOpenArgsPrevious(nil, 0x1111, []byte("owner-a"),
nfs4.ShareAccessBoth, 0),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("reclaim open: status %d, %v", res.Status, err)
}
var newSt nfs4.Stateid
copy(newSt[:], bodies[2])
if newSt != openSt {
t.Fatalf("the recovered stateid %x differs from %x", newSt, openSt)
}
// A different client on the restarted server sees the file intact.
res, _, err = c2.Compound("read", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "kept.txt"),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 64),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("read: status %d, %v", res.Status, err)
}
_ = sidA
_ = sidB
}