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