Add in-memory snapshot filesystem from ls node list

Amolith created

Change summary

internal/form/form.go              |  21 -
internal/restic/lsnodes.go         |  17 
internal/restic/snapshotfs.go      | 272 +++++++++++++++++++
internal/restic/snapshotfs_test.go | 436 ++++++++++++++++++++++++++++++++
mise.toml                          |   2 
5 files changed, 719 insertions(+), 29 deletions(-)

Detailed changes

internal/form/form.go ๐Ÿ”—

@@ -5,6 +5,7 @@ package form
 import (
 	"errors"
 	"fmt"
+	"strings"
 
 	"charm.land/huh/v2"
 )
@@ -98,23 +99,7 @@ func notEmpty(fieldName string) func(string) error {
 	}
 }
 
-// splitFields splits a string on whitespace, like strings.Fields, but kept
-// here to avoid importing strings for a one-liner.
+// splitFields splits a string on whitespace.
 func splitFields(s string) []string {
-	var fields []string
-	start := -1
-	for i, r := range s {
-		if r == ' ' || r == '\t' {
-			if start >= 0 {
-				fields = append(fields, s[start:i])
-				start = -1
-			}
-		} else if start < 0 {
-			start = i
-		}
-	}
-	if start >= 0 {
-		fields = append(fields, s[start:])
-	}
-	return fields
+	return strings.Fields(s)
 }

internal/restic/lsnodes.go ๐Ÿ”—

@@ -25,10 +25,12 @@ type LsNode struct {
 	Mtime       time.Time `json:"mtime"`
 }
 
-// lsLine is the minimal envelope used to distinguish snapshot headers
-// from node entries when scanning `restic ls --json` output.
-type lsLine struct {
+// lsNodeEnvelope wraps LsNode with the message_type discriminator so
+// we can unmarshal each JSON line in a single pass โ€” check the type and
+// extract node fields at once, avoiding a double decode per line.
+type lsNodeEnvelope struct {
 	MessageType string `json:"message_type"`
+	LsNode
 }
 
 // ParseLsNodes parses the JSON-lines output of `restic ls --json`,
@@ -53,8 +55,7 @@ func parseLsReader(r io.Reader) ([]LsNode, error) {
 			continue
 		}
 
-		// Peek at message_type to decide whether to skip.
-		var envelope lsLine
+		var envelope lsNodeEnvelope
 		if err := json.Unmarshal(line, &envelope); err != nil {
 			return nil, fmt.Errorf("parsing ls JSON line: %w", err)
 		}
@@ -62,11 +63,7 @@ func parseLsReader(r io.Reader) ([]LsNode, error) {
 			continue
 		}
 
-		var node LsNode
-		if err := json.Unmarshal(line, &node); err != nil {
-			return nil, fmt.Errorf("parsing ls node: %w", err)
-		}
-		nodes = append(nodes, node)
+		nodes = append(nodes, envelope.LsNode)
 	}
 	if err := scanner.Err(); err != nil {
 		return nil, fmt.Errorf("scanning ls output: %w", err)

internal/restic/snapshotfs.go ๐Ÿ”—

@@ -0,0 +1,272 @@
+package restic
+
+import (
+	"io"
+	"io/fs"
+	"sort"
+	"strings"
+	"time"
+)
+
+// Compile-time interface checks.
+var (
+	_ fs.FS        = (*SnapshotFS)(nil)
+	_ fs.ReadDirFS = (*SnapshotFS)(nil)
+	_ fs.StatFS    = (*SnapshotFS)(nil)
+)
+
+// fsNode is the internal tree node used by SnapshotFS. It holds
+// metadata for one file or directory and, for directories, an index of
+// immediate children keyed by name.
+type fsNode struct {
+	name     string
+	isDir    bool
+	size     int64
+	mode     fs.FileMode
+	modTime  time.Time
+	children map[string]*fsNode // nil for files
+}
+
+// SnapshotFS is a read-only, metadata-only fs.FS built from a flat
+// list of LsNode values (as produced by ParseLsNodes / RunLs).
+//
+// Paths inside the FS are slash-separated and relative; the root is ".".
+// No file content is available โ€” Open returns files whose Read always
+// returns io.EOF. This is a browsing-only filesystem for the file picker.
+type SnapshotFS struct {
+	root *fsNode
+}
+
+// NewSnapshotFS constructs a SnapshotFS from a flat slice of LsNode
+// values. Absolute paths (as restic produces) are converted to
+// relative paths. Intermediate directories that lack explicit node
+// entries are synthesized with conservative metadata.
+func NewSnapshotFS(nodes []LsNode) *SnapshotFS {
+	root := &fsNode{
+		name:     ".",
+		isDir:    true,
+		mode:     fs.ModeDir | 0o555,
+		children: make(map[string]*fsNode),
+	}
+
+	sfs := &SnapshotFS{root: root}
+
+	for _, n := range nodes {
+		// Strip leading slash to make paths relative.
+		rel := strings.TrimPrefix(n.Path, "/")
+		if rel == "" {
+			continue
+		}
+
+		isDir := n.Type == "dir"
+		mode := fs.FileMode(n.Mode) & fs.ModePerm
+		if isDir {
+			mode |= fs.ModeDir
+		}
+
+		leaf := &fsNode{
+			name:    n.Name,
+			isDir:   isDir,
+			size:    n.Size,
+			mode:    mode,
+			modTime: n.Mtime,
+		}
+		if isDir {
+			leaf.children = make(map[string]*fsNode)
+		}
+
+		sfs.insert(rel, leaf)
+	}
+
+	return sfs
+}
+
+// insert places leaf at the given relative path, synthesizing any
+// missing intermediate directories. If a synthesized directory already
+// exists at the target path (created as a parent of an earlier node),
+// the explicit metadata from leaf replaces it while preserving existing
+// children.
+func (sfs *SnapshotFS) insert(rel string, leaf *fsNode) {
+	parts := strings.Split(rel, "/")
+	cur := sfs.root
+
+	// Walk/create all intermediate directories.
+	for _, seg := range parts[:len(parts)-1] {
+		child, ok := cur.children[seg]
+		if !ok {
+			child = &fsNode{
+				name:     seg,
+				isDir:    true,
+				mode:     fs.ModeDir | 0o555,
+				children: make(map[string]*fsNode),
+			}
+			cur.children[seg] = child
+		}
+		cur = child
+	}
+
+	// Place the leaf. If a synthesized dir already occupies this slot,
+	// preserve its children but upgrade metadata.
+	name := parts[len(parts)-1]
+	if existing, ok := cur.children[name]; ok && existing.isDir && leaf.isDir {
+		existing.name = leaf.name
+		existing.mode = leaf.mode
+		existing.modTime = leaf.modTime
+		existing.size = leaf.size
+	} else {
+		cur.children[name] = leaf
+	}
+}
+
+// Open opens the named file or directory. The name must be a valid
+// fs.FS path (slash-separated, no leading slash, root is ".").
+func (sfs *SnapshotFS) Open(name string) (fs.File, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
+	}
+
+	node := sfs.lookup(name)
+	if node == nil {
+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+	}
+
+	return &snapshotFile{node: node, name: name}, nil
+}
+
+// ReadDir reads the named directory and returns a list of directory
+// entries sorted by name.
+func (sfs *SnapshotFS) ReadDir(name string) ([]fs.DirEntry, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
+	}
+
+	node := sfs.lookup(name)
+	if node == nil {
+		return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
+	}
+	if !node.isDir {
+		return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
+	}
+
+	return sortedEntries(node), nil
+}
+
+// sortedEntries returns the children of a directory node as a
+// name-sorted slice of fs.DirEntry values.
+func sortedEntries(node *fsNode) []fs.DirEntry {
+	entries := make([]fs.DirEntry, 0, len(node.children))
+	for _, child := range node.children {
+		entries = append(entries, &snapshotDirEntry{node: child})
+	}
+	sort.Slice(entries, func(i, j int) bool {
+		return entries[i].Name() < entries[j].Name()
+	})
+	return entries
+}
+
+// Stat returns a FileInfo describing the named file or directory.
+func (sfs *SnapshotFS) Stat(name string) (fs.FileInfo, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
+	}
+
+	node := sfs.lookup(name)
+	if node == nil {
+		return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
+	}
+
+	return &snapshotFileInfo{node: node}, nil
+}
+
+// lookup resolves a valid fs.FS path to the corresponding fsNode,
+// or nil if not found.
+func (sfs *SnapshotFS) lookup(name string) *fsNode {
+	if name == "." {
+		return sfs.root
+	}
+
+	cur := sfs.root
+	for seg := range strings.SplitSeq(name, "/") {
+		child, ok := cur.children[seg]
+		if !ok {
+			return nil
+		}
+		cur = child
+	}
+	return cur
+}
+
+// --- fs.File implementation ---
+
+// snapshotFile is a metadata-only fs.File. Read always returns io.EOF.
+// For directories, ReadDir is stateful: successive calls with n > 0
+// advance through the sorted entry list per the fs.ReadDirFile contract.
+type snapshotFile struct {
+	node    *fsNode
+	name    string
+	dirRead int // offset into sorted children for ReadDir(n)
+}
+
+func (f *snapshotFile) Stat() (fs.FileInfo, error) {
+	return &snapshotFileInfo{node: f.node}, nil
+}
+
+func (f *snapshotFile) Read([]byte) (int, error) {
+	return 0, io.EOF
+}
+
+func (f *snapshotFile) Close() error {
+	return nil
+}
+
+// ReadDir implements fs.ReadDirFile. For directories, successive calls
+// with n > 0 advance through the sorted entry list. Calling with n <= 0
+// returns all remaining entries.
+func (f *snapshotFile) ReadDir(n int) ([]fs.DirEntry, error) {
+	if !f.node.isDir {
+		return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid}
+	}
+
+	entries := sortedEntries(f.node)
+	remaining := entries[f.dirRead:]
+
+	if n <= 0 {
+		f.dirRead = len(entries)
+		return remaining, nil
+	}
+
+	if len(remaining) == 0 {
+		return nil, io.EOF
+	}
+	if n > len(remaining) {
+		f.dirRead = len(entries)
+		return remaining, io.EOF
+	}
+	f.dirRead += n
+	return remaining[:n], nil
+}
+
+// --- fs.FileInfo implementation ---
+
+type snapshotFileInfo struct {
+	node *fsNode
+}
+
+func (fi *snapshotFileInfo) Name() string               { return fi.node.name }
+func (fi *snapshotFileInfo) Size() int64                { return fi.node.size }
+func (fi *snapshotFileInfo) Mode() fs.FileMode          { return fi.node.mode }
+func (fi *snapshotFileInfo) ModTime() time.Time         { return fi.node.modTime }
+func (fi *snapshotFileInfo) IsDir() bool                { return fi.node.isDir }
+func (fi *snapshotFileInfo) Sys() any                   { return nil }
+func (fi *snapshotFileInfo) Info() (fs.FileInfo, error) { return fi, nil }
+
+// --- fs.DirEntry implementation ---
+
+type snapshotDirEntry struct {
+	node *fsNode
+}
+
+func (de *snapshotDirEntry) Name() string               { return de.node.name }
+func (de *snapshotDirEntry) IsDir() bool                { return de.node.isDir }
+func (de *snapshotDirEntry) Type() fs.FileMode          { return de.node.mode.Type() }
+func (de *snapshotDirEntry) Info() (fs.FileInfo, error) { return &snapshotFileInfo{node: de.node}, nil }

internal/restic/snapshotfs_test.go ๐Ÿ”—

@@ -0,0 +1,436 @@
+package restic
+
+import (
+	"errors"
+	"io"
+	"io/fs"
+	"testing"
+	"time"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+// testNodes returns a small but representative set of LsNode values for
+// testing SnapshotFS. The structure is:
+//
+//	/music/
+//	/music/album-01/
+//	/music/album-01/01 - track.flac
+//	/music/album-01/02 - track.flac
+//	/music/album-02/
+//	/music/album-02/01 - song.flac
+//	/docs/
+//	/docs/notes.txt
+//	/readme.txt
+//
+// All paths are absolute, as restic ls produces them.
+func testNodes() []LsNode {
+	now := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
+	return []LsNode{
+		{Name: "music", Type: "dir", Path: "/music", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
+		{Name: "album-01", Type: "dir", Path: "/music/album-01", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
+		{Name: "01 - track.flac", Type: "file", Path: "/music/album-01/01 - track.flac", Size: 1000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
+		{Name: "02 - track.flac", Type: "file", Path: "/music/album-01/02 - track.flac", Size: 2000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
+		{Name: "album-02", Type: "dir", Path: "/music/album-02", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
+		{Name: "01 - song.flac", Type: "file", Path: "/music/album-02/01 - song.flac", Size: 3000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
+		{Name: "docs", Type: "dir", Path: "/docs", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
+		{Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
+		{Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
+	}
+}
+
+func TestSnapshotFSReadDirRoot(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	entries, err := fs.ReadDir(sfs, ".")
+	if err != nil {
+		t.Fatalf("ReadDir(.): %v", err)
+	}
+
+	// Root should have: docs, music, readme.txt โ€” sorted.
+	wantNames := []string{"docs", "music", "readme.txt"}
+	if len(entries) != len(wantNames) {
+		t.Fatalf("ReadDir(.) count: got %d, want %d", len(entries), len(wantNames))
+	}
+	for i, want := range wantNames {
+		if entries[i].Name() != want {
+			t.Errorf("ReadDir(.)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
+		}
+	}
+
+	// Verify types.
+	if !entries[0].IsDir() {
+		t.Error("docs should be a directory")
+	}
+	if !entries[1].IsDir() {
+		t.Error("music should be a directory")
+	}
+	if entries[2].IsDir() {
+		t.Error("readme.txt should not be a directory")
+	}
+}
+
+func TestSnapshotFSReadDirSubdir(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	entries, err := fs.ReadDir(sfs, "music")
+	if err != nil {
+		t.Fatalf("ReadDir(music): %v", err)
+	}
+
+	wantNames := []string{"album-01", "album-02"}
+	if len(entries) != len(wantNames) {
+		t.Fatalf("ReadDir(music) count: got %d, want %d", len(entries), len(wantNames))
+	}
+	for i, want := range wantNames {
+		if entries[i].Name() != want {
+			t.Errorf("ReadDir(music)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
+		}
+	}
+}
+
+func TestSnapshotFSReadDirFiles(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	entries, err := fs.ReadDir(sfs, "music/album-01")
+	if err != nil {
+		t.Fatalf("ReadDir(music/album-01): %v", err)
+	}
+
+	wantNames := []string{"01 - track.flac", "02 - track.flac"}
+	if len(entries) != len(wantNames) {
+		t.Fatalf("ReadDir(music/album-01) count: got %d, want %d", len(entries), len(wantNames))
+	}
+	for i, want := range wantNames {
+		if entries[i].Name() != want {
+			t.Errorf("ReadDir(music/album-01)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
+		}
+		if entries[i].IsDir() {
+			t.Errorf("%q should not be a directory", want)
+		}
+	}
+}
+
+func TestSnapshotFSReadDirOfFile(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	_, err := fs.ReadDir(sfs, "readme.txt")
+	if err == nil {
+		t.Fatal("ReadDir(readme.txt) should fail for a file")
+	}
+}
+
+func TestSnapshotFSReadDirNotExist(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	_, err := fs.ReadDir(sfs, "nonexistent")
+	if !errors.Is(err, fs.ErrNotExist) {
+		t.Errorf("ReadDir(nonexistent): got %v, want fs.ErrNotExist", err)
+	}
+}
+
+func TestSnapshotFSStat(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+
+	// Stat a file.
+	info, err := fs.Stat(sfs, "music/album-01/01 - track.flac")
+	if err != nil {
+		t.Fatalf("Stat(file): %v", err)
+	}
+	if info.Name() != "01 - track.flac" {
+		t.Errorf("Stat(file).Name(): got %q, want %q", info.Name(), "01 - track.flac")
+	}
+	if info.Size() != 1000 {
+		t.Errorf("Stat(file).Size(): got %d, want 1000", info.Size())
+	}
+	if info.IsDir() {
+		t.Error("Stat(file).IsDir() should be false")
+	}
+
+	// Stat a directory.
+	info, err = fs.Stat(sfs, "music")
+	if err != nil {
+		t.Fatalf("Stat(dir): %v", err)
+	}
+	if info.Name() != "music" {
+		t.Errorf("Stat(dir).Name(): got %q, want %q", info.Name(), "music")
+	}
+	if !info.IsDir() {
+		t.Error("Stat(dir).IsDir() should be true")
+	}
+
+	// Stat root.
+	info, err = fs.Stat(sfs, ".")
+	if err != nil {
+		t.Fatalf("Stat(.): %v", err)
+	}
+	if !info.IsDir() {
+		t.Error("Stat(.).IsDir() should be true")
+	}
+}
+
+func TestSnapshotFSStatNotExist(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	_, err := fs.Stat(sfs, "no/such/path")
+	if !errors.Is(err, fs.ErrNotExist) {
+		t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
+	}
+}
+
+func TestSnapshotFSOpenFile(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	f, err := sfs.Open("readme.txt")
+	if err != nil {
+		t.Fatalf("Open(readme.txt): %v", err)
+	}
+	defer func() { _ = f.Close() }()
+
+	info, err := f.Stat()
+	if err != nil {
+		t.Fatalf("Open(readme.txt).Stat(): %v", err)
+	}
+	if info.Name() != "readme.txt" {
+		t.Errorf("Name(): got %q, want %q", info.Name(), "readme.txt")
+	}
+	if info.Size() != 100 {
+		t.Errorf("Size(): got %d, want 100", info.Size())
+	}
+}
+
+func TestSnapshotFSOpenNotExist(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+	_, err := sfs.Open("nope.txt")
+	if !errors.Is(err, fs.ErrNotExist) {
+		t.Errorf("Open(missing): got %v, want fs.ErrNotExist", err)
+	}
+}
+
+func TestSnapshotFSSynthesizedParents(t *testing.T) {
+	t.Parallel()
+
+	// Only file nodes, no explicit directory entries.
+	// SnapshotFS must synthesize /a and /a/b as directories.
+	nodes := []LsNode{
+		{Name: "file.txt", Type: "file", Path: "/a/b/file.txt", Size: 42, Mode: 420, Permissions: "-rw-r--r--", Mtime: time.Now()},
+	}
+
+	sfs := NewSnapshotFS(nodes)
+
+	// Root should contain synthesized "a".
+	entries, err := fs.ReadDir(sfs, ".")
+	if err != nil {
+		t.Fatalf("ReadDir(.): %v", err)
+	}
+	if len(entries) != 1 || entries[0].Name() != "a" {
+		t.Fatalf("ReadDir(.): got %v, want [a]", names(entries))
+	}
+	if !entries[0].IsDir() {
+		t.Error("synthesized 'a' should be a directory")
+	}
+
+	// "a" should contain synthesized "b".
+	entries, err = fs.ReadDir(sfs, "a")
+	if err != nil {
+		t.Fatalf("ReadDir(a): %v", err)
+	}
+	if len(entries) != 1 || entries[0].Name() != "b" {
+		t.Fatalf("ReadDir(a): got %v, want [b]", names(entries))
+	}
+
+	// "a/b" should contain the file.
+	entries, err = fs.ReadDir(sfs, "a/b")
+	if err != nil {
+		t.Fatalf("ReadDir(a/b): %v", err)
+	}
+	if len(entries) != 1 || entries[0].Name() != "file.txt" {
+		t.Fatalf("ReadDir(a/b): got %v, want [file.txt]", names(entries))
+	}
+}
+
+func TestSnapshotFSExplicitOverridesSynthesized(t *testing.T) {
+	t.Parallel()
+
+	mtime := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	nodes := []LsNode{
+		// Explicit directory with real metadata.
+		{Name: "a", Type: "dir", Path: "/a", Mode: 2147484157, Permissions: "drwxrwxr-x", Mtime: mtime},
+		// File inside it โ€” would cause "a" to be synthesized if the
+		// explicit entry were missing.
+		{Name: "file.txt", Type: "file", Path: "/a/file.txt", Size: 10, Mode: 420, Permissions: "-rw-r--r--", Mtime: mtime},
+	}
+
+	sfs := NewSnapshotFS(nodes)
+	info, err := fs.Stat(sfs, "a")
+	if err != nil {
+		t.Fatalf("Stat(a): %v", err)
+	}
+	if !info.ModTime().Equal(mtime) {
+		t.Errorf("Stat(a).ModTime(): got %v, want %v (explicit metadata should win)", info.ModTime(), mtime)
+	}
+}
+
+func TestSnapshotFSReadDirFileStateful(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+
+	// Open the root as a file and call ReadDir(n) incrementally.
+	f, err := sfs.Open(".")
+	if err != nil {
+		t.Fatalf("Open(.): %v", err)
+	}
+	defer func() { _ = f.Close() }()
+
+	rdf, ok := f.(fs.ReadDirFile)
+	if !ok {
+		t.Fatal("Open(.) should return an fs.ReadDirFile")
+	}
+
+	// Root has 3 entries: docs, music, readme.txt
+	// Read 2, then 2 more (should get 1 + io.EOF).
+	batch1, err := rdf.ReadDir(2)
+	if err != nil {
+		t.Fatalf("ReadDir(2) first call: %v", err)
+	}
+	if len(batch1) != 2 {
+		t.Fatalf("ReadDir(2) first call: got %d entries, want 2", len(batch1))
+	}
+	if batch1[0].Name() != "docs" || batch1[1].Name() != "music" {
+		t.Errorf("ReadDir(2) first call: got [%q, %q], want [docs, music]",
+			batch1[0].Name(), batch1[1].Name())
+	}
+
+	batch2, err := rdf.ReadDir(2)
+	if err != io.EOF {
+		t.Fatalf("ReadDir(2) second call: err=%v, want io.EOF", err)
+	}
+	if len(batch2) != 1 {
+		t.Fatalf("ReadDir(2) second call: got %d entries, want 1", len(batch2))
+	}
+	if batch2[0].Name() != "readme.txt" {
+		t.Errorf("ReadDir(2) second call: got %q, want readme.txt", batch2[0].Name())
+	}
+
+	// A third call should return nil, io.EOF.
+	batch3, err := rdf.ReadDir(1)
+	if err != io.EOF {
+		t.Fatalf("ReadDir(1) third call: err=%v, want io.EOF", err)
+	}
+	if len(batch3) != 0 {
+		t.Errorf("ReadDir(1) third call: got %d entries, want 0", len(batch3))
+	}
+}
+
+func TestSnapshotFSInvalidPath(t *testing.T) {
+	t.Parallel()
+
+	sfs := NewSnapshotFS(testNodes())
+
+	// Leading slash is not a valid fs.FS path.
+	_, err := sfs.Open("/music")
+	if err == nil {
+		t.Error("Open(/music) should fail for invalid path")
+	}
+}
+
+func TestSnapshotFSFromTestRepo(t *testing.T) {
+	t.Parallel()
+
+	repoPath := testdataRepoPath(t)
+	cfg := &config.ResolvedConfig{
+		Command: "restore",
+		Flags: []config.Flag{
+			{Name: "--repo", Value: repoPath},
+		},
+		Environ: map[string]string{
+			"RESTIC_PASSWORD": "test",
+		},
+	}
+
+	nodes, err := RunLs(cfg, "03b061a6")
+	if err != nil {
+		t.Fatalf("RunLs(): %v", err)
+	}
+
+	sfs := NewSnapshotFS(nodes)
+
+	// Root should contain: .config, documents, music, photos, projects
+	entries, err := fs.ReadDir(sfs, ".")
+	if err != nil {
+		t.Fatalf("ReadDir(.): %v", err)
+	}
+	wantRoot := []string{".config", "documents", "music", "photos", "projects"}
+	if len(entries) != len(wantRoot) {
+		t.Fatalf("ReadDir(.) names: got %v, want %v", names(entries), wantRoot)
+	}
+	for i, want := range wantRoot {
+		if entries[i].Name() != want {
+			t.Errorf("ReadDir(.)[%d]: got %q, want %q", i, entries[i].Name(), want)
+		}
+	}
+
+	// Navigate into a Unicode directory.
+	entries, err = fs.ReadDir(sfs, "music/ใ‚ขใƒผใƒ†ใ‚ฃใ‚นใƒˆ/album-01")
+	if err != nil {
+		t.Fatalf("ReadDir(music/ใ‚ขใƒผใƒ†ใ‚ฃใ‚นใƒˆ/album-01): %v", err)
+	}
+	wantAlbum := []string{"01 - ๅคœๆ˜Žใ‘.flac", "02 - ้ขจใฎๆญŒ.flac", "03 - ๆ˜Ÿ็ฉบ.flac"}
+	if len(entries) != len(wantAlbum) {
+		t.Fatalf("ReadDir(album-01) names: got %v, want %v", names(entries), wantAlbum)
+	}
+	for i, want := range wantAlbum {
+		if entries[i].Name() != want {
+			t.Errorf("album-01[%d]: got %q, want %q", i, entries[i].Name(), want)
+		}
+	}
+
+	// Stat a file with spaces in its path.
+	info, err := fs.Stat(sfs, "music/Jazz Cafรฉ/live/02 - So What.flac")
+	if err != nil {
+		t.Fatalf("Stat(So What): %v", err)
+	}
+	if info.IsDir() {
+		t.Error("So What.flac should not be a directory")
+	}
+	if info.Size() == 0 {
+		t.Error("So What.flac should have nonzero size")
+	}
+
+	// Deep path through projects.
+	entries, err = fs.ReadDir(sfs, "projects/keld/internal")
+	if err != nil {
+		t.Fatalf("ReadDir(projects/keld/internal): %v", err)
+	}
+	wantInternal := []string{"config", "restic"}
+	if len(entries) != len(wantInternal) {
+		t.Fatalf("ReadDir(internal) names: got %v, want %v", names(entries), wantInternal)
+	}
+
+	// Missing path.
+	_, err = fs.Stat(sfs, "nonexistent/path")
+	if !errors.Is(err, fs.ErrNotExist) {
+		t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
+	}
+}
+
+// names extracts entry names for test failure messages.
+func names(entries []fs.DirEntry) []string {
+	out := make([]string, len(entries))
+	for i, e := range entries {
+		out[i] = e.Name()
+	}
+	return out
+}

mise.toml ๐Ÿ”—

@@ -66,4 +66,4 @@ fi
 """
 
 [tasks.check]
-depends = ["fmt:check", "vet", "lint", "vuln", "build", "test:quiet"]
+depends = ["fmt", "vet", "lint", "vuln", "build", "test:quiet"]