// 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" ) // OPENATTR opens the synthetic named attribute directory of a.txt, and // CREATE, LOOKUP, READ and REMOVE over it map to the object's extended // attributes, RFC 8881 section 5.3. func TestNamedAttributes(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, "nattr", 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:] } // OPENATTR on a.txt answers the synthetic directory. seq++ res, bodies := run(seq, [][]byte{ nfs4.AppendPutRootfh(nil), nfs4.AppendLookup(nil, "a.txt"), nfs4.AppendOpenattrArgs(nil, false), nfs4.AppendGetfh(nil), }) if res.Status != nfs4.ErrOK { t.Fatalf("openattr: status %d", res.Status) } dirHandle, derr := xdrDecoder(bodies[3]) if derr != nil || len(dirHandle) == 0 { t.Fatalf("dir handle: %v", derr) } // CREATE makes the attribute with its initial size, WRITE fills it. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutfh(nil, dirHandle), nfs4.AppendCreateArgs(nil, nfs4.NF4Reg, "user.comment", "", 0, 0, 0o644), }) wantStatus(t, "nattr create", res.Status, nfs4.ErrOK) seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutfh(nil, dirHandle), nfs4.AppendLookup(nil, "user.comment"), nfs4.AppendGetfh(nil), }) wantStatus(t, "nattr lookup", res.Status, nfs4.ErrOK) fileHandle, derr := xdrDecoder(bodies[2]) if derr != nil { t.Fatal(derr) } seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutfh(nil, fileHandle), nfs4.AppendWriteArgs(nil, nfs4.Stateid{}, 0, nfs4.StableFileSync, []byte("hello")), }) if res.Status != nfs4.ErrOK { t.Fatalf("nattr write: status %d", res.Status) } seq++ res, bodies = run(seq, [][]byte{ nfs4.AppendPutfh(nil, fileHandle), nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 64), }) if res.Status != nfs4.ErrOK { t.Fatalf("nattr read: status %d", res.Status) } rd := xdr.NewDecoder(bodies[1]) if eof, rerr := rd.Bool(); rerr != nil || !eof { t.Fatalf("eof %v: %v", eof, rerr) } value, rerr := rd.VarOpaque() if rerr != nil || string(value) != "hello" { t.Fatalf("value %q: %v", value, rerr) } // The value is visible through the xattr half of the same object. fh := mustLookup(t, h, "a.txt") got, gerr := h.FS.(nfsfs.XattrFS).GetXattr(fh, "user.comment", 0) if gerr != nil || string(got) != "hello" { t.Fatalf("xattr view %q: %v", got, gerr) } // REMOVE deletes the attribute. seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutfh(nil, dirHandle), nfs4.AppendRemoveArgs(nil, "user.comment"), }) wantStatus(t, "nattr remove", res.Status, nfs4.ErrOK) seq++ res, _ = run(seq, [][]byte{ nfs4.AppendPutfh(nil, dirHandle), nfs4.AppendLookup(nil, "user.comment"), }) wantStatus(t, "nattr lookup gone", res.Status, nfs4.ErrNoEnt) } // xdrDecoder decodes the file handle out of a GETFH result body. func xdrDecoder(body []byte) ([]byte, error) { return xdr.NewDecoder(body).VarOpaque() }