// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package nfs4server import ( "bytes" "testing" "sourcedock.dev/petrbalvin/nfs/internal/nfs4" "sourcedock.dev/petrbalvin/nfs/internal/xdr" ) func TestOps41Matrix(t *testing.T) { h := testTree(t) sid, seq := newSession(t, h) run := func(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, "ops41", 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[1:] } // LOOKUPP walks to the parent: the root is its own parent. var rootFH []byte res, bodies := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendGetfh(nil), }) rootFH, err := xdr.NewDecoder(bodies[1]).VarOpaque() if err != nil { t.Fatal(err) } seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "sub"), nfs4.AppendLookupp(nil), nfs4.AppendGetfh(nil), }) if res.Status != nfs4.ErrOK { t.Fatalf("lookupp: status %d", res.Status) } got, derr := xdr.NewDecoder(bodies[3]).VarOpaque() if derr != nil || !bytes.Equal(got, rootFH) { t.Fatalf("lookupp landed outside the root: % x %v", got, derr) } // PUTPUBFH answers the same handle as PUTROOTFH. seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutPubfh(nil), nfs4.AppendGetfh(nil), }) wantStatus(t, "putpubfh", res.Status, nfs4.ErrOK) pub, derr := xdr.NewDecoder(bodies[1]).VarOpaque() if derr != nil || !bytes.Equal(pub, rootFH) { t.Fatalf("public handle differs from the root") } // VERIFY passes when the file matches and fails with NOT_SAME when it // does not; NVERIFY is the exact opposite. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize), nfs4.Attrs{Mode: 0o644, Size: 9}), }) wantStatus(t, "verify pass", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 8}), }) wantStatus(t, "verify mismatch", res.Status, nfs4.ErrNotSame) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 8}), }) wantStatus(t, "nverify pass", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 9}), }) wantStatus(t, "nverify match", res.Status, nfs4.ErrSame) // Open the file for the stateful half of the matrix. seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendOpenArgs(nil, 0x3333, []byte("ops41-owner"), nfs4.ShareAccessBoth, 0, false, 0, "a.txt"), }) wantStatus(t, "open", res.Status, nfs4.ErrOK) var openSt nfs4.Stateid copy(openSt[:], bodies[1]) var delegSt nfs4.Stateid if len(bodies[1]) >= 68 { copy(delegSt[:], bodies[1][52:68]) } // DELEGRETURN hands the delegation back; a second return is answered // OK the same way, the release of nothing is already done. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutfh(nil, []byte("ignored")), nfs4.AppendDelegReturnArgs(nil, delegSt), }) if res.Status != nfs4.ErrOK && res.Status != nfs4.ErrBadStateid { t.Fatalf("deleg return: status %d", res.Status) } if res.Status == nfs4.ErrOK { seq++ res, _ = run(seq, [][]byte{ nfs4.AppendDelegReturnArgs(nil, delegSt), }) wantStatus(t, "deleg return again", res.Status, nfs4.ErrOK) } // A byte range lock gives the lock owner a stateid; LOCKT of a second // owner sees it, RELEASE_LOCKOWNER removes it. seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendLockArgsNew(nil, openSt, 0x3333, []byte("ops41-locker"), nfs4.LockTypeWrite, false, 0, 10), }) wantStatus(t, "lock", res.Status, nfs4.ErrOK) var lockSt nfs4.Stateid copy(lockSt[:], bodies[2]) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendLocktArgs(nil, nfs4.LockTypeWrite, 0, 10, 0x9999, []byte("other")), }) wantStatus(t, "lockt sees the lock", res.Status, nfs4.ErrDenied) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendReleaseLockOwnerArgs(nil, sid.ClientIDOf(), []byte("ops41-locker")), }) wantStatus(t, "release lock owner", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendLocktArgs(nil, nfs4.LockTypeWrite, 0, 10, 0x9999, []byte("other")), }) wantStatus(t, "lockt after release", res.Status, nfs4.ErrOK) // FREE_STATEID refuses a stateid that never existed and the one that // was just dropped. seq++ res, _ = run(seq, [][]byte{nfs4.AppendFreeStateidArgs(nil, lockSt)}) if res.Status != nfs4.ErrOK && res.Status != nfs4.ErrBadStateid { t.Fatalf("free stateid: status %d", res.Status) } seq++ res, _ = run(seq, [][]byte{nfs4.AppendFreeStateidArgs(nil, lockSt)}) wantStatus(t, "free stateid again", res.Status, nfs4.ErrBadStateid) // TEST_STATEID answers one status per stateid: the live open, a dead // stateid and the anonymous form. seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendTestStateidArgs(nil, []nfs4.Stateid{openSt, lockSt, {}}), }) wantStatus(t, "test stateid", res.Status, nfs4.ErrOK) td := xdr.NewDecoder(bodies[0]) count, terr := td.Uint32() if terr != nil || count != 3 { t.Fatalf("statuses %d: %v", count, terr) } s0, _ := td.Uint32() s1, _ := td.Uint32() s2, _ := td.Uint32() if s0 != nfs4.ErrOK || s1 != nfs4.ErrBadStateid || s2 != nfs4.ErrOK { t.Fatalf("statuses %d %d %d", s0, s1, s2) } // BIND_CONN_TO_SESSION refuses a session that does not exist. seq++ badSID := nfs4.SessionID{} res, _ = run(seq, [][]byte{ nfs4.AppendBindConnToSessionArgs(nil, badSID, nfs4.Cdfc4Fore, false), }) wantStatus(t, "bind unknown session", res.Status, nfs4.ErrBadSession) seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendBindConnToSessionArgs(nil, sid, nfs4.Cdfc4Fore, false), }) wantStatus(t, "bind fore", res.Status, nfs4.ErrOK) bd := xdr.NewDecoder(bodies[0]) var echoed nfs4.SessionID raw, rerr := bd.Raw(16) if rerr != nil { t.Fatal(rerr) } copy(echoed[:], raw) if echoed != sid { t.Fatal("bind echoed another session") } if dir, _ := bd.Uint32(); dir != nfs4.Cdfs4Fore { t.Fatalf("dir %d", dir) } if bd.Remaining() != 0 { t.Fatalf("%d bytes left in the bind result", bd.Remaining()) } // BACKCHANNEL_CTL records the future back channel program. seq++ res, _ = run(seq, [][]byte{nfs4.AppendBackchannelCtlArgs(nil, 0x40000001)}) wantStatus(t, "backchannel ctl", res.Status, nfs4.ErrOK) } func TestVerifyTimeAndBareLookupp(t *testing.T) { h := testTree(t) sid, seq := newSession(t, h) run := func(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, "verify", 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[1:] } // LOOKUPP without a current handle answers NOFILE_HANDLE. seq++ res, _ := run(seq, [][]byte{nfs4.AppendLookupp(nil)}) wantStatus(t, "lookupp bare", res.Status, nfs4.ErrNoFileHandle) // The modify time of a.txt asserts against itself: VERIFY passes, // NVERIFY answers SAME; a wrong time flips both answers. info, err := h.FS.Getattr(mustLookup(t, h, "a.txt")) if err != nil { t.Fatal(err) } modified := nfs4.NfsTime{Seconds: info.ModTime.Unix(), Nseconds: uint32(info.ModTime.Nanosecond())} seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify), nfs4.Attrs{TimeModify: modified}), }) wantStatus(t, "verify time", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify), nfs4.Attrs{TimeModify: modified}), }) wantStatus(t, "nverify time match", res.Status, nfs4.ErrSame) modified.Nseconds++ seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify), nfs4.Attrs{TimeModify: modified}), }) wantStatus(t, "verify time mismatch", res.Status, nfs4.ErrNotSame) } func TestOps41EdgeBranches(t *testing.T) { h := testTree(t) sid, seq := newSession(t, h) run := func(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, "edge", 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[1:] } // A VERIFY of an attribute this server refuses to compare answers // ATTRNOTSUPP. seq++ res, _ := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrMountedOnFileID), nfs4.Attrs{MountedOnFileID: 1}), }) wantStatus(t, "verify unsupported attr", res.Status, nfs4.ErrAttrNotSupp) // A nonsense connection direction answers INVAL. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendBindConnToSessionArgs(nil, sid, 9, false), }) wantStatus(t, "bind direction", res.Status, nfs4.ErrInval) // FREE_STATEID of a lock owner that still holds ranges answers // LOCKS_HELD. seq++ res, bodies := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendOpenArgs(nil, 0x4444, []byte("edge-owner"), nfs4.ShareAccessBoth, 0, false, 0, "a.txt"), }) wantStatus(t, "open", res.Status, nfs4.ErrOK) openSt := bodyStateid(bodies[1]) seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutfh(nil, mustLookup(t, h, "a.txt")), nfs4.AppendLockArgsNew(nil, openSt, 0x4444, []byte("edge-locker"), nfs4.LockTypeWrite, false, 0, 10), }) wantStatus(t, "lock", res.Status, nfs4.ErrOK) lockSt := bodyStateid(bodies[1]) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendFreeStateidArgs(nil, lockSt), }) wantStatus(t, "free with locks held", res.Status, nfs4.ErrLocksHeld) } // bodyStateid reads the stateid that opens every stateful result body. func bodyStateid(body []byte) nfs4.Stateid { var st nfs4.Stateid copy(st[:], body) return st }