Skip to content

Commit aa71a4c

Browse files
committed
fastwalk: don't clean the path argument to Walk on Windows
On Windows don't attempt to clean the path argument to Walk since the existing clean logic (cleanRootPath) transforms paths like "C:\" => "C:" which are not equivalent. This logic only existed to make the joining of paths simpler and should probably be removed since we shouldn't be modifying user provided paths. TODO: Investigate if anything relies on the current clean logic and remove it if nothing does. Fixes: #37
1 parent a984dac commit aa71a4c

File tree

3 files changed

+254
-21
lines changed

3 files changed

+254
-21
lines changed

dirent_export_test.go

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io/fs"
66
"os"
7+
"runtime"
78
"testing"
89
"time"
910
)
@@ -29,26 +30,44 @@ func FormatFileInfo(fi fs.FileInfo) string {
2930
})
3031
}
3132

33+
// NB: this test lives here and not in fastwalk_test.go since we need to
34+
// access the internal cleanRootPath function.
3235
func TestCleanRootPath(t *testing.T) {
33-
tests := map[string]string{
34-
"": "",
35-
"/": "/",
36-
"//": "/",
37-
"/foo": "/foo",
38-
"/foo/": "/foo",
39-
"a": "a",
40-
`C:/`: `C:`,
41-
}
42-
if os.PathSeparator != '/' {
43-
const sep = string(os.PathSeparator)
44-
tests["C:"+sep] = `C:`
45-
tests["C:"+sep+sep] = `C:`
46-
tests[sep+sep] = sep
47-
}
48-
for in, want := range tests {
49-
got := cleanRootPath(in)
50-
if got != want {
51-
t.Errorf("cleanRootPath(%q) = %q; want: %q", in, got, want)
36+
test := func(t *testing.T, tests map[string]string) {
37+
t.Helper()
38+
for in, want := range tests {
39+
got := cleanRootPath(in)
40+
if got != want {
41+
t.Errorf("cleanRootPath(%q) = %q; want: %q", in, got, want)
42+
}
5243
}
5344
}
45+
// NB: The name here isn't exactly correct since we run this for
46+
// any non-Windows OS.
47+
t.Run("Unix", func(t *testing.T) {
48+
if runtime.GOOS == "windows" {
49+
t.Skip("test not supported on Windows")
50+
}
51+
test(t, map[string]string{
52+
"": "",
53+
".": ".",
54+
"/": "/",
55+
"//": "/",
56+
"/foo": "/foo",
57+
"/foo/": "/foo",
58+
"a": "a",
59+
})
60+
})
61+
// Test that cleanRootPath is a no-op on Windows
62+
t.Run("Windows", func(t *testing.T) {
63+
if runtime.GOOS != "windows" {
64+
t.Skip("test only supported on Windows")
65+
}
66+
test(t, map[string]string{
67+
`C:/`: `C:/`,
68+
`C://`: `C://`,
69+
`\\?\GLOBALROOT`: `\\?\GLOBALROOT`,
70+
`\\?\GLOBALROOT\\`: `\\?\GLOBALROOT\\`,
71+
})
72+
})
5473
}

fastwalk.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,12 +563,14 @@ func (w *walker) joinPaths(dir, base string) string {
563563
// Handle the case where the root path argument to Walk is "/"
564564
// without this the returned path is prefixed with "//".
565565
if os.PathSeparator == '/' {
566-
if dir == "/" {
566+
if len(dir) != 0 && dir[len(dir)-1] == '/' {
567567
return dir + base
568568
}
569569
return dir + "/" + base
570570
}
571-
// TODO: handle the above case of the argument to Walk being "/"
571+
if len(dir) != 0 && os.IsPathSeparator(dir[len(dir)-1]) {
572+
return dir + base
573+
}
572574
if w.toSlash {
573575
return dir + "/" + base
574576
}
@@ -625,7 +627,17 @@ func (w *walker) walk(root string, info DirEntry, runUserCallback bool) error {
625627
return nil
626628
}
627629

630+
// cleanRootPath returns the root path trimmed of extraneous trailing slashes.
631+
// This is a no-op on Windows.
628632
func cleanRootPath(root string) string {
633+
if runtime.GOOS == "windows" || len(filepath.VolumeName(root)) != 0 {
634+
// Windows paths or any path with a volume name (which AFAIK should
635+
// only be Windows) are a bit too complicated to clean.
636+
return root
637+
}
638+
if len(filepath.VolumeName(root)) != 0 {
639+
return root
640+
}
629641
for i := len(root) - 1; i >= 0; i-- {
630642
if !os.IsPathSeparator(root[i]) {
631643
return root[:i+1]

fastwalk_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"path/filepath"
1414
"reflect"
15+
"regexp"
1516
"runtime"
1617
"sort"
1718
"strings"
@@ -388,6 +389,207 @@ func TestFastWalk_LongPath(t *testing.T) {
388389
}
389390
}
390391

392+
func TestFastWalk_WindowsRootPaths(t *testing.T) {
393+
if runtime.GOOS != "windows" {
394+
t.Skip("test only supported on Windows")
395+
}
396+
397+
sameFile := func(t *testing.T, name1, name2 string) bool {
398+
fi1, err := os.Stat(name1)
399+
if err != nil {
400+
t.Fatal(err)
401+
}
402+
fi2, err := os.Stat(name2)
403+
if err != nil {
404+
t.Fatal(err)
405+
}
406+
return os.SameFile(fi1, fi2)
407+
}
408+
409+
walk := func(t *testing.T, root string) map[string]fs.DirEntry {
410+
var mu sync.Mutex
411+
seen := make(map[string]fs.DirEntry)
412+
errStop := errors.New("errStop")
413+
fn := func(path string, de fs.DirEntry, err error) error {
414+
if err != nil {
415+
return err
416+
}
417+
mu.Lock()
418+
seen[path] = de
419+
mu.Unlock()
420+
if path != root && de.IsDir() {
421+
return fs.SkipDir
422+
}
423+
return nil
424+
}
425+
err := fastwalk.Walk(nil, root, fastwalk.IgnorePermissionErrors(fn))
426+
if err != nil && err != errStop {
427+
t.Fatal(err)
428+
}
429+
if len(seen) <= 1 {
430+
// If we are a child of the root directory we should have visited at
431+
// least two entries: the root itself and a directory that leads to,
432+
// or is, our current working directory.
433+
t.Fatalf("empty directory: %s", root)
434+
}
435+
return seen
436+
}
437+
438+
pwd, err := filepath.Abs(".")
439+
if err != nil {
440+
t.Fatal(err)
441+
}
442+
443+
vol := filepath.VolumeName(pwd)
444+
if !regexp.MustCompile(`^[A-Za-z]:$`).MatchString(vol) {
445+
// Ignore UNC names and other weird Windows paths to keep this simple.
446+
t.Skipf("unsupported volume name: %s for path: %s", vol, pwd)
447+
}
448+
if !sameFile(t, pwd, vol) {
449+
t.Skipf("skipping %s and %s should be considered the same file", pwd, vol)
450+
}
451+
452+
// Test that walking the disk root ("C:\") actually walks the disk root.
453+
// Previously, there was a bug where the path "C:\" was transformed to "C:"
454+
// before walking which caused fastwalk to walk the current directory.
455+
//
456+
// https://github.com/charlievieth/fastwalk/issues/37
457+
t.Run("FullyQualified", func(t *testing.T) {
458+
root := vol + `\`
459+
if sameFile(t, pwd, root) {
460+
t.Skipf("the current working directory (%s) is the disk root: %s", pwd, root)
461+
}
462+
seen := walk(t, root)
463+
464+
// Make sure we don't append an extraneous slash to the root ("C:\" => "C:\\a").
465+
for path := range seen {
466+
rest := strings.TrimPrefix(path, vol)
467+
if strings.Contains(rest, `\\`) {
468+
t.Errorf(`path contains multiple consecutive slashes after volume (%s): "%s"`,
469+
vol, path)
470+
}
471+
if s := filepath.Clean(path); s != path {
472+
t.Errorf(`filepath.Clean("%s") == "%s"`, path, s)
473+
}
474+
}
475+
476+
// Make sure we didn't walk the current directory. This will happen if
477+
// the root argument to Walk is a drive letter ("C:\") but we strip off
478+
// the trailing slash ("C:\" => "C:") since this makes the path relative
479+
// to the current directory on drive "C".
480+
//
481+
// See: https://github.com/charlievieth/fastwalk/issues/37
482+
//
483+
// Docs: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#fully-qualified-vs-relative-paths
484+
for path, de := range seen {
485+
if path == root {
486+
// Ignore root since filepath.Base("C:\") == "\" and "C:\" and "\"
487+
// are equivalent.
488+
continue
489+
}
490+
fi1, err := de.Info()
491+
if err != nil {
492+
if os.IsNotExist(err) || os.IsPermission(err) {
493+
continue
494+
}
495+
t.Fatal(err)
496+
}
497+
name := filepath.Base(path)
498+
fi2, err := os.Lstat(name)
499+
if err != nil {
500+
continue
501+
}
502+
if os.SameFile(fi1, fi2) {
503+
t.Errorf("Walking root (%s) returned entries for the current working "+
504+
"directory (%s): file %s is the same as %s", root, pwd, path, name)
505+
}
506+
}
507+
508+
// Add file base name mappings
509+
for _, de := range seen {
510+
seen[de.Name()] = de
511+
}
512+
513+
// Make sure we read some files from the disk root.
514+
des, err := os.ReadDir(root)
515+
if err != nil {
516+
t.Fatal(err)
517+
}
518+
if len(des) == 0 {
519+
t.Fatalf("Disk root %s contains no files!", root)
520+
}
521+
same := 0
522+
for _, d2 := range des {
523+
d1 := seen[d2.Name()]
524+
if d1 == nil {
525+
continue
526+
}
527+
fi1, err := d1.Info()
528+
if err != nil {
529+
t.Log(err)
530+
continue
531+
}
532+
fi2, err := d2.Info()
533+
if err != nil {
534+
t.Log(err)
535+
continue
536+
}
537+
if os.SameFile(fi1, fi2) {
538+
same++
539+
}
540+
}
541+
// TODO: Expect to see N% of files and use
542+
// a more descriptive error message
543+
if same == 0 {
544+
t.Fatalf(`Error failed to walk dist root: "%s"`, root)
545+
}
546+
})
547+
548+
// Test that paths like "C:" are treated as a relative path.
549+
t.Run("Relative", func(t *testing.T) {
550+
seen := walk(t, vol)
551+
552+
// Make sure we don't append an extraneous slash to the root ("C:\" => "C:\\a").
553+
for path := range seen {
554+
rest := strings.TrimPrefix(path, vol)
555+
if strings.Contains(rest, `\\`) {
556+
t.Errorf(`path contains multiple consecutive slashes after volume (%s): "%s"`,
557+
vol, path)
558+
}
559+
if path == vol {
560+
continue // Clean("C:") => "C:."
561+
}
562+
if s := filepath.Clean(path); s != path {
563+
t.Errorf(`filepath.Clean("%s") == "%s"`, path, s)
564+
}
565+
}
566+
567+
// Make sure we walk the current directory.
568+
for path, de := range seen {
569+
if path == vol {
570+
// Ignore the volume since filepath.Base("C:") == "\" and "C:" and "\"
571+
// are not equivalent.
572+
continue
573+
}
574+
fi1, err := de.Info()
575+
if err != nil {
576+
t.Fatal(err)
577+
}
578+
name := filepath.Base(path)
579+
fi2, err := os.Lstat(name)
580+
if err != nil {
581+
// NB: This test will fail if this file is removed while it's
582+
// running. There are workarounds for this, but for now it's
583+
// simpler to just error if that happens.
584+
t.Fatal(err)
585+
}
586+
if !os.SameFile(fi1, fi2) {
587+
t.Errorf("Expected files (%s) and (%s) to be the same", path, name)
588+
}
589+
}
590+
})
591+
}
592+
391593
func TestFastWalk_Symlink(t *testing.T) {
392594
testFastWalk(t, map[string]string{
393595
"foo/foo.go": "one",

0 commit comments

Comments
 (0)