Skip to content

Commit

Permalink
Merge pull request #20 from ucan-wg/v1-fuzz-match-and-simple-glob
Browse files Browse the repository at this point in the history
Rewrite simple glob match + FuzzMatch
  • Loading branch information
MichaelMure authored Sep 18, 2024
2 parents 526a34b + ac73cae commit dd1f546
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 10 deletions.
79 changes: 79 additions & 0 deletions capability/policy/glob.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package policy

import "fmt"

type glob string

// parseGlob ensures that the pattern conforms to the spec: only '*' and escaped '\*' are allowed.
func parseGlob(pattern string) (glob, error) {
for i := 0; i < len(pattern); i++ {
if pattern[i] == '*' {
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '*' {
i++ // skip the escaped '*'
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) {
i++ // skip the escaped character
continue
}
if pattern[i] == '\\' {
return "", fmt.Errorf("invalid escape sequence")
}
}

return glob(pattern), nil
}

func mustParseGlob(pattern string) glob {
g, err := parseGlob(pattern)
if err != nil {
panic(err)
}
return g
}

// Match matches a string against the glob pattern with * wildcards, handling escaped '\*' literals.
func (pattern glob) Match(str string) bool {
// i is the index for the pattern
// j is the index for the string
var i, j int

// starIdx keeps track of the position of the last * in the pattern.
// matchIdx keeps track of the position in the string where the last * matched.
var starIdx, matchIdx int = -1, -1

for j < len(str) {
if i < len(pattern) && (pattern[i] == str[j] || pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == str[j]) {
// characters match or if there's an escaped character that matches
if pattern[i] == '\\' {
// skip the escape character
i++
}
i++
j++
} else if i < len(pattern) && pattern[i] == '*' {
// there's a * wildcard in the pattern
starIdx = i
matchIdx = j
i++
} else if starIdx != -1 {
// there's a previous * wildcard, backtrack
i = starIdx + 1
matchIdx++
j = matchIdx
} else {
// no match found
return false
}
}

// check for remaining characters in the pattern
for i < len(pattern) && pattern[i] == '*' {
i++
}

// the entire pattern is processed, it's a match
return i == len(pattern)
}
73 changes: 73 additions & 0 deletions capability/policy/glob_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package policy

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestSimpleGlobMatch(t *testing.T) {
tests := []struct {
pattern string
str string
matches bool
}{
// Basic matching
{"*", "anything", true},
{"a*", "abc", true},
{"*c", "abc", true},
{"a*c", "abc", true},
{"a*c", "abxc", true},
{"a*c", "ac", true},
{"a*c", "a", false},
{"a*c", "ab", false},

// Escaped characters
{"a\\*c", "a*c", true},
{"a\\*c", "abc", false},

// Mixed wildcards and literals
{"a*b*c", "abc", true},
{"a*b*c", "aXbYc", true},
{"a*b*c", "aXbY", false},
{"a*b*c", "abYc", true},
{"a*b*c", "aXbc", true},
{"a*b*c", "aXbYcZ", false},

// Edge cases
{"", "", true},
{"", "a", false},
{"*", "", true},
{"*", "a", true},
{"\\*", "*", true},
{"\\*", "a", false},

// Specified test cases
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Dan, Erin, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob , Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol", false},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol!", false},
{"Alice\\*, Bob*, Carol.", "Alice, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", "Alice Cooper, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", " Alice*, Bob, Carol. ", false},
}

for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.str, func(t *testing.T) {
g, err := parseGlob(tt.pattern)
require.NoError(t, err)
require.Equal(t, tt.matches, g.Match(tt.str))
})
}
}

func BenchmarkGlob(b *testing.B) {
b.ReportAllocs()

for i := 0; i < b.N; i++ {
g := mustParseGlob("Alice\\*, Bob*, Carol.")
g.Match("Alice*, Bob*, Carol!")
}
}
2 changes: 1 addition & 1 deletion capability/policy/ipld.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func statementToIPLD(statement Statement) (datamodel.Node, error) {
if err != nil {
return nil, err
}
err = listBuilder.AssembleValue().AssignString(statement.pattern)
err = listBuilder.AssembleValue().AssignString(string(statement.pattern))
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion capability/policy/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func matchStatement(statement Statement, node ipld.Node) bool {
if err != nil {
return false
}
return s.glob.Match(v)
return s.pattern.Match(v)
}
case KindAll:
if s, ok := statement.(quantifier); ok {
Expand Down
49 changes: 49 additions & 0 deletions capability/policy/match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,52 @@ func TestPolicyExamples(t *testing.T) {
require.True(t, evaluate(`["any", ".a", ["==", ".b", 2]]`, data))
})
}

func FuzzMatch(f *testing.F) {
// Policy + Data examples
f.Add([]byte(`[["==", ".status", "draft"]]`), []byte(`{"status": "draft"}`))
f.Add([]byte(`[["all", ".reviewer", ["like", ".email", "*@example.com"]]]`), []byte(`{"reviewer": [{"email": "alice@example.com"}, {"email": "bob@example.com"}]}`))
f.Add([]byte(`[["any", ".tags", ["or", [["==", ".", "news"], ["==", ".", "press"]]]]]`), []byte(`{"tags": ["news", "press"]}`))
f.Add([]byte(`[["==", ".name", "Alice"]]`), []byte(`{"name": "Alice"}`))
f.Add([]byte(`[[">", ".age", 30]]`), []byte(`{"age": 31}`))
f.Add([]byte(`[["<=", ".height", 180]]`), []byte(`{"height": 170}`))
f.Add([]byte(`[["not", ["==", ".status", "inactive"]]]`), []byte(`{"status": "active"}`))
f.Add([]byte(`[["and", [["==", ".role", "admin"], [">=", ".experience", 5]]]]`), []byte(`{"role": "admin", "experience": 6}`))
f.Add([]byte(`[["or", [["==", ".department", "HR"], ["==", ".department", "Finance"]]]]`), []byte(`{"department": "HR"}`))
f.Add([]byte(`[["like", ".email", "*@company.com"]]`), []byte(`{"email": "user@company.com"}`))
f.Add([]byte(`[["all", ".projects", [">", ".budget", 10000]]]`), []byte(`{"projects": [{"budget": 15000}, {"budget": 8000}]}`))
f.Add([]byte(`[["any", ".skills", ["==", ".", "Go"]]]`), []byte(`{"skills": ["Go", "Python", "JavaScript"]}`))
f.Add(
[]byte(`[["and", [
["==", ".name", "Bob"],
["or", [[">", ".age", 25],["==", ".status", "active"]]],
["all", ".tasks", ["==", ".completed", true]]
]]]`),
[]byte(`{
"name": "Bob",
"age": 26,
"status": "active",
"tasks": [{"completed": true}, {"completed": true}, {"completed": false}]
}`),
)

f.Fuzz(func(t *testing.T, policyBytes []byte, dataBytes []byte) {
policyNode, err := ipld.Decode(policyBytes, dagjson.Decode)
if err != nil {
t.Skip()
}

dataNode, err := ipld.Decode(dataBytes, dagjson.Decode)
if err != nil {
t.Skip()
}

// policy node -> policy object
policy, err := FromIPLD(policyNode)
if err != nil {
t.Skip()
}

Match(policy, dataNode)
})
}
9 changes: 4 additions & 5 deletions capability/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package policy
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#policy

import (
"github.com/gobwas/glob"
"github.com/ipld/go-ipld-prime"

"github.com/ucan-wg/go-ucan/capability/policy/selector"
Expand Down Expand Up @@ -90,20 +89,20 @@ func Or(stmts ...Statement) Statement {

type wildcard struct {
selector selector.Selector
pattern string
glob glob.Glob // not serialized
pattern glob
}

func (n wildcard) Kind() string {
return KindLike
}

func Like(selector selector.Selector, pattern string) (Statement, error) {
g, err := glob.Compile(pattern)
g, err := parseGlob(pattern)
if err != nil {
return nil, err
}
return wildcard{selector: selector, pattern: pattern, glob: g}, nil

return wildcard{selector: selector, pattern: g}, nil
}

type quantifier struct {
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.22
toolchain go1.22.4

require (
github.com/gobwas/glob v0.2.3
github.com/ipfs/go-cid v0.4.1
github.com/ipld/go-ipld-prime v0.21.0
github.com/libp2p/go-libp2p v0.36.3
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
Expand Down

0 comments on commit dd1f546

Please sign in to comment.