Skip to main content

guard_core_engine/redos/
structure.rs

1//! String-level structural analysis primitives and the nested/adjacent
2//! unbounded-quantifier rules.
3//!
4//! Port of the reference `_redos_structure_primitives.py` and
5//! `_redos_structure_rules.py` (assembled by `_redos_structure.py`): all
6//! analysis happens over the raw pattern source, exactly as in Python.
7
8/// Maximum group nesting depth the structural analyzer supports.
9pub const MAX_GROUP_NESTING_DEPTH: usize = 20;
10
11/// Rejection reason returned when the depth cap trips.
12#[must_use]
13pub fn nesting_depth_rejection_reason() -> String {
14    format!(
15        "pattern exceeds the maximum group nesting depth of \
16         {MAX_GROUP_NESTING_DEPTH} the ReDoS structural analyzer supports"
17    )
18}
19
20/// The reference `GroupNestingTooDeep` signal.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct NestingTooDeep;
23
24pub type StructureResult<T> = Result<T, NestingTooDeep>;
25
26/// Skip a `[...]` class starting at `i`; returns the index past `]`.
27#[must_use]
28pub fn skip_char_class(text: &[char], i: usize) -> usize {
29    let mut j = i + 1;
30    while j < text.len() && text[j] != ']' {
31        if text[j] == '\\' && j + 1 < text.len() {
32            j += 2;
33            continue;
34        }
35        j += 1;
36    }
37    if j < text.len() {
38        j += 1;
39    }
40    j
41}
42
43/// Replace escapes and char classes with `X` placeholders.
44#[must_use]
45pub fn strip_escapes_and_char_classes(pattern: &[char]) -> String {
46    let mut result = String::new();
47    let mut i = 0;
48    while i < pattern.len() {
49        let c = pattern[i];
50        if c == '\\' && i + 1 < pattern.len() {
51            result.push('X');
52            i += 2;
53            continue;
54        }
55        if c == '[' {
56            i = skip_char_class(pattern, i);
57            result.push('X');
58            continue;
59        }
60        result.push(c);
61        i += 1;
62    }
63    result
64}
65
66/// Whether a branch is `.` quantified unbounded exactly once: `.*`, `.+`
67/// or `.{n,}`.
68#[must_use]
69pub fn branch_is_unbounded_single(branch: &str) -> bool {
70    let chars: Vec<char> = branch.chars().collect();
71    if chars.len() == 2 && (chars[1] == '*' || chars[1] == '+') {
72        return true;
73    }
74    // `.` + `{` + digits + `,` + `}`
75    if chars.len() >= 5 && chars[1] == '{' && chars[chars.len() - 1] == '}' {
76        let inner = &chars[2..chars.len() - 1];
77        let comma = inner.iter().position(|c| *c == ',');
78        if let Some(comma) = comma {
79            return comma >= 1
80                && comma == inner.len() - 1
81                && inner[..comma].iter().all(|c| c.is_ascii_digit());
82        }
83    }
84    false
85}
86
87/// Advance past an escape pair or a whole char class.
88#[must_use]
89pub fn advance_past_escape_or_char_class(text: &[char], i: usize) -> Option<usize> {
90    if text[i] == '\\' && i + 1 < text.len() {
91        return Some(i + 2);
92    }
93    if text[i] == '[' {
94        return Some(skip_char_class(text, i));
95    }
96    None
97}
98
99/// Index just past the group whose `(` is at `start`.
100#[must_use]
101pub fn find_group_end(text: &[char], start: usize) -> Option<usize> {
102    let mut depth = 1usize;
103    let mut j = start + 1;
104    while j < text.len() && depth > 0 {
105        if let Some(skip_to) = advance_past_escape_or_char_class(text, j) {
106            j = skip_to;
107            continue;
108        }
109        match text[j] {
110            '(' => depth += 1,
111            ')' => depth -= 1,
112            _ => {}
113        }
114        j += 1;
115    }
116    (depth == 0).then_some(j)
117}
118
119/// Strip `?:` / `?P<name>` group prefixes; `None` for unsupported heads.
120#[must_use]
121pub fn normalize_group_inner(inner: &[char]) -> Option<String> {
122    if inner.starts_with(&['?', ':']) {
123        return Some(inner[2..].iter().collect());
124    }
125    if inner.starts_with(&['?', 'P', '=']) {
126        return None;
127    }
128    if inner.starts_with(&['?', 'P', '<']) {
129        let end_name = inner.iter().position(|c| *c == '>')?;
130        return Some(inner[end_name + 1..].iter().collect());
131    }
132    Some(inner.iter().collect())
133}
134
135/// Peel leading transparent wrappers that span the whole text.
136#[must_use]
137pub fn unwrap_transparent_wrapper(text: &str) -> String {
138    let mut text = text.to_owned();
139    loop {
140        if !text.starts_with('(') {
141            break;
142        }
143        let chars: Vec<char> = text.chars().collect();
144        let Some(end) = find_group_end(&chars, 0) else {
145            break;
146        };
147        if end != chars.len() {
148            break;
149        }
150        let Some(candidate) = normalize_group_inner(&chars[1..end - 1]) else {
151            break;
152        };
153        text = candidate;
154    }
155    text
156}
157
158/// Length of the unbounded quantifier at `k` (`*`, `+`, `{n,}`), else 0.
159#[must_use]
160pub fn outer_quantifier_len(text: &[char], k: usize) -> usize {
161    if k < text.len() && (text[k] == '*' || text[k] == '+') {
162        return 1;
163    }
164    if k < text.len()
165        && text[k] == '{'
166        && let Some(offset) = text[k..].iter().position(|c| *c == '}')
167    {
168        let end_brace = k + offset;
169        let brace_inner: String = text[k + 1..end_brace].iter().collect();
170        // The part after the first comma must be empty: `{n,}`.
171        if let Some(pos) = brace_inner.find(',')
172            && brace_inner[pos + 1..].is_empty()
173        {
174            return end_brace - k + 1;
175        }
176    }
177    0
178}
179
180/// Prefix-overlap test for literal branches.
181#[must_use]
182pub fn branches_overlap(branches: &[String]) -> bool {
183    for a in 0..branches.len() {
184        for b in a + 1..branches.len() {
185            let x = &branches[a];
186            let y = &branches[b];
187            if x == y || x.starts_with(y.as_str()) || y.starts_with(x.as_str()) {
188                return true;
189            }
190        }
191    }
192    false
193}
194
195const META_BRANCH_CHARS: &str = "()[]{}.*+?^$|\\";
196
197/// Whether a branch is a nonempty run of non-meta characters.
198#[must_use]
199pub fn is_pure_literal_branch(branch: &str) -> bool {
200    !branch.is_empty() && !branch.chars().any(|c| META_BRANCH_CHARS.contains(c))
201}
202
203/// Split alternation branches at top nesting level.
204#[must_use]
205pub fn split_top_level_alternations(inner: &[char]) -> Vec<String> {
206    let mut branches: Vec<String> = Vec::new();
207    let mut depth = 0usize;
208    let mut start = 0usize;
209    let mut k = 0usize;
210    while k < inner.len() {
211        if let Some(skip_to) = advance_past_escape_or_char_class(inner, k) {
212            k = skip_to;
213            continue;
214        }
215        match inner[k] {
216            '(' => depth += 1,
217            ')' => depth = depth.saturating_sub(1),
218            '|' if depth == 0 => {
219                branches.push(inner[start..k].iter().collect());
220                start = k + 1;
221            }
222            _ => {}
223        }
224        k += 1;
225    }
226    branches.push(inner[start..].iter().collect());
227    branches
228}
229
230/// Whether at least two literal branches share a prefix.
231#[must_use]
232pub fn overlapping_literal_branches(inner: &str) -> bool {
233    let chars: Vec<char> = inner.chars().collect();
234    let literal_branches: Vec<String> = split_top_level_alternations(&chars)
235        .into_iter()
236        .filter(|b| is_pure_literal_branch(b))
237        .collect();
238    literal_branches.len() >= 2 && branches_overlap(&literal_branches)
239}
240
241struct QuantifiedGroup {
242    start: usize,
243    end: usize,
244    inner: String,
245}
246
247fn iter_quantified_group_bodies_at(
248    pattern: &[char],
249    base: usize,
250    depth: usize,
251) -> StructureResult<Vec<QuantifiedGroup>> {
252    if depth > MAX_GROUP_NESTING_DEPTH {
253        return Err(NestingTooDeep);
254    }
255    let mut found: Vec<QuantifiedGroup> = Vec::new();
256    let mut i = 0usize;
257    while i < pattern.len() {
258        if pattern[i] != '(' {
259            i += 1;
260            continue;
261        }
262        let Some(j) = find_group_end(pattern, i) else {
263            i += 1;
264            continue;
265        };
266        let raw_inner: Vec<char> = pattern[i + 1..j - 1].to_vec();
267        found.extend(iter_quantified_group_bodies_at(
268            &raw_inner,
269            base + i + 1,
270            depth + 1,
271        )?);
272        if let Some(inner) = normalize_group_inner(&raw_inner) {
273            let inner = unwrap_transparent_wrapper(&inner);
274            let qlen = outer_quantifier_len(pattern, j);
275            if qlen > 0 {
276                found.push(QuantifiedGroup {
277                    start: base + i,
278                    end: base + j + qlen,
279                    inner,
280                });
281            }
282        }
283        i = j;
284    }
285    Ok(found)
286}
287
288/// Every quantified group body as `(start, end, inner)` over the source.
289pub fn iter_quantified_group_bodies(pattern: &str) -> StructureResult<Vec<(usize, usize, String)>> {
290    let chars: Vec<char> = pattern.chars().collect();
291    Ok(iter_quantified_group_bodies_at(&chars, 0, 0)?
292        .into_iter()
293        .map(|g| (g.start, g.end, g.inner))
294        .collect())
295}
296
297fn chars_in_range(pattern: &str, start: usize, end: usize) -> String {
298    let chars: Vec<char> = pattern.chars().collect();
299    chars[start..end.min(chars.len())].iter().collect()
300}
301
302fn nested_body_is_unbounded(inner: &str) -> bool {
303    let chars: Vec<char> = inner.chars().collect();
304    let stripped = strip_escapes_and_char_classes(&chars);
305    if stripped.split('|').any(branch_is_unbounded_single) {
306        return true;
307    }
308    overlapping_literal_branches(inner)
309}
310
311/// Reference `_detect_nested_unbounded_quantifier`.
312#[must_use]
313pub fn detect_nested_unbounded_quantifier(pattern: &str) -> Option<String> {
314    match iter_quantified_group_bodies(pattern) {
315        Ok(bodies) => bodies
316            .into_iter()
317            .find(|(_, _, inner)| nested_body_is_unbounded(inner))
318            .map(|(start, end, _)| chars_in_range(pattern, start, end)),
319        Err(NestingTooDeep) => Some(nesting_depth_rejection_reason()),
320    }
321}
322
323const BROAD_SHORTHAND_ESCAPE_LETTERS: &str = "SWD";
324
325fn is_broad_char_class_inner(inner: &str) -> bool {
326    if let Some(excluded) = inner.strip_prefix('^') {
327        return !excluded.contains("\\S") && !excluded.contains("\\W") && !excluded.contains("\\D");
328    }
329    matches!(inner, "\\s\\S" | "\\S\\s")
330}
331
332/// Returns the new index and whether the atom is a broad class.
333fn broad_atom_span(pattern: &[char], i: usize) -> (usize, bool) {
334    let c = pattern[i];
335    let n = pattern.len();
336    if c == '\\' && i + 1 < n {
337        return (
338            i + 2,
339            BROAD_SHORTHAND_ESCAPE_LETTERS.contains(pattern[i + 1]),
340        );
341    }
342    if c == '[' {
343        let j = skip_char_class(pattern, i);
344        let inner: String = pattern[i + 1..j - 1].iter().collect();
345        return (j, is_broad_char_class_inner(&inner));
346    }
347    if c == '.' {
348        return (i + 1, true);
349    }
350    (i + 1, false)
351}
352
353fn broad_unbounded_run_at(pattern: &str, depth: usize) -> StructureResult<(usize, Vec<String>)> {
354    if depth > MAX_GROUP_NESTING_DEPTH {
355        return Err(NestingTooDeep);
356    }
357    let chars: Vec<char> = pattern.chars().collect();
358    let mut count = 0usize;
359    let mut spans: Vec<String> = Vec::new();
360    let mut i = 0usize;
361    while i < chars.len() {
362        if chars[i] == '(' {
363            let Some(j) = find_group_end(&chars, i) else {
364                i += 1;
365                continue;
366            };
367            let raw: Vec<char> = chars[i + 1..j - 1].to_vec();
368            if let Some(inner) = normalize_group_inner(&raw) {
369                let inner_chars: Vec<char> = inner.chars().collect();
370                let mut best: Option<(usize, Vec<String>)> = None;
371                for branch in split_top_level_alternations(&inner_chars) {
372                    let result = broad_unbounded_run_at(&branch, depth + 1)?;
373                    let better = match &best {
374                        None => true,
375                        Some((best_count, _)) => result.0 > *best_count,
376                    };
377                    if better {
378                        best = Some(result);
379                    }
380                }
381                if let Some((best_count, best_spans)) = best {
382                    count += best_count;
383                    spans.extend(best_spans);
384                }
385            }
386            i = j;
387            continue;
388        }
389        let (atom_end, is_broad) = broad_atom_span(&chars, i);
390        if is_broad && outer_quantifier_len(&chars, atom_end) > 0 {
391            count += 1;
392            spans.push(chars[i..atom_end].iter().collect());
393        }
394        i = atom_end;
395    }
396    Ok((count, spans))
397}
398
399/// Reference `_detect_adjacent_broad_unbounded_quantifiers`.
400#[must_use]
401pub fn detect_adjacent_broad_unbounded_quantifiers(pattern: &str) -> Option<String> {
402    let (count, spans) = match broad_unbounded_run_at(pattern, 0) {
403        Ok(result) => result,
404        Err(NestingTooDeep) => return Some(nesting_depth_rejection_reason()),
405    };
406    if count >= 2 {
407        let joined = spans
408            .iter()
409            .take(2)
410            .cloned()
411            .collect::<Vec<_>>()
412            .join(" and ");
413        return Some(joined);
414    }
415    None
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn chars(text: &str) -> Vec<char> {
423        text.chars().collect()
424    }
425
426    #[test]
427    fn skip_char_class_walks_escapes_and_stops_at_the_bracket() {
428        let text = chars(r"[a\]b]c");
429        assert_eq!(skip_char_class(&text, 0), text.len() - 1);
430        let unterminated = chars("[ab");
431        assert_eq!(skip_char_class(&unterminated, 0), unterminated.len());
432    }
433
434    #[test]
435    fn strip_escapes_and_char_classes_placeholders() {
436        let stripped = strip_escapes_and_char_classes(&chars(r"a[\d]\w"));
437        assert_eq!(stripped, "aXX");
438    }
439
440    #[test]
441    fn branch_is_unbounded_single_covers_the_three_shapes() {
442        assert!(branch_is_unbounded_single(".*"));
443        assert!(branch_is_unbounded_single(".+"));
444        assert!(branch_is_unbounded_single(".{3,}"));
445        assert!(!branch_is_unbounded_single(".{3}"));
446        assert!(!branch_is_unbounded_single(".{3,4}"));
447        // The reference regex `.[*+]` accepts any leading character.
448        assert!(branch_is_unbounded_single("a*"));
449        assert!(!branch_is_unbounded_single(".?"));
450        assert!(!branch_is_unbounded_single("."));
451    }
452
453    #[test]
454    fn advance_skips_escapes_and_classes() {
455        let text = chars(r"\d[abc]x");
456        assert_eq!(advance_past_escape_or_char_class(&text, 0), Some(2));
457        assert_eq!(advance_past_escape_or_char_class(&text, 2), Some(7));
458        assert_eq!(advance_past_escape_or_char_class(&text, 7), None);
459    }
460
461    #[test]
462    fn find_group_end_tracks_nesting_and_escapes() {
463        let text = chars(r"(a(b)[c]\))d");
464        assert_eq!(find_group_end(&text, 0), Some(11));
465        let unbalanced = chars("(abc");
466        assert_eq!(find_group_end(&unbalanced, 0), None);
467    }
468
469    #[test]
470    fn normalize_group_inner_variants() {
471        assert_eq!(normalize_group_inner(&chars("?:ab")), Some("ab".to_owned()));
472        assert_eq!(
473            normalize_group_inner(&chars("?P<name>x")),
474            Some("x".to_owned())
475        );
476        assert_eq!(normalize_group_inner(&chars("?P=name")), None);
477        assert_eq!(
478            normalize_group_inner(&chars("plain")),
479            Some("plain".to_owned())
480        );
481        let unterminated = chars("?P<name");
482        assert_eq!(normalize_group_inner(&unterminated), None);
483    }
484
485    #[test]
486    fn unwrap_transparent_wrapper_peels_only_full_spans() {
487        assert_eq!(unwrap_transparent_wrapper("(?:(?:ab))"), "ab");
488        assert_eq!(unwrap_transparent_wrapper("(?:ab)c"), "(?:ab)c");
489        assert_eq!(unwrap_transparent_wrapper("(?P=x)"), "(?P=x)");
490        assert_eq!(unwrap_transparent_wrapper("(ab"), "(ab");
491    }
492
493    #[test]
494    fn outer_quantifier_len_detects_symbol_and_brace_forms() {
495        let text = chars("a*bc+d{2,}e{2}f{,}");
496        assert_eq!(outer_quantifier_len(&text, 1), 1);
497        assert_eq!(outer_quantifier_len(&text, 4), 1);
498        assert_eq!(outer_quantifier_len(&text, 6), 4);
499        assert_eq!(outer_quantifier_len(&text, 11), 0);
500        // `{,}` reads as the unbounded `{0,}` form.
501        assert_eq!(outer_quantifier_len(&text, 15), 3);
502        assert_eq!(outer_quantifier_len(&text, text.len()), 0);
503    }
504
505    #[test]
506    fn branches_overlap_on_prefix_shares() {
507        assert!(branches_overlap(&["ab".into(), "abc".into()]));
508        assert!(branches_overlap(&["ab".into(), "ab".into()]));
509        assert!(!branches_overlap(&["ab".into(), "cd".into()]));
510    }
511
512    #[test]
513    fn pure_literal_branches_reject_meta_characters() {
514        assert!(is_pure_literal_branch("abc"));
515        assert!(!is_pure_literal_branch("a.c"));
516        assert!(!is_pure_literal_branch(""));
517    }
518
519    #[test]
520    fn split_top_level_alternations_respects_nesting_and_escapes() {
521        let branches = split_top_level_alternations(&chars(r"a(b|c)|d\|e"));
522        assert_eq!(branches, vec!["a(b|c)", "d\\|e"]);
523    }
524
525    #[test]
526    fn overlapping_literal_branches_needs_two_literal_shares() {
527        assert!(overlapping_literal_branches("ab|abc"));
528        assert!(!overlapping_literal_branches("ab|cd"));
529        assert!(!overlapping_literal_branches("ab|a."));
530    }
531
532    #[test]
533    fn nested_unbounded_quantifier_finds_the_span() {
534        assert_eq!(
535            detect_nested_unbounded_quantifier(r"(\w+)*$"),
536            // The finding span covers the group and its quantifier only.
537            Some(r"(\w+)*".to_owned())
538        );
539        assert_eq!(
540            detect_nested_unbounded_quantifier(r"(?:a*)*"),
541            Some(r"(?:a*)*".to_owned())
542        );
543        assert_eq!(detect_nested_unbounded_quantifier(r"a*b"), None);
544    }
545
546    #[test]
547    fn nested_unbounded_detects_overlapping_literal_branches() {
548        assert_eq!(
549            detect_nested_unbounded_quantifier(r"(?:ab|abc)+"),
550            Some(r"(?:ab|abc)+".to_owned())
551        );
552    }
553
554    #[test]
555    fn nesting_depth_rejection_for_deep_groups() {
556        let deep = format!("{}a*{}", "(".repeat(25), ")".repeat(25));
557        assert_eq!(
558            detect_nested_unbounded_quantifier(&deep),
559            Some(nesting_depth_rejection_reason())
560        );
561        assert_eq!(
562            detect_adjacent_broad_unbounded_quantifiers(&deep),
563            Some(nesting_depth_rejection_reason())
564        );
565    }
566
567    #[test]
568    fn adjacent_broad_unbounded_quantifiers_finds_two_broad_atoms() {
569        assert_eq!(
570            detect_adjacent_broad_unbounded_quantifiers(".*x.*"),
571            Some(". and .".to_owned())
572        );
573        assert_eq!(
574            detect_adjacent_broad_unbounded_quantifiers(r"\S*\W*"),
575            Some(r"\S and \W".to_owned())
576        );
577        assert_eq!(detect_adjacent_broad_unbounded_quantifiers(".*x"), None);
578    }
579
580    #[test]
581    fn broad_class_semantics_cover_negated_and_shorthand_inner() {
582        // [\s\S] and its negated-broad shapes read as broad; [^ab] without
583        // an S/W/D shorthand inside is also broad.
584        assert_eq!(
585            detect_adjacent_broad_unbounded_quantifiers(r"[\s\S]*[\s\S]+"),
586            Some("[\\s\\S] and [\\s\\S]".to_owned())
587        );
588        assert_eq!(
589            detect_adjacent_broad_unbounded_quantifiers(r"[^ab]*[^ab]+"),
590            Some("[^ab] and [^ab]".to_owned())
591        );
592    }
593
594    #[test]
595    fn broad_runs_descend_into_groups_and_pick_the_best_branch() {
596        // The finding names the broad atoms themselves (the `.`), not
597        // their quantifiers, exactly like the reference span slice.
598        assert_eq!(
599            detect_adjacent_broad_unbounded_quantifiers("(?:a|.*x.*)"),
600            Some(". and .".to_owned())
601        );
602        // Two broad atoms in separate groups accumulate.
603        assert!(detect_adjacent_broad_unbounded_quantifiers("(?:.*)(?:.*)").is_some());
604    }
605
606    #[test]
607    fn quantified_group_bodies_walk_with_absolute_positions() {
608        let bodies = iter_quantified_group_bodies("(a(b))+").expect("parses");
609        // Only the quantified outer group yields; the unquantified nested
610        // `(b)` is not a quantified group body.
611        assert_eq!(bodies.len(), 1);
612        let (start, end, inner_outer) = &bodies[0];
613        assert_eq!(*start, 0);
614        assert_eq!(*end, 7);
615        assert_eq!(inner_outer, "a(b)");
616        let bodies = iter_quantified_group_bodies("(a(b)*)+").expect("parses");
617        assert_eq!(bodies.len(), 2);
618        // The body text is the normalized inner source (no quantifier).
619        assert_eq!(bodies[0].2, "b");
620        assert_eq!(bodies[1].2, "a(b)*");
621    }
622
623    #[test]
624    fn quantified_group_bodies_skip_unbalanced_groups() {
625        let bodies = iter_quantified_group_bodies("(unclosed+x").expect("parses");
626        assert!(bodies.is_empty());
627    }
628
629    #[test]
630    fn brace_shapes_without_a_comma_are_not_unbounded_singles() {
631        // A fixed brace repeat has no comma, so the branch is not an
632        // unbounded single.
633        assert!(!branch_is_unbounded_single(".{22}"));
634        assert!(!branch_is_unbounded_single(".{2,3}"));
635        assert!(branch_is_unbounded_single(".{2,}"));
636    }
637
638    #[test]
639    fn named_backreference_groups_are_invisible_to_the_scans() {
640        // `(?P=n)` groups normalize to nothing, so both scanners skip
641        // them; unclosed groups advance one char at a time instead of
642        // panicking.
643        assert_eq!(detect_nested_unbounded_quantifier(r"(?P=x)(a)+"), None);
644        assert_eq!(
645            detect_adjacent_broad_unbounded_quantifiers(r"(?P=x)abc"),
646            None
647        );
648        assert_eq!(detect_adjacent_broad_unbounded_quantifiers("a(b.*c"), None);
649    }
650}