From 0287fc0edda9dde01aa4fbf2406ff739bb716986 Mon Sep 17 00:00:00 2001 From: akmalsyrf Date: Fri, 17 Jul 2026 15:54:06 +0700 Subject: [PATCH 1/3] perf: add fast paths to KeyMatch2/3/4/5 for literals and "*" Skip string transforms and RegexMatch when key2 is equal, a bare "*", or has no pattern characters. Preserves existing semantics while cutting BuildRoleLinks cost for RBAC domain graphs that are mostly concrete domains plus an admin "*" wildcard (see #1004). Co-authored-by: Cursor --- util/builtin_operators.go | 43 ++++++++++++++++++++++++ util/builtin_operators_b_test.go | 57 ++++++++++++++++++++++++++++++++ util/builtin_operators_test.go | 17 ++++++++++ 3 files changed, 117 insertions(+) create mode 100644 util/builtin_operators_b_test.go diff --git a/util/builtin_operators.go b/util/builtin_operators.go index e2c811486..13467b531 100644 --- a/util/builtin_operators.go +++ b/util/builtin_operators.go @@ -137,6 +137,19 @@ func KeyGetFunc(args ...interface{}) (interface{}, error) { // KeyMatch2 determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. // For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/:resource". func KeyMatch2(key1 string, key2 string) bool { + // Fast paths: equality, bare "*", and pattern-free literals avoid the + // string-transform + RegexMatch work that dominates BuildRoleLinks when + // DomainMatchingFunc is KeyMatch2 over mostly-concrete domains (see #1004). + if key1 == key2 { + return true + } + if key2 == "*" { + return true + } + if !strings.ContainsAny(key2, "*:") { + return false + } + key2 = strings.Replace(key2, "/*", "/.*", -1) key2 = keyMatch2Re.ReplaceAllString(key2, "$1[^/]+$2") @@ -194,6 +207,16 @@ func KeyGet2Func(args ...interface{}) (interface{}, error) { // KeyMatch3 determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. // For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/{resource}". func KeyMatch3(key1 string, key2 string) bool { + if key1 == key2 { + return true + } + if key2 == "*" { + return true + } + if !strings.ContainsAny(key2, "*{") { + return false + } + key2 = strings.Replace(key2, "/*", "/.*", -1) key2 = keyMatch3Re.ReplaceAllString(key2, "$1[^/]+$2") @@ -253,6 +276,16 @@ func KeyGet3Func(args ...interface{}) (interface{}, error) { // "/parent/123/child/456" does not match "/parent/{id}/child/{id}" // But KeyMatch3 will match both. func KeyMatch4(key1 string, key2 string) bool { + if key1 == key2 { + return true + } + if key2 == "*" { + return true + } + if !strings.ContainsAny(key2, "*{") { + return false + } + key2 = strings.Replace(key2, "/*", "/.*", -1) tokens := []string{} @@ -312,6 +345,16 @@ func KeyMatch5(key1 string, key2 string) bool { key1 = key1[:i] } + if key1 == key2 { + return true + } + if key2 == "*" { + return true + } + if !strings.ContainsAny(key2, "*{") { + return false + } + key2 = strings.Replace(key2, "/*", "/.*", -1) key2 = keyMatch5Re.ReplaceAllString(key2, "$1[^/]+$2") diff --git a/util/builtin_operators_b_test.go b/util/builtin_operators_b_test.go new file mode 100644 index 000000000..3d50d7b15 --- /dev/null +++ b/util/builtin_operators_b_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "fmt" + "testing" +) + +// BenchmarkKeyMatch2_LiteralDomains mimics DomainManager comparing many +// concrete domains (plus a bare "*" admin wildcard) during BuildRoleLinks. +func BenchmarkKeyMatch2_LiteralDomains(b *testing.B) { + const n = 1200 + domains := make([]string, n) + for i := 0; i < n; i++ { + domains[i] = fmt.Sprintf("team/%d", i) + } + domains = append(domains, "*") + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, d1 := range domains { + for _, d2 := range domains { + _ = KeyMatch2(d1, d2) + } + } + } +} + +func BenchmarkKeyMatch2_RESTPatterns(b *testing.B) { + pairs := [][2]string{ + {"/proxy/myid/res", "/proxy/:id/*"}, + {"/alice/all", "/:id/all"}, + {"/resource1", "/:resource"}, + {"/foo/bar", "/foo/*"}, + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, p := range pairs { + _ = KeyMatch2(p[0], p[1]) + } + } +} diff --git a/util/builtin_operators_test.go b/util/builtin_operators_test.go index ba0f463db..19d9718ef 100644 --- a/util/builtin_operators_test.go +++ b/util/builtin_operators_test.go @@ -114,6 +114,15 @@ func TestKeyMatch2(t *testing.T) { testKeyMatch2(t, "/alice/all", "/:id", false) testKeyMatch2(t, "/alice/all", "/:/all", false) + + // Bare "*" matches any key (used as domain wildcard with AddNamedDomainMatchingFunc). + testKeyMatch2(t, "team/1", "*", true) + testKeyMatch2(t, "", "*", true) + testKeyMatch2(t, "*", "*", true) + testKeyMatch2(t, "*", "team/1", false) + // Pattern-free literals only match via equality. + testKeyMatch2(t, "team/1", "team/2", false) + testKeyMatch2(t, "team/1", "team/1", true) } func testKeyGet2(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -194,6 +203,10 @@ func TestKeyMatch3(t *testing.T) { testKeyMatch3(t, "/proxy/", "/proxy/{id}/*", false) testKeyMatch3(t, "/myid/using/myresid", "/{id/using/{resId}", false) + + testKeyMatch3(t, "team/1", "*", true) + testKeyMatch3(t, "team/1", "team/2", false) + testKeyMatch3(t, "team/1", "team/1", true) } func testKeyGet3(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -270,6 +283,10 @@ func TestKeyMatch4(t *testing.T) { testKeyMatch4(t, "/parent/123/child/456", "/parent/{id}/child/{id}/book/{id}", false) testKeyMatch4(t, "/parent/123/child/123", "/parent/{i/d}/child/{i/d}", false) + + testKeyMatch4(t, "team/1", "*", true) + testKeyMatch4(t, "team/1", "team/2", false) + testKeyMatch4(t, "team/1", "team/1", true) } func testRegexMatch(t *testing.T, key1 string, key2 string, res bool) { From 8749e5c9c4c1795a60ecfebe673a138778a6b689 Mon Sep 17 00:00:00 2001 From: akmalsyrf Date: Fri, 17 Jul 2026 16:05:03 +0700 Subject: [PATCH 2/3] fix: make KeyMatch fast path respect unescaped regexp metas Only treat key2 as a pure literal when QuoteMeta leaves it unchanged (plus KeyMatch2's ":" dialect). Preserves KeyMatch2("acmeXcom","acme.com") and similar cases. Factor shared helper; document bare "*" as #330 fix. Co-authored-by: Cursor --- util/builtin_operators.go | 78 +++++++++++++++++++--------------- util/builtin_operators_test.go | 11 ++++- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/util/builtin_operators.go b/util/builtin_operators.go index 13467b531..160f786f8 100644 --- a/util/builtin_operators.go +++ b/util/builtin_operators.go @@ -56,6 +56,37 @@ func mustCompileOrGet(key string) *regexp.Regexp { return re } +// keyMatchFastPath handles cheap cases shared by KeyMatch2/3/4/5 before the +// string-transform + RegexMatch path. +// +// When handled is true, matched is the final result and the caller must return it. +// +// Cases: +// 1. key1 == key2 → true +// 2. key2 == "*" → true (bare wildcard). Explicitly handled so domain matchers +// using AddNamedDomainMatchingFunc do not depend on compiling "^*$", which +// historically panicked ("missing argument to repetition operator") on some +// Go/regexp versions — see #330. On current Go, "^*$" match-alls; either way +// the intended semantics for a bare "*" is match-any. +// 3. key2 is a pure literal → false (equality already checked). RegexMatch +// embeds key2 unescaped into "^"+key2+"$", so it is not enough to look only +// for matcher-specific pattern characters (* / : / {): values like "acme.com" +// contain no KeyMatch dialect markers but `.` is still a regexp metacharacter. +// We therefore require regexp.QuoteMeta(key2) == key2, plus none of +// extraPatternChars (e.g. ":" for KeyMatch2, which QuoteMeta does not escape). +func keyMatchFastPath(key1, key2, extraPatternChars string) (matched bool, handled bool) { + if key1 == key2 { + return true, true + } + if key2 == "*" { + return true, true + } + if regexp.QuoteMeta(key2) == key2 && !strings.ContainsAny(key2, extraPatternChars) { + return false, true + } + return false, false +} + // validate the variadic parameter size and type as string. func validateVariadicArgs(expectedLen int, args ...interface{}) error { if len(args) != expectedLen { @@ -137,17 +168,12 @@ func KeyGetFunc(args ...interface{}) (interface{}, error) { // KeyMatch2 determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. // For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/:resource". func KeyMatch2(key1 string, key2 string) bool { - // Fast paths: equality, bare "*", and pattern-free literals avoid the - // string-transform + RegexMatch work that dominates BuildRoleLinks when - // DomainMatchingFunc is KeyMatch2 over mostly-concrete domains (see #1004). - if key1 == key2 { - return true - } - if key2 == "*" { - return true - } - if !strings.ContainsAny(key2, "*:") { - return false + // Fast paths avoid string-transform + RegexMatch work that dominates + // BuildRoleLinks when DomainMatchingFunc is KeyMatch2 over mostly-concrete + // domains (see #1004). extraPatternChars ":" covers KeyMatch2's :param + // dialect (not escaped by QuoteMeta). + if matched, handled := keyMatchFastPath(key1, key2, ":"); handled { + return matched } key2 = strings.Replace(key2, "/*", "/.*", -1) @@ -207,14 +233,8 @@ func KeyGet2Func(args ...interface{}) (interface{}, error) { // KeyMatch3 determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. // For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/{resource}". func KeyMatch3(key1 string, key2 string) bool { - if key1 == key2 { - return true - } - if key2 == "*" { - return true - } - if !strings.ContainsAny(key2, "*{") { - return false + if matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched } key2 = strings.Replace(key2, "/*", "/.*", -1) @@ -276,14 +296,8 @@ func KeyGet3Func(args ...interface{}) (interface{}, error) { // "/parent/123/child/456" does not match "/parent/{id}/child/{id}" // But KeyMatch3 will match both. func KeyMatch4(key1 string, key2 string) bool { - if key1 == key2 { - return true - } - if key2 == "*" { - return true - } - if !strings.ContainsAny(key2, "*{") { - return false + if matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched } key2 = strings.Replace(key2, "/*", "/.*", -1) @@ -345,14 +359,8 @@ func KeyMatch5(key1 string, key2 string) bool { key1 = key1[:i] } - if key1 == key2 { - return true - } - if key2 == "*" { - return true - } - if !strings.ContainsAny(key2, "*{") { - return false + if matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched } key2 = strings.Replace(key2, "/*", "/.*", -1) diff --git a/util/builtin_operators_test.go b/util/builtin_operators_test.go index 19d9718ef..e781df4a1 100644 --- a/util/builtin_operators_test.go +++ b/util/builtin_operators_test.go @@ -115,14 +115,19 @@ func TestKeyMatch2(t *testing.T) { testKeyMatch2(t, "/alice/all", "/:/all", false) - // Bare "*" matches any key (used as domain wildcard with AddNamedDomainMatchingFunc). + // Bare "*" is match-any (domain wildcard / #330 — avoid depending on "^*$"). testKeyMatch2(t, "team/1", "*", true) testKeyMatch2(t, "", "*", true) testKeyMatch2(t, "*", "*", true) testKeyMatch2(t, "*", "team/1", false) - // Pattern-free literals only match via equality. + // Pure literals only match via equality. testKeyMatch2(t, "team/1", "team/2", false) testKeyMatch2(t, "team/1", "team/1", true) + // RegexMatch does not QuoteMeta key2: non-matcher regexp metas like "." must + // still take the regex path (behavior-preserving vs pre-fast-path KeyMatch2). + testKeyMatch2(t, "acmeXcom", "acme.com", true) + testKeyMatch2(t, "api.example.com", "api.example.com", true) + testKeyMatch2(t, "api.example.com", "api.example.org", false) } func testKeyGet2(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -207,6 +212,7 @@ func TestKeyMatch3(t *testing.T) { testKeyMatch3(t, "team/1", "*", true) testKeyMatch3(t, "team/1", "team/2", false) testKeyMatch3(t, "team/1", "team/1", true) + testKeyMatch3(t, "acmeXcom", "acme.com", true) } func testKeyGet3(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -287,6 +293,7 @@ func TestKeyMatch4(t *testing.T) { testKeyMatch4(t, "team/1", "*", true) testKeyMatch4(t, "team/1", "team/2", false) testKeyMatch4(t, "team/1", "team/1", true) + testKeyMatch4(t, "acmeXcom", "acme.com", true) } func testRegexMatch(t *testing.T, key1 string, key2 string, res bool) { From 422ddb93545b76f529b460e87297f93fd05c2922 Mon Sep 17 00:00:00 2001 From: akmalsyrf Date: Fri, 17 Jul 2026 16:17:11 +0700 Subject: [PATCH 3/3] fix: gate KeyMatch equality shortcut behind literal check A standalone key1 == key2 short-circuit is not behavior-preserving when key2 contains regexp metacharacters that do not match themselves, e.g. "^a+b$" does not match "a+b" and "^ab)$" panics. Move the equality check inside the pure-literal gate so only genuine literals return via equality; everything else falls through to the existing RegexMatch path unchanged. Also cover KeyMatch5 fast paths, KeyMatch2 vs KeyMatch3 colon asymmetry, and meta-equality regressions across KeyMatch3/4/5. Co-authored-by: Cursor --- util/builtin_operators.go | 28 +++++++---------------- util/builtin_operators_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/util/builtin_operators.go b/util/builtin_operators.go index 160f786f8..f16244a7e 100644 --- a/util/builtin_operators.go +++ b/util/builtin_operators.go @@ -57,32 +57,20 @@ func mustCompileOrGet(key string) *regexp.Regexp { } // keyMatchFastPath handles cheap cases shared by KeyMatch2/3/4/5 before the -// string-transform + RegexMatch path. +// string-transform + RegexMatch path. handled=true means matched is final. // -// When handled is true, matched is the final result and the caller must return it. -// -// Cases: -// 1. key1 == key2 → true -// 2. key2 == "*" → true (bare wildcard). Explicitly handled so domain matchers -// using AddNamedDomainMatchingFunc do not depend on compiling "^*$", which -// historically panicked ("missing argument to repetition operator") on some -// Go/regexp versions — see #330. On current Go, "^*$" match-alls; either way -// the intended semantics for a bare "*" is match-any. -// 3. key2 is a pure literal → false (equality already checked). RegexMatch -// embeds key2 unescaped into "^"+key2+"$", so it is not enough to look only -// for matcher-specific pattern characters (* / : / {): values like "acme.com" -// contain no KeyMatch dialect markers but `.` is still a regexp metacharacter. -// We therefore require regexp.QuoteMeta(key2) == key2, plus none of -// extraPatternChars (e.g. ":" for KeyMatch2, which QuoteMeta does not escape). +// 1. key2 == "*" → true (bare wildcard / domain admin). Avoids depending on +// compiling "^*$", which historically panicked on some Go versions (#330). +// 2. key2 is a pure literal → key1 == key2. Literal means QuoteMeta(key2) == key2 +// and no matcher-specific chars in extraPatternChars (e.g. ":" for KeyMatch2's +// :param dialect). Equality is inside this gate so patterns or stray regexp +// metas (".", "+", …) still take the regex path and keep prior behavior. func keyMatchFastPath(key1, key2, extraPatternChars string) (matched bool, handled bool) { - if key1 == key2 { - return true, true - } if key2 == "*" { return true, true } if regexp.QuoteMeta(key2) == key2 && !strings.ContainsAny(key2, extraPatternChars) { - return false, true + return key1 == key2, true } return false, false } diff --git a/util/builtin_operators_test.go b/util/builtin_operators_test.go index e781df4a1..b79b851d9 100644 --- a/util/builtin_operators_test.go +++ b/util/builtin_operators_test.go @@ -123,11 +123,21 @@ func TestKeyMatch2(t *testing.T) { // Pure literals only match via equality. testKeyMatch2(t, "team/1", "team/2", false) testKeyMatch2(t, "team/1", "team/1", true) + // ":" is KeyMatch2 :param dialect (not a Go regexp meta). Must NOT take the + // literal equality fast path: "team:1" becomes "team[^/]+", so unequal + // suffixes still match. KeyMatch3 treats ":" as a literal (see TestKeyMatch3). + testKeyMatch2(t, "team:1", "team:1", true) + testKeyMatch2(t, "team:1", "team:2", true) + testKeyMatch2(t, "team:1", "other:1", false) // RegexMatch does not QuoteMeta key2: non-matcher regexp metas like "." must // still take the regex path (behavior-preserving vs pre-fast-path KeyMatch2). testKeyMatch2(t, "acmeXcom", "acme.com", true) testKeyMatch2(t, "api.example.com", "api.example.com", true) testKeyMatch2(t, "api.example.com", "api.example.org", false) + // key1 == key2 must NOT short-circuit to true when key2 carries a regexp + // metacharacter that does not match itself: "^a+b$" does not match "a+b". + testKeyMatch2(t, "a+b", "a+b", false) + testKeyMatch2(t, "a?b", "a?b", false) } func testKeyGet2(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -212,7 +222,12 @@ func TestKeyMatch3(t *testing.T) { testKeyMatch3(t, "team/1", "*", true) testKeyMatch3(t, "team/1", "team/2", false) testKeyMatch3(t, "team/1", "team/1", true) + // Unlike KeyMatch2, ":" is not a pattern char here — pure literal equality. + testKeyMatch3(t, "team:1", "team:1", true) + testKeyMatch3(t, "team:1", "team:2", false) testKeyMatch3(t, "acmeXcom", "acme.com", true) + testKeyMatch3(t, "a+b", "a+b", false) + testKeyMatch3(t, "a?b", "a?b", false) } func testKeyGet3(t *testing.T, key1 string, key2 string, pathVar string, res string) { @@ -294,6 +309,33 @@ func TestKeyMatch4(t *testing.T) { testKeyMatch4(t, "team/1", "team/2", false) testKeyMatch4(t, "team/1", "team/1", true) testKeyMatch4(t, "acmeXcom", "acme.com", true) + testKeyMatch4(t, "a+b", "a+b", false) + testKeyMatch4(t, "a?b", "a?b", false) +} + +func testKeyMatch5(t *testing.T, key1 string, key2 string, res bool) { + t.Helper() + myRes := KeyMatch5(key1, key2) + t.Logf("%s < %s: %t", key1, key2, myRes) + + if myRes != res { + t.Errorf("%s < %s: %t, supposed to be %t", key1, key2, !res, res) + } +} + +func TestKeyMatch5(t *testing.T) { + testKeyMatch5(t, "/foo/bar?status=1&type=2", "/foo/bar", true) + testKeyMatch5(t, "/foo/bar?status=1&type=2", "/foo/baz", false) + testKeyMatch5(t, "/parent/child1?status=1", "/parent/*", true) + + testKeyMatch5(t, "team/1", "*", true) + testKeyMatch5(t, "/foo/bar?x=1", "*", true) + testKeyMatch5(t, "team/1", "team/2", false) + testKeyMatch5(t, "team/1", "team/1", true) + testKeyMatch5(t, "/foo/bar?x=1", "/foo/bar", true) + testKeyMatch5(t, "acmeXcom", "acme.com", true) + testKeyMatch5(t, "a+b", "a+b", false) + testKeyMatch5(t, "a?b", "a?b", false) } func testRegexMatch(t *testing.T, key1 string, key2 string, res bool) {