// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package nfs4server import ( "testing" "sourcedock.dev/petrbalvin/nfs/internal/nfs4" "sourcedock.dev/petrbalvin/nfs/internal/nfsfs" "sourcedock.dev/petrbalvin/nfs/internal/xdr" ) func TestXattrOverCompound(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, "xattr", 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:] } seq++ res, _ := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendGetXattrArgs(nil, "user.comment"), }) wantStatus(t, "get before set", res.Status, nfs4.ErrNoXattr) // Set, read back, list, remove: the whole life of one attribute. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.comment", []byte("hello")), }) wantStatus(t, "set", res.Status, nfs4.ErrOK) seq++ res, bodies := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendGetXattrArgs(nil, "user.comment"), }) wantStatus(t, "get", res.Status, nfs4.ErrOK) value, gerr := nfs4.DecodeGetXattrRes(bodies[2]) if gerr != nil || string(value) != "hello" { t.Fatalf("value %q: %v", value, gerr) } // CREATE on a live attribute is refused; REPLACE on it succeeds. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrCreate, "user.comment", []byte("again")), }) if res.Status == nfs4.ErrOK { t.Fatal("create over a live attribute succeeded") } seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrReplace, "user.comment", []byte("second")), }) wantStatus(t, "replace", res.Status, nfs4.ErrOK) seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendListXattrArgs(nil, 0, 1<<16), }) wantStatus(t, "list", res.Status, nfs4.ErrOK) ld := xdr.NewDecoder(bodies[2]) if ck, lerr := ld.Uint64(); lerr != nil || ck != 0 { t.Fatalf("cookie %d: %v", ck, lerr) } n, lerr := ld.Uint32() if lerr != nil || n != 1 { t.Fatalf("names %d: %v", n, lerr) } name, lerr := ld.String() if lerr != nil || name != "user.comment" { t.Fatalf("name %q: %v", name, lerr) } if eof, lerr := ld.Bool(); lerr != nil || !eof { t.Fatalf("eof %v: %v", eof, lerr) } seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendRemoveXattrArgs(nil, "user.comment"), }) wantStatus(t, "remove", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendGetXattrArgs(nil, "user.comment"), }) wantStatus(t, "get after remove", res.Status, nfs4.ErrNoXattr) } // A bare FS wrapper hides the backend's xattr half: the xattr family must // answer NOT_SUPP against it. type bareFS struct { nfsfs.FS } func TestXattrWithoutBackend(t *testing.T) { h := testTree(t) h.FS = bareFS{h.FS} sid, seq := newSession(t, h) all := [][]byte{ nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true), nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendGetXattrArgs(nil, "user.comment"), nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.comment", []byte("v")), nfs4.AppendListXattrArgs(nil, 0, 1<<16), nfs4.AppendRemoveXattrArgs(nil, "user.comment"), } payload := nfs4.AppendCompoundArgs(nil, "xattr", nfs4.MinorVersion, all) body, ok := h.compound(payload, cred{uid: 0, gid: 0}) if !ok { t.Fatal("garbage") } res, _, err := nfs4.DecodeCompoundResBodies(body) if err != nil { t.Fatal(err) } // ops: sequence, putrootfh, lookup, then four xattr ops. for i, op := range res.Ops[3:] { if op.Status != nfs4.ErrNotSupp { t.Fatalf("xattr op %d: status %d, want NOT_SUPP", i, op.Status) } } } func TestXattrWithoutFileHandle(t *testing.T) { h := testTree(t) sid, seq := newSession(t, h) table := []struct { name string op []byte }{ {"get", nfs4.AppendGetXattrArgs(nil, "user.a")}, {"set", nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.a", []byte("v"))}, {"list", nfs4.AppendListXattrArgs(nil, 0, 1<<16)}, {"remove", nfs4.AppendRemoveXattrArgs(nil, "user.a")}, } for _, row := range table { seq++ all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, row.op) payload := nfs4.AppendCompoundArgs(nil, "no-fh", nfs4.MinorVersion, all) body, ok := h.compound(payload, cred{uid: 0, gid: 0}) if !ok { t.Fatalf("%s: garbage", row.name) } res, _, err := nfs4.DecodeCompoundResBodies(body) if err != nil { t.Fatalf("%s: decode %v", row.name, err) } if res.Status != nfs4.ErrNoFileHandle { t.Fatalf("%s: status %d, want NOFILE_HANDLE", row.name, res.Status) } } }