diff --git a/util/builtin_operators.go b/util/builtin_operators.go index e2c81148..f16244a7 100644 --- a/util/builtin_operators.go +++ b/util/builtin_operators.go @@ -56,6 +56,25 @@ func mustCompileOrGet(key string) *regexp.Regexp { return re } +// keyMatchFastPath handles cheap cases shared by KeyMatch2/3/4/5 before the +// string-transform + RegexMatch path. handled=true means matched is final. +// +// 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 key2 == "*" { + return true, true + } + if regexp.QuoteMeta(key2) == key2 && !strings.ContainsAny(key2, extraPatternChars) { + return key1 == key2, 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,6 +156,14 @@ 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 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) key2 = keyMatch2Re.ReplaceAllString(key2, "$1[^/]+$2") @@ -194,6 +221,10 @@ 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 matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched + } + key2 = strings.Replace(key2, "/*", "/.*", -1) key2 = keyMatch3Re.ReplaceAllString(key2, "$1[^/]+$2") @@ -253,6 +284,10 @@ 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 matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched + } + key2 = strings.Replace(key2, "/*", "/.*", -1) tokens := []string{} @@ -312,6 +347,10 @@ func KeyMatch5(key1 string, key2 string) bool { key1 = key1[:i] } + if matched, handled := keyMatchFastPath(key1, key2, ""); handled { + return matched + } + 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 00000000..3d50d7b1 --- /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 ba0f463d..b79b851d 100644 --- a/util/builtin_operators_test.go +++ b/util/builtin_operators_test.go @@ -114,6 +114,30 @@ func TestKeyMatch2(t *testing.T) { testKeyMatch2(t, "/alice/all", "/:id", false) testKeyMatch2(t, "/alice/all", "/:/all", false) + + // 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) + // 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) { @@ -194,6 +218,16 @@ 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) + // 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) { @@ -270,6 +304,38 @@ 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) + 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) {