@@ -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 }
@@ -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
+}