Files
nfs/internal/nfsfs/local_test.go
T

1009 lines
31 KiB
Go
Raw Normal View History

// 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)
}
}