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
1009 lines
31 KiB
Go
1009 lines
31 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package nfsfs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTree(t *testing.T) *Local {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello nfs"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("inner"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("a.txt", filepath.Join(root, "link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatalf("NewLocal: %v", err)
|
|
}
|
|
return l
|
|
}
|
|
|
|
func TestRootAndLookup(t *testing.T) {
|
|
l := newTree(t)
|
|
root, err := l.Root()
|
|
if err != nil {
|
|
t.Fatalf("Root: %v", err)
|
|
}
|
|
if len(root) != handleSize || root[0] != handleMagic {
|
|
t.Fatalf("root handle %x is not the local layout", root)
|
|
}
|
|
|
|
info, err := l.Getattr(root)
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("root: %v, %v", info.IsDir(), err)
|
|
}
|
|
|
|
_, info, err = l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatalf("Lookup a.txt: %v", err)
|
|
}
|
|
if info.IsDir() || info.Size != 9 {
|
|
t.Fatalf("a.txt: size %d dir %v", info.Size, info.IsDir())
|
|
}
|
|
|
|
subH, info, err := l.Lookup(root, "sub")
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("Lookup sub: dir %v, %v", info.IsDir(), err)
|
|
}
|
|
inner, _, err := l.Lookup(subH, "b.txt")
|
|
if err != nil {
|
|
t.Fatalf("Lookup sub/b.txt: %v", err)
|
|
}
|
|
got, err := l.Read(inner, 0, 32)
|
|
if err != nil || string(got) != "inner" {
|
|
t.Fatalf("Read inner: %q, %v", got, err)
|
|
}
|
|
|
|
// Handles are opaque, portable inside the server and stable.
|
|
again, err := l.Root()
|
|
if err != nil || !bytes.Equal(again, root) {
|
|
t.Fatalf("root handle is not stable: %x vs %x", again, root)
|
|
}
|
|
}
|
|
|
|
func TestLookupErrors(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
if _, _, err := l.Lookup(root, "missing"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("missing name: %v", err)
|
|
}
|
|
for _, name := range []string{"", ".", "..", "a/b", "a\x00b"} {
|
|
if _, _, err := l.Lookup(root, name); err == nil {
|
|
t.Fatalf("name %q resolved without error", name)
|
|
}
|
|
}
|
|
if _, _, err := l.Lookup(root, string(make([]byte, 256))); !errors.Is(err, ErrNameTooLong) {
|
|
t.Fatalf("a 256 byte name: %v", err)
|
|
}
|
|
file, _, _ := l.Lookup(root, "a.txt")
|
|
if _, _, err := l.Lookup(file, "anything"); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("lookup under a file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestForeignHandleIsStale(t *testing.T) {
|
|
l := newTree(t)
|
|
bad := Handle{0xde, 0xad, 0xbe, 0xef}
|
|
if _, err := l.Getattr(bad); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("a foreign handle: %v", err)
|
|
}
|
|
// A handle of the right shape whose inode was never issued is stale too.
|
|
forged := append(Handle{handleMagic, handleVersion, typeFile},
|
|
[]byte{0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0x99, 0x99}...)
|
|
if _, err := l.Getattr(forged); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("a forged handle: %v", err)
|
|
}
|
|
}
|
|
|
|
// The handle decoder refuses anything but the exact layout: short and
|
|
// long inputs, a wrong magic and a wrong version are all stale.
|
|
func TestDecodeHandleRejects(t *testing.T) {
|
|
good := append(Handle{handleMagic, handleVersion, typeFile}, make([]byte, 16)...)
|
|
cases := []struct {
|
|
name string
|
|
h Handle
|
|
}{
|
|
{"short", good[:handleSize-1]},
|
|
{"long", append(Handle{}, append(good, 0)...)},
|
|
{"wrong magic", append(Handle{0x4d, handleVersion, typeFile}, good[3:]...)},
|
|
{"wrong version", append(Handle{handleMagic, handleVersion - 1, typeFile}, good[3:]...)},
|
|
}
|
|
for _, c := range cases {
|
|
if _, _, _, err := decode(c.h); !errors.Is(err, ErrStale) {
|
|
t.Errorf("%s: decode answered %v, want stale", c.name, err)
|
|
}
|
|
}
|
|
kind, dev, ino, err := decode(good)
|
|
if err != nil || kind != typeFile || dev != 0 || ino != 0 {
|
|
t.Fatalf("a good handle: kind %d dev %d ino %d, %v", kind, dev, ino, err)
|
|
}
|
|
}
|
|
|
|
// A flipped kind byte must not turn a symlink handle into a file handle:
|
|
// the read of the flipped handle is refused rather than followed out of
|
|
// the export.
|
|
func TestFlippedHandleKindRejected(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
out := t.TempDir()
|
|
secret := filepath.Join(out, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("outside the export"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slh, _, err := l.Create(root, "out", CreateSpec{Kind: KindLnk, LinkData: secret})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forged := append(Handle{}, slh...)
|
|
forged[2] = typeFile
|
|
if _, err := l.Read(forged, 0, 64); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("read through a flipped symlink handle: %v", err)
|
|
}
|
|
if _, err := l.Write(forged, 0, []byte("x")); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("write through a flipped symlink handle: %v", err)
|
|
}
|
|
if _, err := l.Getattr(forged); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("getattr through a flipped symlink handle: %v", err)
|
|
}
|
|
}
|
|
|
|
// A symlink renamed over a registered name does not serve the old
|
|
// handle: the path no longer names the inode the handle encodes, so the
|
|
// handle is stale instead of reading through the link.
|
|
func TestSwappedSymlinkOverNameIsStale(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
out := t.TempDir()
|
|
secret := filepath.Join(out, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("outside the export"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swap := filepath.Join(l.root, "swap")
|
|
if err := os.Symlink(secret, swap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(swap, filepath.Join(l.root, "a.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Read(fh, 0, 64); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("read after the swap: %v", err)
|
|
}
|
|
if _, err := l.Write(fh, 0, []byte("x")); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("write after the swap: %v", err)
|
|
}
|
|
if _, err := l.Getattr(fh); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("getattr after the swap: %v", err)
|
|
}
|
|
mode := uint32(0o600)
|
|
if err := l.Setattr(fh, SetAttrs{Mode: &mode}); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("setattr after the swap: %v", err)
|
|
}
|
|
if err := l.Sync(fh); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("sync after the swap: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadSemantics(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
h, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := l.Read(h, 0, 4)
|
|
if err != nil || string(got) != "hell" {
|
|
t.Fatalf("head: %q, %v", got, err)
|
|
}
|
|
got, err = l.Read(h, 6, 100)
|
|
if err != nil || string(got) != "nfs" {
|
|
t.Fatalf("tail: %q, %v", got, err)
|
|
}
|
|
dir, _, _ := l.Lookup(root, "sub")
|
|
if _, err := l.Read(dir, 0, 4); !errors.Is(err, ErrIsDir) {
|
|
t.Fatalf("read of a directory: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadDirPaging(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
full, err := l.ReadDir(root, 0, 0)
|
|
if err != nil || !full.EOF {
|
|
t.Fatalf("full listing: %v, %v", full.EOF, err)
|
|
}
|
|
var names []string
|
|
for _, e := range full.Entries {
|
|
names = append(names, e.Name)
|
|
}
|
|
want := []string{"a.txt", "link", "sub"}
|
|
if len(names) != len(want) {
|
|
t.Fatalf("listing %v, want %v", names, want)
|
|
}
|
|
for i := range want {
|
|
if names[i] != want[i] {
|
|
t.Fatalf("listing %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
page, err := l.ReadDir(root, 0, 2)
|
|
if err != nil || len(page.Entries) != 2 || page.EOF {
|
|
t.Fatalf("first page: %d entries, eof %v, %v", len(page.Entries), page.EOF, err)
|
|
}
|
|
// Cookies are positions, not inode numbers: continue from the second
|
|
// entry's cookie.
|
|
page2, err := l.ReadDir(root, 2, 0)
|
|
if err != nil || len(page2.Entries) != 1 || !page2.EOF {
|
|
t.Fatalf("second page: %d entries, eof %v, %v", len(page2.Entries), page2.EOF, err)
|
|
}
|
|
if page2.Entries[0].Name != "sub" {
|
|
t.Fatalf("second page starts at %q, want sub", page2.Entries[0].Name)
|
|
}
|
|
}
|
|
|
|
func TestReadDirNotDir(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
h, _, _ := l.Lookup(root, "a.txt")
|
|
if _, err := l.ReadDir(h, 0, 0); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("readdir on a file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAccess(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
h, info, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mask := uint32(AccessRead | AccessModify | AccessExec)
|
|
|
|
// The superuser is granted the whole mask.
|
|
if got, err := l.Access(h, mask, 0, 0, nil); err != nil || got != mask {
|
|
t.Fatalf("root access: %x, %v", got, err)
|
|
}
|
|
// The owner sees the 0644 bits: read yes, modify yes, execute no. A
|
|
// process running as root owns root owned files and rides the
|
|
// superuser grant, so the owner selection is observable only when the
|
|
// process itself is not root.
|
|
if info.UID != 0 {
|
|
if got, err := l.Access(h, mask, info.UID, info.GID, nil); err != nil {
|
|
t.Fatalf("owner access: %v", err)
|
|
} else if got&AccessExec != 0 {
|
|
t.Fatalf("owner access %x carries execute on a 0644 file", got)
|
|
} else if got&AccessRead == 0 || got&AccessModify == 0 {
|
|
t.Fatalf("owner access %x misses read or modify", got)
|
|
}
|
|
}
|
|
// An unrelated user with empty other bits sees only what others see.
|
|
other := uint32(60000)
|
|
got, err := l.Access(h, mask, other, other, nil)
|
|
if err != nil {
|
|
t.Fatalf("other access: %v", err)
|
|
}
|
|
modeOnly := info.Mode.Perm()
|
|
if modeOnly&0o007 != 0o004 && got&AccessRead != 0 {
|
|
t.Fatalf("other access %x exceeds the other bits %o", got, modeOnly)
|
|
}
|
|
}
|
|
|
|
func TestCreateAndWrite(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// A directory created with exact permissions, umask notwithstanding.
|
|
h, info, err := l.Create(root, "newdir", CreateSpec{Kind: KindDir, Perm: 0o755})
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("mkdir: %v, %v", info, err)
|
|
}
|
|
if got, _ := l.Getattr(h); got.Mode.Perm() != 0o755 {
|
|
t.Fatalf("dir mode %o, want 755", got.Mode.Perm())
|
|
}
|
|
|
|
// A symlink carries its target.
|
|
slh, _, err := l.Create(root, "zlink", CreateSpec{Kind: KindLnk, LinkData: "a.txt"})
|
|
if err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
if got, _ := l.Getattr(slh); got.Mode&os.ModeSymlink == 0 {
|
|
t.Fatal("the created symlink is not a symlink")
|
|
}
|
|
|
|
// A fifo is a special file, not a regular one.
|
|
fh, _, err := l.Create(root, "pipe", CreateSpec{Kind: KindFifo, Perm: 0o644})
|
|
if err != nil {
|
|
t.Fatalf("fifo: %v", err)
|
|
}
|
|
if got, _ := l.Getattr(fh); got.Mode&os.ModeNamedPipe == 0 {
|
|
t.Fatal("the created fifo is not a fifo")
|
|
}
|
|
|
|
// An existing target is an error for every kind.
|
|
if _, _, err := l.Create(root, "a.txt", CreateSpec{Kind: KindFifo}); !errors.Is(err, ErrExist) {
|
|
t.Fatalf("create over an existing file: %v", err)
|
|
}
|
|
if _, _, err := l.Create(root, "sub", CreateSpec{Kind: KindDir}); !errors.Is(err, ErrExist) {
|
|
t.Fatalf("mkdir over an existing dir: %v", err)
|
|
}
|
|
|
|
// Create under a file is refused before the name is resolved.
|
|
file, _, _ := l.Lookup(root, "a.txt")
|
|
if _, _, err := l.Create(file, "x", CreateSpec{Kind: KindDir}); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("create under a file: %v", err)
|
|
}
|
|
|
|
// A write into a directory is refused.
|
|
if _, err := l.Write(h, 0, []byte("written")); !errors.Is(err, ErrIsDir) {
|
|
t.Fatalf("write to a directory: %v", err)
|
|
}
|
|
|
|
// A regular file made the way the tests seed one is writable at an
|
|
// offset, and a partial overwrite keeps the rest.
|
|
path := filepath.Join(l.root, "written.txt")
|
|
if err := os.WriteFile(path, []byte("written"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wf, _, err := l.Lookup(root, "written.txt")
|
|
if err != nil {
|
|
t.Fatalf("lookup written.txt: %v", err)
|
|
}
|
|
if n, err := l.Write(wf, 3, []byte("XYZ")); err != nil || n != 3 {
|
|
t.Fatalf("offset write: %d, %v", n, err)
|
|
}
|
|
got, err := l.Read(wf, 0, 64)
|
|
if err != nil || string(got) != "wriXYZn" {
|
|
t.Fatalf("after the offset write: %q, %v", got, err)
|
|
}
|
|
// The written file is bigger than the write: a read past it reports the
|
|
// full new size through Getattr.
|
|
info, err = l.Getattr(wf)
|
|
if err != nil || info.Size != 7 {
|
|
t.Fatalf("size after write: %d, %v", info.Size, err)
|
|
}
|
|
}
|
|
|
|
func TestCreateBadKindAndNames(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
if _, _, err := l.Create(root, "x", CreateSpec{Kind: 99}); !errors.Is(err, ErrBadName) {
|
|
t.Fatalf("an unknown kind: %v", err)
|
|
}
|
|
if _, _, err := l.Create(root, "a/b", CreateSpec{Kind: KindDir}); !errors.Is(err, ErrBadName) {
|
|
t.Fatalf("a name with a separator: %v", err)
|
|
}
|
|
if _, err := l.Write(Handle{0xde, 0xad}, 0, []byte("x")); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("write through a stale handle: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateSocketAndDevice(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// A socket file is created by binding and leaving the file behind.
|
|
sh, info, err := l.Create(root, "sock", CreateSpec{Kind: KindSock, Perm: 0o644})
|
|
if err != nil {
|
|
t.Fatalf("socket create: %v", err)
|
|
}
|
|
if got, _ := l.Getattr(sh); got.Mode&os.ModeSocket == 0 {
|
|
t.Fatal("the created socket is not a socket")
|
|
}
|
|
_ = info
|
|
|
|
// A device node needs privileges this process may or may not carry, so
|
|
// both outcomes are accepted; what is not accepted is another error.
|
|
_, _, err = l.Create(root, "dev", CreateSpec{Kind: KindChr, Perm: 0o600, Major: 1, Minor: 3})
|
|
if err == nil {
|
|
if _, _, gerr := l.Lookup(root, "dev"); gerr != nil {
|
|
t.Fatalf("the device node did not land: %v", gerr)
|
|
}
|
|
} else if !errors.Is(err, ErrPermission) && !errors.Is(err, ErrIO) {
|
|
t.Fatalf("device create: %v", err)
|
|
}
|
|
}
|
|
|
|
// A block device is created as a block device, never as a character
|
|
// device with a second, failing mknod behind it.
|
|
func TestCreateBlockDevice(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
h, _, err := l.Create(root, "blk", CreateSpec{Kind: KindBlk, Perm: 0o600, Major: 7, Minor: 200})
|
|
if err == nil {
|
|
info, gerr := l.Getattr(h)
|
|
if gerr != nil {
|
|
t.Fatal(gerr)
|
|
}
|
|
if info.Mode&os.ModeDevice == 0 || info.Mode&os.ModeCharDevice != 0 {
|
|
t.Fatalf("the node is not a block device: %v", info.Mode)
|
|
}
|
|
} else if !errors.Is(err, ErrPermission) && !errors.Is(err, ErrIO) {
|
|
// With the double mknod, a privileged process first lands a
|
|
// character device and then fails EEXIST on the second call.
|
|
t.Fatalf("block device create: %v", err)
|
|
}
|
|
}
|
|
|
|
// The permission bits are applied exactly: the plain bits survive every
|
|
// creation, a directory carries its sticky bit, and Setattr applies all
|
|
// twelve bits on a regular file, the one kind on which no filesystem
|
|
// drops the privileged ones. A fifo carries no setuid guarantee, because
|
|
// some mounts and kernels strip it from special files at creation.
|
|
func TestSpecialModeBits(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
fh, _, err := l.Create(root, "pipe", CreateSpec{Kind: KindFifo, Perm: 0o4755})
|
|
if err != nil {
|
|
t.Fatalf("fifo with setuid: %v", err)
|
|
}
|
|
info, err := l.Getattr(fh)
|
|
if err != nil || info.Mode.Perm() != 0o755 {
|
|
t.Fatalf("fifo mode %v, want 755, %v", info.Mode, err)
|
|
}
|
|
dh, _, err := l.Create(root, "sticky", CreateSpec{Kind: KindDir, Perm: 0o3755})
|
|
if err != nil {
|
|
t.Fatalf("dir with setgid and sticky: %v", err)
|
|
}
|
|
info, err = l.Getattr(dh)
|
|
if err != nil || info.Mode.Perm() != 0o755 || info.Mode&os.ModeSticky == 0 {
|
|
t.Fatalf("dir mode %v, want the plain bits with sticky, %v", info.Mode, err)
|
|
}
|
|
// Setattr applies the same twelve bits on a regular file, where no
|
|
// filesystem drops them.
|
|
rh, _, _, err := l.Open(root, "plain.txt", true, false, false, 0o644, Owner{})
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
special := uint32(0o4755)
|
|
if err := l.Setattr(rh, SetAttrs{Mode: &special}); err != nil {
|
|
t.Fatalf("setattr: %v", err)
|
|
}
|
|
if info, err := l.Getattr(rh); err != nil || info.Mode.Perm() != 0o755 ||
|
|
info.Mode&os.ModeSetuid == 0 {
|
|
t.Fatalf("mode after setattr %v, want 4755, %v", info.Mode, err)
|
|
}
|
|
}
|
|
|
|
// A name that cannot be examined because a directory denies search is a
|
|
// permission error, not a missing one. The superuser is never denied by
|
|
// the permission bits, so the case exists only for an unprivileged
|
|
// process.
|
|
func TestRemovePermissionDenied(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("the superuser is never denied by the permission bits")
|
|
}
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
locked := filepath.Join(l.root, "locked")
|
|
if err := os.Mkdir(locked, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(locked, "f.txt"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dh, _, err := l.Lookup(root, "locked")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(locked, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chmod(locked, 0o755)
|
|
if err := l.Remove(dh, "f.txt"); !errors.Is(err, ErrPermission) {
|
|
t.Fatalf("remove behind a locked directory: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveAndRename(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// A plain remove takes the entry and its handle away.
|
|
// The rename tests use a.txt, so the plain remove checks run on a
|
|
// second file created for the purpose.
|
|
if err := os.WriteFile(filepath.Join(l.root, "gone.txt"), []byte("bye"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l.Remove(root, "gone.txt"); err != nil {
|
|
t.Fatalf("remove: %v", err)
|
|
}
|
|
if _, _, err := l.Lookup(root, "gone.txt"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("lookup after remove: %v", err)
|
|
}
|
|
if err := l.Remove(root, "gone.txt"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("remove of the removed: %v", err)
|
|
}
|
|
|
|
// A directory with entries refuses to go.
|
|
if err := l.Remove(root, "sub"); !errors.Is(err, ErrNotEmpty) {
|
|
t.Fatalf("remove of a full directory: %v", err)
|
|
}
|
|
|
|
// A rename between two directories moves the entry and keeps the
|
|
// handle of the moved object working.
|
|
sub, _, err := l.Lookup(root, "sub")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inner, _, err := l.Lookup(sub, "b.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ah, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l.Rename(root, "a.txt", sub, "moved.txt"); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
if _, _, err := l.Lookup(root, "a.txt"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("the old name is still there: %v", err)
|
|
}
|
|
got, err := l.Read(ah, 0, 64)
|
|
if err != nil || string(got) != "hello nfs" {
|
|
t.Fatalf("the handle of the moved file: %q, %v", got, err)
|
|
}
|
|
|
|
// A directory rename re-registers its descendants: the handle of
|
|
// b.txt, issued before the move, still reads the file after it.
|
|
if err := l.Rename(root, "sub", root, "sub2"); err != nil {
|
|
t.Fatalf("dir rename: %v", err)
|
|
}
|
|
got, err = l.Read(inner, 0, 64)
|
|
if err != nil || string(got) != "inner" {
|
|
t.Fatalf("b.txt after the directory rename: %q, %v", got, err)
|
|
}
|
|
// And the directory answers under its new name only.
|
|
if _, _, err := l.Lookup(root, "sub"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("the old directory name is still there: %v", err)
|
|
}
|
|
|
|
// Missing sources and invalid names are refused.
|
|
if err := l.Rename(root, "missing", root, "x"); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("rename of a missing file: %v", err)
|
|
}
|
|
if err := l.Rename(root, "sub2", root, "../escape"); !errors.Is(err, ErrBadName) {
|
|
t.Fatalf("rename to an invalid name: %v", err)
|
|
}
|
|
// A remove under a handle that is not a directory is refused.
|
|
sub2h, _, ferr := l.Lookup(root, "sub2")
|
|
if ferr != nil {
|
|
t.Fatal(ferr)
|
|
}
|
|
fileh, _, ferr := l.Lookup(sub2h, "moved.txt")
|
|
if ferr != nil {
|
|
t.Fatal(ferr)
|
|
}
|
|
if err := l.Remove(fileh, "nope"); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("remove under a file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetattrAndLink(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Mode and size go first: the file is cut and locked down.
|
|
mode := uint32(0o600)
|
|
size := int64(4)
|
|
if err := l.Setattr(fh, SetAttrs{Mode: &mode, Size: &size}); err != nil {
|
|
t.Fatalf("setattr: %v", err)
|
|
}
|
|
info, err := l.Getattr(fh)
|
|
if err != nil || info.Size != 4 || info.Mode.Perm() != 0o600 {
|
|
t.Fatalf("after setattr: %d bytes, mode %o, %v", info.Size, info.Mode.Perm(), err)
|
|
}
|
|
// The read sees the truncation.
|
|
got, err := l.Read(fh, 0, 64)
|
|
if err != nil || string(got) != "hell" {
|
|
t.Fatalf("read after truncate: %q, %v", got, err)
|
|
}
|
|
|
|
// A size change on a directory is refused.
|
|
dir, _, _ := l.Lookup(root, "sub")
|
|
if err := l.Setattr(dir, SetAttrs{Size: &size}); !errors.Is(err, ErrIsDir) {
|
|
t.Fatalf("truncate of a directory: %v", err)
|
|
}
|
|
|
|
// The modify time goes to the explicit hour the client names; the
|
|
// access time follows the server clock.
|
|
now := time.Now().Add(time.Hour)
|
|
if err := l.Setattr(fh, SetAttrs{Atime: &TimeSet{Now: true}, Mtime: &TimeSet{Time: now}}); err != nil {
|
|
t.Fatalf("times: %v", err)
|
|
}
|
|
info, _ = l.Getattr(fh)
|
|
if diff := info.ModTime.Sub(now); diff < -time.Second || diff > time.Second {
|
|
t.Fatalf("the modify time did not move to the requested hour: %v", diff)
|
|
}
|
|
|
|
// A hard link to a regular file: the same content under a new name and
|
|
// the link count grows to two.
|
|
lh, info, err := l.Link(fh, root, "hard.txt")
|
|
if err != nil {
|
|
t.Fatalf("link: %v", err)
|
|
}
|
|
if info.Nlink != 2 {
|
|
t.Fatalf("link count %d, want 2", info.Nlink)
|
|
}
|
|
got, err = l.Read(lh, 0, 64)
|
|
if err != nil || string(got) != "hell" {
|
|
t.Fatalf("read through the link: %q, %v", got, err)
|
|
}
|
|
// The second link of the same file bumps the count to three.
|
|
if _, info, err = l.Link(fh, root, "hard2.txt"); err != nil || info.Nlink != 3 {
|
|
t.Fatalf("second link: %d, %v", info.Nlink, err)
|
|
}
|
|
// An existing name and a directory target are refused.
|
|
if _, _, err := l.Link(fh, root, "hard.txt"); !errors.Is(err, ErrExist) {
|
|
t.Fatalf("link over an existing name: %v", err)
|
|
}
|
|
if _, _, err := l.Link(dir, root, "nodir"); !errors.Is(err, ErrIsDir) {
|
|
t.Fatalf("hard link to a directory: %v", err)
|
|
}
|
|
// Remove one name and the rest keeps working.
|
|
if err := l.Remove(root, "hard.txt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = l.Read(lh, 0, 64)
|
|
if err != nil || string(got) != "hell" {
|
|
t.Fatalf("read after removing one link: %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestSetattrOwner(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Chown to the uid the file already carries is a no-op that succeeds
|
|
// even without privileges.
|
|
uid := uint32(os.Geteuid())
|
|
if err := l.Setattr(fh, SetAttrs{UID: &uid}); err != nil {
|
|
t.Fatalf("chown to self: %v", err)
|
|
}
|
|
// A different uid needs privileges this process may or may not have.
|
|
other := uint32(0)
|
|
if err := l.Setattr(fh, SetAttrs{UID: &other}); err != nil &&
|
|
!errors.Is(err, ErrPermission) && !errors.Is(err, ErrIO) {
|
|
t.Fatalf("chown to another uid: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteToVanishedFile(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The file disappears under the server: the handle still resolves, the
|
|
// write reports the file as stale.
|
|
if err := os.Remove(filepath.Join(l.root, "a.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Write(fh, 0, []byte("x")); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("write to a vanished file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLinkUnderFile(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
file, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := l.Link(file, file, "x"); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("link with a file as the directory: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadLinkAndSync(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// The symlink reports its target.
|
|
ln, _, err := l.Lookup(root, "link")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := l.ReadLink(ln)
|
|
if err != nil || target != "a.txt" {
|
|
t.Fatalf("readlink: %q, %v", target, err)
|
|
}
|
|
|
|
// Anything that is not a symlink is refused.
|
|
file, _, _ := l.Lookup(root, "a.txt")
|
|
if _, err := l.ReadLink(file); !errors.Is(err, ErrNotLnk) {
|
|
t.Fatalf("readlink of a file: %v", err)
|
|
}
|
|
dir, _, _ := l.Lookup(root, "sub")
|
|
if _, err := l.ReadLink(dir); !errors.Is(err, ErrNotLnk) {
|
|
t.Fatalf("readlink of a directory: %v", err)
|
|
}
|
|
if _, err := l.ReadLink(Handle{0xde, 0xad}); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("readlink through a stale handle: %v", err)
|
|
}
|
|
|
|
// Sync works on a file, on a directory and reports a stale handle.
|
|
if err := l.Sync(file); err != nil {
|
|
t.Fatalf("sync of a file: %v", err)
|
|
}
|
|
if err := l.Sync(dir); err != nil {
|
|
t.Fatalf("sync of a directory: %v", err)
|
|
}
|
|
if err := l.Sync(Handle{0xde, 0xad}); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("sync through a stale handle: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParent(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// A file reports its parent and its own component name.
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parent, name, err := l.Parent(fh)
|
|
if err != nil {
|
|
t.Fatalf("parent: %v", err)
|
|
}
|
|
if name != "a.txt" {
|
|
t.Fatalf("component %q, want a.txt", name)
|
|
}
|
|
if !bytes.Equal(parent, root) {
|
|
t.Fatalf("the parent handle is not the root: %x vs %x", parent, root)
|
|
}
|
|
|
|
// A nested file names its own directory.
|
|
sub, _, _ := l.Lookup(root, "sub")
|
|
inner, _, _ := l.Lookup(sub, "b.txt")
|
|
parent, name, err = l.Parent(inner)
|
|
if err != nil || name != "b.txt" {
|
|
t.Fatalf("nested parent: %q, %v", name, err)
|
|
}
|
|
info, err := l.Getattr(parent)
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("the parent is not sub: %v, %v", info, err)
|
|
}
|
|
|
|
// The root itself has no parent name.
|
|
if _, _, err := l.Parent(root); !errors.Is(err, ErrInval) {
|
|
t.Fatalf("parent of the root: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetattrOnVanishedFile(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The file disappears under the server: Setattr reports the handle as
|
|
// stale, which is what the protocol wants instead of an io error.
|
|
if err := os.Remove(filepath.Join(l.root, "a.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mode := uint32(0o600)
|
|
if err := l.Setattr(fh, SetAttrs{Mode: &mode}); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("setattr on a vanished file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetattrRejectsNegativeSize(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
fh, _, err := l.Lookup(root, "a.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
neg := int64(-1)
|
|
if err := l.Setattr(fh, SetAttrs{Size: &neg}); !errors.Is(err, ErrInval) {
|
|
t.Fatalf("a negative size: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenSemantics(t *testing.T) {
|
|
l := newTree(t)
|
|
root, _ := l.Root()
|
|
|
|
// NOCREATE on a missing name is ENOENT.
|
|
if _, _, _, err := l.Open(root, "ghost", false, false, false, 0o644, Owner{UID: 0, GID: 0}); !errors.Is(err, ErrNoEnt) {
|
|
t.Fatalf("open without create: %v", err)
|
|
}
|
|
// CREATE makes the file with the exact permissions.
|
|
h, info, created, err := l.Open(root, "made.txt", true, false, false, 0o600, Owner{UID: 0, GID: 0})
|
|
if err != nil || !created {
|
|
t.Fatalf("create: created %v, %v", created, err)
|
|
}
|
|
if got, _ := l.Getattr(h); got.Mode.Perm() != 0o600 {
|
|
t.Fatalf("mode %o, want 600", got.Mode.Perm())
|
|
}
|
|
// Opening it again without truncate keeps the content.
|
|
os.WriteFile(filepath.Join(l.root, "made.txt"), []byte("keepme"), 0o600)
|
|
h, _, created, err = l.Open(root, "made.txt", true, false, false, 0o600, Owner{UID: 0, GID: 0})
|
|
if err != nil || created {
|
|
t.Fatalf("reopen: created %v, %v", created, err)
|
|
}
|
|
if _, err := l.Write(h, 0, []byte("XX")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := l.Read(h, 0, 64)
|
|
if string(got) != "XXepme" {
|
|
t.Fatalf("after partial write: %q", got)
|
|
}
|
|
// CREATE with truncate cuts it to zero.
|
|
h, _, _, err = l.Open(root, "made.txt", true, false, true, 0o600, Owner{UID: 0, GID: 0})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err = l.Getattr(h)
|
|
if err != nil || info.Size != 0 {
|
|
t.Fatalf("after truncate: %d, %v", info.Size, err)
|
|
}
|
|
// A guarded create refuses the existing name, the GUARDED and
|
|
// EXCLUSIVE4_1 contract.
|
|
if _, _, _, err := l.Open(root, "made.txt", true, true, false, 0o600, Owner{UID: 0, GID: 0}); !errors.Is(err, ErrExist) {
|
|
t.Fatalf("guarded create over an existing name: %v", err)
|
|
}
|
|
// OPEN under a file is refused.
|
|
file, _, _ := l.Lookup(root, "a.txt")
|
|
if _, _, _, err := l.Open(file, "x", true, false, false, 0o644, Owner{UID: 0, GID: 0}); !errors.Is(err, ErrNotDir) {
|
|
t.Fatalf("open under a file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandlePersistenceRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
root := filepath.Join(dir, "tree")
|
|
if err := os.Mkdir(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("survives"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// First life: register a handle and persist it.
|
|
l1, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l1.SetPersistPath(dir)
|
|
rootHandle, err := l1.Root()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h1, _, err := l1.Lookup(rootHandle, "f.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l1.PersistHandles(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Second life: a fresh backend over the same tree resolves the old
|
|
// handle through the loaded mapping.
|
|
l2, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l2.LoadPersistedHandles(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := l2.Getattr(h1)
|
|
if err != nil {
|
|
t.Fatalf("getattr through the old handle: %v", err)
|
|
}
|
|
if info.Size != 8 {
|
|
t.Fatalf("size %d, want 8", info.Size)
|
|
}
|
|
}
|
|
|
|
// A persisted entry whose path leaves the export root is never
|
|
// installed: the mapping file is recovery state, not a source of export
|
|
// boundaries.
|
|
func TestLoadPersistedHandlesConfinement(t *testing.T) {
|
|
dir := t.TempDir()
|
|
root := filepath.Join(dir, "tree")
|
|
if err := os.Mkdir(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "in.txt"), []byte("inside"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(dir, "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
l1, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootHandle, err := l1.Root()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := l1.Lookup(rootHandle, "in.txt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l1.PersistHandles(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Poison the mapping with an entry for a file outside the export.
|
|
path := filepath.Join(dir, "handles.json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out map[string]string
|
|
if err := json.Unmarshal(data, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi, err := os.Lstat(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info := stat(fi)
|
|
out[persistKey(fileID{dev: info.Dev, ino: info.Ino})] = outside
|
|
data, err = json.Marshal(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
l2, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l2.LoadPersistedHandles(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The inside entry resolves; the outside one was dropped.
|
|
rootHandle2, err := l2.Root()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := l2.Lookup(rootHandle2, "in.txt"); err != nil {
|
|
t.Fatalf("the inside entry was dropped: %v", err)
|
|
}
|
|
if _, err := l2.Getattr(encodeHandle(stat(fi), "")); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("an outside path was installed: %v", err)
|
|
}
|
|
}
|