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