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
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:
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package nfsfs defines the virtual filesystem the NFS server serves, and
|
||||
// provides a backend over a local directory.
|
||||
//
|
||||
// The interface carries exactly what the protocol layer needs and nothing
|
||||
// more: handles that the backend itself interprets, the attributes each
|
||||
// GETATTR turns into an fattr4, the data operations LOOKUP, READDIR and
|
||||
// READ, and the Writer half that WRITE, CREATE and the other state
|
||||
// changing operations reach.
|
||||
package nfsfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Handle is an opaque file handle. The backend defines its layout; the
|
||||
// protocol layer treats it as bytes.
|
||||
type Handle []byte
|
||||
|
||||
// Sentinels the dispatcher maps onto NFS4ERR statuses. Use errors.Is.
|
||||
var (
|
||||
ErrStale = errors.New("nfsfs: unknown file handle")
|
||||
ErrNoEnt = errors.New("nfsfs: no such file or directory")
|
||||
ErrNotDir = errors.New("nfsfs: not a directory")
|
||||
ErrIsDir = errors.New("nfsfs: is a directory")
|
||||
ErrNameTooLong = errors.New("nfsfs: name too long")
|
||||
ErrBadName = errors.New("nfsfs: invalid name")
|
||||
ErrPermission = errors.New("nfsfs: permission denied")
|
||||
ErrIO = errors.New("nfsfs: io error")
|
||||
ErrExist = errors.New("nfsfs: file exists")
|
||||
ErrNoSpace = errors.New("nfsfs: no space left")
|
||||
ErrNotEmpty = errors.New("nfsfs: directory not empty")
|
||||
ErrInval = errors.New("nfsfs: invalid argument")
|
||||
ErrNotLnk = errors.New("nfsfs: not a symlink")
|
||||
)
|
||||
|
||||
// Access mask bits, RFC 8881 section 15.2.2. The same values the protocol
|
||||
// layer speaks.
|
||||
const (
|
||||
AccessRead = 1 << 0
|
||||
AccessLookup = 1 << 1
|
||||
AccessModify = 1 << 2
|
||||
AccessExtend = 1 << 3
|
||||
AccessDelete = 1 << 4
|
||||
AccessExec = 1 << 5
|
||||
)
|
||||
|
||||
// MaxName is the name length limit the backends enforce.
|
||||
const MaxName = 255
|
||||
|
||||
// An Info carries the file attributes the backends report.
|
||||
type Info struct {
|
||||
Size int64
|
||||
Mode fs.FileMode
|
||||
ModTime time.Time
|
||||
Dev uint64
|
||||
Ino uint64
|
||||
Nlink uint64
|
||||
UID uint32
|
||||
GID uint32
|
||||
}
|
||||
|
||||
// IsDir reports whether the file is a directory.
|
||||
func (i Info) IsDir() bool { return i.Mode.IsDir() }
|
||||
|
||||
// An Entry is one READDIR row: the cookie the client resumes from, the
|
||||
// name, its handle and its attributes.
|
||||
type Entry struct {
|
||||
Cookie uint64
|
||||
Name string
|
||||
Handle Handle
|
||||
Info Info
|
||||
}
|
||||
|
||||
// A DirPage is one READDIR result page: the entries the cookie asked for,
|
||||
// the verifier of the directory order, and whether the listing is complete.
|
||||
type DirPage struct {
|
||||
Entries []Entry
|
||||
Verifier [8]byte
|
||||
EOF bool
|
||||
}
|
||||
|
||||
// An FS is the virtual filesystem the server serves. Implementations must
|
||||
// be safe for concurrent use.
|
||||
type FS interface {
|
||||
// Root returns the handle of the export root.
|
||||
Root() (Handle, error)
|
||||
// Lookup resolves name under the parent handle.
|
||||
Lookup(parent Handle, name string) (Handle, Info, error)
|
||||
// Getattr reports the attributes of a handle.
|
||||
Getattr(h Handle) (Info, error)
|
||||
// ReadLink reports the target of a symlink. A handle that names
|
||||
// anything else is an error.
|
||||
ReadLink(h Handle) (string, error)
|
||||
// Parent resolves the directory that holds h and the component name
|
||||
// of h under it. The root of the export has no parent name.
|
||||
Parent(h Handle) (Handle, string, error)
|
||||
// ReadDir lists the directory from the given cookie, returning at most
|
||||
// count entries, or all of them when count is zero or less.
|
||||
ReadDir(h Handle, cookie uint64, count int) (DirPage, error)
|
||||
// Read reads up to count bytes at the offset. A short result means end
|
||||
// of file was reached.
|
||||
Read(h Handle, off int64, count int) ([]byte, error)
|
||||
// Access evaluates the requested mask bits for the credential and
|
||||
// returns the bits granted.
|
||||
Access(h Handle, mask uint32, uid, gid uint32, groups []uint32) (uint32, error)
|
||||
}
|
||||
|
||||
// ValidName reports whether name can appear in a LOOKUP. Names carrying a
|
||||
// separator or a control byte never belong to the client, because no
|
||||
// backend resolves them.
|
||||
func ValidName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return ErrBadName
|
||||
case name == "." || name == "..":
|
||||
return ErrBadName
|
||||
case len(name) > MaxName:
|
||||
return ErrNameTooLong
|
||||
}
|
||||
for i := range len(name) {
|
||||
if name[i] == '/' || name[i] == 0 {
|
||||
return ErrBadName
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The object kinds a CREATE may carry, the same values the protocol's
|
||||
// createtype4 uses. A regular file is not among them: in NFSv4 regular
|
||||
// files are created by OPEN.
|
||||
const (
|
||||
KindDir = 2
|
||||
KindLnk = 5
|
||||
KindSock = 6
|
||||
KindFifo = 7
|
||||
KindBlk = 3
|
||||
KindChr = 4
|
||||
)
|
||||
|
||||
// An Owner names the unix owner and group an object carries after its
|
||||
// creation. The server runs under its own identity, so a backend that
|
||||
// serves clients of several owners applies these values when a client
|
||||
// makes a new object.
|
||||
type Owner struct {
|
||||
UID uint32
|
||||
GID uint32
|
||||
}
|
||||
|
||||
// A CreateSpec describes one object a CREATE makes.
|
||||
type CreateSpec struct {
|
||||
Kind uint32
|
||||
Perm fs.FileMode // permission bits, applied exactly, umask aside
|
||||
LinkData string // the target of a symlink
|
||||
Major uint32 // device numbers of a character or block device
|
||||
Minor uint32
|
||||
Owner Owner // the owner a fresh object carries
|
||||
}
|
||||
|
||||
// A Writer is the mutating half of an FS. A backend that serves reads only
|
||||
// does not implement it, and the dispatcher answers NFS4ERR_ROFS.
|
||||
type Writer interface {
|
||||
// Create makes the object the spec describes under the parent handle.
|
||||
// An existing target is an error for every kind.
|
||||
Create(parent Handle, name string, spec CreateSpec) (Handle, Info, error)
|
||||
// Write writes all of data at the offset of a regular file and
|
||||
// returns how many bytes landed.
|
||||
Write(h Handle, off int64, data []byte) (int, error)
|
||||
// Remove takes the named entry out of the directory. Removing a
|
||||
// directory that is not empty is an error.
|
||||
Remove(dir Handle, name string) error
|
||||
// Rename moves oldName from the oldDir directory to newName in the
|
||||
// newDir directory, replacing an existing plain target the way POSIX
|
||||
// rename does. The handles of the moved object and of its descendants
|
||||
// keep working after the move.
|
||||
Rename(oldDir Handle, oldName string, newDir Handle, newName string) error
|
||||
// Setattr applies the named changes to a file. Changes that the
|
||||
// backend cannot apply make the whole call fail.
|
||||
Setattr(h Handle, s SetAttrs) error
|
||||
// Link makes newName in dir a hard link to the target file.
|
||||
Link(target Handle, dir Handle, name string) (Handle, Info, error)
|
||||
// Sync flushes the file's dirty data to stable storage.
|
||||
Sync(h Handle) error
|
||||
// Open opens the regular file name under dir for writing. When create
|
||||
// is set a missing file is made with perm and carried by owner; when
|
||||
// guarded is set an existing name answers ErrExist instead of
|
||||
// opening, which the GUARDED and EXCLUSIVE4_1 create modes require;
|
||||
// when truncate is set an existing file is cut to zero first. The
|
||||
// boolean reports whether the file was created by this call.
|
||||
Open(dir Handle, name string, create, guarded, truncate bool, perm fs.FileMode, owner Owner) (h Handle, info Info, created bool, err error)
|
||||
}
|
||||
|
||||
// A TimeSet is one time attribute of a SETATTR: either the server's
|
||||
// current time or the time the client names.
|
||||
type TimeSet struct {
|
||||
Now bool
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// SetAttrs carries the changes a SETATTR names. A nil field is a change
|
||||
// the client did not ask for.
|
||||
type SetAttrs struct {
|
||||
Mode *uint32
|
||||
Size *int64
|
||||
UID *uint32
|
||||
GID *uint32
|
||||
Atime *TimeSet
|
||||
Mtime *TimeSet
|
||||
}
|
||||
|
||||
// XattrFS is the optional extended attribute half of a backend. A backend
|
||||
// that does not implement it answers NOT_SUPP to the xattr family.
|
||||
type XattrFS interface {
|
||||
// GetXattr reads one named attribute of the object.
|
||||
GetXattr(h Handle, name string, max int) ([]byte, error)
|
||||
// SetXattr writes one named attribute; the mode follows the
|
||||
// SETXATTR4mode4 enum of RFC 8276.
|
||||
SetXattr(h Handle, name string, value []byte, mode uint32) error
|
||||
// ListXattr names the attributes of the object.
|
||||
ListXattr(h Handle, max int) ([]string, error)
|
||||
// RemoveXattr deletes one named attribute.
|
||||
RemoveXattr(h Handle, name string) error
|
||||
}
|
||||
|
||||
// ErrNoXattr marks a missing attribute, NFS4ERR_NOXATTR on the wire.
|
||||
var ErrNoXattr = errors.New("nfsfs: no such attribute")
|
||||
|
||||
// ErrXattrNotSupp marks a backend that carries no extended attributes at
|
||||
// all, NFS4ERR_NOT_SUPP on the wire.
|
||||
var ErrXattrNotSupp = errors.New("nfsfs: extended attributes are not supported")
|
||||
|
||||
// ErrBeyondEOF marks a SEEK that starts past the end of the file,
|
||||
// NFS4ERR_NXIO on the wire per RFC 7862 section 15.11.
|
||||
var ErrBeyondEOF = errors.New("nfsfs: seek past the end")
|
||||
|
||||
// ErrNoSparse marks a backend whose platform carries no space
|
||||
// reservation or hole seeking calls, NFS4ERR_NOT_SUPP on the wire.
|
||||
var ErrNoSparse = errors.New("nfsfs: sparse file operations are not supported")
|
||||
|
||||
// The SETXATTR create modes of RFC 8276, mirrored from the wire
|
||||
// protocol. They are platform independent: a backend that carries no
|
||||
// extended attributes answers NOT_SUPP regardless of the mode.
|
||||
const (
|
||||
XattrModeCreate = 1
|
||||
XattrModeReplace = 2
|
||||
)
|
||||
|
||||
// A RangeCloner is the optional half that clones or copies a byte range
|
||||
// between two regular files inside the kernel: the bytes never travel
|
||||
// through userspace. A backend that does not carry it, or a filesystem
|
||||
// that refuses the call for one pair of files, leaves the caller its
|
||||
// userspace path.
|
||||
type RangeCloner interface {
|
||||
// CloneRange makes dstOff carry the same bytes as srcOff through a
|
||||
// reflink where the filesystem provides one.
|
||||
CloneRange(src Handle, srcOff int64, dst Handle, dstOff int64, length int64) error
|
||||
// CopyRange copies length bytes through the kernel's copy syscall.
|
||||
CopyRange(src Handle, srcOff int64, dst Handle, dstOff int64, length int64) error
|
||||
}
|
||||
Reference in New Issue
Block a user