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