Skip to content

Commit de7b7c5

Browse files
Vonngklauspost
andcommitted
fix(wildcard): bound matching while preserving legacy semantics
Replace exponential recursion with a single star backtracking point. Preserve byte matching and MatchSimple's historical acceptance when a question mark is reached after the name is exhausted. Check these exits within each star-free segment to avoid repeatedly matching whole prefixes on long question-mark near misses. Preserve the immediate success for a trailing star so long object names do not add needless scanning. Keep constant auxiliary space. Adapt the matcher and equivalence tests from minio/pkg commit 911bb0d. Add differential fuzzing, explicit legacy cases, and star-backtracking and near-miss benchmarks. Co-authored-by: Klaus Post <klauspost@gmail.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
1 parent fd2cb71 commit de7b7c5

2 files changed

Lines changed: 299 additions & 24 deletions

File tree

wildcard/match.go

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,19 @@
1717

1818
package wildcard
1919

20-
import (
21-
"cmp"
22-
"strings"
23-
)
20+
import "strings"
2421

2522
// MatchSimple - finds whether the text matches/satisfies the pattern string.
2623
// supports '*' wildcard in the pattern and ? for single characters.
27-
// Only difference to Match is that `?` at the end is optional,
28-
// meaning `a?` pattern will match name `a`.
24+
// Unlike Match, reaching '?' after name is exhausted accepts the rest of the
25+
// pattern. For example, both "a?" and "a?b" match "a".
2926
func MatchSimple(pattern, name string) bool {
3027
if pattern == "" {
3128
return name == pattern
3229
}
3330
if pattern == "*" {
3431
return true
3532
}
36-
// Do an extended wildcard '*' and '?' match.
3733
return deepMatchRune(name, pattern, true)
3834
}
3935

@@ -54,29 +50,73 @@ func Match(pattern, name string) (matched bool) {
5450

5551
// Has returns true if the input pattern has a wildcard (pattern).
5652
func Has(pattern string) bool {
57-
return cmp.Or(strings.Contains(pattern, "*"), strings.Contains(pattern, "?"))
53+
return strings.ContainsAny(pattern, "*?")
5854
}
5955

56+
// Keep one backtracking point instead of recursively exploring both
57+
// alternatives for every '*'.
6058
func deepMatchRune(str, pattern string, simple bool) bool {
61-
for len(pattern) > 0 {
62-
switch pattern[0] {
63-
default:
64-
if len(str) == 0 || str[0] != pattern[0] {
65-
return false
66-
}
67-
case '?':
68-
if len(str) == 0 {
69-
return simple
59+
var s, p int
60+
// Position of the '*' to resume from, and how much of str it has consumed.
61+
star, mark := -1, 0
62+
for s < len(str) || p < len(pattern) {
63+
if p < len(pattern) {
64+
switch pattern[p] {
65+
case '*':
66+
star, mark = p, s
67+
p++
68+
if p == len(pattern) {
69+
return true
70+
}
71+
if simple {
72+
// Before a later '*' replaces this one, check whether its
73+
// star-free segment can reach '?' with the name exhausted.
74+
for end := p; end < len(pattern) && pattern[end] != '*' && end-p <= len(str)-s; end++ {
75+
if pattern[end] == '?' && matchFixedSuffix(str[s:], pattern[p:end]) {
76+
return true
77+
}
78+
}
79+
}
80+
continue
81+
case '?':
82+
if simple && s == len(str) {
83+
return true
84+
}
85+
if s < len(str) {
86+
s++
87+
p++
88+
continue
89+
}
90+
default:
91+
if s < len(str) && pattern[p] == str[s] {
92+
s++
93+
p++
94+
continue
95+
}
7096
}
71-
case '*':
72-
return len(pattern) == 1 || // Pattern ends with this star
73-
deepMatchRune(str, pattern[1:], simple) || // Matches next part of pattern
74-
(len(str) > 0 && deepMatchRune(str[1:], pattern, simple)) // Continue searching forward
7597
}
76-
str = str[1:]
77-
pattern = pattern[1:]
98+
if star < 0 {
99+
return false
100+
}
101+
// Let the last '*' swallow one more byte and retry from there.
102+
mark++
103+
if mark > len(str) {
104+
return false
105+
}
106+
s, p = mark, star+1
107+
}
108+
return true
109+
}
110+
111+
// matchFixedSuffix checks a star-free pattern already known to fit in str.
112+
func matchFixedSuffix(str, pattern string) bool {
113+
str = str[len(str)-len(pattern):]
114+
for i := len(pattern) - 1; i >= 0; i-- {
115+
if pattern[i] != '?' && pattern[i] != str[i] {
116+
return false
117+
}
78118
}
79-
return len(str) == 0 && len(pattern) == 0
119+
return true
80120
}
81121

82122
// MatchAsPatternPrefix matches text as a prefix of the given pattern. Examples:

wildcard/match_equivalence_test.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright (c) 2015-2026 MinIO, Inc.
2+
//
3+
// This file is part of MinIO Object Storage stack
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU Affero General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU Affero General Public License
16+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
package wildcard
19+
20+
import (
21+
"strings"
22+
"testing"
23+
)
24+
25+
// oldDeepMatchRune is the recursive matcher deepMatchRune replaced, kept here so
26+
// the rewrite can be proven equivalent rather than asserted to be.
27+
func oldDeepMatchRune(str, pattern string, simple bool) bool {
28+
for len(pattern) > 0 {
29+
switch pattern[0] {
30+
default:
31+
if len(str) == 0 || str[0] != pattern[0] {
32+
return false
33+
}
34+
case '?':
35+
if len(str) == 0 {
36+
return simple
37+
}
38+
case '*':
39+
return len(pattern) == 1 ||
40+
oldDeepMatchRune(str, pattern[1:], simple) ||
41+
(len(str) > 0 && oldDeepMatchRune(str[1:], pattern, simple))
42+
}
43+
str = str[1:]
44+
pattern = pattern[1:]
45+
}
46+
return len(str) == 0 && len(pattern) == 0
47+
}
48+
49+
func gen(alphabet string, maxLen int) []string {
50+
out := []string{""}
51+
cur := []string{""}
52+
for range maxLen {
53+
var next []string
54+
for _, s := range cur {
55+
for _, c := range alphabet {
56+
next = append(next, s+string(c))
57+
}
58+
}
59+
out = append(out, next...)
60+
cur = next
61+
}
62+
return out
63+
}
64+
65+
// Keep the previous matcher as the compatibility oracle for both modes.
66+
func TestDeepMatchEquivalenceExhaustive(t *testing.T) {
67+
pats := gen("ab*?", 5)
68+
names := gen("ab", 5)
69+
var n int
70+
for _, p := range pats {
71+
for _, name := range names {
72+
if got, want := Match(p, name), oldMatch(p, name); got != want {
73+
t.Fatalf("Match(%q, %q) = %v, old = %v", p, name, got, want)
74+
}
75+
if got, want := MatchSimple(p, name), oldMatchSimple(p, name); got != want {
76+
t.Fatalf("MatchSimple(%q, %q) = %v, old = %v", p, name, got, want)
77+
}
78+
n += 2
79+
}
80+
}
81+
t.Logf("%d pattern/name/mode combinations agree", n)
82+
}
83+
84+
// Same for the exported entry points, over a colon-bearing alphabet closer to
85+
// policy actions and resource ARNs.
86+
func TestMatchEquivalenceExhaustive(t *testing.T) {
87+
pats := gen("a:*?", 4)
88+
names := gen("a:/", 4)
89+
var n int
90+
for _, p := range pats {
91+
for _, name := range names {
92+
if got, want := Match(p, name), oldMatch(p, name); got != want {
93+
t.Fatalf("Match(%q, %q) = %v, old = %v", p, name, got, want)
94+
}
95+
if got, want := MatchSimple(p, name), oldMatchSimple(p, name); got != want {
96+
t.Fatalf("MatchSimple(%q, %q) = %v, old = %v", p, name, got, want)
97+
}
98+
n += 2
99+
}
100+
}
101+
t.Logf("%d exported-entry-point combinations agree", n)
102+
}
103+
104+
func oldMatch(pattern, name string) bool {
105+
if pattern == "" {
106+
return name == pattern
107+
}
108+
if pattern == "*" {
109+
return true
110+
}
111+
return oldDeepMatchRune(name, pattern, false)
112+
}
113+
114+
func oldMatchSimple(pattern, name string) bool {
115+
if pattern == "" {
116+
return name == pattern
117+
}
118+
if pattern == "*" {
119+
return true
120+
}
121+
return oldDeepMatchRune(name, pattern, true)
122+
}
123+
124+
func TestMatchManyStars(t *testing.T) {
125+
name := "admin:ServerInfo"
126+
for _, stars := range []int{8, 16, 64, 256} {
127+
pattern := strings.Repeat("*", stars) + "X"
128+
if Match(pattern, name) {
129+
t.Fatalf("pattern %d stars + X should not match %q", stars, name)
130+
}
131+
if MatchSimple(pattern, name) {
132+
t.Fatalf("simple pattern %d stars + X should not match %q", stars, name)
133+
}
134+
}
135+
// Interleaved stars are the harder shape.
136+
pattern := strings.Repeat("*a", 32) + "X"
137+
name = strings.Repeat("a", 128)
138+
if Match(pattern, name) {
139+
t.Errorf("pattern %q should not match %q", pattern, name)
140+
}
141+
if MatchSimple(pattern, name) {
142+
t.Errorf("simple pattern %q should not match %q", pattern, name)
143+
}
144+
}
145+
146+
func BenchmarkMatchStarBacktracking(b *testing.B) {
147+
pattern, name := "********X", "admin:ServerInfo"
148+
for _, matcher := range []struct {
149+
name string
150+
match func(string, string) bool
151+
}{
152+
{"current", Match},
153+
{"previous", oldMatch},
154+
} {
155+
b.Run(matcher.name, func(b *testing.B) {
156+
for b.Loop() {
157+
matcher.match(pattern, name)
158+
}
159+
})
160+
}
161+
}
162+
163+
// Cover optional question marks and long near misses. Star counts stay low:
164+
// the previous matcher is exponential in them.
165+
func BenchmarkMatchSimpleQuestionMarks(b *testing.B) {
166+
cases := []struct {
167+
name string
168+
pattern string
169+
text string
170+
}{
171+
{"star-free", strings.Repeat("a?", 16), strings.Repeat("aa", 16)},
172+
{"all-marks", strings.Repeat("?", 32), strings.Repeat("a", 16)},
173+
{"leading-star", "*" + strings.Repeat("a?", 8), strings.Repeat("a", 24)},
174+
{"no-mark", "arn:aws:s3:::*", "arn:aws:s3:::bucket/object"},
175+
{"trailing-star-1024", "bucket/*", "bucket/" + strings.Repeat("a", 1024)},
176+
{"near-miss-64", "*" + strings.Repeat("a?", 32) + "X", strings.Repeat("a", 64) + "b"},
177+
{"near-miss-256", "*" + strings.Repeat("a?", 128) + "X", strings.Repeat("a", 256) + "b"},
178+
{"near-miss-1024", "*" + strings.Repeat("a?", 512) + "X", strings.Repeat("a", 1024) + "b"},
179+
}
180+
for _, c := range cases {
181+
b.Run("new/"+c.name, func(b *testing.B) {
182+
for i := 0; i < b.N; i++ {
183+
_ = MatchSimple(c.pattern, c.text)
184+
}
185+
})
186+
b.Run("old/"+c.name, func(b *testing.B) {
187+
for i := 0; i < b.N; i++ {
188+
_ = oldMatchSimple(c.pattern, c.text)
189+
}
190+
})
191+
}
192+
}
193+
194+
func TestMatchSimpleExhaustedName(t *testing.T) {
195+
for _, tc := range []struct {
196+
pattern, name string
197+
want bool
198+
}{
199+
{"a?b", "a", true},
200+
{"a?b", "ac", false},
201+
{"?suffix", "", true},
202+
{"*?*a", "a", true},
203+
{"*a?b", "ca", true},
204+
{"*a?b", "cb", false},
205+
{"?", "é", false},
206+
{"??", "é", true},
207+
} {
208+
if got := MatchSimple(tc.pattern, tc.name); got != tc.want {
209+
t.Errorf("MatchSimple(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want)
210+
}
211+
}
212+
}
213+
214+
func FuzzDeepMatchEquivalence(f *testing.F) {
215+
for _, s := range []string{"", "*", "?", "a*b", "admin:*", "**?", "a", "*a*a*b"} {
216+
f.Add(s, "admin:Heal")
217+
}
218+
f.Add("*?*a", "a")
219+
f.Add("?", "\xff")
220+
f.Add("??", "é")
221+
f.Add("对象/*", "对象/路径")
222+
f.Fuzz(func(t *testing.T, pattern, name string) {
223+
// Bound the old implementation's exponential blowup so the fuzzer
224+
// compares results instead of timing out on the bug being fixed.
225+
if strings.Count(pattern, "*") > 4 || len(pattern) > 24 || len(name) > 24 {
226+
t.Skip()
227+
}
228+
if got, want := Match(pattern, name), oldMatch(pattern, name); got != want {
229+
t.Fatalf("Match(%q, %q) = %v, old = %v", pattern, name, got, want)
230+
}
231+
if got, want := MatchSimple(pattern, name), oldMatchSimple(pattern, name); got != want {
232+
t.Fatalf("MatchSimple(%q, %q) = %v, old = %v", pattern, name, got, want)
233+
}
234+
})
235+
}

0 commit comments

Comments
 (0)