// Copyright (c) 2026 Petr BalvĂ­n (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 }