Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions storage/pkg/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -1012,24 +1012,9 @@ func tarWithOptionsTo(dest io.WriteCloser, srcPath string, options *TarOptions)
return nil
}

// No exceptions (!...) in patterns so just skip dir
if !pm.Exclusions() {
return filepath.SkipDir
}

dirSlash := relFilePath + string(filepath.Separator)

for _, pat := range pm.Patterns() {
if !pat.Exclusion() {
continue
}
if strings.HasPrefix(pat.String()+string(filepath.Separator), dirSlash) {
// found a match - so can't skip this dir
return nil
}
if fileutils.ShouldDescendExcludedDir(relFilePath, pm) {
return nil
}

// No matching exclusion dir so just skip dir
return filepath.SkipDir
}

Expand Down
44 changes: 44 additions & 0 deletions storage/pkg/fileutils/fileutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,50 @@ func Matches(file string, patterns []string) (bool, error) {
return pm.IsMatch(file)
}

// ShouldDescendExcludedDir checks whether an excluded directory should still be
// descended into because a negation pattern in pm might match files under it.
// It handles literal prefix matches (e.g. !cmd/main.go for dir "cmd") and
// wildcard negations (e.g. !**/*.go, !*/*.go). The wildcard check extracts
// the literal prefix before the first wildcard and may intentionally
// overmatch (descend into directories that won't ultimately contain matches),
// which is safe because actual file-level matching happens later.
func ShouldDescendExcludedDir(dirPath string, pm *PatternMatcher) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider making this a method of PatternMatcher? Any reason not to do that?

if pm == nil || !pm.Exclusions() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Do we need the pm == nil here? I think this is a caller error and not silently accepting it might make it easier to discover such errors.

return false
}
dir := filepath.ToSlash(strings.Trim(dirPath, string(os.PathSeparator)))
for _, pattern := range pm.Patterns() {
if !pattern.Exclusion() {
continue
}
slashPattern := filepath.ToSlash(strings.Trim(pattern.String(), string(os.PathSeparator)))

// Literal-prefix check: the negation spec starts with this
// directory path, for example: !cmd/main.go matches dir "cmd"
if strings.HasPrefix(slashPattern, dir+"/") {
return true
}

// Wildcard-aware check: extract the literal prefix before
// the first wildcard character (*, ?, [), for example: !cmd/**/*.go matches dir "cmd"
// if the directory is at or under that literal prefix, a file beneath this
// directory could match the negation, so keep descending.
if firstWild := strings.IndexAny(slashPattern, "*?["); firstWild >= 0 {
var literalPrefix string
if idx := strings.LastIndex(slashPattern[:firstWild], "/"); idx >= 0 {
literalPrefix = slashPattern[:idx]
}
if literalPrefix == "" {
return true
}
if dir == literalPrefix || strings.HasPrefix(dir, literalPrefix+"/") {
return true
}
}
}
return false
}

// CopyFile copies from src to dst until either EOF is reached
// on src or an error occurs. It verifies src exists and removes
// the dst if it exists.
Expand Down
166 changes: 166 additions & 0 deletions storage/pkg/fileutils/fileutils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,169 @@ func TestMatchesAmount(t *testing.T) {
assert.Equal(t, testCase.isMatch, isMatch, desc)
}
}

func TestShouldDescendExcludedDir(t *testing.T) {
tests := []struct {
name string
path string
patterns []string
want bool
}{
{
name: "nil matcher",
path: "cmd",
patterns: nil,
want: false,
},
{
name: "no exclusions",
path: "cmd",
patterns: []string{"*"},
want: false,
},
{
name: "literal prefix match",
path: "cmd",
patterns: []string{"*", "!cmd/main.go"},
want: true,
},
{
name: "literal prefix no match",
path: "other",
patterns: []string{"*", "!cmd/main.go"},
want: false,
},
{
name: "double star at start matches any dir",
path: "cmd",
patterns: []string{"**", "!**/*.go"},
want: true,
},
{
name: "double star at start matches nested dir",
path: "cmd/sub",
patterns: []string{"**", "!**/*.go"},
want: true,
},
{
name: "double star with prefix matches dir under prefix",
path: "cmd/sub",
patterns: []string{"**", "!cmd/**/*.go"},
want: true,
},
{
name: "double star with prefix no match for other dir",
path: "other",
patterns: []string{"**", "!cmd/**/*.go"},
want: false,
},
{
name: "single star at start matches any dir",
path: "cmd",
patterns: []string{"*", "!*/*.go"},
want: true,
},
{
name: "single star at start matches nested dir",
path: "cmd/sub",
patterns: []string{"*", "!*/*.go"},
want: true,
},
{
name: "single star with prefix matches dir under prefix",
path: "src/pkg",
patterns: []string{"**", "!src/*/*.go"},
want: true,
},
{
name: "single star with prefix no match for other dir",
path: "other",
patterns: []string{"**", "!src/*/*.go"},
want: false,
},
{
name: "leading slash is stripped",
path: "/cmd",
patterns: []string{"*", "!cmd/main.go"},
want: true,
},
{
name: "deep nested with double star prefix",
path: "src/internal/pkg",
patterns: []string{"**", "!src/**/*.go"},
want: true,
},
{
name: "dir prefix match is not a partial match",
path: "cmds",
patterns: []string{"*", "!cmd/main.go"},
want: false,
},
{
name: "wildcard mid-segment descends parent dir",
path: "cmd/images",
patterns: []string{"**", "!cmd/image*/main.go"},
want: true,
},
{
name: "wildcard mid-segment matches parent",
path: "cmd",
patterns: []string{"**", "!cmd/image*"},
want: true,
},
{
name: "question mark wildcard matches any dir",
path: "cmd",
patterns: []string{"**", "!cm?/*.go"},
want: true,
},
{
name: "question mark wildcard no literal prefix matches any dir",
path: "other",
patterns: []string{"**", "!?md/*.go"},
want: true,
},
{
name: "bracket wildcard matches dir",
path: "cmd",
patterns: []string{"**", "!cm[d]/*.go"},
want: true,
},
{
name: "bracket wildcard no literal prefix matches any dir",
path: "other",
patterns: []string{"**", "![c]md/*.go"},
want: true,
},
{
name: "bracket wildcard with prefix matches dir under prefix",
path: "src/cmd",
patterns: []string{"**", "!src/cm[d]/*.go"},
want: true,
},
{
name: "bracket wildcard with prefix no match for other dir",
path: "other",
patterns: []string{"**", "!src/cm[d]/*.go"},
want: false,
},
{
name: "non-exclusion patterns are ignored",
path: "cmd",
patterns: []string{"cmd/**/*.go"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var pm *PatternMatcher
if tt.patterns != nil {
var err error
pm, err = NewPatternMatcher(tt.patterns)
require.NoError(t, err)
}
got := ShouldDescendExcludedDir(tt.path, pm)
assert.Equal(t, tt.want, got, "ShouldDescendExcludedDir(%q)", tt.path)
})
}
}
Loading