Skip to content

Commit

Permalink
i/prompting: implement path pattern matching and validation
Browse files Browse the repository at this point in the history
Path pattern matching is implemented via the doublestar package, which
emulates bash's globstar matching. Patterns may include '*' wildcard
characters (which match any number of non-separator characters), '**'
doublestars (which match zero or more subdirectories), '?' wildcard
characters (which match exactly one non-separator character), and nested
groups delimited by '{' and '}'. Notably, path patterns are *not* allowed
to have character classes delimited by '[' and ']', nor inverted
classes of the form "[^abc]".

There is a limit on the number of groups allowed in path patterns, but
up to that limit, groups may be arbitrarily nested or sequential.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>

i/prompting: fix typo and add notes to remove test boilerplate

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>

i/prompting: use separate test suite for patterns

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>

i/prompting: improve unit test coverage

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
  • Loading branch information
olivercalder committed May 2, 2024
1 parent 356d7c1 commit 6e34c06
Show file tree
Hide file tree
Showing 4 changed files with 611 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ go 1.18
replace maze.io/x/crypto => github.com/snapcore/maze.io-x-crypto v0.0.0-20190131090603-9b94c9afe066

require (
github.com/bmatcuk/doublestar/v4 v4.6.1
github.com/canonical/go-efilib v0.3.1-0.20220815143333-7e5151412e93 // indirect
github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 // indirect
github.com/canonical/go-tpm2 v0.0.0-20210827151749-f80ff5afff61
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/canonical/go-efilib v0.3.1-0.20220815143333-7e5151412e93 h1:F0bRDzPy/j2IX/iIWqCEA23S1nal+f7A+/vLyj6Ye+4=
github.com/canonical/go-efilib v0.3.1-0.20220815143333-7e5151412e93/go.mod h1:9b2PNAuPcZsB76x75/uwH99D8CyH/A2y4rq1/+bvplg=
github.com/canonical/go-sp800.108-kdf v0.0.0-20210314145419-a3359f2d21b9 h1:USzKjrfWo/ESzozv2i3OMM7XDgxrZRvaHFrKkIKRtwU=
Expand Down
116 changes: 116 additions & 0 deletions interfaces/prompting/patterns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2024 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package prompting

import (
"fmt"
"strings"

doublestar "github.com/bmatcuk/doublestar/v4"
)

// Limit the number of groups which are allowed to occur in a pattern.
// This number includes all groups, including those nested or in series.
const maxGroupsInPattern = 10

// ValidatePathPattern returns nil if the pattern is valid, otherwise an error.
func ValidatePathPattern(pattern string) error {
if pattern == "" || pattern[0] != '/' {
return fmt.Errorf("invalid path pattern: must start with '/': %q", pattern)
}
depth := 0
totalGroups := 0
reader := strings.NewReader(pattern)
for {
r, _, err := reader.ReadRune()
if err != nil {
// No more runes
break
}
switch r {
case '{':
depth += 1
totalGroups += 1
if totalGroups > maxGroupsInPattern {
return fmt.Errorf("invalid path pattern: exceeded maximum number of groups (%d): %q", maxGroupsInPattern, pattern)
}
case '}':
depth -= 1
if depth < 0 {
return fmt.Errorf("invalid path pattern: unmatched '}' character: %q", pattern)
}
case '\\':
// Skip next rune
_, _, err = reader.ReadRune()
if err != nil {
return fmt.Errorf(`invalid path pattern: trailing unescaped '\' character: %q`, pattern)
}
case '[', ']':
return fmt.Errorf("invalid path pattern: cannot contain unescaped '[' or ']': %q", pattern)
}
}
if depth != 0 {
return fmt.Errorf("invalid path pattern: unmatched '{' character: %q", pattern)
}
if !doublestar.ValidatePattern(pattern) {
return fmt.Errorf("invalid path pattern: %q", pattern)
}
return nil
}

// PathPatternMatch returns true if the given pattern matches the given path.
//
// The pattern should not contain groups, and should likely have been an output
// of ExpandPathPattern.
//
// Paths to directories are received with trailing slashes, but we don't want
// to require the user to include a trailing '/' if they want to match
// directories (and end their pattern with `{,/}` if they want to match both
// directories and non-directories). Thus, we want to ensure that patterns
// without trailing slashes match paths with trailing slashes. However,
// patterns with trailing slashes should not match paths without trailing
// slashes.
//
// The doublestar package has special cases for patterns ending in `/**` and
// `/**/`: `/foo/**`, and `/foo/**/` both match `/foo` and `/foo/`. We want to
// override this behavior to make `/foo/**/` not match `/foo`. We also want to
// override doublestar to make `/foo` match `/foo/`.
func PathPatternMatch(pattern string, path string) (bool, error) {
// Check the usual doublestar match first, in case the pattern is malformed
// and causes an error, and return the error if so.
matched, err := doublestar.Match(pattern, path)
if err != nil {
return false, err
}
// No matter if doublestar matched, return false if pattern ends in '/' but
// path is not a directory.
if strings.HasSuffix(pattern, "/") && !strings.HasSuffix(path, "/") {
return false, nil
}
if matched {
return true, nil
}
if strings.HasSuffix(pattern, "/") {
return false, nil
}
// Try again with a '/' appended to the pattern, so patterns like `/foo`
// match paths like `/foo/`.
return doublestar.Match(pattern+"/", path)
}

0 comments on commit 6e34c06

Please sign in to comment.