// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package nfsfs import ( "errors" "os" "path/filepath" "sync" "testing" ) // cacheTestRoot builds a backend over a fresh directory holding one file // and returns the backend, the file handle and the path. func cacheTestRoot(t *testing.T, content string) (*Local, Handle, string) { t.Helper() root := t.TempDir() l, err := NewLocal(root) if err != nil { t.Fatalf("NewLocal: %v", err) } p := filepath.Join(root, "file") if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } rh, err := l.Root() if err != nil { t.Fatalf("Root: %v", err) } h, _, err := l.Lookup(rh, "file") if err != nil { t.Fatalf("Lookup: %v", err) } return l, h, p } // TestCacheStaleAfterRemove covers the identity contract on a cache // hit: a file removed while a descriptor of it is cached answers stale, // exactly as it does without the cache. func TestCacheStaleAfterRemove(t *testing.T) { l, h, p := cacheTestRoot(t, "hello") if _, err := l.Read(h, 0, 5); err != nil { t.Fatalf("warm read: %v", err) } if err := os.Remove(p); err != nil { t.Fatalf("Remove: %v", err) } if _, err := l.Read(h, 0, 5); !errors.Is(err, ErrStale) { t.Fatalf("read after remove: %v, want ErrStale", err) } } // TestCacheStaleAfterReplace covers a name swapped for another inode: the // cached descriptor of the old inode must never serve through it. func TestCacheStaleAfterReplace(t *testing.T) { l, h, p := cacheTestRoot(t, "old") if _, err := l.Read(h, 0, 3); err != nil { t.Fatalf("warm read: %v", err) } if err := os.Remove(p); err != nil { t.Fatalf("Remove: %v", err) } if err := os.WriteFile(p, []byte("new"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } if _, err := l.Read(h, 0, 3); !errors.Is(err, ErrStale) { t.Fatalf("read after replace: %v, want ErrStale", err) } } // TestCacheInodeReuse covers the recycled inode number at the same path: // a cached descriptor of the unlinked old inode must never serve the // identity the recycled file now carries. The map entry is forged onto // the new file, which is exactly the state a reuse produces. func TestCacheInodeReuse(t *testing.T) { l, h, p := cacheTestRoot(t, "stale data") if _, err := l.Read(h, 0, 4); err != nil { t.Fatalf("warm read: %v", err) } // Remove the file behind the backend's back and recreate a fresh one // at the same path, then point the old handle's identity at it the // way a recycled inode number would. fi, err := os.Lstat(p) if err != nil { t.Fatalf("Lstat: %v", err) } oldID := fileID{dev: stat(fi).Dev, ino: stat(fi).Ino} if err := os.Remove(p); err != nil { t.Fatalf("Remove: %v", err) } if err := os.WriteFile(p, []byte("fresh"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } fi2, err := os.Lstat(p) if err != nil { t.Fatalf("Lstat: %v", err) } newID := fileID{dev: stat(fi2).Dev, ino: stat(fi2).Ino} l.mu.Lock() l.paths[newID] = p if newID == oldID { // The filesystem handed back the very same inode; the scenario // holds without forging anything. l.paths[oldID] = p } else { delete(l.paths, oldID) } l.mu.Unlock() // Whatever the inode numbers did, a fresh read must serve the fresh // content, never the unlinked inode's bytes. got, err := l.Read(h, 0, 5) if err != nil { // A stale answer is also safe: the identity broke and the cache // refused. Serving the old bytes is the only failure. if !errors.Is(err, ErrStale) { t.Fatalf("read after reuse: %v", err) } return } if string(got) != "fresh" { t.Fatalf("read after reuse: %q, want the fresh content", got) } } // TestCacheWriteThrough covers that writes land and that a follow up read // of the same cached file sees them. func TestCacheWriteThrough(t *testing.T) { l, h, p := cacheTestRoot(t, "0123456789") if _, err := l.Read(h, 0, 10); err != nil { t.Fatalf("warm read: %v", err) } if n, err := l.Write(h, 2, []byte("AB")); err != nil || n != 2 { t.Fatalf("Write: %d, %v", n, err) } got, err := l.Read(h, 0, 10) if err != nil { t.Fatalf("Read: %v", err) } if string(got) != "01AB456789" { t.Fatalf("Read: %q", got) } raw, err := os.ReadFile(p) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(raw) != "01AB456789" { t.Fatalf("file on disk: %q", raw) } } // TestCacheConcurrent drives reads and writes of one file from many // goroutines; the race detector is the judge. func TestCacheConcurrent(t *testing.T) { l, h, _ := cacheTestRoot(t, "concurrent") var wg sync.WaitGroup for i := range 8 { wg.Go(func() { for range 50 { if _, err := l.Read(h, 0, 4); err != nil { t.Errorf("Read: %v", err) return } if i%2 == 0 { if _, err := l.Write(h, 0, []byte("writ")); err != nil { t.Errorf("Write: %v", err) return } } } }) } wg.Wait() }