1pub const MAX_GROUP_NESTING_DEPTH: usize = 20;
10
11#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct NestingTooDeep;
23
24pub type StructureResult<T> = Result<T, NestingTooDeep>;
25
26#[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#[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#[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 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#[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#[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#[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#[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#[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 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#[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#[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#[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#[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
288pub 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#[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
332fn 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#[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 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 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 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 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 assert_eq!(
599 detect_adjacent_broad_unbounded_quantifiers("(?:a|.*x.*)"),
600 Some(". and .".to_owned())
601 );
602 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 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 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 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 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}