lsnodes_test.go

  1package restic
  2
  3import (
  4	"errors"
  5	"os"
  6	"path/filepath"
  7	"testing"
  8	"time"
  9
 10	"git.secluded.site/keld/internal/config"
 11)
 12
 13// fixtureLsJSON is real output from:
 14//
 15//	keld --preset music-final@b2 ls --json --recursive 8bd729b1 /home/amolith/Music/Final/3nd
 16//
 17// 24 lines: 1 snapshot header + 4 directories + 19 .flac files across 3 album
 18// directories. The snapshot header line has message_type "snapshot" and must be
 19// skipped; all remaining lines have message_type "node".
 20const fixtureLsJSON = `{"time":"2026-03-24T00:20:53.867687549-06:00","parent":"2a1eb755327d446b938ef5a6e991f83f86abf12911f3bc87602fc696a7d1d4af","tree":"011fe38da2e96cb6ea9b2bb8bbc710d5b92ab9f4ea2d91be089bba532d329aae","paths":["/home/amolith/Music/Final"],"hostname":"angmar","username":"amolith","uid":1000,"gid":1000,"program_version":"restic 0.18.1","summary":{"backup_start":"2026-03-24T00:20:53.867687549-06:00","backup_end":"2026-03-24T00:20:58.420948969-06:00","files_new":0,"files_changed":4,"files_unmodified":6010,"dirs_new":0,"dirs_changed":4,"dirs_unmodified":862,"data_blobs":1,"tree_blobs":5,"data_added":89361,"data_added_packed":13072,"total_files_processed":6014,"total_bytes_processed":121289853644},"id":"8bd729b16043cef621423ca2f6100832e8add471374a3a54c555d624b011988c","short_id":"8bd729b1","message_type":"snapshot","struct_type":"snapshot"}
 21{"name":"3nd","type":"dir","path":"/home/amolith/Music/Final/3nd","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2021-04-17T18:01:57.376612-06:00","atime":"2021-04-17T18:01:57.376612-06:00","ctime":"2024-09-26T19:41:45.573886986-06:00","inode":42207275,"message_type":"node","struct_type":"node"}
 22{"name":"World Tour","type":"dir","path":"/home/amolith/Music/Final/3nd/World Tour","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.86776-06:00","atime":"2024-06-10T09:00:35.86776-06:00","ctime":"2024-09-26T19:41:45.563886805-06:00","inode":42207421,"message_type":"node","struct_type":"node"}
 23{"name":"01 - monsoon.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac","uid":1000,"gid":1000,"size":30037820,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:20.840315-07:00","atime":"2021-11-25T17:31:20.840315-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213020,"message_type":"node","struct_type":"node"}
 24{"name":"02 - china.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/02 - china.flac","uid":1000,"gid":1000,"size":17705986,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:22.00033-07:00","atime":"2021-11-25T17:31:22.00033-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213321,"message_type":"node","struct_type":"node"}
 25{"name":"03 - SSK.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/03 - SSK.flac","uid":1000,"gid":1000,"size":32263802,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:22.753673-07:00","atime":"2021-11-25T17:31:22.753673-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213685,"message_type":"node","struct_type":"node"}
 26{"name":"04 - midroll.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/04 - midroll.flac","uid":1000,"gid":1000,"size":37431281,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:23.643684-07:00","atime":"2021-11-25T17:31:23.643684-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213686,"message_type":"node","struct_type":"node"}
 27{"name":"05 - LOTUS.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/05 - LOTUS.flac","uid":1000,"gid":1000,"size":33509064,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.157024-07:00","atime":"2021-11-25T17:31:24.157024-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213687,"message_type":"node","struct_type":"node"}
 28{"name":"06 - 眠る.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/06 - 眠る.flac","uid":1000,"gid":1000,"size":21319060,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.383693-07:00","atime":"2021-11-25T17:31:24.383693-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213249,"message_type":"node","struct_type":"node"}
 29{"name":"07 - filter in dust.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/07 - filter in dust.flac","uid":1000,"gid":1000,"size":21849635,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.613696-07:00","atime":"2021-11-25T17:31:24.613696-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213250,"message_type":"node","struct_type":"node"}
 30{"name":"08 - 夏終る.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/08 - 夏終る.flac","uid":1000,"gid":1000,"size":28061640,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.817032-07:00","atime":"2021-11-25T17:31:24.817032-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212532,"message_type":"node","struct_type":"node"}
 31{"name":"view from here","type":"dir","path":"/home/amolith/Music/Final/3nd/view from here","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.874427-06:00","atime":"2024-06-10T09:00:35.874427-06:00","ctime":"2024-09-26T19:41:45.570553593-06:00","inode":42207422,"message_type":"node","struct_type":"node"}
 32{"name":"01 - clockworker.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/01 - clockworker.flac","uid":1000,"gid":1000,"size":38342556,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:12.526878-07:00","atime":"2021-11-25T17:31:12.526878-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42214320,"message_type":"node","struct_type":"node"}
 33{"name":"02 - augustline.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/02 - augustline.flac","uid":1000,"gid":1000,"size":27357365,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:13.413555-07:00","atime":"2021-11-25T17:31:13.413555-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212371,"message_type":"node","struct_type":"node"}
 34{"name":"03 - season.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/03 - season.flac","uid":1000,"gid":1000,"size":26918886,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:14.256899-07:00","atime":"2021-11-25T17:31:14.256899-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212245,"message_type":"node","struct_type":"node"}
 35{"name":"04 - ハルオト.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/04 - ハルオト.flac","uid":1000,"gid":1000,"size":45722382,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:15.253578-07:00","atime":"2021-11-25T17:31:15.253578-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212533,"message_type":"node","struct_type":"node"}
 36{"name":"05 - walk in the brown.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/05 - walk in the brown.flac","uid":1000,"gid":1000,"size":38267170,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:16.300258-07:00","atime":"2021-11-25T17:31:16.300258-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42214321,"message_type":"node","struct_type":"node"}
 37{"name":"we dance × avec toi","type":"dir","path":"/home/amolith/Music/Final/3nd/we dance × avec toi","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.87776-06:00","atime":"2024-06-10T09:00:35.87776-06:00","ctime":"2024-09-26T19:41:45.573886986-06:00","inode":42207423,"message_type":"node","struct_type":"node"}
 38{"name":"01 - Bender.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/01 - Bender.flac","uid":1000,"gid":1000,"size":24044464,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:17.563608-07:00","atime":"2021-11-25T17:31:17.563608-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211884,"message_type":"node","struct_type":"node"}
 39{"name":"02 - テレプシコラー.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/02 - テレプシコラー.flac","uid":1000,"gid":1000,"size":29003735,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:18.106948-07:00","atime":"2021-11-25T17:31:18.106948-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211885,"message_type":"node","struct_type":"node"}
 40{"name":"03 - コロコロ転がる.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/03 - コロコロ転がる.flac","uid":1000,"gid":1000,"size":27127530,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:18.643621-07:00","atime":"2021-11-25T17:31:18.643621-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212285,"message_type":"node","struct_type":"node"}
 41{"name":"04 - algorhythm.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/04 - algorhythm.flac","uid":1000,"gid":1000,"size":36728278,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.070293-07:00","atime":"2021-11-25T17:31:19.070293-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212286,"message_type":"node","struct_type":"node"}
 42{"name":"05 - Rain Song.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/05 - Rain Song.flac","uid":1000,"gid":1000,"size":27059422,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.540299-07:00","atime":"2021-11-25T17:31:19.540299-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212287,"message_type":"node","struct_type":"node"}
 43{"name":"06 - untroubled terror.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/06 - untroubled terror.flac","uid":1000,"gid":1000,"size":6965876,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.853636-07:00","atime":"2021-11-25T17:31:19.853636-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211886,"message_type":"node","struct_type":"node"}
 44`
 45
 46func TestParseLsNodes(t *testing.T) {
 47	t.Parallel()
 48
 49	nodes, err := ParseLsNodes([]byte(fixtureLsJSON))
 50	if err != nil {
 51		t.Fatalf("ParseLsNodes(): unexpected error: %v", err)
 52	}
 53
 54	// 23 node lines (4 dirs + 19 files), snapshot header skipped.
 55	if got := len(nodes); got != 23 {
 56		t.Fatalf("len(nodes): got %d, want 23", got)
 57	}
 58
 59	// First node should be the root dir of the listing.
 60	first := nodes[0]
 61	if first.Name != "3nd" {
 62		t.Errorf("first node name: got %q, want %q", first.Name, "3nd")
 63	}
 64	if first.Type != "dir" {
 65		t.Errorf("first node type: got %q, want %q", first.Type, "dir")
 66	}
 67	if first.Path != "/home/amolith/Music/Final/3nd" {
 68		t.Errorf("first node path: got %q, want %q", first.Path, "/home/amolith/Music/Final/3nd")
 69	}
 70	if first.Size != 0 {
 71		t.Errorf("first node size: got %d, want 0 (dirs have no size)", first.Size)
 72	}
 73	if first.Mode != 2147484157 {
 74		t.Errorf("first node mode: got %d, want %d", first.Mode, 2147484157)
 75	}
 76	if first.Permissions != "drwxrwxr-x" {
 77		t.Errorf("first node permissions: got %q, want %q", first.Permissions, "drwxrwxr-x")
 78	}
 79	wantMtime, _ := time.Parse(time.RFC3339Nano, "2021-04-17T18:01:57.376612-06:00")
 80	if !first.Mtime.Equal(wantMtime) {
 81		t.Errorf("first node mtime: got %v, want %v", first.Mtime, wantMtime)
 82	}
 83
 84	// Spot-check a file node: first .flac in "World Tour".
 85	monsoon := nodes[2]
 86	if monsoon.Name != "01 - monsoon.flac" {
 87		t.Errorf("monsoon name: got %q, want %q", monsoon.Name, "01 - monsoon.flac")
 88	}
 89	if monsoon.Type != "file" {
 90		t.Errorf("monsoon type: got %q, want %q", monsoon.Type, "file")
 91	}
 92	if monsoon.Path != "/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac" {
 93		t.Errorf("monsoon path: got %q, want %q",
 94			monsoon.Path, "/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac")
 95	}
 96	if monsoon.Size != 30037820 {
 97		t.Errorf("monsoon size: got %d, want %d", monsoon.Size, 30037820)
 98	}
 99	if monsoon.Mode != 509 {
100		t.Errorf("monsoon mode: got %d, want %d", monsoon.Mode, 509)
101	}
102	if monsoon.Permissions != "-rwxrwxr-x" {
103		t.Errorf("monsoon permissions: got %q, want %q", monsoon.Permissions, "-rwxrwxr-x")
104	}
105
106	// Spot-check a file with Unicode in the name.
107	nemuru := nodes[7]
108	if nemuru.Name != "06 - 眠る.flac" {
109		t.Errorf("nemuru name: got %q, want %q", nemuru.Name, "06 - 眠る.flac")
110	}
111	if nemuru.Size != 21319060 {
112		t.Errorf("nemuru size: got %d, want %d", nemuru.Size, 21319060)
113	}
114
115	// Count dirs vs files.
116	var dirs, files int
117	for _, n := range nodes {
118		switch n.Type {
119		case "dir":
120			dirs++
121		case "file":
122			files++
123		default:
124			t.Errorf("unexpected node type %q for %q", n.Type, n.Path)
125		}
126	}
127	if dirs != 4 {
128		t.Errorf("dir count: got %d, want 4", dirs)
129	}
130	if files != 19 {
131		t.Errorf("file count: got %d, want 19", files)
132	}
133}
134
135func TestParseLsNodesEmpty(t *testing.T) {
136	t.Parallel()
137
138	nodes, err := ParseLsNodes([]byte(""))
139	if err != nil {
140		t.Fatalf("ParseLsNodes(empty): unexpected error: %v", err)
141	}
142	if len(nodes) != 0 {
143		t.Errorf("expected 0 nodes from empty input, got %d", len(nodes))
144	}
145}
146
147func TestParseLsNodesSnapshotOnly(t *testing.T) {
148	t.Parallel()
149
150	// A snapshot header with no node lines — e.g. an empty snapshot or a
151	// path filter that matched nothing.
152	input := `{"time":"2026-03-24T00:20:53.867687549-06:00","paths":["/home/amolith/Music/Final"],"hostname":"angmar","id":"8bd729b1","short_id":"8bd729b1","message_type":"snapshot","struct_type":"snapshot"}
153`
154
155	nodes, err := ParseLsNodes([]byte(input))
156	if err != nil {
157		t.Fatalf("ParseLsNodes(snapshot-only): unexpected error: %v", err)
158	}
159	if len(nodes) != 0 {
160		t.Errorf("expected 0 nodes from snapshot-only input, got %d", len(nodes))
161	}
162}
163
164func TestBuildLsCmd(t *testing.T) {
165	t.Parallel()
166
167	tests := []struct {
168		name     string
169		cfg      *config.ResolvedConfig
170		snapshot string
171		wantArgv []string
172		wantErr  bool
173	}{
174		{
175			name: "basic repo flag",
176			cfg: &config.ResolvedConfig{
177				Command: "restore",
178				Flags: []config.Flag{
179					{Name: "--repo", Value: "/srv/backup"},
180					{Name: "--target", Value: "/tmp/restore"},
181				},
182			},
183			snapshot: "8bd729b1",
184			wantArgv: []string{"restic", "ls", "--json", "--repo", "/srv/backup", "8bd729b1"},
185		},
186		{
187			name: "repo with password-file and non-global flags stripped",
188			cfg: &config.ResolvedConfig{
189				Command: "restore",
190				Flags: []config.Flag{
191					{Name: "--repo", Value: "rclone:remote:/backup"},
192					{Name: "--password-file", Value: "/etc/restic/pw"},
193					{Name: "--target", Value: "/tmp/restore"},
194					{Name: "--overwrite", Value: "if-changed"},
195				},
196			},
197			snapshot: "latest",
198			wantArgv: []string{
199				"restic", "ls", "--json",
200				"--repo", "rclone:remote:/backup",
201				"--password-file", "/etc/restic/pw",
202				"latest",
203			},
204		},
205		{
206			name: "cache-dir and no-lock forwarded",
207			cfg: &config.ResolvedConfig{
208				Command: "restore",
209				Flags: []config.Flag{
210					{Name: "--repo", Value: "/srv/backup"},
211					{Name: "--cache-dir", Value: "/tmp/cache"},
212					{Name: "--no-lock"},
213					{Name: "--target", Value: "/tmp/restore"},
214				},
215			},
216			snapshot: "abc123",
217			wantArgv: []string{
218				"restic", "ls", "--json",
219				"--repo", "/srv/backup",
220				"--cache-dir", "/tmp/cache",
221				"--no-lock",
222				"abc123",
223			},
224		},
225		{
226			name: "snapshot selectors forwarded for latest",
227			cfg: &config.ResolvedConfig{
228				Command: "restore",
229				Flags: []config.Flag{
230					{Name: "--repo", Value: "/srv/backup"},
231					{Name: "--host", Value: "angmar"},
232					{Name: "--path", Value: "/home/amolith/Music"},
233					{Name: "--tag", Value: "daily"},
234					{Name: "--target", Value: "/tmp/restore"},
235				},
236			},
237			snapshot: "latest",
238			wantArgv: []string{
239				"restic", "ls", "--json",
240				"--repo", "/srv/backup",
241				"--host", "angmar",
242				"--path", "/home/amolith/Music",
243				"--tag", "daily",
244				"latest",
245			},
246		},
247		{
248			name: "short host flag forwarded",
249			cfg: &config.ResolvedConfig{
250				Command: "restore",
251				Flags: []config.Flag{
252					{Name: "--repo", Value: "/srv/backup"},
253					{Name: "-H", Value: "angmar"},
254				},
255			},
256			snapshot: "latest",
257			wantArgv: []string{
258				"restic", "ls", "--json",
259				"--repo", "/srv/backup",
260				"-H", "angmar",
261				"latest",
262			},
263		},
264		{
265			name: "repo via environ only",
266			cfg: &config.ResolvedConfig{
267				Command: "restore",
268				Flags: []config.Flag{
269					{Name: "--target", Value: "/tmp/restore"},
270				},
271				Environ: map[string]string{
272					"RESTIC_REPOSITORY": "/srv/backup",
273				},
274			},
275			snapshot: "8bd729b1",
276			wantArgv: []string{"restic", "ls", "--json", "8bd729b1"},
277		},
278	}
279
280	for _, tt := range tests {
281		tt := tt
282		t.Run(tt.name, func(t *testing.T) {
283			t.Parallel()
284
285			argv, err := buildLsCmd(tt.cfg, tt.snapshot)
286			if (err != nil) != tt.wantErr {
287				t.Fatalf("buildLsCmd(): err=%v, wantErr=%v", err, tt.wantErr)
288			}
289			if err != nil {
290				return
291			}
292
293			if len(argv) != len(tt.wantArgv) {
294				t.Fatalf("argv length: got %d %v, want %d %v",
295					len(argv), argv, len(tt.wantArgv), tt.wantArgv)
296			}
297			for i := range argv {
298				if argv[i] != tt.wantArgv[i] {
299					t.Errorf("argv[%d]: got %q, want %q", i, argv[i], tt.wantArgv[i])
300				}
301			}
302		})
303	}
304}
305
306func TestBuildLsCmdNoRepo(t *testing.T) {
307	t.Setenv("RESTIC_REPOSITORY", "")
308	t.Setenv("RESTIC_REPOSITORY_FILE", "")
309
310	cfg := &config.ResolvedConfig{
311		Command: "restore",
312		Flags: []config.Flag{
313			{Name: "--target", Value: "/tmp/restore"},
314		},
315	}
316
317	_, err := buildLsCmd(cfg, "8bd729b1")
318	if !errors.Is(err, ErrNoRepo) {
319		t.Errorf("expected ErrNoRepo, got %v", err)
320	}
321}
322
323// testdataRepoPath returns the absolute path to the committed test
324// restic repository. Tests using this fixture require restic to be
325// installed.
326func testdataRepoPath(t *testing.T) string {
327	t.Helper()
328	// Discover the repo relative to this test file's package dir.
329	path, err := filepath.Abs("testdata/repo")
330	if err != nil {
331		t.Fatalf("resolving testdata/repo: %v", err)
332	}
333	if _, err := os.Stat(path); err != nil {
334		t.Fatalf("testdata/repo not found at %s: %v", path, err)
335	}
336	return path
337}
338
339func TestRunLs(t *testing.T) {
340	t.Parallel()
341
342	repoPath := testdataRepoPath(t)
343	cfg := &config.ResolvedConfig{
344		Command: "restore",
345		Flags: []config.Flag{
346			{Name: "--repo", Value: repoPath},
347		},
348		Environ: map[string]string{
349			"RESTIC_PASSWORD": "test",
350		},
351	}
352
353	nodes, err := RunLs(cfg, "03b061a6")
354	if err != nil {
355		t.Fatalf("RunLs(): unexpected error: %v", err)
356	}
357
358	// The test repo has 29 files + 26 dirs = 55 nodes.
359	if got := len(nodes); got != 55 {
360		t.Fatalf("len(nodes): got %d, want 55", got)
361	}
362
363	// Count types.
364	var dirs, files int
365	for _, n := range nodes {
366		switch n.Type {
367		case "dir":
368			dirs++
369		case "file":
370			files++
371		default:
372			t.Errorf("unexpected node type %q for %q", n.Type, n.Path)
373		}
374	}
375	if dirs != 26 {
376		t.Errorf("dir count: got %d, want 26", dirs)
377	}
378	if files != 29 {
379		t.Errorf("file count: got %d, want 29", files)
380	}
381
382	// Spot-check a Unicode path.
383	var found bool
384	for _, n := range nodes {
385		if n.Name == "01 - 夜明け.flac" {
386			found = true
387			if n.Type != "file" {
388				t.Errorf("夜明け type: got %q, want %q", n.Type, "file")
389			}
390			break
391		}
392	}
393	if !found {
394		t.Error("expected to find node '01 - 夜明け.flac'")
395	}
396
397	// Spot-check a space-in-path entry.
398	found = false
399	for _, n := range nodes {
400		if n.Name == "Jazz Café" {
401			found = true
402			if n.Type != "dir" {
403				t.Errorf("Jazz Café type: got %q, want %q", n.Type, "dir")
404			}
405			break
406		}
407	}
408	if !found {
409		t.Error("expected to find node 'Jazz Café'")
410	}
411}