feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 18:51:17 +02:00
commit a9b8039ef7
153 changed files with 34403 additions and 0 deletions
+823
View File
@@ -0,0 +1,823 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"errors"
"fmt"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// maxOps bounds the COMPOUND arrays on both encode and decode. The
// protocol has no fixed limit; a request longer than this is not a client,
// it is an attack.
const maxOps = 1024
// maxTag bounds the COMPOUND tag.
const maxTag = 128
// AppendCompoundArgs encodes the COMPOUND4args: the tag, the minor version
// and the operation array, where each element is an encoded argop: its
// operation number followed by its own arguments.
func AppendCompoundArgs(b []byte, tag string, minor uint32, ops [][]byte) []byte {
b = xdr.AppendString(b, tag)
b = xdr.AppendUint32(b, minor)
b = xdr.AppendUint32(b, uint32(len(ops)))
for _, op := range ops {
b = append(b, op...)
}
return b
}
// CompoundHeader is the head of a decoded COMPOUND4args: the tag, the minor
// version and the operation count. The returned decoder is positioned at
// the first argop, and the dispatcher reads each operation's own arguments
// from it as it dispatches.
type CompoundHeader struct {
Tag string
Minor uint32
OpCount uint32
}
// DecodeCompoundArgs splits COMPOUND4args into its header and a decoder
// over the argops.
func DecodeCompoundArgs(payload []byte) (CompoundHeader, *xdr.Decoder, error) {
d := xdr.NewDecoder(payload)
var h CompoundHeader
var err error
if h.Tag, err = d.String(); err != nil {
return h, nil, err
}
if len(h.Tag) > maxTag {
return h, nil, fmt.Errorf("nfs4: tag of %d bytes exceeds %d", len(h.Tag), maxTag)
}
if h.Minor, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount > maxOps {
return h, nil, fmt.Errorf("nfs4: %d operations exceeds %d", h.OpCount, maxOps)
}
return h, d, nil
}
// AppendCompoundRes encodes the COMPOUND4res: the top level status, the
// echoed tag and the result array. On success the results hold one entry
// per executed operation; on failure they hold the results up to and
// including the operation that failed, and the top level status carries its
// status.
func AppendCompoundRes(b []byte, status uint32, tag string, results [][]byte) []byte {
b = xdr.AppendUint32(b, status)
b = xdr.AppendString(b, tag)
b = xdr.AppendUint32(b, uint32(len(results)))
for _, op := range results {
b = append(b, op...)
}
return b
}
// AppendOpHeader appends the head of one nfs_resop4: the operation number
// and its status. A successful result appends its payload afterwards; a
// failed one is complete as it stands.
func AppendOpHeader(b []byte, op, status uint32) []byte {
b = xdr.AppendUint32(b, op)
return xdr.AppendUint32(b, status)
}
// A ResOp is one decoded result operation: its number, its status, and the
// bytes that follow the status. Body is meaningful only while the status is
// NFS4ERR_OK, and its shape depends on the operation.
type ResOp struct {
Op uint32
Status uint32
Body []byte
}
// A CompoundRes is the decoded COMPOUND4res.
type CompoundRes struct {
Status uint32
Tag string
Ops []ResOp
}
// DecodeCompoundResBodies decodes a COMPOUND4res and, for every operation,
// the bytes between its status and the next operation number. A body is
// empty when the operation carried no payload or when it failed. The
// bodies are not length prefixed on the wire: the length of each follows
// from its operation number, so the decoder measures each body as it goes.
func DecodeCompoundResBodies(payload []byte) (CompoundRes, [][]byte, error) {
d := xdr.NewDecoder(payload)
var res CompoundRes
var err error
if res.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
if res.Tag, err = d.String(); err != nil {
return res, nil, err
}
count, err := d.Uint32()
if err != nil {
return res, nil, err
}
if count > maxOps {
return res, nil, fmt.Errorf("nfs4: %d results exceeds %d", count, maxOps)
}
var ops []ResOp
var bodies [][]byte
for range count {
var op ResOp
if op.Op, err = d.Uint32(); err != nil {
return res, nil, err
}
if op.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
start := len(payload) - d.Remaining()
n, err := resBodyLen(op.Op, op.Status, payload[start:])
if err != nil {
return res, nil, err
}
if n < 0 || start+n > len(payload) {
return res, nil, fmt.Errorf("nfs4: result body of %d bytes overruns the payload", n)
}
op.Body = payload[start : start+n]
if _, err := d.Raw(n); err != nil {
return res, nil, err
}
ops = append(ops, op)
bodies = append(bodies, op.Body)
}
if d.Remaining() > 0 {
return res, nil, fmt.Errorf("nfs4: %d bytes trail the declared operations", d.Remaining())
}
res.Ops = ops
return res, bodies, nil
}
// resBodyLen measures how many bytes one result body occupies. A failed
// operation carries no body. An operation this build cannot measure, with a
// successful status, is a response the client has no business receiving.
func resBodyLen(op, status uint32, body []byte) (int, error) {
// Every failed operation carries an empty body, except LOCKT: its
// DENIED answer names the conflicting lock.
if status != ErrOK && op != OpLockt {
return 0, nil
}
d := xdr.NewDecoder(body)
switch op {
case OpPutfh, OpPutRootfh, OpLookup, OpLookupp, OpSavefh, OpRestorefh:
return 0, nil
case OpGetfh:
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpGetattr:
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpAccess:
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
case OpCreate:
// changeinfo4 (atomic, before, after) then the bitmap of the
// attributes set, RFC 8881 section 18.4.2.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpWrite:
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
case OpReadlink:
// The link text, length prefixed.
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
return len(body) - d.Remaining(), nil
case OpCommit:
// The write verifier.
return 8, nil
case OpSecinfo, OpSecinfoNoName:
// An array of secinfo4 unions: the flavour, and the
// rpcsec_gss_info body only on the RPCSEC_GSS branch.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n > maxOps {
return 0, ErrUnknownOp
}
for range n {
flavor, ferr := d.Uint32()
if ferr != nil {
return 0, ferr
}
if flavor == SecFlavorGSS {
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
}
return len(body) - d.Remaining(), nil
case OpRemove, OpOpenDowngrade:
// change_info4: an atomic flag and two 64 bit counters.
return 20, nil
case OpDestroyClientID, OpReclaimComplete, OpPutPubfh,
OpReleaseLockOwner, OpDelegReturn, OpFreeStateid, OpBackchannelCtl,
OpVerify, OpNverify:
return 0, nil
case OpBindConnToSession:
// sessionid and the granted direction.
return 20, nil
case OpTestStateid:
// One status per stateid the arguments named.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n > maxOps {
return 0, ErrUnknownOp
}
return 4 + 4*int(n), nil
case OpRename:
// Two change_info4 values, one per directory.
return 40, nil
case OpLink:
// change_info4 of the target directory.
return 20, nil
case OpSequence:
// sessionid and the five words of SEQUENCE4resok, RFC 7863.
if _, err := d.Raw(16); err != nil {
return 0, err
}
for range 5 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
return 36, nil
case OpDestroySession:
return 0, nil
case OpExchangeID:
// clientid, sequence, flags, state protect how, eir_server_owner
// (uint64 minor id, opaque major id), eir_server_scope and the
// impl_id array, RFC 8881 section 18.35.
if _, err := d.Uint64(); err != nil {
return 0, err
}
for range 3 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
for range 2 { // so_major_id then server_scope
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
for range 2 { // domain and name
if _, err := d.String(); err != nil {
return 0, err
}
}
if _, err := d.Raw(12); err != nil { // nfstime4
return 0, err
}
}
return len(body) - d.Remaining(), nil
case OpCreateSession:
// sessionid, sequence, flags and two channel attrs.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
for range 2 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
for range 5 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
count, cerr := d.Uint32()
if cerr != nil {
return 0, cerr
}
if count != 0xffffffff {
for range count {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
}
}
return len(body) - d.Remaining(), nil
case OpOpen:
// stateid, change_info4, rflags, attrset and the delegation union.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
delegType, derr := d.Uint32()
if derr != nil {
return 0, derr
}
switch delegType {
case OpenDelegRead:
// stateid, recall flag and the permissions ace.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if err := walkACE(d); err != nil {
return 0, err
}
case OpenDelegWrite:
// stateid, recall flag, the space limit union and the ace.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if err := walkSpaceLimit(d); err != nil {
return 0, err
}
if err := walkACE(d); err != nil {
return 0, err
}
}
return len(body) - d.Remaining(), nil
case OpClose:
return 16, nil
case OpLock, OpLocku:
// The stateid.
return 16, nil
case OpLockt:
return resBodyLenLockt(status, body)
case OpSetattr:
// attrsset: only a bitmap4.
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpRead:
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpReaddir:
if _, err := d.Raw(8); err != nil {
return 0, err
}
for {
more, err := d.Bool()
if err != nil {
return 0, err
}
if !more {
break
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.String(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpLayoutGet:
// The layout stateid, the return on close flag and the layout
// array: offset, length, IO mode, type and body per segment.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
case OpLayoutCommit:
// The newsize4 union: the changed flag and the size.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
case OpLayoutReturn:
// The layoutreturn_stateid4 union: the returned flag and the
// stateid.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Raw(16); err != nil {
return 0, err
}
case OpSeek:
// The end of file flag and the offset.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
case OpAllocate, OpDeallocate, OpClone, OpLayoutError, OpLayoutStats, OpOffloadCancel:
return 0, nil
case OpIoAdvise:
// The accepted hints bitmap4.
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpCopy:
// write_response4 then the two requirement flags.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n == 1 {
if _, err := d.Raw(16); err != nil {
return 0, err
}
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpCopyNotify:
// lease time, empty owner confirm, stateid, source list.
if _, err := d.Int64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(16); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
kind, kerr := d.Uint32()
if kerr != nil {
return 0, kerr
}
switch kind {
case 1, 2:
if _, err := d.String(); err != nil {
return 0, err
}
default:
if _, err := d.String(); err != nil {
return 0, err
}
if _, err := d.String(); err != nil {
return 0, err
}
}
}
case OpOffloadStatus:
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
case OpReadPlus:
// eof flag and the content array.
if _, err := d.Bool(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
kind, kerr := d.Uint32()
if kerr != nil {
return 0, kerr
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if kind == uint32(ContentData) {
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
} else if _, err := d.Uint64(); err != nil {
return 0, err
}
}
case OpWriteSame:
// write_response4 without a callback arm.
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
case OpGetDirDelegation:
// The non fatal union: the status, then the resok body or the
// will signal flag.
gdd, err := d.Uint32()
if err != nil {
return 0, err
}
if gdd == GddOK {
if _, err := d.Raw(24); err != nil {
return 0, err
}
for range 3 {
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
}
} else if _, err := d.Bool(); err != nil {
return 0, err
}
case OpOpenattr:
return 0, nil
case OpGetDeviceList:
// The cookie, its verifier, the device id array and the eof flag.
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
if _, err := d.Raw(int(n) * 16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpGetxattr:
// The single opaque value.
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpSetxattr, OpRemovexattr:
return 0, nil
case OpListxattr:
// The cookie of the last name, the names and the end of list
// flag.
if _, err := d.Uint64(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
if _, err := d.String(); err != nil {
return 0, err
}
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpGetDeviceInfo:
// The address type, the address body and the notification bitmap.
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
default:
return 0, ErrUnknownOp
}
return len(body) - d.Remaining(), nil
}
// walkACE walks one nfsace4 of a result body.
func walkACE(d *xdr.Decoder) error {
for range 3 {
if _, err := d.Uint32(); err != nil {
return err
}
}
_, err := d.VarOpaque()
return err
}
// walkSpaceLimit walks one nfs_space_limit4 union of a result body.
func walkSpaceLimit(d *xdr.Decoder) error {
limitBy, err := d.Uint32()
if err != nil {
return err
}
switch limitBy {
case LimitBySize:
_, err = d.Uint64()
return err
case 2: // NFS_LIMIT_BLOCKS
if _, err = d.Uint32(); err != nil {
return err
}
_, err = d.Uint32()
return err
default:
return ErrBadDeleg
}
}
// ErrUnknownOp marks a successful result whose shape this build cannot
// measure: the client and the server disagree on what was sent.
var ErrUnknownOp = errors.New("nfs4: unknown operation result")
// resBodyLenCB measures one CB result body. CB operation numbers share
// values with the fore channel, so the two families are never mixed in
// one measurement.
func resBodyLenCB(op, status uint32, body []byte) (int, error) {
if status != ErrOK {
return 0, nil
}
d := xdr.NewDecoder(body)
switch op {
case OpCBSequence:
// The session id and the four words of CB_SEQUENCE4resok,
// RFC 7863.
if _, err := d.Raw(16); err != nil {
return 0, err
}
for range 4 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
return 32, nil
case OpCBRecall, OpCBNotify, OpCBNotifyLock:
return 0, nil
default:
return 0, nil
}
}
// DecodeCompoundResBodiesCB decodes a CB_COMPOUND reply: the same walk as
// the fore channel variant, with the callback operation shapes.
func DecodeCompoundResBodiesCB(payload []byte) (CompoundRes, [][]byte, error) {
d := xdr.NewDecoder(payload)
var res CompoundRes
var err error
if res.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
if res.Tag, err = d.String(); err != nil {
return res, nil, err
}
count, err := d.Uint32()
if err != nil {
return res, nil, err
}
if count > maxOps {
return res, nil, fmt.Errorf("nfs4: %d results exceeds %d", count, maxOps)
}
var ops []ResOp
var bodies [][]byte
for range count {
var op ResOp
if op.Op, err = d.Uint32(); err != nil {
return res, nil, err
}
if op.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
start := len(payload) - d.Remaining()
n, err := resBodyLenCB(op.Op, op.Status, payload[start:])
if err != nil {
return res, nil, err
}
if n < 0 || start+n > len(payload) {
return res, nil, fmt.Errorf("nfs4: result body of %d bytes overruns the payload", n)
}
op.Body = payload[start : start+n]
if _, err := d.Raw(n); err != nil {
return res, nil, err
}
ops = append(ops, op)
bodies = append(bodies, op.Body)
}
if d.Remaining() > 0 {
return res, nil, fmt.Errorf("nfs4: %d bytes trail the declared operations", d.Remaining())
}
res.Ops = ops
return res, bodies, nil
}