From fea34df057a40f05a3b38278b25ea444f98b3036 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 25 Mar 2026 22:58:12 -0600 Subject: [PATCH] Add in-memory snapshot filesystem from ls node list --- 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(-) create mode 100644 internal/restic/snapshotfs.go create mode 100644 internal/restic/snapshotfs_test.go diff --git a/internal/form/form.go b/internal/form/form.go index 64d4c1e33b641a099d407405451fc63f9b27a82c..a2a60e9a926f2b6e9b45f0074a2ae086c176f084 100644 --- a/internal/form/form.go +++ b/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) } diff --git a/internal/restic/lsnodes.go b/internal/restic/lsnodes.go index 9d73717b63d0e4a248041e67e9668f28b535e43a..d78e4c3c1e46d4390e241882ef2e9d660e2d4df1 100644 --- a/internal/restic/lsnodes.go +++ b/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) diff --git a/internal/restic/snapshotfs.go b/internal/restic/snapshotfs.go new file mode 100644 index 0000000000000000000000000000000000000000..f6d7b182bda9a8f18144fea4397fc27399065eaa --- /dev/null +++ b/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 } diff --git a/internal/restic/snapshotfs_test.go b/internal/restic/snapshotfs_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a1c41367abc52c9e15caab474fec38ca38518260 --- /dev/null +++ b/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 +} diff --git a/mise.toml b/mise.toml index 5b5322a6506ccf55c209588a29fa5d353d1ba3cc..5f1bacba67a757cbf68d9324f025e4c355e5471c 100644 --- a/mise.toml +++ b/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"]