// Copyright (c) 2026 Petr BalvĂ­n (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 }