Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

i/prompting: implement path pattern matching and validation #13866

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
11 changes: 4 additions & 7 deletions interfaces/prompting/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ type Constraints struct {
// ValidateForInterface returns nil if the constraints are valid for the given
// interface, otherwise returns an error.
func (c *Constraints) ValidateForInterface(iface string) error {
// TODO: change to this once PR #13866 is merged:
// if err := ValidatePathPattern(c.PathPattern); err != nil {
// return err
// }
if err := ValidatePathPattern(c.PathPattern); err != nil {
return err
}
return c.validatePermissions(iface)
}

Expand Down Expand Up @@ -81,9 +80,7 @@ func (c *Constraints) validatePermissions(iface string) error {
//
// If the constraints or path are invalid, returns an error.
func (c *Constraints) Match(path string) (bool, error) {
// TODO: change to this once PR #13866 is merged:
// return PathPatternMatch(c.PathPattern, path)
return true, nil
return PathPatternMatch(c.PathPattern, path)
}

// RemovePermission removes every instance of the given permission from the
Expand Down
36 changes: 15 additions & 21 deletions interfaces/prompting/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ import (

. "gopkg.in/check.v1"

// TODO: add this once PR #13730 is merged:
// doublestar "github.com/bmatcuk/doublestar/v4"
doublestar "github.com/bmatcuk/doublestar/v4"

"github.com/snapcore/snapd/interfaces/prompting"
"github.com/snapcore/snapd/sandbox/apparmor/notify"
Expand All @@ -44,17 +43,16 @@ func (s *constraintsSuite) TestConstraintsValidateForInterface(c *C) {
}{
{
"foo",
"invalid/path",
"/invalid/path",
[]string{"read"},
"unsupported interface.*",
},
// TODO: add this once PR #13730 is merged:
// {
// "home",
// "invalid/path",
// []string{"read"},
// "invalid path pattern.*",
// },
{
"home",
"invalid/path",
[]string{"read"},
"invalid path pattern.*",
},
{
"home",
"/valid/path",
Expand Down Expand Up @@ -151,12 +149,11 @@ func (*constraintsSuite) TestConstraintsMatch(c *C) {
"/home/test/Documents/foo.txt",
true,
},
// TODO: add this once PR #13730 is merged:
// {
// "/home/test/Documents/foo",
// "/home/test/Documents/foo.txt",
// false,
// },
{
"/home/test/Documents/foo",
"/home/test/Documents/foo.txt",
false,
},
}
for _, testCase := range cases {
constraints := &prompting.Constraints{
Expand All @@ -176,11 +173,8 @@ func (s *constraintsSuite) TestConstraintsMatchUnhappy(c *C) {
Permissions: []string{"read"},
}
matches, err := badConstraints.Match(badPath)
// TODO: change to this once PR #13730 is merged:
// c.Check(err, Equals, doublestar.ErrBadPattern)
// c.Check(matches, Equals, false)
c.Check(err, Equals, nil)
c.Check(matches, Equals, true)
c.Check(err, Equals, doublestar.ErrBadPattern)
c.Check(matches, Equals, false)
}

func (s *constraintsSuite) TestConstraintsRemovePermission(c *C) {
Expand Down
150 changes: 150 additions & 0 deletions interfaces/prompting/patterns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// -*- 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"
)

type countStack []int

func (c *countStack) push(x int) {
*c = append(*c, x)
}

func (c *countStack) pop() int {
x := (*c)[len(*c)-1]
*c = (*c)[:len(*c)-1]
return x
}

// Limit the number of expanded path patterns for a particular pattern.
// When fully expanded, the number of patterns for a given unexpanded pattern
// may not exceed this limit.
const maxExpandedPatterns = 1000

// 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
var currentGroupStack countStack
var currentOptionStack countStack
// Final currentOptionCount will be total expanded patterns for full pattern
currentGroupCount := 0
currentOptionCount := 1
reader := strings.NewReader(pattern)
for {
r, _, err := reader.ReadRune()
if err != nil {
// No more runes
break
}
switch r {
case '{':
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I'm confused even more now. The spec does not mention {} for path patterns, so why validate this? Also, there's inconsistency between ValidatePathPattern and MatchPathPatter, where the latter is oblivious to {}, which means that a definition of a valid pattern is different for each of those calls.

Copy link
Member Author

Choose a reason for hiding this comment

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

MatchPathPattern directly calls doublestar.Match to do matching (discussed some here: https://docs.google.com/document/d/1tBnefdukP69EUJOlH8bgD2hrvZCYoE8-1ZlqRRYlOqc/edit?pli=1#heading=h.a0e0vfqho1ip). This should be called on expanded patterns, but this isn't technically necessary, since doublestar.Match happily matches patterns with '{' '}' groups in them (see: https://pkg.go.dev/github.com/bmatcuk/doublestar#Match). Path pattern validation should occur on patterns before they are expanded, so they may contain '{' '}'-defined groups.

depth++
if depth >= maxExpandedPatterns {
return fmt.Errorf("invalid path pattern: nested group depth exceeded maximum number of expanded path patterns (%d): %q", maxExpandedPatterns, pattern)
}
currentGroupStack.push(currentGroupCount)
currentOptionStack.push(currentOptionCount)
currentGroupCount = 0
currentOptionCount = 1
case ',':
if depth == 0 {
// Ignore commas outside of groups
break
}
currentGroupCount += currentOptionCount
currentOptionCount = 1
case '}':
depth--
if depth < 0 {
return fmt.Errorf("invalid path pattern: unmatched '}' character: %q", pattern)
}
currentGroupCount += currentOptionCount
currentOptionCount = currentOptionStack.pop() // option count of parent
currentOptionCount *= currentGroupCount // parent option count * current group count
currentGroupCount = currentGroupStack.pop() // group count of parent
case '\\':
// Skip next rune
_, _, err = reader.ReadRune()
Copy link
Collaborator

Choose a reason for hiding this comment

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

isn't the escape sequence valid only for some of the characters that follow? eg what would a pattern like \c mean?

Copy link
Member Author

Choose a reason for hiding this comment

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

According to doublestar.Match (https://pkg.go.dev/github.com/bmatcuk/doublestar#Match), \c matches c for any character c. I'm not adjusting that behavior at all, at the moment.

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 currentOptionCount > maxExpandedPatterns {
return fmt.Errorf("invalid path pattern: exceeded maximum number of expanded path patterns (%d): %q expands to %d patterns", maxExpandedPatterns, pattern, currentOptionCount)
}
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
}
Comment on lines +132 to +135
Copy link
Collaborator

Choose a reason for hiding this comment

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

wouldn't the pattern be validated prior to being used? Perhaps ValidatePathPatter should call doublestar.Match with say "/" as path, discard the result and only check the error?

Copy link
Member Author

Choose a reason for hiding this comment

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

doublestar.ValidatePattern is called as part of ValidatePathPattern, so the pattern should be valid. This error should thus never occur when the PathPatternMatch is called in practice, but it's a check in case PathPatternMatch is called in isolation without being validated. So I'm inclined to leave the error return in place rather than discarding the error, for completeness.

// 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)
}