| package patternmatcher | |
| import ( | |
| "errors" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| "text/scanner" | |
| "unicode/utf8" | |
| ) | |
| // escapeBytes is a bitmap used to check whether a character should be escaped when creating the regex. | |
| var escapeBytes [8]byte | |
| // shouldEscape reports whether a rune should be escaped as part of the regex. | |
| // | |
| // This only includes characters that require escaping in regex but are also NOT valid filepath pattern characters. | |
| // Additionally, '\' is not excluded because there is specific logic to properly handle this, as it's a path separator | |
| // on Windows. | |
| // | |
| // Adapted from regexp::QuoteMeta in go stdlib. | |
| // See https://cs.opensource.google/go/go/+/refs/tags/go1.17.2:src/regexp/regexp.go;l=703-715;drc=refs%2Ftags%2Fgo1.17.2 | |
| func shouldEscape(b rune) bool { | |
| return b < utf8.RuneSelf && escapeBytes[b%8]&(1<<(b/8)) != 0 | |
| } | |
| func init() { | |
| for _, b := range []byte(`.+()|{}$`) { | |
| escapeBytes[b%8] |= 1 << (b / 8) | |
| } | |
| } | |
| // PatternMatcher allows checking paths against a list of patterns | |
| type PatternMatcher struct { | |
| patterns []*Pattern | |
| exclusions bool | |
| } | |
| // New creates a new matcher object for specific patterns that can | |
| // be used later to match against patterns against paths | |
| func New(patterns []string) (*PatternMatcher, error) { | |
| pm := &PatternMatcher{ | |
| patterns: make([]*Pattern, 0, len(patterns)), | |
| } | |
| for _, p := range patterns { | |
| // Eliminate leading and trailing whitespace. | |
| p = strings.TrimSpace(p) | |
| if p == "" { | |
| continue | |
| } | |
| p = filepath.Clean(p) | |
| newp := &Pattern{} | |
| if p[0] == '!' { | |
| if len(p) == 1 { | |
| return nil, errors.New("illegal exclusion pattern: \"!\"") | |
| } | |
| newp.exclusion = true | |
| p = p[1:] | |
| pm.exclusions = true | |
| } | |
| // Do some syntax checking on the pattern. | |
| // filepath's Match() has some really weird rules that are inconsistent | |
| // so instead of trying to dup their logic, just call Match() for its | |
| // error state and if there is an error in the pattern return it. | |
| // If this becomes an issue we can remove this since its really only | |
| // needed in the error (syntax) case - which isn't really critical. | |
| if _, err := filepath.Match(p, "."); err != nil { | |
| return nil, err | |
| } | |
| newp.cleanedPattern = p | |
| newp.dirs = strings.Split(p, string(os.PathSeparator)) | |
| pm.patterns = append(pm.patterns, newp) | |
| } | |
| return pm, nil | |
| } | |
| // Matches returns true if "file" matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. | |
| // | |
| // The "file" argument should be a slash-delimited path. | |
| // | |
| // Matches is not safe to call concurrently. | |
| // | |
| // Deprecated: This implementation is buggy (it only checks a single parent dir | |
| // against the pattern) and will be removed soon. Use either | |
| // MatchesOrParentMatches or MatchesUsingParentResults instead. | |
| func (pm *PatternMatcher) Matches(file string) (bool, error) { | |
| matched := false | |
| file = filepath.FromSlash(file) | |
| parentPath := filepath.Dir(file) | |
| parentPathDirs := strings.Split(parentPath, string(os.PathSeparator)) | |
| for _, pattern := range pm.patterns { | |
| // Skip evaluation if this is an inclusion and the filename | |
| // already matched the pattern, or it's an exclusion and it has | |
| // not matched the pattern yet. | |
| if pattern.exclusion != matched { | |
| continue | |
| } | |
| match, err := pattern.match(file) | |
| if err != nil { | |
| return false, err | |
| } | |
| if !match && parentPath != "." { | |
| // Check to see if the pattern matches one of our parent dirs. | |
| if len(pattern.dirs) <= len(parentPathDirs) { | |
| match, _ = pattern.match(strings.Join(parentPathDirs[:len(pattern.dirs)], string(os.PathSeparator))) | |
| } | |
| } | |
| if match { | |
| matched = !pattern.exclusion | |
| } | |
| } | |
| return matched, nil | |
| } | |
| // MatchesOrParentMatches returns true if "file" matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. | |
| // | |
| // The "file" argument should be a slash-delimited path. | |
| // | |
| // Matches is not safe to call concurrently. | |
| func (pm *PatternMatcher) MatchesOrParentMatches(file string) (bool, error) { | |
| matched := false | |
| file = filepath.FromSlash(file) | |
| parentPath := filepath.Dir(file) | |
| parentPathDirs := strings.Split(parentPath, string(os.PathSeparator)) | |
| for _, pattern := range pm.patterns { | |
| // Skip evaluation if this is an inclusion and the filename | |
| // already matched the pattern, or it's an exclusion and it has | |
| // not matched the pattern yet. | |
| if pattern.exclusion != matched { | |
| continue | |
| } | |
| match, err := pattern.match(file) | |
| if err != nil { | |
| return false, err | |
| } | |
| if !match && parentPath != "." { | |
| // Check to see if the pattern matches one of our parent dirs. | |
| for i := range parentPathDirs { | |
| match, _ = pattern.match(strings.Join(parentPathDirs[:i+1], string(os.PathSeparator))) | |
| if match { | |
| break | |
| } | |
| } | |
| } | |
| if match { | |
| matched = !pattern.exclusion | |
| } | |
| } | |
| return matched, nil | |
| } | |
| // MatchesUsingParentResult returns true if "file" matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. The functionality is | |
| // the same as Matches, but as an optimization, the caller keeps track of | |
| // whether the parent directory matched. | |
| // | |
| // The "file" argument should be a slash-delimited path. | |
| // | |
| // MatchesUsingParentResult is not safe to call concurrently. | |
| // | |
| // Deprecated: this function does behave correctly in some cases (see | |
| // https://github.com/docker/buildx/issues/850). | |
| // | |
| // Use MatchesUsingParentResults instead. | |
| func (pm *PatternMatcher) MatchesUsingParentResult(file string, parentMatched bool) (bool, error) { | |
| matched := parentMatched | |
| file = filepath.FromSlash(file) | |
| for _, pattern := range pm.patterns { | |
| // Skip evaluation if this is an inclusion and the filename | |
| // already matched the pattern, or it's an exclusion and it has | |
| // not matched the pattern yet. | |
| if pattern.exclusion != matched { | |
| continue | |
| } | |
| match, err := pattern.match(file) | |
| if err != nil { | |
| return false, err | |
| } | |
| if match { | |
| matched = !pattern.exclusion | |
| } | |
| } | |
| return matched, nil | |
| } | |
| // MatchInfo tracks information about parent dir matches while traversing a | |
| // filesystem. | |
| type MatchInfo struct { | |
| parentMatched []bool | |
| } | |
| // MatchesUsingParentResults returns true if "file" matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. The functionality is | |
| // the same as Matches, but as an optimization, the caller passes in | |
| // intermediate results from matching the parent directory. | |
| // | |
| // The "file" argument should be a slash-delimited path. | |
| // | |
| // MatchesUsingParentResults is not safe to call concurrently. | |
| func (pm *PatternMatcher) MatchesUsingParentResults(file string, parentMatchInfo MatchInfo) (bool, MatchInfo, error) { | |
| parentMatched := parentMatchInfo.parentMatched | |
| if len(parentMatched) != 0 && len(parentMatched) != len(pm.patterns) { | |
| return false, MatchInfo{}, errors.New("wrong number of values in parentMatched") | |
| } | |
| file = filepath.FromSlash(file) | |
| matched := false | |
| matchInfo := MatchInfo{ | |
| parentMatched: make([]bool, len(pm.patterns)), | |
| } | |
| for i, pattern := range pm.patterns { | |
| match := false | |
| // If the parent matched this pattern, we don't need to recheck. | |
| if len(parentMatched) != 0 { | |
| match = parentMatched[i] | |
| } | |
| if !match { | |
| // Skip evaluation if this is an inclusion and the filename | |
| // already matched the pattern, or it's an exclusion and it has | |
| // not matched the pattern yet. | |
| if pattern.exclusion != matched { | |
| continue | |
| } | |
| var err error | |
| match, err = pattern.match(file) | |
| if err != nil { | |
| return false, matchInfo, err | |
| } | |
| // If the zero value of MatchInfo was passed in, we don't have | |
| // any information about the parent dir's match results, and we | |
| // apply the same logic as MatchesOrParentMatches. | |
| if !match && len(parentMatched) == 0 { | |
| if parentPath := filepath.Dir(file); parentPath != "." { | |
| parentPathDirs := strings.Split(parentPath, string(os.PathSeparator)) | |
| // Check to see if the pattern matches one of our parent dirs. | |
| for i := range parentPathDirs { | |
| match, _ = pattern.match(strings.Join(parentPathDirs[:i+1], string(os.PathSeparator))) | |
| if match { | |
| break | |
| } | |
| } | |
| } | |
| } | |
| } | |
| matchInfo.parentMatched[i] = match | |
| if match { | |
| matched = !pattern.exclusion | |
| } | |
| } | |
| return matched, matchInfo, nil | |
| } | |
| // Exclusions returns true if any of the patterns define exclusions | |
| func (pm *PatternMatcher) Exclusions() bool { | |
| return pm.exclusions | |
| } | |
| // Patterns returns array of active patterns | |
| func (pm *PatternMatcher) Patterns() []*Pattern { | |
| return pm.patterns | |
| } | |
| // Pattern defines a single regexp used to filter file paths. | |
| type Pattern struct { | |
| matchType matchType | |
| cleanedPattern string | |
| dirs []string | |
| regexp *regexp.Regexp | |
| exclusion bool | |
| } | |
| type matchType int | |
| const ( | |
| unknownMatch matchType = iota | |
| exactMatch | |
| prefixMatch | |
| suffixMatch | |
| regexpMatch | |
| ) | |
| func (p *Pattern) String() string { | |
| return p.cleanedPattern | |
| } | |
| // Exclusion returns true if this pattern defines exclusion | |
| func (p *Pattern) Exclusion() bool { | |
| return p.exclusion | |
| } | |
| func (p *Pattern) match(path string) (bool, error) { | |
| if p.matchType == unknownMatch { | |
| if err := p.compile(string(os.PathSeparator)); err != nil { | |
| return false, filepath.ErrBadPattern | |
| } | |
| } | |
| switch p.matchType { | |
| case exactMatch: | |
| return path == p.cleanedPattern, nil | |
| case prefixMatch: | |
| // strip trailing ** | |
| return strings.HasPrefix(path, p.cleanedPattern[:len(p.cleanedPattern)-2]), nil | |
| case suffixMatch: | |
| // strip leading ** | |
| suffix := p.cleanedPattern[2:] | |
| if strings.HasSuffix(path, suffix) { | |
| return true, nil | |
| } | |
| // **/foo matches "foo" | |
| return suffix[0] == os.PathSeparator && path == suffix[1:], nil | |
| case regexpMatch: | |
| return p.regexp.MatchString(path), nil | |
| } | |
| return false, nil | |
| } | |
| func (p *Pattern) compile(sl string) error { | |
| regStr := "^" | |
| pattern := p.cleanedPattern | |
| // Go through the pattern and convert it to a regexp. | |
| // We use a scanner so we can support utf-8 chars. | |
| var scan scanner.Scanner | |
| scan.Init(strings.NewReader(pattern)) | |
| escSL := sl | |
| if sl == `\` { | |
| escSL += `\` | |
| } | |
| p.matchType = exactMatch | |
| for i := 0; scan.Peek() != scanner.EOF; i++ { | |
| ch := scan.Next() | |
| if ch == '*' { | |
| if scan.Peek() == '*' { | |
| // is some flavor of "**" | |
| scan.Next() | |
| // Treat **/ as ** so eat the "/" | |
| if string(scan.Peek()) == sl { | |
| scan.Next() | |
| } | |
| if scan.Peek() == scanner.EOF { | |
| // is "**EOF" - to align with .gitignore just accept all | |
| if p.matchType == exactMatch { | |
| p.matchType = prefixMatch | |
| } else { | |
| regStr += ".*" | |
| p.matchType = regexpMatch | |
| } | |
| } else { | |
| // is "**" | |
| // Note that this allows for any # of /'s (even 0) because | |
| // the .* will eat everything, even /'s | |
| regStr += "(.*" + escSL + ")?" | |
| p.matchType = regexpMatch | |
| } | |
| if i == 0 { | |
| p.matchType = suffixMatch | |
| } | |
| } else { | |
| // is "*" so map it to anything but "/" | |
| regStr += "[^" + escSL + "]*" | |
| p.matchType = regexpMatch | |
| } | |
| } else if ch == '?' { | |
| // "?" is any char except "/" | |
| regStr += "[^" + escSL + "]" | |
| p.matchType = regexpMatch | |
| } else if shouldEscape(ch) { | |
| // Escape some regexp special chars that have no meaning | |
| // in golang's filepath.Match | |
| regStr += `\` + string(ch) | |
| } else if ch == '\\' { | |
| // escape next char. Note that a trailing \ in the pattern | |
| // will be left alone (but need to escape it) | |
| if sl == `\` { | |
| // On windows map "\" to "\\", meaning an escaped backslash, | |
| // and then just continue because filepath.Match on | |
| // Windows doesn't allow escaping at all | |
| regStr += escSL | |
| continue | |
| } | |
| if scan.Peek() != scanner.EOF { | |
| regStr += `\` + string(scan.Next()) | |
| p.matchType = regexpMatch | |
| } else { | |
| regStr += `\` | |
| } | |
| } else if ch == '[' || ch == ']' { | |
| regStr += string(ch) | |
| p.matchType = regexpMatch | |
| } else { | |
| regStr += string(ch) | |
| } | |
| } | |
| if p.matchType != regexpMatch { | |
| return nil | |
| } | |
| regStr += "$" | |
| re, err := regexp.Compile(regStr) | |
| if err != nil { | |
| return err | |
| } | |
| p.regexp = re | |
| p.matchType = regexpMatch | |
| return nil | |
| } | |
| // Matches returns true if file matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. | |
| // | |
| // This implementation is buggy (it only checks a single parent dir against the | |
| // pattern) and will be removed soon. Use MatchesOrParentMatches instead. | |
| func Matches(file string, patterns []string) (bool, error) { | |
| pm, err := New(patterns) | |
| if err != nil { | |
| return false, err | |
| } | |
| file = filepath.Clean(file) | |
| if file == "." { | |
| // Don't let them exclude everything, kind of silly. | |
| return false, nil | |
| } | |
| return pm.Matches(file) | |
| } | |
| // MatchesOrParentMatches returns true if file matches any of the patterns | |
| // and isn't excluded by any of the subsequent patterns. | |
| func MatchesOrParentMatches(file string, patterns []string) (bool, error) { | |
| pm, err := New(patterns) | |
| if err != nil { | |
| return false, err | |
| } | |
| file = filepath.Clean(file) | |
| if file == "." { | |
| // Don't let them exclude everything, kind of silly. | |
| return false, nil | |
| } | |
| return pm.MatchesOrParentMatches(file) | |
| } |