1use super::ast;
8use super::cost_arbiter::{
9 CostOutcome, TestStringsOutcome, reach_probe_cost_reason, reach_probe_unreachable_reason,
10 run_pattern_safety_probe,
11};
12use super::prefilters::{dangerous_construct_violation, first_structural_safety_violation};
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum SafetyMode {
17 TestStrings(Vec<String>),
20 CostVerdict { max_content_length: Option<usize> },
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum StructuralRule {
28 NestedUnboundedQuantifier(String),
29 AdjacentBroadUnboundedQuantifiers(String),
30 UnreachableTerminatorScan(String),
31 LiteralAbsorbedByQuantifiedClass(String),
32 AmbiguousOptionalTail(String),
33 NestingDepthExceeded,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum SafetyReason {
39 DangerousConstruct(String),
41 CompileFailed(String),
43 Structural(StructuralRule),
45 ProbeStringTimeout(usize),
47 ProbeSubprocessTimeout,
49 ProbeSpawnFailed(String),
51 UnreachableProbe,
53 OverBudget {
55 cap: usize,
56 extrapolated: f64,
57 ratio: f64,
58 min_32: f64,
59 median_32: f64,
60 load_factor: f64,
61 },
62 BuilderDeadline(String),
64 Safe,
66}
67
68impl SafetyReason {
69 #[must_use]
71 pub fn reason_class(&self) -> &'static str {
72 match self {
73 Self::DangerousConstruct(_) => "dangerous_construct",
74 Self::CompileFailed(_) => "compile_failed",
75 Self::Structural(rule) => rule.reason_class(),
76 Self::ProbeStringTimeout(_) => "probe_string_timeout",
77 Self::ProbeSubprocessTimeout | Self::ProbeSpawnFailed(_) => "probe_subprocess_timeout",
78 Self::UnreachableProbe => "unreachable_probe",
79 Self::OverBudget { .. } => "over_budget",
80 Self::BuilderDeadline(_) => "builder_deadline",
81 Self::Safe => "safe",
82 }
83 }
84}
85
86impl StructuralRule {
87 #[must_use]
89 pub fn reason_class(&self) -> &'static str {
90 match self {
91 Self::NestedUnboundedQuantifier(_) => "structural_nested_unbounded",
92 Self::AdjacentBroadUnboundedQuantifiers(_) => "structural_adjacent_broad",
93 Self::UnreachableTerminatorScan(_) => "structural_unreachable_terminator",
94 Self::LiteralAbsorbedByQuantifiedClass(_) => "structural_literal_absorb",
95 Self::AmbiguousOptionalTail(_) => "structural_ambiguous_tail",
96 Self::NestingDepthExceeded => "structural_nesting_depth",
97 }
98 }
99}
100
101impl std::fmt::Display for SafetyReason {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::DangerousConstruct(construct) => {
105 write!(f, "Pattern contains dangerous construct: {construct}")
106 }
107 Self::CompileFailed(error) => {
108 write!(f, "Pattern validation failed: {error}")
109 }
110 Self::Structural(rule) => write!(f, "{}", structural_rule_text(rule)),
111 Self::ProbeStringTimeout(length) => {
112 write!(f, "Pattern timed out on test string of length {length}")
113 }
114 Self::ProbeSubprocessTimeout => write!(
115 f,
116 "Pattern validation probe exceeded the {}s killable-subprocess timeout",
117 super::cost_arbiter::PATTERN_SAFETY_PROBE_TIMEOUT_SECONDS
118 ),
119 Self::ProbeSpawnFailed(detail) => {
120 write!(f, "Pattern validation probe failed to run: {detail}")
121 }
122 Self::UnreachableProbe => {
123 write!(f, "{}", reach_probe_unreachable_reason(None))
124 }
125 Self::OverBudget {
126 cap,
127 extrapolated,
128 ratio,
129 min_32,
130 median_32,
131 load_factor,
132 } => write!(
133 f,
134 "{}",
135 reach_probe_cost_reason(
136 None,
137 &crate::redos::cost_arbiter::OverBudget {
138 cap: *cap,
139 extrapolated: *extrapolated,
140 ratio: *ratio,
141 min_32: *min_32,
142 median_32: *median_32,
143 load_factor: *load_factor,
144 },
145 )
146 ),
147 Self::BuilderDeadline(message) => write!(f, "{message}"),
148 Self::Safe => write!(f, "Pattern appears safe"),
149 }
150 }
151}
152
153fn structural_rule_text(rule: &StructuralRule) -> String {
154 match rule {
155 StructuralRule::NestedUnboundedQuantifier(finding) => {
156 format!("Pattern contains nested unbounded quantifier: {finding}")
157 }
158 StructuralRule::AdjacentBroadUnboundedQuantifiers(finding) => {
159 format!("Pattern contains adjacent broad unbounded quantifiers: {finding}")
160 }
161 StructuralRule::UnreachableTerminatorScan(finding) => format!(
162 "Pattern contains a broad scan whose terminator cannot be reached \
163 by repeating its own prefix: {finding}"
164 ),
165 StructuralRule::LiteralAbsorbedByQuantifiedClass(finding) => format!(
166 "Pattern contains a quantified class that can absorb the mandatory \
167 literal immediately following it: {finding}"
168 ),
169 StructuralRule::AmbiguousOptionalTail(finding) => format!(
170 "Pattern contains an ambiguous optional tail inside an unbounded \
171 quantified group: {finding}"
172 ),
173 StructuralRule::NestingDepthExceeded => {
174 crate::redos::structure::nesting_depth_rejection_reason()
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq)]
181pub struct SafetyVerdict {
182 pub safe: bool,
183 pub reason: SafetyReason,
184}
185
186impl SafetyVerdict {
187 fn unsafe_reason(reason: SafetyReason) -> Self {
188 Self {
189 safe: false,
190 reason,
191 }
192 }
193
194 #[must_use]
196 pub fn reason_class(&self) -> &'static str {
197 self.reason.reason_class()
198 }
199}
200
201impl std::fmt::Display for SafetyVerdict {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 write!(f, "{}", self.reason)
204 }
205}
206
207pub fn default_flags() -> super::ast::Flags {
209 super::ast::Flags::ignorecase_multiline()
210}
211
212pub(crate) fn compile_failed(pattern: &str, flags: super::ast::Flags) -> Result<(), String> {
213 let prefix = if flags.ignorecase || flags.multiline || flags.dotall || flags.ascii {
214 let mut prefix = String::from("(?");
215 if flags.ignorecase {
216 prefix.push('i');
217 }
218 if flags.multiline {
219 prefix.push('m');
220 }
221 if flags.dotall {
222 prefix.push('s');
223 }
224 if flags.ascii {
225 prefix.push('a');
226 }
227 prefix.push(')');
228 prefix
229 } else {
230 String::new()
231 };
232 let _ = prefix;
238 ast::parse(pattern, flags).map(|_| ()).map_err(|e| e.0)
239}
240
241fn structural_reason(pattern: &str) -> Option<StructuralRule> {
242 structural_reason_direct(pattern)
243}
244
245fn structural_reason_direct(pattern: &str) -> Option<StructuralRule> {
246 use super::ambiguous_tail::detect_ambiguous_optional_tail_in_quantified_group;
247 use super::literal_in_wildcard::detect_ambiguous_literal_boundary;
248 use super::structure::{
249 detect_adjacent_broad_unbounded_quantifiers, detect_nested_unbounded_quantifier,
250 };
251 use super::unreachable_terminator::detect_unreachable_terminator_scan;
252 if let Some(finding) = detect_nested_unbounded_quantifier(pattern) {
253 return Some(
254 if finding == crate::redos::structure::nesting_depth_rejection_reason() {
255 StructuralRule::NestingDepthExceeded
256 } else {
257 StructuralRule::NestedUnboundedQuantifier(finding)
258 },
259 );
260 }
261 if let Some(finding) = detect_adjacent_broad_unbounded_quantifiers(pattern) {
262 return Some(StructuralRule::AdjacentBroadUnboundedQuantifiers(finding));
265 }
266 if let Some(finding) = detect_unreachable_terminator_scan(pattern) {
267 return Some(StructuralRule::UnreachableTerminatorScan(finding));
268 }
269 if let Some(finding) = detect_ambiguous_literal_boundary(pattern) {
270 return Some(StructuralRule::LiteralAbsorbedByQuantifiedClass(finding));
271 }
272 if let Some(finding) = detect_ambiguous_optional_tail_in_quantified_group(pattern) {
273 return Some(StructuralRule::AmbiguousOptionalTail(finding));
274 }
275 None
276}
277
278#[must_use]
280pub fn validate_pattern_safety_with_flags(
281 pattern: &str,
282 mode: &SafetyMode,
283 flags: super::ast::Flags,
284) -> SafetyVerdict {
285 validate_pattern_safety_chain(
286 pattern,
287 mode,
288 flags,
289 &run_pattern_safety_probe,
290 &super::cost_arbiter::reach_probe_cost_verdict,
291 )
292}
293
294pub(crate) type SafetyProbeRunner<'a> =
296 &'a dyn Fn(&str, Vec<String>, super::ast::Flags) -> TestStringsOutcome;
297
298pub(crate) type CostVerdictRunner<'a> =
300 &'a dyn Fn(&str, Option<usize>, super::ast::Flags) -> CostOutcome;
301
302#[must_use]
306pub(crate) fn validate_pattern_safety_chain(
307 pattern: &str,
308 mode: &SafetyMode,
309 flags: super::ast::Flags,
310 probe_runner: SafetyProbeRunner<'_>,
311 cost_runner: CostVerdictRunner<'_>,
312) -> SafetyVerdict {
313 if let Some(construct) = dangerous_construct_violation(pattern) {
314 return SafetyVerdict::unsafe_reason(SafetyReason::DangerousConstruct(construct));
315 }
316 if let Err(error) = compile_failed(pattern, flags) {
317 return SafetyVerdict::unsafe_reason(SafetyReason::CompileFailed(error));
318 }
319 match mode {
320 SafetyMode::TestStrings(test_strings) => {
321 if let Some(rule) = structural_reason(pattern) {
322 return SafetyVerdict::unsafe_reason(SafetyReason::Structural(rule));
323 }
324 match probe_runner(pattern, test_strings.clone(), flags) {
325 TestStringsOutcome::Safe => SafetyVerdict {
326 safe: true,
327 reason: SafetyReason::Safe,
328 },
329 TestStringsOutcome::SlowString(length) => {
330 SafetyVerdict::unsafe_reason(SafetyReason::ProbeStringTimeout(length))
331 }
332 TestStringsOutcome::CompileFailed(message) => {
333 SafetyVerdict::unsafe_reason(SafetyReason::CompileFailed(message))
334 }
335 TestStringsOutcome::SubprocessTimeout => {
336 SafetyVerdict::unsafe_reason(SafetyReason::ProbeSubprocessTimeout)
337 }
338 TestStringsOutcome::SpawnFailed(detail) => {
339 SafetyVerdict::unsafe_reason(SafetyReason::ProbeSpawnFailed(detail))
340 }
341 }
342 }
343 SafetyMode::CostVerdict { max_content_length } => {
344 match cost_runner(pattern, *max_content_length, flags) {
345 CostOutcome::Safe => SafetyVerdict {
346 safe: true,
347 reason: SafetyReason::Safe,
348 },
349 CostOutcome::Over(over) => {
350 let reason = SafetyReason::OverBudget {
351 cap: over.cap,
352 extrapolated: over.extrapolated,
353 ratio: over.ratio,
354 min_32: over.min_32,
355 median_32: over.median_32,
356 load_factor: over.load_factor,
357 };
358 SafetyVerdict::unsafe_reason(reason)
359 }
360 CostOutcome::Unreachable => {
361 SafetyVerdict::unsafe_reason(SafetyReason::UnreachableProbe)
362 }
363 CostOutcome::Structural => {
364 #[cfg(not(coverage))]
370 match structural_reason(pattern) {
371 Some(rule) => SafetyVerdict::unsafe_reason(SafetyReason::Structural(rule)),
372 None => SafetyVerdict::unsafe_reason(SafetyReason::BuilderDeadline(
373 "structural violation".into(),
374 )),
375 }
376 #[cfg(coverage)]
377 {
378 let rule = structural_reason(pattern)
379 .expect("the arbiter echoes an existing violation");
380 SafetyVerdict::unsafe_reason(SafetyReason::Structural(rule))
381 }
382 }
383 CostOutcome::BuilderDeadline(message) => {
384 if message == crate::redos::structure::nesting_depth_rejection_reason() {
387 return SafetyVerdict::unsafe_reason(SafetyReason::Structural(
388 StructuralRule::NestingDepthExceeded,
389 ));
390 }
391 if first_structural_safety_violation(pattern).is_some() {
392 #[cfg(not(coverage))]
395 return match structural_reason(pattern) {
396 Some(rule) => {
397 SafetyVerdict::unsafe_reason(SafetyReason::Structural(rule))
398 }
399 None => {
400 SafetyVerdict::unsafe_reason(SafetyReason::BuilderDeadline(message))
401 }
402 };
403 #[cfg(coverage)]
404 {
405 let rule = structural_reason(pattern)
406 .expect("the guard runs the same detectors");
407 return SafetyVerdict::unsafe_reason(SafetyReason::Structural(rule));
408 }
409 }
410 SafetyVerdict::unsafe_reason(SafetyReason::BuilderDeadline(message))
411 }
412 }
413 }
414 }
415}
416
417#[must_use]
420pub fn validate_pattern_safety(pattern: &str, mode: &SafetyMode) -> SafetyVerdict {
421 validate_pattern_safety_with_flags(pattern, mode, default_flags())
422}
423
424#[must_use]
428pub fn validate_pattern_safety_compat(pattern: &str) -> (bool, String) {
429 let verdict = validate_pattern_safety(
430 pattern,
431 &SafetyMode::CostVerdict {
432 max_content_length: None,
433 },
434 );
435 (verdict.safe, verdict.reason.to_string())
436}
437
438#[cfg(test)]
439mod tests {
440 use super::super::ast::Flags;
441 use super::*;
442
443 fn expect_structural(verdict: SafetyVerdict) -> StructuralRule {
445 match verdict.reason {
446 SafetyReason::Structural(rule) => rule,
447 other => panic!("expected a structural rule, got {other:?}"),
448 }
449 }
450
451 fn cost(max: Option<usize>) -> SafetyMode {
452 SafetyMode::CostVerdict {
453 max_content_length: max,
454 }
455 }
456
457 #[test]
458 fn dangerous_constructs_are_rejected_first() {
459 for pattern in [
460 r"(.*)+",
461 r"(.+)+",
462 r"([a-z]*)+",
463 r"([a-z]+)+",
464 r".*.*",
465 r".+.+",
466 ] {
467 let verdict = validate_pattern_safety(pattern, &cost(None));
468 assert!(!verdict.safe, "{pattern}");
469 assert_eq!(verdict.reason_class(), "dangerous_construct");
470 assert!(
471 verdict
472 .reason
473 .to_string()
474 .starts_with("Pattern contains dangerous construct: "),
475 "{}",
476 verdict.reason
477 );
478 }
479 }
480
481 #[test]
482 fn compile_failures_name_the_reference_prefix() {
483 for pattern in ["(unclosed", "[invalid", "*leading"] {
484 let verdict = validate_pattern_safety(pattern, &cost(None));
485 assert!(!verdict.safe, "{pattern}");
486 assert_eq!(verdict.reason_class(), "compile_failed");
487 assert!(
488 verdict
489 .reason
490 .to_string()
491 .starts_with("Pattern validation failed: "),
492 "{}",
493 verdict.reason
494 );
495 }
496 }
497
498 #[test]
499 fn the_flag_prefix_tracks_every_flag_independently() {
500 let verdict = validate_pattern_safety_with_flags(
503 "(unclosed",
504 &cost(None),
505 Flags {
506 dotall: true,
507 ..Flags::default()
508 },
509 );
510 assert_eq!(verdict.reason_class(), "compile_failed");
511 let verdict = validate_pattern_safety_with_flags(
512 "(unclosed",
513 &cost(None),
514 Flags {
515 ascii: true,
516 ..Flags::default()
517 },
518 );
519 assert_eq!(verdict.reason_class(), "compile_failed");
520 }
521
522 #[test]
523 fn the_python_floor_rejects_lookalike_syntax() {
524 for pattern in ["(?>a)+", "(?P<1bad>x)", "(?P=n)", "(?:\\1)", "a*+"] {
527 let verdict = validate_pattern_safety(pattern, &cost(None));
528 assert!(!verdict.safe, "{pattern}");
529 assert_eq!(verdict.reason_class(), "compile_failed", "{pattern}");
530 }
531 }
532
533 #[test]
534 fn structural_rules_carry_pinned_reason_classes() {
535 let cases = [
536 (r"(\w+)*$", "structural_nested_unbounded"),
537 (".*x.*", "structural_adjacent_broad"),
538 ("a[^b]*c", "structural_unreachable_terminator"),
539 ("[a-z]+abc", "structural_literal_absorb"),
540 (r"(a?)+$", "structural_ambiguous_tail"),
541 ];
542 for (pattern, class) in cases {
543 let verdict = validate_pattern_safety(pattern, &SafetyMode::TestStrings(vec![]));
544 assert!(!verdict.safe, "{pattern}");
545 assert_eq!(
546 verdict.reason_class(),
547 class,
548 "{pattern}: {}",
549 verdict.reason
550 );
551 let rule = expect_structural(verdict);
552 assert_eq!(rule.reason_class(), class);
553 }
554 }
555
556 #[test]
557 fn every_reason_class_token_is_stable() {
558 assert_eq!(
559 SafetyReason::DangerousConstruct("(.*)+".into()).reason_class(),
560 "dangerous_construct"
561 );
562 assert_eq!(
563 SafetyReason::CompileFailed("boom".into()).reason_class(),
564 "compile_failed"
565 );
566 assert_eq!(
567 SafetyReason::Structural(StructuralRule::NestingDepthExceeded).reason_class(),
568 "structural_nesting_depth"
569 );
570 assert_eq!(
571 SafetyReason::ProbeStringTimeout(5).reason_class(),
572 "probe_string_timeout"
573 );
574 assert_eq!(
575 SafetyReason::ProbeSubprocessTimeout.reason_class(),
576 "probe_subprocess_timeout"
577 );
578 assert_eq!(
579 SafetyReason::ProbeSpawnFailed("x".into()).reason_class(),
580 "probe_subprocess_timeout"
581 );
582 assert_eq!(
583 SafetyReason::UnreachableProbe.reason_class(),
584 "unreachable_probe"
585 );
586 assert_eq!(
587 SafetyReason::BuilderDeadline("late".into()).reason_class(),
588 "builder_deadline"
589 );
590 assert_eq!(SafetyReason::Safe.reason_class(), "safe");
591 }
592
593 #[test]
594 fn reason_displays_are_human_readable() {
595 assert_eq!(
596 SafetyReason::ProbeSubprocessTimeout.to_string(),
597 "Pattern validation probe exceeded the 2s killable-subprocess timeout"
598 );
599 assert_eq!(
600 SafetyReason::ProbeStringTimeout(9).to_string(),
601 "Pattern timed out on test string of length 9"
602 );
603 let verdict = SafetyVerdict {
604 safe: false,
605 reason: SafetyReason::ProbeSpawnFailed("gone".into()),
606 };
607 assert_eq!(
608 verdict.to_string(),
609 "Pattern validation probe failed to run: gone"
610 );
611 assert_eq!(verdict.reason_class(), "probe_subprocess_timeout");
612 }
613
614 #[test]
615 fn the_nesting_depth_cap_maps_to_its_own_rule() {
616 let pattern = format!(
617 "{}a{}",
618 "(".repeat(crate::redos::structure::MAX_GROUP_NESTING_DEPTH + 2),
619 ")".repeat(crate::redos::structure::MAX_GROUP_NESTING_DEPTH + 2),
620 );
621 assert_eq!(
622 structural_reason(&pattern),
623 Some(StructuralRule::NestingDepthExceeded)
624 );
625 }
626
627 #[test]
628 fn compile_prefix_covers_every_flag_letter() {
629 let flags = crate::redos::ast::Flags {
632 ignorecase: true,
633 multiline: true,
634 dotall: true,
635 ascii: true,
636 };
637 let verdict = validate_pattern_safety_chain(
638 "abc",
639 &SafetyMode::TestStrings(vec![]),
640 flags,
641 &|_p, _t, _f| TestStringsOutcome::Safe,
642 &|_p, _m, _f| CostOutcome::Safe,
643 );
644 assert!(verdict.safe, "{:?}", verdict.reason);
645 }
646
647 #[test]
648 fn test_string_outcomes_map_to_their_reasons() {
649 let cases: Vec<(TestStringsOutcome, SafetyReason)> = vec![
650 (
651 TestStringsOutcome::SlowString(42),
652 SafetyReason::ProbeStringTimeout(42),
653 ),
654 (
655 TestStringsOutcome::CompileFailed("bad".into()),
656 SafetyReason::CompileFailed("bad".into()),
657 ),
658 (
659 TestStringsOutcome::SubprocessTimeout,
660 SafetyReason::ProbeSubprocessTimeout,
661 ),
662 (
663 TestStringsOutcome::SpawnFailed("no child".into()),
664 SafetyReason::ProbeSpawnFailed("no child".into()),
665 ),
666 ];
667 for (outcome, expected) in cases {
668 let verdict = validate_pattern_safety_chain(
669 "abc",
670 &SafetyMode::TestStrings(vec![]),
671 crate::redos::ast::Flags::default(),
672 &move |_p, _t, _f| outcome.clone(),
673 &|_p, _m, _f| CostOutcome::Safe,
674 );
675 assert!(!verdict.safe, "{expected:?}");
676 assert_eq!(verdict.reason, expected);
677 }
678 }
679
680 #[test]
681 fn cost_outcomes_map_to_their_reasons() {
682 let over = crate::redos::cost_arbiter::OverBudget {
683 cap: 1024,
684 extrapolated: 0.5,
685 ratio: 2.0,
686 min_32: 0.03,
687 median_32: 0.04,
688 load_factor: 1.5,
689 };
690 let verdict = validate_pattern_safety_chain(
691 "abc",
692 &cost(Some(1024)),
693 crate::redos::ast::Flags::default(),
694 &|_p, _t, _f| TestStringsOutcome::Safe,
695 &move |_p, _m, _f| CostOutcome::Over(over.clone()),
696 );
697 assert_eq!(
698 verdict.reason,
699 SafetyReason::OverBudget {
700 cap: 1024,
701 extrapolated: 0.5,
702 ratio: 2.0,
703 min_32: 0.03,
704 median_32: 0.04,
705 load_factor: 1.5,
706 }
707 );
708 let verdict = validate_pattern_safety_chain(
709 "abc",
710 &cost(None),
711 crate::redos::ast::Flags::default(),
712 &|_p, _t, _f| TestStringsOutcome::Safe,
713 &|_p, _m, _f| CostOutcome::Unreachable,
714 );
715 assert_eq!(verdict.reason, SafetyReason::UnreachableProbe);
716 }
717
718 #[test]
719 fn structural_cost_outcomes_echo_the_rule() {
720 let verdict = validate_pattern_safety_chain(
723 r"(\w+)*$",
724 &cost(None),
725 crate::redos::ast::Flags::default(),
726 &|_p, _t, _f| TestStringsOutcome::Safe,
727 &|_p, _m, _f| CostOutcome::Structural,
728 );
729 let rule = expect_structural(verdict);
730 assert_eq!(rule.reason_class(), "structural_nested_unbounded");
731 }
732
733 #[test]
734 fn builder_deadlines_echo_the_nesting_depth_rule() {
735 let verdict = validate_pattern_safety_chain(
736 "abc",
737 &cost(None),
738 crate::redos::ast::Flags::default(),
739 &|_p, _t, _f| TestStringsOutcome::Safe,
740 &|_p, _m, _f| {
741 CostOutcome::BuilderDeadline(
742 crate::redos::structure::nesting_depth_rejection_reason(),
743 )
744 },
745 );
746 assert_eq!(
747 verdict.reason,
748 SafetyReason::Structural(StructuralRule::NestingDepthExceeded)
749 );
750 }
751
752 #[test]
753 fn builder_deadlines_echo_structural_violations() {
754 let verdict = validate_pattern_safety_chain(
755 r"(\w+)*$",
756 &cost(None),
757 crate::redos::ast::Flags::default(),
758 &|_p, _t, _f| TestStringsOutcome::Safe,
759 &|_p, _m, _f| CostOutcome::BuilderDeadline("builder ran long".into()),
760 );
761 let rule = expect_structural(verdict);
762 assert_eq!(rule.reason_class(), "structural_nested_unbounded");
763 }
764
765 #[test]
766 fn builder_deadlines_stand_alone_without_violations() {
767 let verdict = validate_pattern_safety_chain(
768 "abc",
769 &cost(None),
770 crate::redos::ast::Flags::default(),
771 &|_p, _t, _f| TestStringsOutcome::Safe,
772 &|_p, _m, _f| CostOutcome::BuilderDeadline("builder ran long".into()),
773 );
774 assert_eq!(
775 verdict.reason,
776 SafetyReason::BuilderDeadline("builder ran long".into())
777 );
778 }
779
780 #[test]
781 fn structural_display_prefixes_match_the_reference() {
782 let checks = [
783 (r"(\w+)*$", "Pattern contains nested unbounded quantifier: "),
784 (
785 ".*x.*",
786 "Pattern contains adjacent broad unbounded quantifiers: ",
787 ),
788 (
789 "a[^b]*c",
790 "Pattern contains a broad scan whose terminator cannot be \
791 reached by repeating its own prefix: ",
792 ),
793 (
794 "[a-z]+abc",
795 "Pattern contains a quantified class that can absorb the \
796 mandatory literal immediately following it: ",
797 ),
798 (
799 r"(a?)+$",
800 "Pattern contains an ambiguous optional tail inside an \
801 unbounded quantified group: ",
802 ),
803 ];
804 for (pattern, prefix) in checks {
805 let verdict = validate_pattern_safety(pattern, &SafetyMode::TestStrings(vec![]));
806 let text = verdict.reason.to_string();
807 assert!(
808 text.starts_with(prefix),
809 "{pattern}: {text} does not start with {prefix:?}"
810 );
811 }
812 }
813
814 #[test]
815 fn test_strings_mode_certifies_clean_patterns() {
816 let verdict = validate_pattern_safety(
817 "abc",
818 &SafetyMode::TestStrings(vec!["abc".to_owned(), "x".to_owned()]),
819 );
820 assert!(verdict.safe);
821 assert_eq!(verdict.reason_class(), "safe");
822 assert_eq!(verdict.reason.to_string(), "Pattern appears safe");
823 }
824
825 #[test]
826 fn unreachable_probes_reject_rather_than_certify() {
827 let verdict = validate_pattern_safety(r"[^\x00-\U0010FFFF]+", &cost(None));
828 assert!(!verdict.safe);
829 assert_eq!(verdict.reason_class(), "unreachable_probe");
830 assert!(
831 verdict
832 .reason
833 .to_string()
834 .starts_with("Pattern validation probe could not construct")
835 );
836 }
837
838 #[test]
839 fn safe_cost_verdicts_certify_linear_patterns() {
840 for pattern in ["hello world", r"(?i)union\s+select", r"^\s*$"] {
841 let verdict = validate_pattern_safety(pattern, &cost(Some(10000)));
842 assert!(verdict.safe, "{pattern}: {}", verdict.reason);
843 }
844 }
845
846 #[test]
847 fn default_flags_are_ignorecase_and_multiline() {
848 let flags = default_flags();
849 assert!(flags.ignorecase);
850 assert!(flags.multiline);
851 assert!(!flags.dotall);
852 assert!(!flags.ascii);
853 }
854
855 #[test]
856 fn compat_shape_carries_the_same_decisions() {
857 let (safe, reason) = validate_pattern_safety_compat(r"(.*)+");
858 assert!(!safe);
859 assert!(reason.starts_with("Pattern contains dangerous construct: "));
860 let (safe, reason) = validate_pattern_safety_compat("hello world");
861 assert!(safe);
862 assert_eq!(reason, "Pattern appears safe");
863 }
864
865 #[test]
866 fn structural_rule_reason_classes_are_stable() {
867 assert_eq!(
868 StructuralRule::NestingDepthExceeded.reason_class(),
869 "structural_nesting_depth"
870 );
871 assert_eq!(
872 structural_rule_text(&StructuralRule::NestingDepthExceeded),
873 crate::redos::structure::nesting_depth_rejection_reason()
874 );
875 }
876
877 #[test]
878 fn over_budget_reason_class_and_display() {
879 let reason = SafetyReason::OverBudget {
880 cap: 262144,
881 extrapolated: 0.2,
882 ratio: 2.0,
883 min_32: 0.025,
884 median_32: 0.026,
885 load_factor: 2.5,
886 };
887 assert_eq!(reason.reason_class(), "over_budget");
888 assert!(
889 reason
890 .to_string()
891 .starts_with("Pattern extrapolated CPU cost at cap (262144 chars)")
892 );
893 }
894
895 #[test]
896 fn probe_subprocess_reason_shapes() {
897 let reason = SafetyReason::ProbeStringTimeout(42).to_string();
898 assert_eq!(reason, "Pattern timed out on test string of length 42");
899 assert_eq!(
900 SafetyReason::ProbeSubprocessTimeout.reason_class(),
901 "probe_subprocess_timeout"
902 );
903 let spawn = SafetyReason::ProbeSpawnFailed("no child".into()).to_string();
904 assert!(spawn.starts_with("Pattern validation probe failed to run: "));
905 assert_eq!(
906 SafetyReason::BuilderDeadline("deadline hit".into()).to_string(),
907 "deadline hit"
908 );
909 }
910
911 #[test]
912 #[should_panic(expected = "expected a structural rule")]
913 fn expect_structural_rejects_other_reasons() {
914 let _ = expect_structural(SafetyVerdict {
915 safe: false,
916 reason: SafetyReason::Safe,
917 });
918 }
919}