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
+99
View File
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsfs
import (
"errors"
"os"
"path/filepath"
"testing"
)
// roTestRoot builds a backend over a fresh directory holding one file.
func roTestRoot(t *testing.T) (*Local, Handle, string) {
t.Helper()
root := t.TempDir()
l, err := NewLocal(root)
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
p := filepath.Join(root, "file")
if err := os.WriteFile(p, []byte("read only"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
rh, err := l.Root()
if err != nil {
t.Fatalf("Root: %v", err)
}
h, _, err := l.Lookup(rh, "file")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
return l, h, p
}
// TestReadOnlyHidesWriter covers the contract the dispatcher relies on: a
// read only export carries no Writer half, so every mutating operation
// answers NFS4ERR_ROFS without the backend ever being asked.
func TestReadOnlyHidesWriter(t *testing.T) {
l, _, _ := roTestRoot(t)
ro := ReadOnly(l)
if _, ok := ro.(Writer); ok {
t.Fatal("the read only export still carries a Writer")
}
var fs FS = l
if _, ok := fs.(Writer); !ok {
t.Fatal("the plain backend lost its Writer")
}
}
// TestReadOnlyRefusesWrites covers the optional halves: their reads pass
// through, their writes answer ErrReadOnly.
func TestReadOnlyRefusesWrites(t *testing.T) {
l, h, _ := roTestRoot(t)
ro := ReadOnly(l)
if err := ro.(XattrFS).SetXattr(h, "user.note", []byte("x"), XattrModeCreate); !errors.Is(err, ErrReadOnly) {
t.Fatalf("SetXattr: %v, want ErrReadOnly", err)
}
if err := ro.(XattrFS).RemoveXattr(h, "user.note"); !errors.Is(err, ErrReadOnly) {
t.Fatalf("RemoveXattr: %v, want ErrReadOnly", err)
}
if err := ro.(interface {
Allocate(Handle, int64, int64) error
Deallocate(Handle, int64, int64) error
}).Allocate(h, 0, 4096); !errors.Is(err, ErrReadOnly) {
t.Fatalf("Allocate: %v, want ErrReadOnly", err)
}
if err := ro.(interface {
Allocate(Handle, int64, int64) error
Deallocate(Handle, int64, int64) error
}).Deallocate(h, 0, 4096); !errors.Is(err, ErrReadOnly) {
t.Fatalf("Deallocate: %v, want ErrReadOnly", err)
}
// The reads pass through: whatever the platform answers for a missing
// attribute and a seeking question, neither is the read only refusal.
if _, err := ro.(XattrFS).GetXattr(h, "user.note", 1024); errors.Is(err, ErrReadOnly) {
t.Fatal("GetXattr refused as a write")
}
if _, _, err := ro.(interface {
SeekHole(Handle, int64) (int64, bool, error)
SeekData(Handle, int64) (int64, bool, error)
}).SeekHole(h, 0); errors.Is(err, ErrReadOnly) {
t.Fatal("SeekHole refused as a write")
}
}
// TestReadOnlyReadsWork covers that the read side of the wrapped export
// is the export itself.
func TestReadOnlyReadsWork(t *testing.T) {
l, h, _ := roTestRoot(t)
ro := ReadOnly(l)
got, err := ro.Read(h, 0, 9)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != "read only" {
t.Fatalf("Read: %q", got)
}
}