Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions util/builtin_operators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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")

Expand Down
57 changes: 57 additions & 0 deletions util/builtin_operators_b_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
66 changes: 66 additions & 0 deletions util/builtin_operators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down