Skip to main content

kaish_glob/
glob.rs

1//! Glob pattern matching for case statements and file paths.
2//!
3//! Implements shell-style glob patterns:
4//! - `*` matches zero or more characters
5//! - `?` matches exactly one character
6//! - `[abc]` matches any character in the set
7//! - `[a-z]` matches any character in the range
8//! - `[!abc]` or `[^abc]` matches any character NOT in the set
9//! - `{a,b,c}` brace expansion (matches any of the alternatives)
10
11/// Maximum number of recursive calls for glob matching. Protects against
12/// adversarial patterns like `*a*a*a*...*a` that cause O(n^k) backtracking.
13/// Counted as total work (calls), not stack depth, to bound actual CPU cost.
14const MAX_MATCH_CALLS: usize = 100_000;
15
16/// Match a string against a glob pattern.
17///
18/// Returns true if the pattern matches the entire input string.
19/// Supports brace expansion: `*.{rs,go}` matches `main.rs` or `main.go`.
20///
21/// # Examples
22/// ```
23/// use kaish_glob::glob_match;
24///
25/// assert!(glob_match("*.rs", "main.rs"));
26/// assert!(glob_match("test?", "test1"));
27/// assert!(glob_match("[abc]", "b"));
28/// assert!(glob_match("*.{rs,go}", "main.rs"));
29/// assert!(!glob_match("*.txt", "main.rs"));
30/// ```
31/// Check if a string contains glob metacharacters (`*`, `?`, `[`).
32///
33/// Useful for builtins that want to detect when a path argument is a glob
34/// pattern and switch to pattern-matching mode.
35///
36/// ```
37/// use kaish_glob::contains_glob;
38/// assert!(contains_glob("*.rs"));
39/// assert!(contains_glob("src/[ab]*.txt"));
40/// assert!(!contains_glob("src/main.rs"));
41/// ```
42pub fn contains_glob(s: &str) -> bool {
43    s.contains('*') || s.contains('?') || s.contains('[')
44}
45
46pub fn glob_match(pattern: &str, input: &str) -> bool {
47    use std::cell::Cell;
48
49    // Expand braces first, then match each expanded pattern
50    let expanded = expand_braces(pattern);
51    let calls = Cell::new(0usize);
52    for pat in expanded {
53        let pat_chars: Vec<char> = pat.chars().collect();
54        let input_chars: Vec<char> = input.chars().collect();
55        if match_bounded(&pat_chars, 0, &input_chars, 0, &calls) {
56            return true;
57        }
58    }
59    false
60}
61
62/// Expand brace expressions in a pattern.
63///
64/// `{a,b,c}` expands to multiple patterns. Supports nested braces.
65/// Returns a vector of all expanded patterns.
66///
67/// # Examples
68/// ```
69/// use kaish_glob::expand_braces;
70///
71/// assert_eq!(expand_braces("simple"), vec!["simple"]);
72/// assert_eq!(expand_braces("{a,b}"), vec!["a", "b"]);
73/// ```
74pub fn expand_braces(pattern: &str) -> Vec<String> {
75    let chars: Vec<char> = pattern.chars().collect();
76
77    // Find the first top-level brace group
78    let mut depth = 0;
79    let mut brace_start = None;
80    let mut brace_end = None;
81
82    for (i, &c) in chars.iter().enumerate() {
83        match c {
84            '{' => {
85                if depth == 0 {
86                    brace_start = Some(i);
87                }
88                depth += 1;
89            }
90            '}' => {
91                depth -= 1;
92                if depth == 0 && brace_start.is_some() {
93                    brace_end = Some(i);
94                    break;
95                }
96            }
97            _ => {}
98        }
99    }
100
101    // No braces found - return pattern as-is
102    let (start, end) = match (brace_start, brace_end) {
103        (Some(s), Some(e)) => (s, e),
104        _ => return vec![pattern.to_string()],
105    };
106
107    // Extract prefix, alternatives, and suffix
108    let prefix: String = chars[..start].iter().collect();
109    let suffix: String = chars[end + 1..].iter().collect();
110    let brace_content: String = chars[start + 1..end].iter().collect();
111
112    // Split alternatives (respecting nested braces)
113    let alternatives = split_brace_alternatives(&brace_content);
114
115    // Recursively expand each alternative combined with prefix/suffix
116    let mut results = Vec::new();
117    for alt in alternatives {
118        let combined = format!("{}{}{}", prefix, alt, suffix);
119        // Recursively expand in case there are more braces
120        results.extend(expand_braces(&combined));
121    }
122
123    results
124}
125
126/// Split brace content by commas, respecting nested braces.
127fn split_brace_alternatives(content: &str) -> Vec<String> {
128    let mut alternatives = Vec::new();
129    let mut current = String::new();
130    let mut depth = 0;
131
132    for c in content.chars() {
133        match c {
134            '{' => {
135                depth += 1;
136                current.push(c);
137            }
138            '}' => {
139                depth -= 1;
140                current.push(c);
141            }
142            ',' if depth == 0 => {
143                alternatives.push(current);
144                current = String::new();
145            }
146            _ => current.push(c),
147        }
148    }
149
150    // Don't forget the last alternative
151    alternatives.push(current);
152
153    alternatives
154}
155
156/// Work-bounded recursive matching with backtracking for `*`.
157///
158/// Returns `false` (non-match) if total recursive calls exceed `MAX_MATCH_CALLS`,
159/// preventing ReDoS from adversarial patterns.
160fn match_bounded(
161    pattern: &[char],
162    pi: usize,
163    input: &[char],
164    ii: usize,
165    calls: &std::cell::Cell<usize>,
166) -> bool {
167    let count = calls.get() + 1;
168    calls.set(count);
169    if count > MAX_MATCH_CALLS {
170        return false;
171    }
172
173    // Both exhausted - match!
174    if pi >= pattern.len() && ii >= input.len() {
175        return true;
176    }
177
178    // Pattern exhausted but input remains - no match
179    if pi >= pattern.len() {
180        return false;
181    }
182
183    match pattern[pi] {
184        '*' => {
185            // Skip consecutive stars
186            let mut next_pi = pi;
187            while next_pi < pattern.len() && pattern[next_pi] == '*' {
188                next_pi += 1;
189            }
190
191            // Star at end matches everything remaining
192            if next_pi >= pattern.len() {
193                return true;
194            }
195
196            // Try matching star with 0, 1, 2, ... characters
197            for skip in 0..=(input.len() - ii) {
198                if match_bounded(pattern, next_pi, input, ii + skip, calls) {
199                    return true;
200                }
201            }
202            false
203        }
204
205        '?' => {
206            // Must have at least one character to match
207            if ii >= input.len() {
208                return false;
209            }
210            match_bounded(pattern, pi + 1, input, ii + 1, calls)
211        }
212
213        '[' => {
214            // Must have at least one character to match
215            if ii >= input.len() {
216                return false;
217            }
218
219            // Parse character class
220            let (matches, end_idx) = parse_char_class(&pattern[pi..], input[ii]);
221            if matches {
222                match_bounded(pattern, pi + end_idx, input, ii + 1, calls)
223            } else {
224                false
225            }
226        }
227
228        // Escape next character
229        '\\' if pi + 1 < pattern.len() => {
230            if ii >= input.len() {
231                return false;
232            }
233            if pattern[pi + 1] == input[ii] {
234                match_bounded(pattern, pi + 2, input, ii + 1, calls)
235            } else {
236                false
237            }
238        }
239
240        c => {
241            // Literal character match
242            if ii >= input.len() {
243                return false;
244            }
245            if c == input[ii] {
246                match_bounded(pattern, pi + 1, input, ii + 1, calls)
247            } else {
248                false
249            }
250        }
251    }
252}
253
254/// Parse a character class `[...]` and return whether the character matches.
255///
256/// Returns (matches, length) where length is how many pattern chars were consumed.
257fn parse_char_class(pattern: &[char], ch: char) -> (bool, usize) {
258    if pattern.is_empty() || pattern[0] != '[' {
259        return (false, 0);
260    }
261
262    let mut idx = 1;
263    let mut negate = false;
264
265    // Check for negation
266    if idx < pattern.len() && (pattern[idx] == '!' || pattern[idx] == '^') {
267        negate = true;
268        idx += 1;
269    }
270
271    // Special case: ] as first char is literal
272    let first_char = idx;
273    let mut matched = false;
274
275    while idx < pattern.len() {
276        let c = pattern[idx];
277
278        // End of character class
279        if c == ']' && idx > first_char {
280            idx += 1;
281            break;
282        }
283
284        // Range a-z
285        if idx + 2 < pattern.len() && pattern[idx + 1] == '-' && pattern[idx + 2] != ']' {
286            let start = c;
287            let end = pattern[idx + 2];
288            if ch >= start && ch <= end {
289                matched = true;
290            }
291            idx += 3;
292            continue;
293        }
294
295        // Single character
296        if c == ch {
297            matched = true;
298        }
299        idx += 1;
300    }
301
302    // Handle unclosed bracket - treat as literal
303    if idx >= pattern.len() && (pattern.len() < 2 || pattern[pattern.len() - 1] != ']') {
304        return (pattern[0] == ch, 1);
305    }
306
307    (if negate { !matched } else { matched }, idx)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn literal_matches() {
316        assert!(glob_match("hello", "hello"));
317        assert!(glob_match("", ""));
318        assert!(!glob_match("hello", "world"));
319        assert!(!glob_match("hello", "hell"));
320        assert!(!glob_match("hello", "helloo"));
321    }
322
323    #[test]
324    fn star_wildcard() {
325        assert!(glob_match("*", ""));
326        assert!(glob_match("*", "anything"));
327        assert!(glob_match("*.rs", "main.rs"));
328        assert!(glob_match("*.rs", ".rs"));
329        assert!(glob_match("test*", "test"));
330        assert!(glob_match("test*", "testing"));
331        assert!(glob_match("*test*", "mytestfile"));
332        assert!(glob_match("a*b*c", "abc"));
333        assert!(glob_match("a*b*c", "aXXXbYYYc"));
334        assert!(!glob_match("*.rs", "main.txt"));
335        assert!(!glob_match("test*", "mytest"));
336    }
337
338    #[test]
339    fn question_wildcard() {
340        assert!(glob_match("?", "a"));
341        assert!(glob_match("???", "abc"));
342        assert!(glob_match("test?", "test1"));
343        assert!(glob_match("?est", "test"));
344        assert!(!glob_match("?", ""));
345        assert!(!glob_match("?", "ab"));
346        assert!(!glob_match("???", "ab"));
347    }
348
349    #[test]
350    fn char_class_simple() {
351        assert!(glob_match("[abc]", "a"));
352        assert!(glob_match("[abc]", "b"));
353        assert!(glob_match("[abc]", "c"));
354        assert!(!glob_match("[abc]", "d"));
355        assert!(!glob_match("[abc]", ""));
356    }
357
358    #[test]
359    fn char_class_range() {
360        assert!(glob_match("[a-z]", "m"));
361        assert!(glob_match("[a-z]", "a"));
362        assert!(glob_match("[a-z]", "z"));
363        assert!(!glob_match("[a-z]", "A"));
364        assert!(!glob_match("[a-z]", "0"));
365        assert!(glob_match("[0-9]", "5"));
366        assert!(glob_match("[a-zA-Z]", "M"));
367    }
368
369    #[test]
370    fn char_class_negated() {
371        assert!(glob_match("[!abc]", "d"));
372        assert!(glob_match("[^abc]", "d"));
373        assert!(!glob_match("[!abc]", "a"));
374        assert!(!glob_match("[^abc]", "b"));
375    }
376
377    #[test]
378    fn escape_sequence() {
379        assert!(glob_match("\\*", "*"));
380        assert!(glob_match("\\?", "?"));
381        assert!(glob_match("test\\*", "test*"));
382        assert!(!glob_match("\\*", "a"));
383    }
384
385    #[test]
386    fn combined_patterns() {
387        assert!(glob_match("*.tar.gz", "archive.tar.gz"));
388        assert!(glob_match("file[0-9].txt", "file5.txt"));
389        assert!(glob_match("test_?_*.rs", "test_a_foo.rs"));
390        assert!(!glob_match("file[0-9].txt", "filea.txt"));
391    }
392
393    #[test]
394    fn case_statement_patterns() {
395        assert!(glob_match("*.rs", "main.rs"));
396        assert!(glob_match("*.py", "script.py"));
397        assert!(glob_match("y", "y"));
398        assert!(glob_match("yes", "yes"));
399        assert!(glob_match("[Yy]*", "Yes"));
400        assert!(glob_match("[Yy]*", "yes"));
401        assert!(glob_match("[Yy]*", "y"));
402        assert!(glob_match("[Nn]*", "no"));
403        assert!(glob_match("*", "anything"));
404    }
405
406    #[test]
407    fn consecutive_stars() {
408        assert!(glob_match("**", "anything"));
409        assert!(glob_match("a**b", "ab"));
410        assert!(glob_match("a**b", "aXXXb"));
411    }
412
413    #[test]
414    fn edge_cases() {
415        assert!(glob_match("", ""));
416        assert!(!glob_match("", "a"));
417        assert!(glob_match("a*", "a"));
418        assert!(glob_match("*a", "a"));
419        assert!(glob_match("*/*", "foo/bar"));
420        assert!(!glob_match("*/*", "foobar"));
421    }
422
423    #[test]
424    fn char_class_literal_dash() {
425        assert!(glob_match("[-abc]", "-"));
426        assert!(glob_match("[-abc]", "a"));
427        assert!(glob_match("[abc-]", "-"));
428        assert!(glob_match("[abc-]", "c"));
429        assert!(glob_match("[a-c]", "b"));
430        assert!(!glob_match("[a-c]", "-"));
431    }
432
433    #[test]
434    fn char_class_literal_bracket() {
435        assert!(glob_match("[]abc]", "]"));
436        assert!(glob_match("[]abc]", "a"));
437        assert!(glob_match("[!]abc]", "x"));
438        assert!(!glob_match("[!]abc]", "]"));
439    }
440
441    #[test]
442    fn char_class_multiple_ranges() {
443        assert!(glob_match("[a-zA-Z0-9]", "m"));
444        assert!(glob_match("[a-zA-Z0-9]", "M"));
445        assert!(glob_match("[a-zA-Z0-9]", "5"));
446        assert!(!glob_match("[a-zA-Z0-9]", "_"));
447        assert!(!glob_match("[a-zA-Z0-9]", " "));
448    }
449
450    #[test]
451    fn char_class_with_wildcards() {
452        assert!(glob_match("[abc]*", "aXXX"));
453        assert!(glob_match("[abc]*", "a"));
454        assert!(!glob_match("[abc]*", "dXXX"));
455        assert!(glob_match("*[0-9]", "test5"));
456        assert!(glob_match("*[0-9]", "5"));
457        assert!(!glob_match("*[0-9]", "test"));
458        assert!(glob_match("[abc]?", "a1"));
459        assert!(!glob_match("[abc]?", "a"));
460        assert!(!glob_match("[abc]?", "a12"));
461    }
462
463    #[test]
464    fn multiple_char_classes() {
465        assert!(glob_match("[abc][123]", "a1"));
466        assert!(glob_match("[abc][123]", "c3"));
467        assert!(!glob_match("[abc][123]", "a4"));
468        assert!(!glob_match("[abc][123]", "d1"));
469        assert!(glob_match("[a-z][A-Z][0-9]", "xY9"));
470    }
471
472    #[test]
473    fn backtracking_stress() {
474        assert!(glob_match("a*a*a*a*a*a*a*a", "aaaaaaaaaaaaaaaa"));
475        assert!(!glob_match("a*a*a*a*a*a*a*ab", "aaaaaaaaaaaaaaaa"));
476        assert!(glob_match("*a*b*c", "XXXaYYYbZZZc"));
477        assert!(glob_match("*a*b*c", "abc"));
478        assert!(!glob_match("*a*b*c", "XXXaYYYcZZZb"));
479        assert!(glob_match("*.*.txt", "file.backup.txt"));
480        assert!(!glob_match("*.*.txt", "file.txt"));
481    }
482
483    #[test]
484    fn real_world_file_patterns() {
485        assert!(glob_match("*.rs", "main.rs"));
486        assert!(glob_match("*.rs", "lib.rs"));
487        assert!(glob_match("*_test.rs", "parser_test.rs"));
488        assert!(!glob_match("*_test.rs", "parser.rs"));
489        assert!(glob_match(".*", ".gitignore"));
490        assert!(glob_match(".*", ".env"));
491        assert!(!glob_match(".*", "visible"));
492        assert!(glob_match("*.tar.gz", "archive.tar.gz"));
493        assert!(glob_match("*.tar.gz", "backup.tar.gz"));
494        assert!(!glob_match("*.tar.gz", "archive.tar"));
495        assert!(!glob_match("*.tar.gz", "archive.gz"));
496        assert!(glob_match("app.log.[0-9]", "app.log.1"));
497        assert!(glob_match("app.log.[0-9]", "app.log.9"));
498        assert!(!glob_match("app.log.[0-9]", "app.log.10"));
499        assert!(glob_match("*.{json,yaml,toml}", "config.json"));
500        assert!(glob_match("*.{json,yaml,toml}", "config.yaml"));
501        assert!(glob_match("*.{json,yaml,toml}", "config.toml"));
502        assert!(!glob_match("*.{json,yaml,toml}", "config.xml"));
503    }
504
505    #[test]
506    fn special_characters_in_input() {
507        assert!(glob_match("test", "test"));
508        assert!(!glob_match("test", "te*t"));
509        assert!(!glob_match("test", "te?t"));
510        assert!(glob_match("file\\[1\\]", "file[1]"));
511        assert!(glob_match("test\\?", "test?"));
512    }
513
514    #[test]
515    fn whitespace_handling() {
516        assert!(glob_match("hello world", "hello world"));
517        assert!(glob_match("hello*world", "hello   world"));
518        assert!(glob_match("* *", "hello world"));
519        assert!(glob_match("*\t*", "hello\tworld"));
520    }
521
522    #[test]
523    fn case_sensitivity() {
524        assert!(glob_match("Hello", "Hello"));
525        assert!(!glob_match("Hello", "hello"));
526        assert!(!glob_match("hello", "Hello"));
527        assert!(glob_match("[Hh]ello", "Hello"));
528        assert!(glob_match("[Hh]ello", "hello"));
529    }
530
531    #[test]
532    fn long_strings() {
533        let long_str = "a".repeat(1000);
534        assert!(glob_match("*", &long_str));
535        assert!(glob_match("a*", &long_str));
536        assert!(glob_match("*a", &long_str));
537        let mixed = format!("{}X{}", "a".repeat(500), "a".repeat(500));
538        assert!(glob_match("*X*", &mixed));
539        assert!(!glob_match("*Y*", &mixed));
540    }
541
542    #[test]
543    fn unicode_basic() {
544        assert!(glob_match("héllo", "héllo"));
545        assert!(glob_match("*ñ*", "español"));
546        assert!(glob_match("?", "ü"));
547        assert!(glob_match("[αβγ]", "β"));
548    }
549
550    #[test]
551    fn negated_char_class_edge_cases() {
552        assert!(glob_match("[!a-z]", "A"));
553        assert!(glob_match("[!a-z]", "5"));
554        assert!(!glob_match("[!a-z]", "m"));
555        assert!(glob_match("[!a-zA-Z]", "5"));
556        assert!(!glob_match("[!a-zA-Z]", "x"));
557        assert!(!glob_match("[!a-zA-Z]", "X"));
558    }
559
560    #[test]
561    fn path_like_patterns() {
562        assert!(glob_match("src/*.rs", "src/main.rs"));
563        assert!(!glob_match("src/*.rs", "test/main.rs"));
564        assert!(glob_match("*/*/*.rs", "src/foo/bar.rs"));
565        assert!(!glob_match("*/*/*.rs", "src/bar.rs"));
566        assert!(glob_match("v?.0", "v1.0"));
567        assert!(glob_match("v?.0", "v2.0"));
568        assert!(!glob_match("v?.0", "v10.0"));
569    }
570
571    #[test]
572    fn brace_expansion_basic() {
573        assert!(glob_match("{foo,bar}", "foo"));
574        assert!(glob_match("{foo,bar}", "bar"));
575        assert!(!glob_match("{foo,bar}", "baz"));
576        assert!(glob_match("test_{a,b,c}", "test_a"));
577        assert!(glob_match("test_{a,b,c}", "test_b"));
578        assert!(glob_match("test_{a,b,c}", "test_c"));
579        assert!(!glob_match("test_{a,b,c}", "test_d"));
580        assert!(glob_match("{debug,release}.exe", "debug.exe"));
581        assert!(glob_match("{debug,release}.exe", "release.exe"));
582        assert!(glob_match("lib{foo,bar}.so", "libfoo.so"));
583        assert!(glob_match("lib{foo,bar}.so", "libbar.so"));
584    }
585
586    #[test]
587    fn brace_expansion_with_wildcards() {
588        assert!(glob_match("*.{rs,go,py}", "main.rs"));
589        assert!(glob_match("*.{rs,go,py}", "server.go"));
590        assert!(glob_match("*.{rs,go,py}", "script.py"));
591        assert!(!glob_match("*.{rs,go,py}", "style.css"));
592        assert!(glob_match("file{1,2,3}.txt", "file1.txt"));
593        assert!(glob_match("test?.{log,txt}", "test1.log"));
594        assert!(glob_match("test?.{log,txt}", "testA.txt"));
595        assert!(glob_match("[abc].{x,y}", "a.x"));
596        assert!(glob_match("[abc].{x,y}", "b.y"));
597    }
598
599    #[test]
600    fn brace_expansion_multiple_braces() {
601        assert!(glob_match("{a,b}{1,2}", "a1"));
602        assert!(glob_match("{a,b}{1,2}", "a2"));
603        assert!(glob_match("{a,b}{1,2}", "b1"));
604        assert!(glob_match("{a,b}{1,2}", "b2"));
605        assert!(!glob_match("{a,b}{1,2}", "c1"));
606        assert!(glob_match("{a,b}{1,2}{x,y}", "a1x"));
607        assert!(glob_match("{a,b}{1,2}{x,y}", "b2y"));
608    }
609
610    #[test]
611    fn brace_expansion_nested() {
612        assert!(glob_match("{a,{b,c}}", "a"));
613        assert!(glob_match("{a,{b,c}}", "b"));
614        assert!(glob_match("{a,{b,c}}", "c"));
615        assert!(glob_match("{{a,b},{c,d}}", "a"));
616        assert!(glob_match("{{a,b},{c,d}}", "b"));
617        assert!(glob_match("{{a,b},{c,d}}", "c"));
618        assert!(glob_match("{{a,b},{c,d}}", "d"));
619    }
620
621    #[test]
622    fn brace_expansion_empty_alternatives() {
623        assert!(glob_match("{,un}do", "do"));
624        assert!(glob_match("{,un}do", "undo"));
625        assert!(glob_match("test{,s}", "test"));
626        assert!(glob_match("test{,s}", "tests"));
627    }
628
629    #[test]
630    fn brace_expansion_single_item() {
631        assert!(glob_match("{foo}", "foo"));
632        assert!(!glob_match("{foo}", "bar"));
633        assert!(glob_match("test_{only}.rs", "test_only.rs"));
634    }
635
636    #[test]
637    fn brace_expansion_real_world() {
638        assert!(glob_match("src/**/*.{ts,tsx,js,jsx}", "src/**/*.ts"));
639        assert!(glob_match("{M,m}akefile", "Makefile"));
640        assert!(glob_match("{M,m}akefile", "makefile"));
641        assert!(glob_match("README{,.md,.txt}", "README"));
642        assert!(glob_match("README{,.md,.txt}", "README.md"));
643        assert!(glob_match("README{,.md,.txt}", "README.txt"));
644        assert!(glob_match("{LICENSE,LICENCE}{,.md,.txt}", "LICENSE"));
645        assert!(glob_match("{LICENSE,LICENCE}{,.md,.txt}", "LICENCE.md"));
646        assert!(glob_match("{,.}config{,.json,.yaml}", "config"));
647        assert!(glob_match("{,.}config{,.json,.yaml}", ".config"));
648        assert!(glob_match("{,.}config{,.json,.yaml}", "config.json"));
649        assert!(glob_match("{,.}config{,.json,.yaml}", ".config.yaml"));
650        assert!(glob_match("{D,d}ocker{file,-compose.yml}", "Dockerfile"));
651        assert!(glob_match("{D,d}ocker{file,-compose.yml}", "dockerfile"));
652        assert!(glob_match("{D,d}ocker{file,-compose.yml}", "Docker-compose.yml"));
653    }
654
655    #[test]
656    fn brace_expansion_no_braces() {
657        assert!(glob_match("simple", "simple"));
658        assert!(glob_match("*.rs", "main.rs"));
659        assert!(glob_match("[abc]", "b"));
660    }
661
662    #[test]
663    fn brace_expansion_unclosed() {
664        assert!(glob_match("{abc", "{abc"));
665        assert!(glob_match("test{", "test{"));
666        assert!(glob_match("abc}", "abc}"));
667    }
668
669    #[test]
670    fn expand_braces_unit() {
671        assert_eq!(expand_braces("simple"), vec!["simple"]);
672        assert_eq!(expand_braces("{a,b}"), vec!["a", "b"]);
673        assert_eq!(expand_braces("x{a,b}y"), vec!["xay", "xby"]);
674        let mut result = expand_braces("{a,b}{1,2}");
675        result.sort();
676        assert_eq!(result, vec!["a1", "a2", "b1", "b2"]);
677    }
678
679    #[test]
680    fn redos_protection() {
681        // Adversarial pattern: *a*a*a*...*a causes O(n^k) backtracking without depth limits.
682        // With MAX_MATCH_DEPTH protection, this must complete quickly (non-match is acceptable).
683        let pattern = format!("{}b", "*a".repeat(50));
684        let input = "a".repeat(100);
685        // The important thing is that this returns in bounded time, not that it matches.
686        let _result = glob_match(&pattern, &input);
687    }
688}