Skip to main content

guard_core_engine/redos/
safety.rs

1//! The pattern-safety verdict API.
2//!
3//! Replaces the engine's 6-regex stub with the full reference chain:
4//! dangerous constructs, compile check, structural detectors, reach-probe
5//! synthesis, and the empirical cost arbiter over killable children.
6
7use 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/// Which validation run to perform (the reference's two modes).
15#[derive(Debug, Clone, PartialEq)]
16pub enum SafetyMode {
17    /// Search the given strings under the per-string threshold after the
18    /// structural check (reference `test_strings` argument).
19    TestStrings(Vec<String>),
20    /// Synthesize probes and arbitrate extrapolated cost (reference
21    /// `max_content_length` argument).
22    CostVerdict { max_content_length: Option<usize> },
23}
24
25/// The five structural rules plus the nesting-depth rejection.
26#[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/// Why a pattern was rejected or accepted.
37#[derive(Debug, Clone, PartialEq)]
38pub enum SafetyReason {
39    /// One of the three dangerous backtracking constructs.
40    DangerousConstruct(String),
41    /// Neither engine can compile the pattern.
42    CompileFailed(String),
43    /// A structural detector flagged the pattern.
44    Structural(StructuralRule),
45    /// A test string exceeded the per-string threshold.
46    ProbeStringTimeout(usize),
47    /// The test-strings child was killed by its deadline.
48    ProbeSubprocessTimeout,
49    /// The timing child failed to spawn or exited unexpectedly.
50    ProbeSpawnFailed(String),
51    /// No reaching probe could be synthesized.
52    UnreachableProbe,
53    /// Extrapolated cost exceeds the budget at the cap.
54    OverBudget {
55        cap: usize,
56        extrapolated: f64,
57        ratio: f64,
58        min_32: f64,
59        median_32: f64,
60        load_factor: f64,
61    },
62    /// Probe construction exceeded its deadline or a builder budget.
63    BuilderDeadline(String),
64    /// No adversarial trigger found; certified safe.
65    Safe,
66}
67
68impl SafetyReason {
69    /// The stable reason class token (the corpus pins these).
70    #[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    /// The stable reason class token per structural rule.
88    #[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/// The full validation verdict.
180#[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    /// The stable reason class token.
195    #[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
207/// The reference's default flags: IGNORECASE | MULTILINE.
208pub 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    // The compile check mirrors the oracle's `re.compile` (the reference
233    // `compile_pattern_sync`): Python-syntax validity only. The
234    // Python-syntax floor the oracle pins rejects lookalike syntax the
235    // `regex` crate would take (and vice versa); the timing child still
236    // falls back to `fancy-regex` for patterns the crate cannot compile.
237    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        // The nesting-depth rejection cannot reach this detector: the
263        // nested rule runs first in the chain and claims deep patterns.
264        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/// Validate a pattern with the full safety chain under `flags`.
279#[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
294/// An injected pattern-safety probe runner (test seam).
295pub(crate) type SafetyProbeRunner<'a> =
296    &'a dyn Fn(&str, Vec<String>, super::ast::Flags) -> TestStringsOutcome;
297
298/// An injected cost-verdict runner (test seam).
299pub(crate) type CostVerdictRunner<'a> =
300    &'a dyn Fn(&str, Option<usize>, super::ast::Flags) -> CostOutcome;
301
302/// The pure half of [`validate_pattern_safety_with_flags`]: the probe and
303/// cost-verdict runners are injected so tests can force every arm
304/// deterministically.
305#[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                    // Unreachable fallback: CostOutcome::Structural only
365                    // comes back when first_structural_safety_violation
366                    // found a violation, and that check runs the same five
367                    // structural detectors in the same order as
368                    // structural_reason.
369                    #[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                    // A builder timeout echoes the structural violation when
385                    // one exists (reference behavior).
386                    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                        // Unreachable fallback: the guard above runs the
393                        // same detectors as structural_reason.
394                        #[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/// Validate a pattern with the full safety chain under the reference's
418/// default flags (IGNORECASE | MULTILINE).
419#[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/// Compat shim preserving the old two-tuple shape for existing consumers
425/// (the pyo3 facade and config paths): cost-verdict mode under default
426/// flags.
427#[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    /// Extract the structural rule; panics on any other reason.
444    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        // dotall-only flags still enter the prefix builder, so the
501        // ignorecase and multiline arms stay independent of each other.
502        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        // Atomic groups and possessive quantifiers are compile failures at
525        // the reference floor the oracle pins.
526        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        // The compile step is exercised before the injected runners run, so
630        // each flag letter's prefix branch is covered by a chain call.
631        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        // r"(\w+)*$" carries a nested-unbounded structural violation (and
721        // is not a dangerous construct), so the chain echoes the rule back.
722        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}