diff --git a/capability/policy/glob.go b/capability/policy/glob.go new file mode 100644 index 0000000..56cb505 --- /dev/null +++ b/capability/policy/glob.go @@ -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) +} diff --git a/capability/policy/glob_test.go b/capability/policy/glob_test.go new file mode 100644 index 0000000..a89bce3 --- /dev/null +++ b/capability/policy/glob_test.go @@ -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!") + } +} diff --git a/capability/policy/ipld.go b/capability/policy/ipld.go index 36f1519..2aea8d8 100644 --- a/capability/policy/ipld.go +++ b/capability/policy/ipld.go @@ -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 } diff --git a/capability/policy/match.go b/capability/policy/match.go index 14cdc94..5313af4 100644 --- a/capability/policy/match.go +++ b/capability/policy/match.go @@ -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 { diff --git a/capability/policy/match_test.go b/capability/policy/match_test.go index b18aee9..4f7dc9b 100644 --- a/capability/policy/match_test.go +++ b/capability/policy/match_test.go @@ -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) + }) +} diff --git a/capability/policy/policy.go b/capability/policy/policy.go index 73bdb12..e6f385e 100644 --- a/capability/policy/policy.go +++ b/capability/policy/policy.go @@ -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" @@ -90,8 +89,7 @@ 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 { @@ -99,11 +97,12 @@ func (n wildcard) Kind() string { } 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 { diff --git a/go.mod b/go.mod index 9a34565..d18a964 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3d3968c..4121534 100644 --- a/go.sum +++ b/go.sum @@ -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=