Files
nfs/internal/nfs4server/state.go
T
petrbalvin a9b8039ef7
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
feat: full NFSv4.2 server and client in pure Go
Assisted-by: GLM 5.3 Flash
2026-09-21 18:51:17 +02:00

644 lines
18 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
crand "crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
// randCounter draws a random starting value for a state counter, so the
// other field of a stateid this server mints is difficult to guess,
// RFC 8881 section 8.2.2. A counter that started at one would let a
// client walk another client's stateids by enumeration.
func randCounter() uint64 {
var b [8]byte
if _, err := crand.Read(b[:]); err != nil {
panic("nfs4server: the random source failed: " + err.Error())
}
return binary.BigEndian.Uint64(b[:])
}
var dbgMu sync.Mutex
// stateidSeq answers the sequence field of a stateid as the 32 bit
// big-endian word the wire carries, RFC 7863.
func stateidSeq(st nfs4.Stateid) uint32 {
return binary.BigEndian.Uint32(st[0:4])
}
// setStateidSeq writes the sequence field of a stateid as a big-endian
// word.
func setStateidSeq(st *nfs4.Stateid, seq uint32) {
binary.BigEndian.PutUint32(st[0:4], seq)
}
// shareConflict reports whether a new open with the given access and deny
// bits collides with an existing one on the same file. A requested access
// fights an existing deny of the same kind, and a requested deny fights an
// existing access.
func shareConflicts(access, deny uint32, existing *openEntry) bool {
if access&nfs4.ShareAccessRead != 0 && existing.deny&nfs4.ShareDenyRead != 0 {
return true
}
if access&nfs4.ShareAccessWrite != 0 && existing.deny&nfs4.ShareDenyWrite != 0 {
return true
}
if deny&nfs4.ShareDenyRead != 0 && existing.access&nfs4.ShareAccessRead != 0 {
return true
}
if deny&nfs4.ShareDenyWrite != 0 && existing.access&nfs4.ShareAccessWrite != 0 {
return true
}
return false
}
// An openEntry is one live OPEN: the stateid the client holds, the share
// reservation it made, and the file it points at. A recovered open is
// one the store loaded back after a server restart: it still carries
// the client id of its past life, which a CLAIM_PREVIOUS in the grace
// window rebinds to the client that comes back for it.
type openEntry struct {
stateid nfs4.Stateid
clientID uint64
owner []byte
fh nfsfs.Handle
fileKey string
access uint32
deny uint32
delegSt *nfs4.Stateid
recovered bool
}
// stateStore keeps the OPEN state of the server: every live open, the
// share reservations grouped per file, and the tombstones of closed
// stateids, which turn a reuse of an old stateid into NFS4ERR_OLD_STATEID
// instead of the vaguer NFS4ERR_BAD_STATEID.
type stateStore struct {
mu sync.Mutex
next uint64
dir string // when set, live opens persist here across restarts
opens map[string]*openEntry
byFile map[string][]*openEntry
closed map[string]uint32
}
func newStateStore(dir string) *stateStore {
s := &stateStore{
next: randCounter(),
dir: dir,
opens: make(map[string]*openEntry),
byFile: make(map[string][]*openEntry),
closed: make(map[string]uint32),
}
if dir != "" {
s.load(dir)
}
return s
}
// persist writes the live opens into dir, atomically.
func (s *stateStore) persist(dir string) {
if dir == "" {
return
}
s.mu.Lock()
list := make([]persistedOpen, 0, len(s.opens))
for other, e := range s.opens {
list = append(list, persistedOpen{
Other: hex.EncodeToString([]byte(other)),
Seqid: stateidSeq(e.stateid),
ClientID: e.clientID,
FileKey: hex.EncodeToString([]byte(e.fileKey)),
Access: e.access,
Deny: e.deny,
})
}
s.mu.Unlock()
image, err := json.Marshal(map[string][]persistedOpen{"opens": list})
if err != nil {
return
}
tmp := filepath.Join(dir, "opens.json.tmp")
if err := os.WriteFile(tmp, image, 0o600); err != nil {
return
}
_ = os.Rename(tmp, filepath.Join(dir, "opens.json"))
}
// loadDir reads the persisted opens from dir into the store.
func (s *stateStore) load(dir string) {
data, err := os.ReadFile(filepath.Join(dir, "opens.json"))
if err != nil {
return
}
var image struct {
Opens []persistedOpen `json:"opens"`
}
if json.Unmarshal(data, &image) != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range image.Opens {
other, derr := hex.DecodeString(p.Other)
if derr != nil || len(other) != 12 {
continue
}
fileKeyBytes, derr := hex.DecodeString(p.FileKey)
if derr != nil {
continue
}
var st nfs4.Stateid
setStateidSeq(&st, p.Seqid)
copy(st[4:], other)
e := &openEntry{
stateid: st,
clientID: p.ClientID,
fileKey: string(fileKeyBytes),
access: p.Access,
deny: p.Deny,
recovered: true,
}
s.opens[string(other)] = e
s.byFile[e.fileKey] = append(s.byFile[e.fileKey], e)
}
}
// fileKey names one file across all its handles: the backend handle bytes
// already encode the stable identity of the file.
func fileKey(fh nfsfs.Handle) string {
return string(fh)
}
// open registers a new OPEN of a file, enforcing the share reservations of
// the opens already live on it. It answers the stateid of the new open.
func (s *stateStore) open(fh nfsfs.Handle, clientid uint64, owner []byte, access, deny uint32) (nfs4.Stateid, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.byFile[key] {
if shareConflicts(access, deny, e) {
return nfs4.Stateid{}, nfs4.ErrShareDenied
}
}
s.next++
var other [12]byte
copy(other[:4], []byte("OPEN"))
otherUint := s.next
other[4] = byte(otherUint >> 56)
other[5] = byte(otherUint >> 48)
other[6] = byte(otherUint >> 40)
other[7] = byte(otherUint >> 32)
other[8] = byte(otherUint >> 24)
other[9] = byte(otherUint >> 16)
other[10] = byte(otherUint >> 8)
other[11] = byte(otherUint)
st := nfs4.Stateid{}
setStateidSeq(&st, 1) // the stateid of the first state change
copy(st[4:], other[:])
e := &openEntry{
stateid: st,
clientID: clientid,
owner: owner,
fh: fh,
fileKey: key,
access: access,
deny: deny,
}
s.opens[string(other[:])] = e
s.byFile[key] = append(s.byFile[key], e)
return st, nfs4.ErrOK
}
// close releases an OPEN by its stateid. Only the client the open
// belongs to may close it. A stateid older than the live one answers
// NFS4ERR_OLD_STATEID, an unknown one NFS4ERR_BAD_STATEID.
func (s *stateStore) close(st nfs4.Stateid, clientid uint64) (nfs4.Stateid, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if e.clientID != clientid {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
switch {
case stateidSeq(st) == 0: // a cleared sequence names the current version
case stateidSeq(st) < stateidSeq(e.stateid):
return nfs4.Stateid{}, nfs4.ErrOldStateid
case stateidSeq(st) > stateidSeq(e.stateid):
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
closed := e.stateid
setStateidSeq(&closed, stateidSeq(e.stateid)+1) // CLOSE answers a dead stateid
delete(s.opens, string(other))
s.closed[string(other)] = stateidSeq(closed)
list := s.byFile[e.fileKey]
for i, cand := range list {
if cand == e {
s.byFile[e.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
return closed, nfs4.ErrOK
}
// currentStateid is the special CURRENT_STATEID of RFC 8881 section
// 8.2.3: sequence one and an empty other field name the most recent
// stateid the caller holds on the file.
func currentStateid() nfs4.Stateid {
var st nfs4.Stateid
setStateidSeq(&st, 1)
return st
}
// checkStateid resolves a stateid handed to READ, WRITE or SETATTR. The
// anonymous forms pass through with no entry; CURRENT_STATEID resolves
// to the caller's open of the file; anything else must name a live open
// of the same file that belongs to the asking client.
func (s *stateStore) checkStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) (uint32, uint32) {
allOnes := nfs4.Stateid{}
for i := range allOnes {
allOnes[i] = 0xff
}
if st == allOnes || st == (nfs4.Stateid{}) {
return 0, nfs4.ErrOK
}
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
var e *openEntry
if st == currentStateid() {
// The current stateid names the caller's own open of this file.
for _, cand := range s.byFile[fileKey(fh)] {
if cand.clientID == clientid {
e = cand
break
}
}
if e == nil {
return 0, nfs4.ErrBadStateid
}
} else {
var ok bool
e, ok = s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return 0, nfs4.ErrOldStateid
}
return 0, nfs4.ErrBadStateid
}
}
if e.clientID != clientid {
return 0, nfs4.ErrBadStateid
}
// A zero sequence names whatever version is current, RFC 8881
// section 8.2.2: conformant clients present stateids with the
// sequence field cleared, and the server honours them as the live
// version.
if st != currentStateid() && stateidSeq(st) != 0 {
if stateidSeq(st) < stateidSeq(e.stateid) {
return 0, nfs4.ErrOldStateid
}
if stateidSeq(st) > stateidSeq(e.stateid) {
return 0, nfs4.ErrBadStateid
}
}
if e.fileKey != fileKey(fh) {
return 0, nfs4.ErrBadStateid
}
return e.access, nfs4.ErrOK
}
// lookupOpen resolves a stateid to its live open entry, checking that
// the stateid names the file the caller says it does and belongs to the
// asking client. The anonymous forms never resolve here.
func (s *stateStore) lookupOpen(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) (*openEntry, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
return nil, nfs4.ErrBadStateid
}
if e.fileKey != fileKey(fh) || e.clientID != clientid {
return nil, nfs4.ErrBadStateid
}
return e, nfs4.ErrOK
}
// downgrade reduces the share access and deny bits of a live open. The
// stateid sequence moves one up; an older stateid is OLD_STATEID, an
// unknown one BAD_STATEID. Only the client the open belongs to may
// narrow it.
func (s *stateStore) downgrade(st nfs4.Stateid, clientid uint64, access, deny uint32) (nfs4.Stateid, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if e.clientID != clientid {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if stateidSeq(st) != stateidSeq(e.stateid) && stateidSeq(st) != 0 {
// A cleared sequence names the current version, RFC 8881
// section 8.2.2.
if stateidSeq(st) < stateidSeq(e.stateid) {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
// The narrowed bits must not collide with the other opens of the file.
for _, cand := range s.byFile[e.fileKey] {
if cand == e {
continue
}
if shareConflicts(access, deny, cand) {
return nfs4.Stateid{}, nfs4.ErrShareDenied
}
}
e.access = access
e.deny = deny
setStateidSeq(&e.stateid, stateidSeq(e.stateid)+1)
return e.stateid, nfs4.ErrOK
}
// dropClient releases every OPEN of the client, which is what
// DESTROY_CLIENTID and lease expiry require.
func (s *stateStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for other, e := range s.opens {
if e.clientID == clientid {
delete(s.opens, other)
list := s.byFile[e.fileKey]
for i, cand := range list {
if cand == e {
s.byFile[e.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
}
}
}
// A delegation is one granted OPEN delegation: the stateid the client
// holds, the session its recalls travel over, and the file it names.
type delegation struct {
stateid nfs4.Stateid
sessID nfs4.SessionID
fileKey string
clientID uint64
kind uint32 // nfs4.OpenDelegRead or nfs4.OpenDelegWrite
}
// delegStore tracks the live delegations. A file carries at most one; the
// store only answers whether a grant is possible and who holds what;
// the recall travels over the holder's back channel.
type delegStore struct {
mu sync.Mutex
next uint64
byKey map[string]*delegation // one per file key
}
func newDelegStore() *delegStore {
return &delegStore{next: randCounter(), byKey: make(map[string]*delegation)}
}
// grant registers a delegation of the file for the client and answers the
// delegation stateid. A file already delegated to somebody else is
// refused, which keeps the grants exclusive.
func (s *delegStore) grant(sessID nfs4.SessionID, clientid uint64, key string, kind uint32) (nfs4.Stateid, uint32) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byKey[key]; ok {
return nfs4.Stateid{}, nfs4.ErrDenied
}
s.next++
var other [12]byte
copy(other[:4], []byte("DELE"))
be := uint64(s.next)
for i := range 8 {
other[11-i] = byte(be >> (8 * i))
}
var st nfs4.Stateid
setStateidSeq(&st, 1)
copy(st[4:], other[:])
d := &delegation{stateid: st, sessID: sessID, fileKey: key, clientID: clientid, kind: kind}
s.byKey[key] = d
return st, nfs4.ErrOK
}
// holder returns the live delegation of a file, if any.
func (s *delegStore) holder(key string) (*delegation, bool) {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.byKey[key]
return d, ok
}
// revoke drops the delegation of a file.
func (s *delegStore) revoke(key string) {
s.mu.Lock()
delete(s.byKey, key)
s.mu.Unlock()
}
// dropClient releases every delegation the client holds.
func (s *delegStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byKey {
if d.clientID == clientid {
delete(s.byKey, key)
}
}
}
// countOpens reports how many live opens the file carries.
func (s *stateStore) countOpens(fh nfsfs.Handle) int {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
return len(s.byFile[key])
}
// bindDelegation records the delegation stateid on the open, so a CLOSE
// of the open revokes the delegation with it.
func (s *stateStore) bindDelegation(open nfs4.Stateid, deleg nfs4.Stateid) {
other := open[4:]
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.opens[string(other)]; ok {
e.delegSt = &deleg
}
}
// delegOf reports the delegation bound to an open, if any.
func (s *stateStore) delegOf(open nfs4.Stateid) (nfs4.Stateid, bool) {
other := open[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok || e.delegSt == nil {
return nfs4.Stateid{}, false
}
return *e.delegSt, true
}
// A persistedOpen is the on disk image of one live OPEN: enough to
// re-register the state after a server restart, so the handles clients
// hold keep their state across the restart.
type persistedOpen struct {
Other string `json:"other"` // hex of the 12 byte other field
Seqid uint32 `json:"seqid"`
ClientID uint64 `json:"clientid"`
FileKey string `json:"file_key"` // hex of the backend file key
Access uint32 `json:"access"`
Deny uint32 `json:"deny"`
}
// reclaimOpen resolves a CLAIM_PREVIOUS open: the pre restart open of
// the file whose handle the client presented. The client's own open
// answers first; otherwise a recovered open of the file, one the store
// loaded back after the restart, is rebound to the claiming client,
// because client ids do not survive a restart and the grace window is
// the only gate. It answers the open's stateid by value, so the caller
// holds no pointer into the store. When nothing answers it returns
// BAD_STATEID.
func (s *stateStore) reclaimOpen(fh nfsfs.Handle, clientid uint64) (nfs4.Stateid, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.byFile[key] {
if e.clientID == clientid {
e.recovered = false
return e.stateid, nfs4.ErrOK
}
}
for _, e := range s.byFile[key] {
if e.recovered {
e.clientID = clientid
e.recovered = false
return e.stateid, nfs4.ErrOK
}
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
// dropStateid removes the delegation the stateid names, when it belongs
// to the asking client, and reports whether there was one, which
// DELEGRETURN requires.
func (s *delegStore) dropStateid(st nfs4.Stateid, clientid uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byKey {
if d.stateid == st {
if d.clientID != clientid {
return false
}
delete(s.byKey, key)
return true
}
}
return false
}
// revokeIf drops the delegation of the file when it is still the one the
// recall named, so a recall that completes after a fresh grant never
// kills the new holder.
func (s *delegStore) revokeIf(key string, st nfs4.Stateid) {
s.mu.Lock()
defer s.mu.Unlock()
if d, ok := s.byKey[key]; ok && d.stateid == st {
delete(s.byKey, key)
}
}
// hasStateid reports whether the delegation stateid is live.
func (s *delegStore) hasStateid(st nfs4.Stateid) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.byKey {
if d.stateid == st {
return true
}
}
return false
}
// checkDataStateid validates a stateid handed to a data operation
// against the delegation store: RFC 8881 sections 8.2.3 and 10.3 let a
// client present the delegation stateid of the file to READ, WRITE and
// their kin. The stateid must be live, name the asking client and name
// this very file.
func (s *delegStore) checkDataStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) uint32 {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.byKey {
// The presented sequence is irrelevant: a conformant client
// may present the stateid with the sequence field cleared,
// RFC 8881 section 8.2.2.
if string(d.stateid[4:]) == string(st[4:]) {
if d.clientID != clientid || d.fileKey != key {
return nfs4.ErrBadStateid
}
return nfs4.ErrOK
}
}
return nfs4.ErrBadStateid
}
// testStateid reports the status of one stateid against the open store:
// the answer TEST_STATEID hands back without touching any state.
func (s *stateStore) testStateid(st nfs4.Stateid) uint32 {
allOnes := nfs4.Stateid{}
for i := range allOnes {
allOnes[i] = 0xff
}
if st == allOnes || st == (nfs4.Stateid{}) {
return nfs4.ErrOK
}
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.opens[string(other)]; ok {
if stateidSeq(st) < stateidSeq(e.stateid) {
return nfs4.ErrOldStateid
}
if stateidSeq(st) > stateidSeq(e.stateid) {
return nfs4.ErrBadStateid
}
return nfs4.ErrOK
}
if last, was := s.closed[string(other)]; was {
if stateidSeq(st) <= last {
return nfs4.ErrOldStateid
}
}
return nfs4.ErrBadStateid
}