snapshotfs_test.go

  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}