1// Copyright 2020 The Go Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style 3// license that can be found in the LICENSE file. 4 5// Package fstest implements support for testing implementations and users of file systems. 6package fstest 7 8import ( 9 "errors" 10 "fmt" 11 "io" 12 "io/fs" 13 "path" 14 "reflect" 15 "slices" 16 "strings" 17 "testing/iotest" 18) 19 20// TestFS tests a file system implementation. 21// It walks the entire tree of files in fsys, 22// opening and checking that each file behaves correctly. 23// It also checks that the file system contains at least the expected files. 24// As a special case, if no expected files are listed, fsys must be empty. 25// Otherwise, fsys must contain at least the listed files; it can also contain others. 26// The contents of fsys must not change concurrently with TestFS. 27// 28// If TestFS finds any misbehaviors, it returns either the first error or a 29// list of errors. Use [errors.Is] or [errors.As] to inspect. 30// 31// Typical usage inside a test is: 32// 33// if err := fstest.TestFS(myFS, "file/that/should/be/present"); err != nil { 34// t.Fatal(err) 35// } 36func TestFS(fsys fs.FS, expected ...string) error { 37 if err := testFS(fsys, expected...); err != nil { 38 return err 39 } 40 for _, name := range expected { 41 if i := strings.Index(name, "/"); i >= 0 { 42 dir, dirSlash := name[:i], name[:i+1] 43 var subExpected []string 44 for _, name := range expected { 45 if strings.HasPrefix(name, dirSlash) { 46 subExpected = append(subExpected, name[len(dirSlash):]) 47 } 48 } 49 sub, err := fs.Sub(fsys, dir) 50 if err != nil { 51 return err 52 } 53 if err := testFS(sub, subExpected...); err != nil { 54 return fmt.Errorf("testing fs.Sub(fsys, %s): %w", dir, err) 55 } 56 break // one sub-test is enough 57 } 58 } 59 return nil 60} 61 62func testFS(fsys fs.FS, expected ...string) error { 63 t := fsTester{fsys: fsys} 64 t.checkDir(".") 65 t.checkOpen(".") 66 found := make(map[string]bool) 67 for _, dir := range t.dirs { 68 found[dir] = true 69 } 70 for _, file := range t.files { 71 found[file] = true 72 } 73 delete(found, ".") 74 if len(expected) == 0 && len(found) > 0 { 75 var list []string 76 for k := range found { 77 if k != "." { 78 list = append(list, k) 79 } 80 } 81 slices.Sort(list) 82 if len(list) > 15 { 83 list = append(list[:10], "...") 84 } 85 t.errorf("expected empty file system but found files:\n%s", strings.Join(list, "\n")) 86 } 87 for _, name := range expected { 88 if !found[name] { 89 t.errorf("expected but not found: %s", name) 90 } 91 } 92 if len(t.errors) == 0 { 93 return nil 94 } 95 return fmt.Errorf("TestFS found errors:\n%w", errors.Join(t.errors...)) 96} 97 98// An fsTester holds state for running the test. 99type fsTester struct { 100 fsys fs.FS 101 errors []error 102 dirs []string 103 files []string 104} 105 106// errorf adds an error to the list of errors. 107func (t *fsTester) errorf(format string, args ...any) { 108 t.errors = append(t.errors, fmt.Errorf(format, args...)) 109} 110 111func (t *fsTester) openDir(dir string) fs.ReadDirFile { 112 f, err := t.fsys.Open(dir) 113 if err != nil { 114 t.errorf("%s: Open: %w", dir, err) 115 return nil 116 } 117 d, ok := f.(fs.ReadDirFile) 118 if !ok { 119 f.Close() 120 t.errorf("%s: Open returned File type %T, not a fs.ReadDirFile", dir, f) 121 return nil 122 } 123 return d 124} 125 126// checkDir checks the directory dir, which is expected to exist 127// (it is either the root or was found in a directory listing with IsDir true). 128func (t *fsTester) checkDir(dir string) { 129 // Read entire directory. 130 t.dirs = append(t.dirs, dir) 131 d := t.openDir(dir) 132 if d == nil { 133 return 134 } 135 list, err := d.ReadDir(-1) 136 if err != nil { 137 d.Close() 138 t.errorf("%s: ReadDir(-1): %w", dir, err) 139 return 140 } 141 142 // Check all children. 143 var prefix string 144 if dir == "." { 145 prefix = "" 146 } else { 147 prefix = dir + "/" 148 } 149 for _, info := range list { 150 name := info.Name() 151 switch { 152 case name == ".", name == "..", name == "": 153 t.errorf("%s: ReadDir: child has invalid name: %#q", dir, name) 154 continue 155 case strings.Contains(name, "/"): 156 t.errorf("%s: ReadDir: child name contains slash: %#q", dir, name) 157 continue 158 case strings.Contains(name, `\`): 159 t.errorf("%s: ReadDir: child name contains backslash: %#q", dir, name) 160 continue 161 } 162 path := prefix + name 163 t.checkStat(path, info) 164 t.checkOpen(path) 165 if info.IsDir() { 166 t.checkDir(path) 167 } else { 168 t.checkFile(path) 169 } 170 } 171 172 // Check ReadDir(-1) at EOF. 173 list2, err := d.ReadDir(-1) 174 if len(list2) > 0 || err != nil { 175 d.Close() 176 t.errorf("%s: ReadDir(-1) at EOF = %d entries, %w, wanted 0 entries, nil", dir, len(list2), err) 177 return 178 } 179 180 // Check ReadDir(1) at EOF (different results). 181 list2, err = d.ReadDir(1) 182 if len(list2) > 0 || err != io.EOF { 183 d.Close() 184 t.errorf("%s: ReadDir(1) at EOF = %d entries, %w, wanted 0 entries, EOF", dir, len(list2), err) 185 return 186 } 187 188 // Check that close does not report an error. 189 if err := d.Close(); err != nil { 190 t.errorf("%s: Close: %w", dir, err) 191 } 192 193 // Check that closing twice doesn't crash. 194 // The return value doesn't matter. 195 d.Close() 196 197 // Reopen directory, read a second time, make sure contents match. 198 if d = t.openDir(dir); d == nil { 199 return 200 } 201 defer d.Close() 202 list2, err = d.ReadDir(-1) 203 if err != nil { 204 t.errorf("%s: second Open+ReadDir(-1): %w", dir, err) 205 return 206 } 207 t.checkDirList(dir, "first Open+ReadDir(-1) vs second Open+ReadDir(-1)", list, list2) 208 209 // Reopen directory, read a third time in pieces, make sure contents match. 210 if d = t.openDir(dir); d == nil { 211 return 212 } 213 defer d.Close() 214 list2 = nil 215 for { 216 n := 1 217 if len(list2) > 0 { 218 n = 2 219 } 220 frag, err := d.ReadDir(n) 221 if len(frag) > n { 222 t.errorf("%s: third Open: ReadDir(%d) after %d: %d entries (too many)", dir, n, len(list2), len(frag)) 223 return 224 } 225 list2 = append(list2, frag...) 226 if err == io.EOF { 227 break 228 } 229 if err != nil { 230 t.errorf("%s: third Open: ReadDir(%d) after %d: %w", dir, n, len(list2), err) 231 return 232 } 233 if n == 0 { 234 t.errorf("%s: third Open: ReadDir(%d) after %d: 0 entries but nil error", dir, n, len(list2)) 235 return 236 } 237 } 238 t.checkDirList(dir, "first Open+ReadDir(-1) vs third Open+ReadDir(1,2) loop", list, list2) 239 240 // If fsys has ReadDir, check that it matches and is sorted. 241 if fsys, ok := t.fsys.(fs.ReadDirFS); ok { 242 list2, err := fsys.ReadDir(dir) 243 if err != nil { 244 t.errorf("%s: fsys.ReadDir: %w", dir, err) 245 return 246 } 247 t.checkDirList(dir, "first Open+ReadDir(-1) vs fsys.ReadDir", list, list2) 248 249 for i := 0; i+1 < len(list2); i++ { 250 if list2[i].Name() >= list2[i+1].Name() { 251 t.errorf("%s: fsys.ReadDir: list not sorted: %s before %s", dir, list2[i].Name(), list2[i+1].Name()) 252 } 253 } 254 } 255 256 // Check fs.ReadDir as well. 257 list2, err = fs.ReadDir(t.fsys, dir) 258 if err != nil { 259 t.errorf("%s: fs.ReadDir: %w", dir, err) 260 return 261 } 262 t.checkDirList(dir, "first Open+ReadDir(-1) vs fs.ReadDir", list, list2) 263 264 for i := 0; i+1 < len(list2); i++ { 265 if list2[i].Name() >= list2[i+1].Name() { 266 t.errorf("%s: fs.ReadDir: list not sorted: %s before %s", dir, list2[i].Name(), list2[i+1].Name()) 267 } 268 } 269 270 t.checkGlob(dir, list2) 271} 272 273// formatEntry formats an fs.DirEntry into a string for error messages and comparison. 274func formatEntry(entry fs.DirEntry) string { 275 return fmt.Sprintf("%s IsDir=%v Type=%v", entry.Name(), entry.IsDir(), entry.Type()) 276} 277 278// formatInfoEntry formats an fs.FileInfo into a string like the result of formatEntry, for error messages and comparison. 279func formatInfoEntry(info fs.FileInfo) string { 280 return fmt.Sprintf("%s IsDir=%v Type=%v", info.Name(), info.IsDir(), info.Mode().Type()) 281} 282 283// formatInfo formats an fs.FileInfo into a string for error messages and comparison. 284func formatInfo(info fs.FileInfo) string { 285 return fmt.Sprintf("%s IsDir=%v Mode=%v Size=%d ModTime=%v", info.Name(), info.IsDir(), info.Mode(), info.Size(), info.ModTime()) 286} 287 288// checkGlob checks that various glob patterns work if the file system implements GlobFS. 289func (t *fsTester) checkGlob(dir string, list []fs.DirEntry) { 290 if _, ok := t.fsys.(fs.GlobFS); !ok { 291 return 292 } 293 294 // Make a complex glob pattern prefix that only matches dir. 295 var glob string 296 if dir != "." { 297 elem := strings.Split(dir, "/") 298 for i, e := range elem { 299 var pattern []rune 300 for j, r := range e { 301 if r == '*' || r == '?' || r == '\\' || r == '[' || r == '-' { 302 pattern = append(pattern, '\\', r) 303 continue 304 } 305 switch (i + j) % 5 { 306 case 0: 307 pattern = append(pattern, r) 308 case 1: 309 pattern = append(pattern, '[', r, ']') 310 case 2: 311 pattern = append(pattern, '[', r, '-', r, ']') 312 case 3: 313 pattern = append(pattern, '[', '\\', r, ']') 314 case 4: 315 pattern = append(pattern, '[', '\\', r, '-', '\\', r, ']') 316 } 317 } 318 elem[i] = string(pattern) 319 } 320 glob = strings.Join(elem, "/") + "/" 321 } 322 323 // Test that malformed patterns are detected. 324 // The error is likely path.ErrBadPattern but need not be. 325 if _, err := t.fsys.(fs.GlobFS).Glob(glob + "nonexist/[]"); err == nil { 326 t.errorf("%s: Glob(%#q): bad pattern not detected", dir, glob+"nonexist/[]") 327 } 328 329 // Try to find a letter that appears in only some of the final names. 330 c := rune('a') 331 for ; c <= 'z'; c++ { 332 have, haveNot := false, false 333 for _, d := range list { 334 if strings.ContainsRune(d.Name(), c) { 335 have = true 336 } else { 337 haveNot = true 338 } 339 } 340 if have && haveNot { 341 break 342 } 343 } 344 if c > 'z' { 345 c = 'a' 346 } 347 glob += "*" + string(c) + "*" 348 349 var want []string 350 for _, d := range list { 351 if strings.ContainsRune(d.Name(), c) { 352 want = append(want, path.Join(dir, d.Name())) 353 } 354 } 355 356 names, err := t.fsys.(fs.GlobFS).Glob(glob) 357 if err != nil { 358 t.errorf("%s: Glob(%#q): %w", dir, glob, err) 359 return 360 } 361 if reflect.DeepEqual(want, names) { 362 return 363 } 364 365 if !slices.IsSorted(names) { 366 t.errorf("%s: Glob(%#q): unsorted output:\n%s", dir, glob, strings.Join(names, "\n")) 367 slices.Sort(names) 368 } 369 370 var problems []string 371 for len(want) > 0 || len(names) > 0 { 372 switch { 373 case len(want) > 0 && len(names) > 0 && want[0] == names[0]: 374 want, names = want[1:], names[1:] 375 case len(want) > 0 && (len(names) == 0 || want[0] < names[0]): 376 problems = append(problems, "missing: "+want[0]) 377 want = want[1:] 378 default: 379 problems = append(problems, "extra: "+names[0]) 380 names = names[1:] 381 } 382 } 383 t.errorf("%s: Glob(%#q): wrong output:\n%s", dir, glob, strings.Join(problems, "\n")) 384} 385 386// checkStat checks that a direct stat of path matches entry, 387// which was found in the parent's directory listing. 388func (t *fsTester) checkStat(path string, entry fs.DirEntry) { 389 file, err := t.fsys.Open(path) 390 if err != nil { 391 t.errorf("%s: Open: %w", path, err) 392 return 393 } 394 info, err := file.Stat() 395 file.Close() 396 if err != nil { 397 t.errorf("%s: Stat: %w", path, err) 398 return 399 } 400 fentry := formatEntry(entry) 401 fientry := formatInfoEntry(info) 402 // Note: mismatch here is OK for symlink, because Open dereferences symlink. 403 if fentry != fientry && entry.Type()&fs.ModeSymlink == 0 { 404 t.errorf("%s: mismatch:\n\tentry = %s\n\tfile.Stat() = %s", path, fentry, fientry) 405 } 406 407 einfo, err := entry.Info() 408 if err != nil { 409 t.errorf("%s: entry.Info: %w", path, err) 410 return 411 } 412 finfo := formatInfo(info) 413 if entry.Type()&fs.ModeSymlink != 0 { 414 // For symlink, just check that entry.Info matches entry on common fields. 415 // Open deferences symlink, so info itself may differ. 416 feentry := formatInfoEntry(einfo) 417 if fentry != feentry { 418 t.errorf("%s: mismatch\n\tentry = %s\n\tentry.Info() = %s\n", path, fentry, feentry) 419 } 420 } else { 421 feinfo := formatInfo(einfo) 422 if feinfo != finfo { 423 t.errorf("%s: mismatch:\n\tentry.Info() = %s\n\tfile.Stat() = %s\n", path, feinfo, finfo) 424 } 425 } 426 427 // Stat should be the same as Open+Stat, even for symlinks. 428 info2, err := fs.Stat(t.fsys, path) 429 if err != nil { 430 t.errorf("%s: fs.Stat: %w", path, err) 431 return 432 } 433 finfo2 := formatInfo(info2) 434 if finfo2 != finfo { 435 t.errorf("%s: fs.Stat(...) = %s\n\twant %s", path, finfo2, finfo) 436 } 437 438 if fsys, ok := t.fsys.(fs.StatFS); ok { 439 info2, err := fsys.Stat(path) 440 if err != nil { 441 t.errorf("%s: fsys.Stat: %w", path, err) 442 return 443 } 444 finfo2 := formatInfo(info2) 445 if finfo2 != finfo { 446 t.errorf("%s: fsys.Stat(...) = %s\n\twant %s", path, finfo2, finfo) 447 } 448 } 449} 450 451// checkDirList checks that two directory lists contain the same files and file info. 452// The order of the lists need not match. 453func (t *fsTester) checkDirList(dir, desc string, list1, list2 []fs.DirEntry) { 454 old := make(map[string]fs.DirEntry) 455 checkMode := func(entry fs.DirEntry) { 456 if entry.IsDir() != (entry.Type()&fs.ModeDir != 0) { 457 if entry.IsDir() { 458 t.errorf("%s: ReadDir returned %s with IsDir() = true, Type() & ModeDir = 0", dir, entry.Name()) 459 } else { 460 t.errorf("%s: ReadDir returned %s with IsDir() = false, Type() & ModeDir = ModeDir", dir, entry.Name()) 461 } 462 } 463 } 464 465 for _, entry1 := range list1 { 466 old[entry1.Name()] = entry1 467 checkMode(entry1) 468 } 469 470 var diffs []string 471 for _, entry2 := range list2 { 472 entry1 := old[entry2.Name()] 473 if entry1 == nil { 474 checkMode(entry2) 475 diffs = append(diffs, "+ "+formatEntry(entry2)) 476 continue 477 } 478 if formatEntry(entry1) != formatEntry(entry2) { 479 diffs = append(diffs, "- "+formatEntry(entry1), "+ "+formatEntry(entry2)) 480 } 481 delete(old, entry2.Name()) 482 } 483 for _, entry1 := range old { 484 diffs = append(diffs, "- "+formatEntry(entry1)) 485 } 486 487 if len(diffs) == 0 { 488 return 489 } 490 491 slices.SortFunc(diffs, func(a, b string) int { 492 fa := strings.Fields(a) 493 fb := strings.Fields(b) 494 // sort by name (i < j) and then +/- (j < i, because + < -) 495 return strings.Compare(fa[1]+" "+fb[0], fb[1]+" "+fa[0]) 496 }) 497 498 t.errorf("%s: diff %s:\n\t%s", dir, desc, strings.Join(diffs, "\n\t")) 499} 500 501// checkFile checks that basic file reading works correctly. 502func (t *fsTester) checkFile(file string) { 503 t.files = append(t.files, file) 504 505 // Read entire file. 506 f, err := t.fsys.Open(file) 507 if err != nil { 508 t.errorf("%s: Open: %w", file, err) 509 return 510 } 511 512 data, err := io.ReadAll(f) 513 if err != nil { 514 f.Close() 515 t.errorf("%s: Open+ReadAll: %w", file, err) 516 return 517 } 518 519 if err := f.Close(); err != nil { 520 t.errorf("%s: Close: %w", file, err) 521 } 522 523 // Check that closing twice doesn't crash. 524 // The return value doesn't matter. 525 f.Close() 526 527 // Check that ReadFile works if present. 528 if fsys, ok := t.fsys.(fs.ReadFileFS); ok { 529 data2, err := fsys.ReadFile(file) 530 if err != nil { 531 t.errorf("%s: fsys.ReadFile: %w", file, err) 532 return 533 } 534 t.checkFileRead(file, "ReadAll vs fsys.ReadFile", data, data2) 535 536 // Modify the data and check it again. Modifying the 537 // returned byte slice should not affect the next call. 538 for i := range data2 { 539 data2[i]++ 540 } 541 data2, err = fsys.ReadFile(file) 542 if err != nil { 543 t.errorf("%s: second call to fsys.ReadFile: %w", file, err) 544 return 545 } 546 t.checkFileRead(file, "Readall vs second fsys.ReadFile", data, data2) 547 548 t.checkBadPath(file, "ReadFile", 549 func(name string) error { _, err := fsys.ReadFile(name); return err }) 550 } 551 552 // Check that fs.ReadFile works with t.fsys. 553 data2, err := fs.ReadFile(t.fsys, file) 554 if err != nil { 555 t.errorf("%s: fs.ReadFile: %w", file, err) 556 return 557 } 558 t.checkFileRead(file, "ReadAll vs fs.ReadFile", data, data2) 559 560 // Use iotest.TestReader to check small reads, Seek, ReadAt. 561 f, err = t.fsys.Open(file) 562 if err != nil { 563 t.errorf("%s: second Open: %w", file, err) 564 return 565 } 566 defer f.Close() 567 if err := iotest.TestReader(f, data); err != nil { 568 t.errorf("%s: failed TestReader:\n\t%s", file, strings.ReplaceAll(err.Error(), "\n", "\n\t")) 569 } 570} 571 572func (t *fsTester) checkFileRead(file, desc string, data1, data2 []byte) { 573 if string(data1) != string(data2) { 574 t.errorf("%s: %s: different data returned\n\t%q\n\t%q", file, desc, data1, data2) 575 return 576 } 577} 578 579// checkBadPath checks that various invalid forms of file's name cannot be opened using t.fsys.Open. 580func (t *fsTester) checkOpen(file string) { 581 t.checkBadPath(file, "Open", func(file string) error { 582 f, err := t.fsys.Open(file) 583 if err == nil { 584 f.Close() 585 } 586 return err 587 }) 588} 589 590// checkBadPath checks that various invalid forms of file's name cannot be opened using open. 591func (t *fsTester) checkBadPath(file string, desc string, open func(string) error) { 592 bad := []string{ 593 "/" + file, 594 file + "/.", 595 } 596 if file == "." { 597 bad = append(bad, "/") 598 } 599 if i := strings.Index(file, "/"); i >= 0 { 600 bad = append(bad, 601 file[:i]+"//"+file[i+1:], 602 file[:i]+"/./"+file[i+1:], 603 file[:i]+`\`+file[i+1:], 604 file[:i]+"/../"+file, 605 ) 606 } 607 if i := strings.LastIndex(file, "/"); i >= 0 { 608 bad = append(bad, 609 file[:i]+"//"+file[i+1:], 610 file[:i]+"/./"+file[i+1:], 611 file[:i]+`\`+file[i+1:], 612 file+"/../"+file[i+1:], 613 ) 614 } 615 616 for _, b := range bad { 617 if err := open(b); err == nil { 618 t.errorf("%s: %s(%s) succeeded, want error", file, desc, b) 619 } 620 } 621} 622