Skip to content

Commit c6c670b

Browse files
authored
fix(lite): fix glob cache key mutation, use QuoteMeta, and share single regex cache (minekube#675)
* fix(lite): simplify matchWithGroups and add cache/escaping tests Build on minekube#669: unify matchWithGroups to use regexp.QuoteMeta (same approach as the cache loader) instead of manual character-by-character escaping that missed some regex metacharacters. Add dedicated tests for cache consistency on repeated lookups and literal dot escaping. * fix(lite): cache matchWithGroups regex and add comprehensive tests - Cache compiled regexes in matchWithGroups via a separate ttlcache (same pattern as match), avoiding recompilation on every call - Simplify matchWithGroups to use regexp.QuoteMeta instead of manual character-by-character escaping that missed some metacharacters - Add cache consistency tests for both match and matchWithGroups - Add literal dot escaping tests to prevent regex wildcard regression - Add BenchmarkMatchWithGroups * refactor(lite): consolidate to single shared regex cache Both match and matchWithGroups now share one cache via getRegexp. With anchored patterns (^...$), lazy capture-group regexes produce the same boolean result as greedy non-capturing ones, so there's no need for two separate caches. * fix(lite): use TTL for regex cache to evict stale patterns on config reload Replace NoTTL with 1-hour TTL so patterns from removed routes are evicted. Active patterns stay alive since Get() refreshes the TTL.
1 parent f3e7f43 commit c6c670b

2 files changed

Lines changed: 110 additions & 59 deletions

File tree

pkg/edition/java/lite/match.go

Lines changed: 33 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package lite
33
import (
44
"regexp"
55
"strings"
6+
"time"
67

78
"github.com/jellydator/ttlcache/v3"
89
"go.minekube.com/gate/pkg/edition/java/lite/config"
@@ -36,32 +37,43 @@ func FindRouteWithGroups(pattern string, routes ...config.Route) (host string, r
3637
return "", nil, nil
3738
}
3839

40+
// compiledRegexCache caches compiled glob-to-regex patterns with capture groups.
41+
// Used by both match (boolean) and matchWithGroups (with captures).
42+
var compiledRegexCache = ttlcache.New[string, *regexp.Regexp](
43+
ttlcache.WithLoader[string, *regexp.Regexp](ttlcache.LoaderFunc[string, *regexp.Regexp](
44+
func(c *ttlcache.Cache[string, *regexp.Regexp], pattern string) *ttlcache.Item[string, *regexp.Regexp] {
45+
// pattern is the cache key, we must not modify it for the Set call.
46+
// Escape all regex metacharacters, then restore glob wildcards as capture groups.
47+
regexStr := regexp.QuoteMeta(pattern)
48+
regexStr = "^" + strings.ReplaceAll(regexStr, "\\?", "(.)") + "$"
49+
regexStr = strings.ReplaceAll(regexStr, "\\*", "(.*?)")
50+
reg, _ := regexp.Compile(regexStr)
51+
52+
return c.Set(pattern, reg, time.Hour)
53+
}),
54+
),
55+
)
56+
57+
func getRegexp(pattern string) *regexp.Regexp {
58+
pattern = strings.ToLower(pattern)
59+
item := compiledRegexCache.Get(pattern)
60+
if item == nil {
61+
return nil
62+
}
63+
return item.Value()
64+
}
65+
3966
// match takes in two strings, s and pattern, and returns a boolean indicating whether s matches pattern.
4067
//
4168
// The following special characters are used in pattern:
4269
//
4370
// '*': matches any sequence of characters (including an empty sequence)
4471
// '?': matches any single character
4572
func match(s, pattern string) bool {
46-
s = strings.ToLower(s)
47-
reg := compiledRegexCache.Get(pattern)
48-
return reg != nil && reg.Value() != nil && reg.Value().MatchString(s)
73+
reg := getRegexp(pattern)
74+
return reg != nil && reg.MatchString(strings.ToLower(s))
4975
}
5076

51-
var compiledRegexCache = ttlcache.New[string, *regexp.Regexp](
52-
ttlcache.WithLoader[string, *regexp.Regexp](ttlcache.LoaderFunc[string, *regexp.Regexp](
53-
func(c *ttlcache.Cache[string, *regexp.Regexp], pattern string) *ttlcache.Item[string, *regexp.Regexp] {
54-
55-
pattern = strings.ToLower(pattern)
56-
pattern = "^" + strings.ReplaceAll(pattern, "?", ".") + "$"
57-
pattern = strings.ReplaceAll(pattern, "*", ".*")
58-
reg, _ := regexp.Compile(pattern)
59-
60-
return c.Set(pattern, reg, ttlcache.NoTTL)
61-
}),
62-
),
63-
)
64-
6577
// matchWithGroups takes in two strings, s and pattern, and returns a boolean indicating whether s matches pattern,
6678
// along with captured groups from wildcards.
6779
//
@@ -72,50 +84,12 @@ var compiledRegexCache = ttlcache.New[string, *regexp.Regexp](
7284
//
7385
// Returns (matched bool, groups []string) where groups contains the captured values in order.
7486
func matchWithGroups(s, pattern string) (bool, []string) {
75-
s = strings.ToLower(s)
76-
pattern = strings.ToLower(pattern)
77-
78-
// Build regex pattern with capture groups
79-
regexPattern := "^"
80-
81-
// Track if we're inside a character class (not used in current patterns, but for safety)
82-
escapeNext := false
83-
for _, r := range pattern {
84-
if escapeNext {
85-
regexPattern += string(r)
86-
escapeNext = false
87-
continue
88-
}
89-
90-
switch r {
91-
case '*':
92-
// Each * becomes a capture group (.*?)
93-
regexPattern += "(.*?)"
94-
case '?':
95-
// Each ? becomes a capture group (.)
96-
regexPattern += "(.)"
97-
case '\\':
98-
// Escape next character
99-
escapeNext = true
100-
regexPattern += "\\"
101-
default:
102-
// Escape regex special characters
103-
if strings.ContainsRune(".^$+()[]{}|", r) {
104-
regexPattern += "\\"
105-
}
106-
regexPattern += string(r)
107-
}
108-
}
109-
110-
regexPattern += "$"
111-
112-
reg, err := regexp.Compile(regexPattern)
113-
if err != nil {
114-
// If regex compilation fails, fall back to simple match
115-
return match(s, pattern), nil
87+
reg := getRegexp(pattern)
88+
if reg == nil {
89+
return false, nil
11690
}
11791

118-
matches := reg.FindStringSubmatch(s)
92+
matches := reg.FindStringSubmatch(strings.ToLower(s))
11993
if matches == nil {
12094
return false, nil
12195
}

pkg/edition/java/lite/match_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ func Test_match(t *testing.T) {
3737
{"a", "A*", true},
3838
{"A", "a*", true},
3939
{"abc.example.COm", "*.Example.Com", true},
40+
// Dots are treated as literal characters, not regex wildcards
41+
{"exampleXcom", "example.com", false},
42+
{"example.com", "example.com", true},
43+
// Repeated calls with same pattern hit the cache and still work
44+
{"foo.example.com", "*.example.com", true},
45+
{"bar.example.com", "*.example.com", true},
46+
{"notmatching.other.com", "*.example.com", false},
4047
}
4148

4249
for _, test := range tests {
@@ -54,6 +61,60 @@ func BenchmarkMatch(b *testing.B) {
5461
}
5562
}
5663

64+
func BenchmarkMatchWithGroups(b *testing.B) {
65+
s := "abc.example.com"
66+
pattern := "*.example.*"
67+
for b.Loop() {
68+
matchWithGroups(s, pattern)
69+
}
70+
}
71+
72+
func Test_matchCacheConsistency(t *testing.T) {
73+
// Verify that the cache returns correct results on repeated lookups
74+
// with the same pattern. This caught a bug where the loader mutated
75+
// the pattern key before storing, causing perpetual cache misses.
76+
pattern := "*.example.com"
77+
cases := []struct {
78+
s string
79+
want bool
80+
}{
81+
{"first.example.com", true},
82+
{"second.example.com", true},
83+
{"third.example.com", true},
84+
{"notmatching.other.com", false},
85+
{"fourth.example.com", true},
86+
}
87+
for _, tc := range cases {
88+
if got := match(tc.s, pattern); got != tc.want {
89+
t.Errorf("match(%q, %q) = %v, want %v", tc.s, pattern, got, tc.want)
90+
}
91+
}
92+
}
93+
94+
func Test_matchWithGroupsCacheConsistency(t *testing.T) {
95+
pattern := "*.example.*"
96+
cases := []struct {
97+
s string
98+
want bool
99+
wantGroups []string
100+
}{
101+
{"first.example.com", true, []string{"first", "com"}},
102+
{"second.example.org", true, []string{"second", "org"}},
103+
{"third.example.net", true, []string{"third", "net"}},
104+
{"notmatching.other.com", false, nil},
105+
{"fourth.example.io", true, []string{"fourth", "io"}},
106+
}
107+
for _, tc := range cases {
108+
got, groups := matchWithGroups(tc.s, pattern)
109+
if got != tc.want {
110+
t.Errorf("matchWithGroups(%q, %q) = %v, want %v", tc.s, pattern, got, tc.want)
111+
}
112+
if !slices.Equal(groups, tc.wantGroups) {
113+
t.Errorf("matchWithGroups(%q, %q) groups = %v, want %v", tc.s, pattern, groups, tc.wantGroups)
114+
}
115+
}
116+
}
117+
57118
func Test_matchWithGroups(t *testing.T) {
58119
tests := []struct {
59120
name string
@@ -212,6 +273,22 @@ func Test_matchWithGroups(t *testing.T) {
212273
wantGroups: []string{"abc", "com"},
213274
},
214275

276+
// Dot treated as literal
277+
{
278+
name: "dot is literal not regex wildcard",
279+
s: "exampleXcom",
280+
pattern: "example.com",
281+
want: false,
282+
wantGroups: nil,
283+
},
284+
{
285+
name: "dot matches literal dot",
286+
s: "example.com",
287+
pattern: "example.com",
288+
want: true,
289+
wantGroups: []string{},
290+
},
291+
215292
// Complex patterns
216293
{
217294
name: "multiple stars in sequence",

0 commit comments

Comments
 (0)