1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// URL glob matching, shared by client-side waits (`Frame::wait_for_url`) and
// client-side route matching (`Page::route`, `BrowserContext::route`).
//
// This is a port of the driver's `globToRegexPattern`
// (`packages/isomorphic/urlMatch.ts`), not an independent implementation.
// Both sides have to agree on whether a URL matches: the server decides
// which requests to report, and the client decides which handler to run, so
// a pattern the two read differently means a route event nobody answers and
// a request that hangs until it times out.
/// Characters the driver escapes when translating a glob to a regex.
///
/// Kept as the driver's exact set rather than deferring to `regex::escape`,
/// because `{`, `}` and `,` are glob syntax here (alternation) and must not
/// be escaped, and because a wider set would silently diverge.
const ESCAPED_CHARS: [char; 14] = [
'$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']',
];
fn push_escaped(out: &mut String, c: char) {
if ESCAPED_CHARS.contains(&c) {
out.push('\\');
}
out.push(c);
}
/// Translates a Playwright URL glob into an anchored regex pattern.
///
/// Ported from the driver so the two stay in step:
///
/// - `\x` is an escape; the next character is always literal.
/// - `*` matches within one path segment.
/// - `**` crosses segments. Between slashes (`/**/`) it also matches *zero*
/// directories, so `a/**/b` matches `a/b`.
/// - `{a,b}` is alternation. Nested or unbalanced braces are an error.
/// - Everything else is literal.
///
/// Returns `None` for a malformed pattern (nested `{`, unmatched `}` or `{`),
/// which the driver rejects by throwing.
pub(crate) fn glob_to_regex_pattern(glob: &str) -> Option<String> {
let mut out = String::with_capacity(glob.len() + 2);
out.push('^');
let mut in_group = false;
// The driver indexes the glob directly and reads `glob[i - 1]` for the
// character before a `*` run. Walking with an iterator instead means
// carrying that character forward, which costs one variable and makes an
// off-by-one or a non-advancing loop impossible to write.
let mut previous: Option<char> = None;
let mut chars = glob.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
// A trailing lone backslash has nothing to escape, so it is
// literal; the driver reaches the same place via its `i + 1 <
// length` guard falling through to the default branch.
let literal = chars.next().unwrap_or('\\');
push_escaped(&mut out, literal);
previous = Some(literal);
continue;
}
if c == '*' {
let char_before = previous;
// Consume the rest of the run with a bounded construct rather
// than a `while peek() == '*'`: an exhausted iterator yields
// `None` forever, so a loop keyed on that comparison can spin
// rather than end. `next_if_eq` stops the moment it does not
// match, which cannot.
let crosses_segments = std::iter::from_fn(|| chars.next_if_eq(&'*')).count() > 0;
if crosses_segments && chars.peek() == Some(&'/') {
chars.next();
if char_before == Some('/') {
// `/**/` also matches zero directories.
out.push_str("((.+/)|)");
} else {
out.push_str("(.*/)");
}
previous = Some('/');
} else {
if crosses_segments {
out.push_str("(.*)");
} else {
out.push_str("([^/]*)");
}
previous = Some('*');
}
continue;
}
match c {
'{' => {
if in_group {
return None;
}
in_group = true;
out.push('(');
}
'}' => {
if !in_group {
return None;
}
in_group = false;
out.push(')');
}
',' if in_group => out.push('|'),
other => push_escaped(&mut out, other),
}
previous = Some(c);
}
if in_group {
return None;
}
out.push('$');
Some(out)
}
/// Returns whether `text` matches the glob `pattern`.
///
/// Returns `false` for a malformed pattern, matching the driver's behavior
/// of treating it as matching nothing rather than panicking.
///
/// Callers that match repeatedly against one pattern should compile it once
/// with [`GlobMatcher::new`] instead.
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
GlobMatcher::new(pattern).is_some_and(|m| m.matches(text))
}
/// A glob compiled once, for callers that match the same pattern repeatedly.
///
/// `Frame::wait_for_url` polls every 50ms, so compiling per call meant
/// hundreds of identical regex compilations for a single wait.
pub(crate) struct GlobMatcher {
regex: regex::Regex,
}
impl GlobMatcher {
/// Compiles `pattern`, returning `None` if it is malformed.
pub(crate) fn new(pattern: &str) -> Option<Self> {
let regex_pattern = glob_to_regex_pattern(pattern)?;
regex::Regex::new(®ex_pattern)
.ok()
.map(|regex| Self { regex })
}
pub(crate) fn matches(&self, text: &str) -> bool {
self.regex.is_match(text)
}
}
#[cfg(test)]
mod properties {
use super::{GlobMatcher, glob_match, glob_to_regex_pattern};
use proptest::prelude::*;
/// Glob-shaped text over the full syntax: wildcards, group punctuation,
/// escapes, separators and regex metacharacters, in any order.
///
/// Unlike `url_atom` this is allowed to be malformed. The point is to
/// reach the states hand-written cases do not: an unbalanced brace, a
/// backslash at the very end, a run of stars against a separator.
fn glob_soup() -> impl Strategy<Value = String> {
proptest::collection::vec(
proptest::sample::select(vec![
"a", "/", "*", "**", "{", "}", ",", "\\", ".", "?", "[", "]", "(", ")", "|", "+",
"$", "^",
]),
0..10,
)
.prop_map(|parts| parts.concat())
}
/// URL-ish text containing no glob metacharacter, over a deliberately
/// narrow alphabet that nonetheless includes the regex metacharacters a
/// real URL carries (`?`, `+`, `(`, `[`, ...).
///
/// The alphabet is the point. Identifier-shaped input is already covered
/// by the example tests below, so letting the generator roam there would
/// rediscover those instead of exercising the escaping.
fn url_atom() -> impl Strategy<Value = String> {
proptest::collection::vec(
proptest::sample::select(vec![
"a", "b", "1", "-", "_", "=", "&", "?", "+", "(", ")", "[", "]", "|", "$", "^", ".",
]),
1..6,
)
.prop_map(|parts| parts.concat())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// A pattern with no wildcard matches itself. Translating a glob to a
/// regex is exactly where "everything else is literal" stops holding.
#[test]
fn a_pattern_without_a_wildcard_is_literal(text in url_atom()) {
prop_assert!(
glob_match(&text, &text),
"pattern `{}` should match itself literally", text
);
}
/// A trailing `*` matches any one segment, whatever it contains.
#[test]
fn a_trailing_wildcard_matches_any_single_segment(
prefix in url_atom(),
segment in url_atom(),
) {
let pattern = format!("https://example.com/{prefix}/*");
let text = format!("https://example.com/{prefix}/{segment}");
prop_assert!(
glob_match(&pattern, &text),
"`{}` should match `{}`", pattern, text
);
}
/// Escaping a character makes it literal, whatever it is. This is the
/// escape hatch for matching a URL that really does contain a `*`.
#[test]
fn a_backslash_escape_is_always_literal(text in url_atom()) {
let pattern: String = text.chars().flat_map(|c| ['\\', c]).collect();
prop_assert!(
glob_match(&pattern, &text),
"escaped `{}` should match `{}`", pattern, text
);
}
/// Anything this accepts, the regex engine must accept too.
///
/// The two failure modes are indistinguishable to a caller: a glob we
/// reject and a glob we translate into an uncompilable regex both come
/// back as "matches nothing". This is the invariant that keeps that
/// silence honest, and it is the shape of the bug this module was
/// rewritten to fix.
#[test]
fn an_accepted_pattern_always_compiles(pattern in glob_soup()) {
if let Some(translated) = glob_to_regex_pattern(&pattern) {
prop_assert!(
regex::Regex::new(&translated).is_ok(),
"accepted `{}` but produced uncompilable regex `{}`",
pattern, translated
);
prop_assert!(
GlobMatcher::new(&pattern).is_some(),
"`{}` translated but did not compile", pattern
);
}
}
/// Matching is total: no pattern, however malformed, panics.
#[test]
fn matching_never_panics(pattern in glob_soup(), text in glob_soup()) {
let _ = glob_match(&pattern, &text);
}
}
}
#[cfg(test)]
mod tests {
use super::glob_match;
#[test]
fn exact_match_is_anchored() {
assert!(glob_match("https://example.com/", "https://example.com/"));
// anchored at both ends: a trailing extra segment must not match
assert!(!glob_match("https://example.com/", "https://example.com/x"));
// ...nor a prefix
assert!(!glob_match("example.com", "an.example.com"));
}
#[test]
fn single_star_stays_within_one_segment() {
assert!(glob_match(
"https://example.com/*",
"https://example.com/foo"
));
// `*` does not cross a `/`
assert!(!glob_match(
"https://example.com/*",
"https://example.com/foo/bar"
));
// `*` matches the empty string
assert!(glob_match("https://example.com/*", "https://example.com/"));
// A single star immediately before a `/` is still a single star: only
// a run of two or more takes the cross-segment branch.
assert!(glob_match(
"https://example.com/*/b",
"https://example.com/a/b"
));
assert!(!glob_match(
"https://example.com/*/b",
"https://example.com/a/x/b"
));
}
#[test]
fn double_star_crosses_segments() {
assert!(glob_match(
"https://example.com/**",
"https://example.com/a/b/c"
));
assert!(glob_match(
"**/*.png",
"https://cdn.example.com/img/logo.png"
));
}
#[test]
fn double_star_between_slashes_matches_zero_directories() {
// The driver emits `((.+/)|)` here, so the directories are optional.
assert!(glob_match(
"https://example.com/**/edit",
"https://example.com/edit"
));
assert!(glob_match(
"https://example.com/**/edit",
"https://example.com/a/b/edit"
));
}
#[test]
fn braces_are_alternation() {
assert!(glob_match("**/*.{png,jpg}", "https://example.com/a.png"));
assert!(glob_match("**/*.{png,jpg}", "https://example.com/a.jpg"));
assert!(!glob_match("**/*.{png,jpg}", "https://example.com/a.gif"));
// A comma outside a group is literal. The negative is the load-bearing
// half: were the comma alternation, `.../a,b` would still match itself
// through the `b$` branch while also matching `.../a`.
assert!(glob_match(
"https://example.com/a,b",
"https://example.com/a,b"
));
assert!(!glob_match(
"https://example.com/a,b",
"https://example.com/a"
));
}
#[test]
fn malformed_braces_match_nothing() {
assert!(!glob_match("{a{b}}", "ab"));
assert!(!glob_match("a}", "a}"));
assert!(!glob_match("{a", "a"));
}
#[test]
fn regex_metacharacters_are_literal() {
// These used to reach the regex engine: `[unterminated` failed to
// compile (matching nothing), and `?`/`+` silently changed what the
// pattern meant.
assert!(!glob_match("[unterminated", "anything"));
assert!(glob_match("[unterminated", "[unterminated"));
// A query string is the common case, and the one that motivated this.
assert!(glob_match(
"https://example.com/search?q=*",
"https://example.com/search?q=widget"
));
// ...and it must not match the URL the unescaped `?` used to allow.
assert!(!glob_match(
"https://example.com/search?q=*",
"https://example.com/searcq=widget"
));
}
#[test]
fn backslash_escapes_the_next_character() {
// the escape hatch for a URL that really contains a `*`
assert!(glob_match(
r"https://example.com/\*",
"https://example.com/*"
));
assert!(!glob_match(
r"https://example.com/\*",
"https://example.com/x"
));
}
#[test]
fn dot_is_literal_not_wildcard() {
assert!(glob_match(
"https://example.com/a.png",
"https://example.com/a.png"
));
assert!(!glob_match(
"https://example.com/a.png",
"https://example.com/axpng"
));
}
#[test]
fn extension_globs() {
assert!(glob_match("**/*.png", "https://example.com/a/b/c.png"));
assert!(!glob_match("**/*.png", "https://example.com/a/b/c.jpg"));
// `**/` before a segment emits `(.*/)`, which requires the slash, so
// a slashless subject does not match. Pins the star-run boundary:
// miscounting the stars here turns this into `(.*)`, which does.
assert!(!glob_match("**/*.png", "a.png"));
}
#[test]
fn a_trailing_backslash_is_literal() {
// There is nothing to escape, so the backslash is its own character.
// Pins the escape lookahead bound: reading past it panics.
assert!(glob_match(r"a\", r"a\"));
assert!(!glob_match(r"a\", "a"));
}
}