1package restic
2
3import (
4 "errors"
5 "io"
6 "io/fs"
7 "testing"
8 "time"
9
10 "git.secluded.site/keld/internal/config"
11)
12
13// testNodes returns a small but representative set of LsNode values for
14// testing SnapshotFS. The structure is:
15//
16// /music/
17// /music/album-01/
18// /music/album-01/01 - track.flac
19// /music/album-01/02 - track.flac
20// /music/album-02/
21// /music/album-02/01 - song.flac
22// /docs/
23// /docs/notes.txt
24// /readme.txt
25//
26// All paths are absolute, as restic ls produces them.
27func testNodes() []LsNode {
28 now := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
29 return []LsNode{
30 {Name: "music", Type: "dir", Path: "/music", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
31 {Name: "album-01", Type: "dir", Path: "/music/album-01", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
32 {Name: "01 - track.flac", Type: "file", Path: "/music/album-01/01 - track.flac", Size: 1000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
33 {Name: "02 - track.flac", Type: "file", Path: "/music/album-01/02 - track.flac", Size: 2000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
34 {Name: "album-02", Type: "dir", Path: "/music/album-02", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
35 {Name: "01 - song.flac", Type: "file", Path: "/music/album-02/01 - song.flac", Size: 3000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
36 {Name: "docs", Type: "dir", Path: "/docs", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
37 {Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
38 {Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
39 }
40}
41
42func TestSnapshotFSReadDirRoot(t *testing.T) {
43 t.Parallel()
44
45 sfs := NewSnapshotFS(testNodes())
46 entries, err := fs.ReadDir(sfs, ".")
47 if err != nil {
48 t.Fatalf("ReadDir(.): %v", err)
49 }
50
51 // Root should have: docs, music, readme.txt — sorted.
52 wantNames := []string{"docs", "music", "readme.txt"}
53 if len(entries) != len(wantNames) {
54 t.Fatalf("ReadDir(.) count: got %d, want %d", len(entries), len(wantNames))
55 }
56 for i, want := range wantNames {
57 if entries[i].Name() != want {
58 t.Errorf("ReadDir(.)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
59 }
60 }
61
62 // Verify types.
63 if !entries[0].IsDir() {
64 t.Error("docs should be a directory")
65 }
66 if !entries[1].IsDir() {
67 t.Error("music should be a directory")
68 }
69 if entries[2].IsDir() {
70 t.Error("readme.txt should not be a directory")
71 }
72}
73
74func TestSnapshotFSReadDirSubdir(t *testing.T) {
75 t.Parallel()
76
77 sfs := NewSnapshotFS(testNodes())
78 entries, err := fs.ReadDir(sfs, "music")
79 if err != nil {
80 t.Fatalf("ReadDir(music): %v", err)
81 }
82
83 wantNames := []string{"album-01", "album-02"}
84 if len(entries) != len(wantNames) {
85 t.Fatalf("ReadDir(music) count: got %d, want %d", len(entries), len(wantNames))
86 }
87 for i, want := range wantNames {
88 if entries[i].Name() != want {
89 t.Errorf("ReadDir(music)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
90 }
91 }
92}
93
94func TestSnapshotFSReadDirFiles(t *testing.T) {
95 t.Parallel()
96
97 sfs := NewSnapshotFS(testNodes())
98 entries, err := fs.ReadDir(sfs, "music/album-01")
99 if err != nil {
100 t.Fatalf("ReadDir(music/album-01): %v", err)
101 }
102
103 wantNames := []string{"01 - track.flac", "02 - track.flac"}
104 if len(entries) != len(wantNames) {
105 t.Fatalf("ReadDir(music/album-01) count: got %d, want %d", len(entries), len(wantNames))
106 }
107 for i, want := range wantNames {
108 if entries[i].Name() != want {
109 t.Errorf("ReadDir(music/album-01)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
110 }
111 if entries[i].IsDir() {
112 t.Errorf("%q should not be a directory", want)
113 }
114 }
115}
116
117func TestSnapshotFSReadDirOfFile(t *testing.T) {
118 t.Parallel()
119
120 sfs := NewSnapshotFS(testNodes())
121 _, err := fs.ReadDir(sfs, "readme.txt")
122 if err == nil {
123 t.Fatal("ReadDir(readme.txt) should fail for a file")
124 }
125}
126
127func TestSnapshotFSReadDirNotExist(t *testing.T) {
128 t.Parallel()
129
130 sfs := NewSnapshotFS(testNodes())
131 _, err := fs.ReadDir(sfs, "nonexistent")
132 if !errors.Is(err, fs.ErrNotExist) {
133 t.Errorf("ReadDir(nonexistent): got %v, want fs.ErrNotExist", err)
134 }
135}
136
137func TestSnapshotFSStat(t *testing.T) {
138 t.Parallel()
139
140 sfs := NewSnapshotFS(testNodes())
141
142 // Stat a file.
143 info, err := fs.Stat(sfs, "music/album-01/01 - track.flac")
144 if err != nil {
145 t.Fatalf("Stat(file): %v", err)
146 }
147 if info.Name() != "01 - track.flac" {
148 t.Errorf("Stat(file).Name(): got %q, want %q", info.Name(), "01 - track.flac")
149 }
150 if info.Size() != 1000 {
151 t.Errorf("Stat(file).Size(): got %d, want 1000", info.Size())
152 }
153 if info.IsDir() {
154 t.Error("Stat(file).IsDir() should be false")
155 }
156
157 // Stat a directory.
158 info, err = fs.Stat(sfs, "music")
159 if err != nil {
160 t.Fatalf("Stat(dir): %v", err)
161 }
162 if info.Name() != "music" {
163 t.Errorf("Stat(dir).Name(): got %q, want %q", info.Name(), "music")
164 }
165 if !info.IsDir() {
166 t.Error("Stat(dir).IsDir() should be true")
167 }
168
169 // Stat root.
170 info, err = fs.Stat(sfs, ".")
171 if err != nil {
172 t.Fatalf("Stat(.): %v", err)
173 }
174 if !info.IsDir() {
175 t.Error("Stat(.).IsDir() should be true")
176 }
177}
178
179func TestSnapshotFSStatNotExist(t *testing.T) {
180 t.Parallel()
181
182 sfs := NewSnapshotFS(testNodes())
183 _, err := fs.Stat(sfs, "no/such/path")
184 if !errors.Is(err, fs.ErrNotExist) {
185 t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
186 }
187}
188
189func TestSnapshotFSOpenFile(t *testing.T) {
190 t.Parallel()
191
192 sfs := NewSnapshotFS(testNodes())
193 f, err := sfs.Open("readme.txt")
194 if err != nil {
195 t.Fatalf("Open(readme.txt): %v", err)
196 }
197 defer func() { _ = f.Close() }()
198
199 info, err := f.Stat()
200 if err != nil {
201 t.Fatalf("Open(readme.txt).Stat(): %v", err)
202 }
203 if info.Name() != "readme.txt" {
204 t.Errorf("Name(): got %q, want %q", info.Name(), "readme.txt")
205 }
206 if info.Size() != 100 {
207 t.Errorf("Size(): got %d, want 100", info.Size())
208 }
209}
210
211func TestSnapshotFSOpenNotExist(t *testing.T) {
212 t.Parallel()
213
214 sfs := NewSnapshotFS(testNodes())
215 _, err := sfs.Open("nope.txt")
216 if !errors.Is(err, fs.ErrNotExist) {
217 t.Errorf("Open(missing): got %v, want fs.ErrNotExist", err)
218 }
219}
220
221func TestSnapshotFSSynthesizedParents(t *testing.T) {
222 t.Parallel()
223
224 // Only file nodes, no explicit directory entries.
225 // SnapshotFS must synthesize /a and /a/b as directories.
226 nodes := []LsNode{
227 {Name: "file.txt", Type: "file", Path: "/a/b/file.txt", Size: 42, Mode: 420, Permissions: "-rw-r--r--", Mtime: time.Now()},
228 }
229
230 sfs := NewSnapshotFS(nodes)
231
232 // Root should contain synthesized "a".
233 entries, err := fs.ReadDir(sfs, ".")
234 if err != nil {
235 t.Fatalf("ReadDir(.): %v", err)
236 }
237 if len(entries) != 1 || entries[0].Name() != "a" {
238 t.Fatalf("ReadDir(.): got %v, want [a]", names(entries))
239 }
240 if !entries[0].IsDir() {
241 t.Error("synthesized 'a' should be a directory")
242 }
243
244 // "a" should contain synthesized "b".
245 entries, err = fs.ReadDir(sfs, "a")
246 if err != nil {
247 t.Fatalf("ReadDir(a): %v", err)
248 }
249 if len(entries) != 1 || entries[0].Name() != "b" {
250 t.Fatalf("ReadDir(a): got %v, want [b]", names(entries))
251 }
252
253 // "a/b" should contain the file.
254 entries, err = fs.ReadDir(sfs, "a/b")
255 if err != nil {
256 t.Fatalf("ReadDir(a/b): %v", err)
257 }
258 if len(entries) != 1 || entries[0].Name() != "file.txt" {
259 t.Fatalf("ReadDir(a/b): got %v, want [file.txt]", names(entries))
260 }
261}
262
263func TestSnapshotFSExplicitOverridesSynthesized(t *testing.T) {
264 t.Parallel()
265
266 mtime := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
267 nodes := []LsNode{
268 // Explicit directory with real metadata.
269 {Name: "a", Type: "dir", Path: "/a", Mode: 2147484157, Permissions: "drwxrwxr-x", Mtime: mtime},
270 // File inside it — would cause "a" to be synthesized if the
271 // explicit entry were missing.
272 {Name: "file.txt", Type: "file", Path: "/a/file.txt", Size: 10, Mode: 420, Permissions: "-rw-r--r--", Mtime: mtime},
273 }
274
275 sfs := NewSnapshotFS(nodes)
276 info, err := fs.Stat(sfs, "a")
277 if err != nil {
278 t.Fatalf("Stat(a): %v", err)
279 }
280 if !info.ModTime().Equal(mtime) {
281 t.Errorf("Stat(a).ModTime(): got %v, want %v (explicit metadata should win)", info.ModTime(), mtime)
282 }
283}
284
285func TestSnapshotFSReadDirFileStateful(t *testing.T) {
286 t.Parallel()
287
288 sfs := NewSnapshotFS(testNodes())
289
290 // Open the root as a file and call ReadDir(n) incrementally.
291 f, err := sfs.Open(".")
292 if err != nil {
293 t.Fatalf("Open(.): %v", err)
294 }
295 defer func() { _ = f.Close() }()
296
297 rdf, ok := f.(fs.ReadDirFile)
298 if !ok {
299 t.Fatal("Open(.) should return an fs.ReadDirFile")
300 }
301
302 // Root has 3 entries: docs, music, readme.txt
303 // Read 2, then 2 more (should get 1 + io.EOF).
304 batch1, err := rdf.ReadDir(2)
305 if err != nil {
306 t.Fatalf("ReadDir(2) first call: %v", err)
307 }
308 if len(batch1) != 2 {
309 t.Fatalf("ReadDir(2) first call: got %d entries, want 2", len(batch1))
310 }
311 if batch1[0].Name() != "docs" || batch1[1].Name() != "music" {
312 t.Errorf("ReadDir(2) first call: got [%q, %q], want [docs, music]",
313 batch1[0].Name(), batch1[1].Name())
314 }
315
316 batch2, err := rdf.ReadDir(2)
317 if err != io.EOF {
318 t.Fatalf("ReadDir(2) second call: err=%v, want io.EOF", err)
319 }
320 if len(batch2) != 1 {
321 t.Fatalf("ReadDir(2) second call: got %d entries, want 1", len(batch2))
322 }
323 if batch2[0].Name() != "readme.txt" {
324 t.Errorf("ReadDir(2) second call: got %q, want readme.txt", batch2[0].Name())
325 }
326
327 // A third call should return nil, io.EOF.
328 batch3, err := rdf.ReadDir(1)
329 if err != io.EOF {
330 t.Fatalf("ReadDir(1) third call: err=%v, want io.EOF", err)
331 }
332 if len(batch3) != 0 {
333 t.Errorf("ReadDir(1) third call: got %d entries, want 0", len(batch3))
334 }
335}
336
337func TestSnapshotFSInvalidPath(t *testing.T) {
338 t.Parallel()
339
340 sfs := NewSnapshotFS(testNodes())
341
342 // Leading slash is not a valid fs.FS path.
343 _, err := sfs.Open("/music")
344 if err == nil {
345 t.Error("Open(/music) should fail for invalid path")
346 }
347}
348
349func TestSnapshotFSFromTestRepo(t *testing.T) {
350 t.Parallel()
351
352 repoPath := testdataRepoPath(t)
353 cfg := &config.ResolvedConfig{
354 Command: "restore",
355 Flags: []config.Flag{
356 {Name: "--repo", Value: repoPath},
357 },
358 Environ: map[string]string{
359 "RESTIC_PASSWORD": "test",
360 },
361 }
362
363 nodes, err := RunLs(cfg, "03b061a6")
364 if err != nil {
365 t.Fatalf("RunLs(): %v", err)
366 }
367
368 sfs := NewSnapshotFS(nodes)
369
370 // Root should contain: .config, documents, music, photos, projects
371 entries, err := fs.ReadDir(sfs, ".")
372 if err != nil {
373 t.Fatalf("ReadDir(.): %v", err)
374 }
375 wantRoot := []string{".config", "documents", "music", "photos", "projects"}
376 if len(entries) != len(wantRoot) {
377 t.Fatalf("ReadDir(.) names: got %v, want %v", names(entries), wantRoot)
378 }
379 for i, want := range wantRoot {
380 if entries[i].Name() != want {
381 t.Errorf("ReadDir(.)[%d]: got %q, want %q", i, entries[i].Name(), want)
382 }
383 }
384
385 // Navigate into a Unicode directory.
386 entries, err = fs.ReadDir(sfs, "music/アーティスト/album-01")
387 if err != nil {
388 t.Fatalf("ReadDir(music/アーティスト/album-01): %v", err)
389 }
390 wantAlbum := []string{"01 - 夜明け.flac", "02 - 風の歌.flac", "03 - 星空.flac"}
391 if len(entries) != len(wantAlbum) {
392 t.Fatalf("ReadDir(album-01) names: got %v, want %v", names(entries), wantAlbum)
393 }
394 for i, want := range wantAlbum {
395 if entries[i].Name() != want {
396 t.Errorf("album-01[%d]: got %q, want %q", i, entries[i].Name(), want)
397 }
398 }
399
400 // Stat a file with spaces in its path.
401 info, err := fs.Stat(sfs, "music/Jazz Café/live/02 - So What.flac")
402 if err != nil {
403 t.Fatalf("Stat(So What): %v", err)
404 }
405 if info.IsDir() {
406 t.Error("So What.flac should not be a directory")
407 }
408 if info.Size() == 0 {
409 t.Error("So What.flac should have nonzero size")
410 }
411
412 // Deep path through projects.
413 entries, err = fs.ReadDir(sfs, "projects/keld/internal")
414 if err != nil {
415 t.Fatalf("ReadDir(projects/keld/internal): %v", err)
416 }
417 wantInternal := []string{"config", "restic"}
418 if len(entries) != len(wantInternal) {
419 t.Fatalf("ReadDir(internal) names: got %v, want %v", names(entries), wantInternal)
420 }
421
422 // Missing path.
423 _, err = fs.Stat(sfs, "nonexistent/path")
424 if !errors.Is(err, fs.ErrNotExist) {
425 t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
426 }
427}
428
429// names extracts entry names for test failure messages.
430func names(entries []fs.DirEntry) []string {
431 out := make([]string, len(entries))
432 for i, e := range entries {
433 out[i] = e.Name()
434 }
435 return out
436}