diff --git a/README.md b/README.md index 91f3084..be715ff 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,24 @@ If passed, doublestar will only return "files" from `Glob`, `GlobWalk`, or `FilepathGlob`. In this context, "files" are anything that is not a directory or a symlink to a directory. +Note: if combined with the WithNoFollow option, symlinks to directories _will_ +be included in the result since no attempt is made to follow the symlink. + +```go +WithNoFollow() +``` + +If passed, doublestar will not follow symlinks while traversing the filesystem. +However, due to io/fs's _very_ poor support for querying the filesystem about +symlinks, there's a caveat here: if part of the pattern before any meta +characters contains a reference to a symlink, it will be followed. For example, +a pattern such as `path/to/symlink/*` will be followed assuming it is a valid +symlink to a directory. However, from this same example, a pattern such as +`path/to/**` will not traverse the `symlink`, nor would `path/*/symlink/*` + +Note: if combined with the WithFilesOnly option, symlinks to directories _will_ +be included in the result since no attempt is made to follow the symlink. + ### Glob ```go diff --git a/doublestar_test.go b/doublestar_test.go index cddd619..82507d8 100644 --- a/doublestar_test.go +++ b/doublestar_test.go @@ -146,6 +146,7 @@ var matchTests = []MatchTest{ {"working-symlink/c/*", "working-symlink/c/d", true, true, nil, false, false, true, !onWindows, 1, 1}, {"working-sym*/*", "working-symlink/c", true, true, nil, false, false, true, !onWindows, 1, 1}, {"b/**/f", "b/symlink-dir/f", true, true, nil, false, false, false, !onWindows, 2, 2}, + {"*/symlink-dir/*", "b/symlink-dir/f", true, true, nil, !onWindows, false, true, !onWindows, 2, 2}, {"e/**", "e/**", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/**", "e/*", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/**", "e/?", true, true, nil, false, false, false, !onWindows, 11, 6}, @@ -188,10 +189,18 @@ var matchTests = []MatchTest{ {"nopermission/file", "nopermission/file", true, false, nil, true, false, true, !onWindows, 0, 0}, } -// Calculate the number of results that we expect WithFilesOnly at runtime and -// memoize them here +// Calculate the number of results that we expect +// WithFilesOnly at runtime and memoize them here var numResultsFilesOnly []int +// Calculate the number of results that we expect +// WithNoFollow at runtime and memoize them here +var numResultsNoFollow []int + +// Calculate the number of results that we expect with all +// of the options enabled at runtime and memoize them here +var numResultsAllOpts []int + func TestValidatePattern(t *testing.T) { for idx, tt := range matchTests { testValidatePatternWith(t, idx, tt) @@ -374,8 +383,12 @@ func TestGlobWithFilesOnly(t *testing.T) { doGlobTest(t, WithFilesOnly()) } +func TestGlobWithNoFollow(t *testing.T) { + doGlobTest(t, WithNoFollow()) +} + func TestGlobWithAllOptions(t *testing.T) { - doGlobTest(t, WithFailOnIOErrors(), WithFailOnPatternNotExist(), WithFilesOnly()) + doGlobTest(t, WithFailOnIOErrors(), WithFailOnPatternNotExist(), WithFilesOnly(), WithNoFollow()) } func doGlobTest(t *testing.T, opts ...GlobOption) { @@ -418,8 +431,12 @@ func TestGlobWalkWithFilesOnly(t *testing.T) { doGlobWalkTest(t, WithFilesOnly()) } +func TestGlobWalkWithNoFollow(t *testing.T) { + doGlobWalkTest(t, WithNoFollow()) +} + func TestGlobWalkWithAllOptions(t *testing.T) { - doGlobWalkTest(t, WithFailOnIOErrors(), WithFailOnPatternNotExist(), WithFilesOnly()) + doGlobWalkTest(t, WithFailOnIOErrors(), WithFailOnPatternNotExist(), WithFilesOnly(), WithNoFollow()) } func doGlobWalkTest(t *testing.T, opts ...GlobOption) { @@ -475,6 +492,10 @@ func TestFilepathGlobWithFilesOnly(t *testing.T) { doFilepathGlobTest(t, WithFilesOnly()) } +func TestFilepathGlobWithNoFollow(t *testing.T) { + doFilepathGlobTest(t, WithNoFollow()) +} + func doFilepathGlobTest(t *testing.T, opts ...GlobOption) { glob := newGlob(opts...) fsys := os.DirFS("test") @@ -537,13 +558,19 @@ func verifyGlobResults(t *testing.T, idx int, fn string, tt MatchTest, g *glob, numResults = tt.winNumResults } if g.filesOnly { - numResults = numResultsFilesOnly[idx] + if g.noFollow { + numResults = numResultsAllOpts[idx] + } else { + numResults = numResultsFilesOnly[idx] + } + } else if g.noFollow { + numResults = numResultsNoFollow[idx] } if len(matches) != numResults { t.Errorf("#%v. %v(%#q, %#v) = %#v - should have %#v results, got %#v", idx, fn, tt.pattern, g, matches, numResults, len(matches)) } - if !g.filesOnly && inSlice(tt.testPath, matches) != tt.shouldMatchGlob { + if !g.filesOnly && !g.noFollow && inSlice(tt.testPath, matches) != tt.shouldMatchGlob { if tt.shouldMatchGlob { t.Errorf("#%v. %v(%#q, %#v) = %#v - doesn't contain %v, but should", idx, fn, tt.pattern, g, matches, tt.testPath) } else { @@ -656,23 +683,40 @@ func compareSlices(a, b []string) bool { return len(diff) == 0 } -func buildNumResultsFilesOnly() { +func buildNumResults() { testLen := len(matchTests) numResultsFilesOnly = make([]int, testLen, testLen) + numResultsNoFollow = make([]int, testLen, testLen) + numResultsAllOpts = make([]int, testLen, testLen) fsys := os.DirFS("test") g := newGlob() for idx, tt := range matchTests { if tt.testOnDisk { - count := 0 + filesOnly := 0 + noFollow := 0 + allOpts := 0 GlobWalk(fsys, tt.pattern, func(p string, d fs.DirEntry) error { isDir, _ := g.isDir(fsys, "", p, d) if !isDir { - count++ + filesOnly++ + } + + hasNoFollow := (strings.HasPrefix(tt.pattern, "working-symlink") || !strings.Contains(p, "working-symlink/")) && !strings.Contains(p, "/symlink-dir/") + if hasNoFollow { + noFollow++ } + + if hasNoFollow && (!isDir || p == "working-symlink") { + allOpts++ + } + return nil }) - numResultsFilesOnly[idx] = count + + numResultsFilesOnly[idx] = filesOnly + numResultsNoFollow[idx] = noFollow + numResultsAllOpts[idx] = allOpts } } } @@ -770,7 +814,7 @@ func TestMain(m *testing.M) { } // initialize numResultsFilesOnly - buildNumResultsFilesOnly() + buildNumResults() os.Exit(m.Run()) } diff --git a/glob.go b/glob.go index 0393a27..519601b 100644 --- a/glob.go +++ b/glob.go @@ -389,7 +389,7 @@ func (g *glob) isPathDir(fsys fs.FS, name string, beforeMeta bool) (fs.FileInfo, // represents a symbolic link, the link is followed by running fs.Stat() on // `path.Join(dir, name)` (if dir is "", name will be used without joining) func (g *glob) isDir(fsys fs.FS, dir, name string, info fs.DirEntry) (bool, error) { - if (info.Type() & fs.ModeSymlink) > 0 { + if !g.noFollow && (info.Type()&fs.ModeSymlink) > 0 { p := name if dir != "" { p = path.Join(dir, name) diff --git a/globoptions.go b/globoptions.go index 6b3d057..9483c4b 100644 --- a/globoptions.go +++ b/globoptions.go @@ -7,6 +7,7 @@ type glob struct { failOnIOErrors bool failOnPatternNotExist bool filesOnly bool + noFollow bool } // GlobOption represents a setting that can be passed to Glob, GlobWalk, and @@ -52,12 +53,36 @@ func WithFailOnPatternNotExist() GlobOption { // FilepathGlob. If passed, doublestar will only return files that match the // pattern, not directories. // +// Note: if combined with the WithNoFollow option, symlinks to directories +// _will_ be included in the result since no attempt is made to follow the +// symlink. +// func WithFilesOnly() GlobOption { return func(g *glob) { g.filesOnly = true } } +// WithNoFollow is an option that can be passed to Glob, GlobWalk, or +// FilepathGlob. If passed, doublestar will not follow symlinks while +// traversing the filesystem. However, due to io/fs's _very_ poor support for +// querying the filesystem about symlinks, there's a caveat here: if part of +// the pattern before any meta characters contains a reference to a symlink, it +// will be followed. For example, a pattern such as `path/to/symlink/*` will be +// followed assuming it is a valid symlink to a directory. However, from this +// same example, a pattern such as `path/to/**` will not traverse the +// `symlink`, nor would `path/*/symlink/*` +// +// Note: if combined with the WithFilesOnly option, symlinks to directories +// _will_ be included in the result since no attempt is made to follow the +// symlink. +// +func WithNoFollow() GlobOption { + return func(g *glob) { + g.noFollow = true + } +} + // forwardErrIfFailOnIOErrors is used to wrap the return values of I/O // functions. When failOnIOErrors is enabled, it will return err; otherwise, it // always returns nil. @@ -86,24 +111,31 @@ func (g *glob) GoString() string { b.WriteString("opts: ") hasOpts := false - if (g.failOnIOErrors) { + if g.failOnIOErrors { b.WriteString("WithFailOnIOErrors") hasOpts = true } - if (g.failOnPatternNotExist) { + if g.failOnPatternNotExist { if hasOpts { b.WriteString(", ") } b.WriteString("WithFailOnPatternNotExist") hasOpts = true } - if (g.filesOnly) { + if g.filesOnly { if hasOpts { b.WriteString(", ") } b.WriteString("WithFilesOnly") hasOpts = true } + if g.noFollow { + if hasOpts { + b.WriteString(", ") + } + b.WriteString("WithNoFollow") + hasOpts = true + } if !hasOpts { b.WriteString("nil") diff --git a/utils_test.go b/utils_test.go index b4ca696..5488a86 100644 --- a/utils_test.go +++ b/utils_test.go @@ -1,8 +1,8 @@ package doublestar import ( - "testing" "path/filepath" + "testing" ) var filepathGlobTests = []string{