Files
os/filesystem/filesystem_test.go
zzg 661faba845
ci / test (push) Canceled after 0s
initial commit
2026-09-25 22:31:34 -07:00

540 lines
13 KiB
Go

package filesystem
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func newFS() *FS { return New(32, 128) }
func TestCreateAndRead(t *testing.T) {
fs := newFS()
data := []byte("hello, filesystem")
if err := fs.WriteFile("/hello.txt", data, 0o644); err != nil {
t.Fatal(err)
}
got, err := fs.ReadFile("/hello.txt")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) {
t.Errorf("= %q, want %q", got, data)
}
}
// A file larger than one block must span blocks correctly.
func TestMultiBlockFile(t *testing.T) {
fs := newFS()
data := bytes.Repeat([]byte("abcdefgh"), BlockSize/4) // 2 blocks' worth
if err := fs.WriteFile("/big.bin", data, 0o644); err != nil {
t.Fatal(err)
}
node, err := fs.Stat("/big.bin")
if err != nil {
t.Fatal(err)
}
if len(node.Blocks) < 2 {
t.Errorf("file uses %d blocks, want at least 2", len(node.Blocks))
}
got, err := fs.ReadFile("/big.bin")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) {
t.Errorf("multi-block round trip corrupted the data (%d vs %d bytes)", len(got), len(data))
}
}
func TestDirectories(t *testing.T) {
fs := newFS()
if err := fs.Mkdir("/dir", 0o755); err != nil {
t.Fatal(err)
}
if err := fs.WriteFile("/dir/file.txt", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
entries, err := fs.ReadDir("/dir")
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name != "file.txt" {
t.Errorf("entries = %+v", entries)
}
}
func TestMkdirAll(t *testing.T) {
fs := newFS()
if err := fs.MkdirAll("/a/b/c/d", 0o755); err != nil {
t.Fatal(err)
}
for _, p := range []string{"/a", "/a/b", "/a/b/c", "/a/b/c/d"} {
if !fs.Exists(p) {
t.Errorf("%s was not created", p)
}
}
// Repeating is not an error.
if err := fs.MkdirAll("/a/b/c/d", 0o755); err != nil {
t.Errorf("MkdirAll should be idempotent: %v", err)
}
}
// Listings must be sorted, or they would vary run to run with Go's map
// iteration.
func TestReadDirIsSorted(t *testing.T) {
fs := newFS()
for _, name := range []string{"zebra", "apple", "mango", "banana"} {
fs.WriteFile("/"+name, []byte("x"), 0o644)
}
first, err := fs.ReadDir("/")
if err != nil {
t.Fatal(err)
}
want := []string{"apple", "banana", "mango", "zebra"}
for i, w := range want {
if first[i].Name != w {
t.Errorf("entry %d = %s, want %s", i, first[i].Name, w)
}
}
// And stable across repeated calls.
for range 20 {
again, _ := fs.ReadDir("/")
for i := range again {
if again[i].Name != first[i].Name {
t.Fatal("directory listing order varies between calls")
}
}
}
}
// A hard link is a second name for one inode: the data outlives the removal
// of either name, and the link count is what decides when it is freed.
func TestHardLinks(t *testing.T) {
fs := newFS()
data := []byte("shared content")
fs.WriteFile("/original.txt", data, 0o644)
if err := fs.Link("/original.txt", "/link.txt"); err != nil {
t.Fatal(err)
}
node, _ := fs.Stat("/original.txt")
linked, _ := fs.Stat("/link.txt")
if node.Number != linked.Number {
t.Errorf("link points at inode %d, original is %d", linked.Number, node.Number)
}
if node.Links != 2 {
t.Errorf("link count = %d, want 2", node.Links)
}
// Removing one name leaves the data reachable through the other.
if err := fs.Remove("/original.txt"); err != nil {
t.Fatal(err)
}
got, err := fs.ReadFile("/link.txt")
if err != nil {
t.Fatalf("data should survive removal of the other name: %v", err)
}
if !bytes.Equal(got, data) {
t.Errorf("= %q, want %q", got, data)
}
if n, _ := fs.Stat("/link.txt"); n.Links != 1 {
t.Errorf("link count = %d, want 1", n.Links)
}
}
// Removing the last link must free the blocks, or the filesystem leaks.
func TestRemoveFreesBlocks(t *testing.T) {
fs := newFS()
before := fs.Statfs().FreeBlocks
fs.WriteFile("/temp.bin", bytes.Repeat([]byte("x"), BlockSize*3), 0o644)
if during := fs.Statfs().FreeBlocks; during >= before {
t.Fatal("writing did not consume blocks")
}
if err := fs.Remove("/temp.bin"); err != nil {
t.Fatal(err)
}
if after := fs.Statfs().FreeBlocks; after != before {
t.Errorf("free blocks = %d after removal, want %d — blocks leaked", after, before)
}
}
// Blocks must not be freed while another link still refers to the inode.
func TestRemoveWithRemainingLinkKeepsBlocks(t *testing.T) {
fs := newFS()
fs.WriteFile("/a.txt", bytes.Repeat([]byte("x"), BlockSize*2), 0o644)
fs.Link("/a.txt", "/b.txt")
before := fs.Statfs().FreeBlocks
fs.Remove("/a.txt")
if after := fs.Statfs().FreeBlocks; after != before {
t.Errorf("blocks were freed while a link remained: %d -> %d", before, after)
}
}
func TestRemoveNonEmptyDirectoryFails(t *testing.T) {
fs := newFS()
fs.Mkdir("/dir", 0o755)
fs.WriteFile("/dir/file", []byte("x"), 0o644)
if err := fs.Remove("/dir"); !errors.Is(err, ErrNotEmpty) {
t.Errorf("err = %v, want ErrNotEmpty", err)
}
// It works once emptied.
fs.Remove("/dir/file")
if err := fs.Remove("/dir"); err != nil {
t.Errorf("removing an empty directory failed: %v", err)
}
}
func TestRename(t *testing.T) {
fs := newFS()
fs.WriteFile("/old.txt", []byte("content"), 0o644)
fs.Mkdir("/dir", 0o755)
if err := fs.Rename("/old.txt", "/dir/new.txt"); err != nil {
t.Fatal(err)
}
if fs.Exists("/old.txt") {
t.Error("the old name still resolves")
}
got, err := fs.ReadFile("/dir/new.txt")
if err != nil {
t.Fatal(err)
}
if string(got) != "content" {
t.Errorf("= %q", got)
}
}
// Paths must be absolute and must not escape the root.
func TestPathValidation(t *testing.T) {
fs := newFS()
fs.Mkdir("/dir", 0o755)
bad := []string{
"relative/path",
"",
"/../escaped",
"/dir/../../escaped",
}
for _, p := range bad {
t.Run(p, func(t *testing.T) {
if _, err := fs.Stat(p); err == nil {
t.Errorf("%q should not resolve", p)
}
})
}
// path.Clean resolves an interior "..", which is legitimate.
fs.WriteFile("/dir/f.txt", []byte("x"), 0o644)
if _, err := fs.Stat("/dir/../dir/f.txt"); err != nil {
t.Errorf("an interior .. that stays in the tree should resolve: %v", err)
}
}
func TestErrors(t *testing.T) {
fs := newFS()
fs.Mkdir("/dir", 0o755)
fs.WriteFile("/file.txt", []byte("x"), 0o644)
if _, err := fs.ReadFile("/nonexistent"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
if _, err := fs.ReadFile("/dir"); !errors.Is(err, ErrIsDir) {
t.Errorf("err = %v, want ErrIsDir", err)
}
if _, err := fs.ReadDir("/file.txt"); !errors.Is(err, ErrNotDir) {
t.Errorf("err = %v, want ErrNotDir", err)
}
if err := fs.Mkdir("/dir", 0o755); !errors.Is(err, ErrExists) {
t.Errorf("err = %v, want ErrExists", err)
}
if err := fs.Mkdir("/file.txt/sub", 0o755); err == nil {
t.Error("creating under a file should fail")
}
if err := fs.Link("/dir", "/dirlink"); !errors.Is(err, ErrIsDir) {
t.Errorf("hard-linking a directory should fail: %v", err)
}
}
// Running out of space must be reported, and must not leave a partially
// written file holding blocks.
func TestNoSpaceRollsBack(t *testing.T) {
fs := New(32, 4) // only 4 blocks
// More data than fits.
err := fs.WriteFile("/toobig.bin", bytes.Repeat([]byte("x"), BlockSize*10), 0o644)
if !errors.Is(err, ErrNoSpace) {
t.Fatalf("err = %v, want ErrNoSpace", err)
}
// The partially allocated blocks must have been returned.
if got := fs.Statfs().FreeBlocks; got != 4 {
t.Errorf("free blocks = %d after a failed write, want 4 — allocation was not rolled back", got)
}
}
func TestNoFreeInodes(t *testing.T) {
fs := New(3, 64) // root plus two
fs.WriteFile("/a", []byte("x"), 0o644)
fs.WriteFile("/b", []byte("x"), 0o644)
if err := fs.WriteFile("/c", []byte("x"), 0o644); !errors.Is(err, ErrNoInodes) {
t.Errorf("err = %v, want ErrNoInodes", err)
}
}
func TestOpenReadWriteSeek(t *testing.T) {
fs := newFS()
fd, err := fs.Open("/f.txt", O_CREATE|O_RDWR)
if err != nil {
t.Fatal(err)
}
data := []byte("hello world")
n, err := fs.Write(fd, data)
if err != nil {
t.Fatal(err)
}
if n != len(data) {
t.Errorf("wrote %d bytes, want %d", n, len(data))
}
if _, err := fs.Seek(fd, 0, io.SeekStart); err != nil {
t.Fatal(err)
}
buf := make([]byte, len(data))
if _, err := fs.Read(fd, buf); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf, data) {
t.Errorf("= %q, want %q", buf, data)
}
// Seeking from the end.
pos, err := fs.Seek(fd, -5, io.SeekEnd)
if err != nil {
t.Fatal(err)
}
if pos != len(data)-5 {
t.Errorf("position = %d, want %d", pos, len(data)-5)
}
if err := fs.Close(fd); err != nil {
t.Fatal(err)
}
if fs.OpenCount() != 0 {
t.Errorf("%d descriptors still open", fs.OpenCount())
}
}
// Each descriptor has its own offset: that is the point of putting it in
// the open file description rather than the inode.
func TestDescriptorsHaveIndependentOffsets(t *testing.T) {
fs := newFS()
fs.WriteFile("/f.txt", []byte("abcdefghij"), 0o644)
a, _ := fs.Open("/f.txt", O_RDONLY)
b, _ := fs.Open("/f.txt", O_RDONLY)
defer fs.Close(a)
defer fs.Close(b)
buf := make([]byte, 5)
fs.Read(a, buf)
if string(buf) != "abcde" {
t.Errorf("first descriptor read %q", buf)
}
// The second descriptor must still be at the start.
fs.Read(b, buf)
if string(buf) != "abcde" {
t.Errorf("second descriptor read %q; offsets are shared", buf)
}
}
func TestAppendMode(t *testing.T) {
fs := newFS()
fs.WriteFile("/log.txt", []byte("first\n"), 0o644)
fd, err := fs.Open("/log.txt", O_WRONLY|O_APPEND)
if err != nil {
t.Fatal(err)
}
fs.Write(fd, []byte("second\n"))
fs.Close(fd)
got, _ := fs.ReadFile("/log.txt")
if string(got) != "first\nsecond\n" {
t.Errorf("= %q, want %q", got, "first\nsecond\n")
}
}
func TestTruncateOnOpen(t *testing.T) {
fs := newFS()
fs.WriteFile("/f.txt", []byte("original content"), 0o644)
fd, err := fs.Open("/f.txt", O_WRONLY|O_TRUNC)
if err != nil {
t.Fatal(err)
}
fs.Write(fd, []byte("new"))
fs.Close(fd)
got, _ := fs.ReadFile("/f.txt")
if string(got) != "new" {
t.Errorf("= %q, want %q", got, "new")
}
}
func TestReadAtEOF(t *testing.T) {
fs := newFS()
fs.WriteFile("/f.txt", []byte("abc"), 0o644)
fd, _ := fs.Open("/f.txt", O_RDONLY)
defer fs.Close(fd)
buf := make([]byte, 10)
n, _ := fs.Read(fd, buf)
if n != 3 {
t.Errorf("read %d bytes, want 3", n)
}
// A second read is at EOF.
if _, err := fs.Read(fd, buf); !errors.Is(err, io.EOF) {
t.Errorf("err = %v, want EOF", err)
}
}
func TestInvalidDescriptor(t *testing.T) {
fs := newFS()
buf := make([]byte, 10)
if _, err := fs.Read(999, buf); !errors.Is(err, ErrInvalidFD) {
t.Errorf("err = %v, want ErrInvalidFD", err)
}
if _, err := fs.Write(999, buf); !errors.Is(err, ErrInvalidFD) {
t.Errorf("err = %v, want ErrInvalidFD", err)
}
if err := fs.Close(999); !errors.Is(err, ErrInvalidFD) {
t.Errorf("err = %v, want ErrInvalidFD", err)
}
}
// Writing into the middle of a file must not shrink it.
func TestWriteInMiddleDoesNotTruncate(t *testing.T) {
fs := newFS()
fs.WriteFile("/f.txt", []byte("0123456789"), 0o644)
fd, _ := fs.Open("/f.txt", O_RDWR)
fs.Seek(fd, 3, io.SeekStart)
fs.Write(fd, []byte("XX"))
fs.Close(fd)
got, _ := fs.ReadFile("/f.txt")
if string(got) != "012XX56789" {
t.Errorf("= %q, want %q", got, "012XX56789")
}
}
func TestStatfs(t *testing.T) {
fs := New(10, 20)
s := fs.Statfs()
if s.TotalInodes != 10 || s.TotalBlocks != 20 {
t.Errorf("totals = %d inodes, %d blocks", s.TotalInodes, s.TotalBlocks)
}
// Root occupies one inode.
if s.FreeInodes != 9 {
t.Errorf("free inodes = %d, want 9", s.FreeInodes)
}
if s.UsedBlocks() != 0 {
t.Errorf("used blocks = %d, want 0", s.UsedBlocks())
}
fs.WriteFile("/f", bytes.Repeat([]byte("x"), BlockSize), 0o644)
if got := fs.Statfs().UsedBlocks(); got != 1 {
t.Errorf("used blocks = %d, want 1", got)
}
}
func TestEmptyFile(t *testing.T) {
fs := newFS()
if err := fs.WriteFile("/empty", nil, 0o644); err != nil {
t.Fatal(err)
}
got, err := fs.ReadFile("/empty")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("= %q, want empty", got)
}
// An empty file consumes no blocks.
node, _ := fs.Stat("/empty")
if len(node.Blocks) != 0 {
t.Errorf("empty file holds %d blocks", len(node.Blocks))
}
}
func TestDeepNesting(t *testing.T) {
fs := New(64, 128)
path := ""
for range 20 {
path += "/d"
if err := fs.Mkdir(path, 0o755); err != nil {
t.Fatalf("at depth %s: %v", path, err)
}
}
if err := fs.WriteFile(path+"/deep.txt", []byte("found"), 0o644); err != nil {
t.Fatal(err)
}
got, err := fs.ReadFile(path + "/deep.txt")
if err != nil {
t.Fatal(err)
}
if string(got) != "found" {
t.Errorf("= %q", got)
}
}
func TestLargeFileThroughDescriptor(t *testing.T) {
fs := New(32, 256)
fd, err := fs.Open("/big.bin", O_CREATE|O_RDWR)
if err != nil {
t.Fatal(err)
}
// Write in many small pieces, crossing block boundaries repeatedly.
var want bytes.Buffer
for i := range 500 {
chunk := []byte(strings.Repeat(string(rune('a'+i%26)), 7))
if _, err := fs.Write(fd, chunk); err != nil {
t.Fatal(err)
}
want.Write(chunk)
}
fs.Seek(fd, 0, io.SeekStart)
got := make([]byte, want.Len())
total := 0
for total < len(got) {
n, err := fs.Read(fd, got[total:])
if err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if n == 0 {
break
}
total += n
}
fs.Close(fd)
if !bytes.Equal(got[:total], want.Bytes()) {
t.Errorf("large file round trip corrupted the data (%d of %d bytes match)", total, want.Len())
}
}