|
| 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