Skip to main content

keyhog_core/spec/
validate.rs

1//! Detector quality gate validation rules used while loading TOML specs.
2
3use super::{CanonicalHexKeyMaterialSpec, DetectorKind, DetectorSpec, VerifySpec};
4use regex_syntax::ast;
5use serde::Serialize;
6use std::collections::{hash_map::Entry, HashMap};
7
8const MAX_REGEX_PATTERN_LEN: usize = 4096;
9const MAX_COMPANION_WITHIN_LINES: usize = 100;
10const MIN_HTTP_STATUS: u16 = 100;
11const MAX_HTTP_STATUS: u16 = 599;
12// MAX_REGEX_AST_NODES / MAX_REGEX_ALTERNATION_BRANCHES /
13// MAX_REGEX_REPEAT_BOUND were originally defined here too but are the
14// canonical constants in `validate/regex_complexity.rs` (which is where
15// they're actually consumed). Duplicates here had no consumers - clippy
16// `dead_code` flagged them. Re-imports happen via the `use
17// regex_complexity::validate_regex_complexity;` below.
18
19/// Quality issue found in a detector spec.
20///
21/// # Examples
22///
23/// ```rust
24/// use keyhog_core::QualityIssue;
25///
26/// let issue = QualityIssue::Warning("add keywords".into());
27/// assert!(matches!(issue, QualityIssue::Warning(_)));
28/// ```
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub enum QualityIssue {
31    Error(String),
32    Warning(String),
33}
34
35/// Validate a detector spec against the quality gate.
36///
37/// # Examples
38///
39/// ```rust
40/// use keyhog_core::{DetectorSpec, PatternSpec, Severity, validate_detector};
41///
42/// let detector = DetectorSpec {
43///     id: "demo".into(),
44///     name: "Demo".into(),
45///     service: "demo".into(),
46///     severity: Severity::High,
47///     patterns: vec![PatternSpec {
48///         regex: "demo_[A-Z0-9]{8}".into(),
49///         ..Default::default()
50///     }],
51///     keywords: vec!["demo_".into()],
52///     ..Default::default()
53/// };
54///
55/// assert!(validate_detector(&detector).is_empty());
56/// ```
57pub fn validate_detector(spec: &DetectorSpec) -> Vec<QualityIssue> {
58    let mut issues = Vec::new();
59    let mut regex_cache = RegexAstCache::default();
60    validate_identity(spec, &mut issues);
61    validate_patterns_present(spec, &mut issues);
62    validate_regexes(spec, &mut issues, &mut regex_cache);
63    validate_required_literals(spec, &mut issues);
64    validate_pattern_groups(spec, &mut issues, &mut regex_cache);
65    validate_keywords(spec, &mut issues);
66    validate_simdsieve_prefixes(spec, &mut issues);
67    validate_offline_validators(spec, &mut issues);
68    validate_decode_transforms(spec, &mut issues);
69    validate_pattern_specificity(spec, &mut issues, &mut regex_cache);
70    validate_companions(spec, &mut issues, &mut regex_cache);
71    validate_verify_spec(spec, &mut issues);
72    validate_thresholds(spec, &mut issues);
73    validate_entropy_floor(spec, &mut issues);
74    validate_decoded_hex_key_material_lengths(spec, &mut issues);
75    validate_canonical_hex_key_material(spec, &mut issues);
76    validate_credential_shape(spec, &mut issues);
77    validate_generic_assignment_suffixes(spec, &mut issues);
78    validate_detector_allowlists(spec, &mut issues);
79    issues
80}
81fn validate_generic_assignment_suffixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
82    for (field, suffixes) in [
83        ("generic_vendor_suffixes", &spec.generic_vendor_suffixes),
84        (
85            "generic_assignment_tail_suffixes",
86            &spec.generic_assignment_tail_suffixes,
87        ),
88    ] {
89        if !suffixes.is_empty() && spec.kind != crate::DetectorKind::Phase2Generic {
90            issues.push(QualityIssue::Error(format!(
91                "{field} is only valid for a phase2-generic detector"
92            )));
93        }
94        let mut seen = std::collections::BTreeSet::new();
95        for suffix in suffixes {
96            if suffix.is_empty()
97                || suffix != &suffix.to_ascii_lowercase()
98                || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
99            {
100                issues.push(QualityIssue::Error(format!(
101                    "{field} entry {suffix:?} must be non-empty lowercase ASCII alphanumeric"
102                )));
103            } else if !seen.insert(suffix.as_str()) {
104                issues.push(QualityIssue::Error(format!(
105                    "{field} contains duplicate suffix {suffix:?}"
106                )));
107            }
108        }
109    }
110}
111
112fn validate_decode_transforms(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
113    for issue in spec.decode_transforms.validate() {
114        issues.push(QualityIssue::Error(format!("decode_transforms.{issue}")));
115    }
116}
117
118fn validate_required_literals(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
119    for (index, pattern) in spec.patterns.iter().enumerate() {
120        if let Err(reason) = pattern.validate_required_literals() {
121            issues.push(QualityIssue::Error(format!(
122                "patterns[{index}].required_literals: {reason}"
123            )));
124        }
125    }
126}
127
128fn validate_offline_validators(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
129    let mut claimed_prefixes = std::collections::HashSet::new();
130    for (index, validator) in spec.validators.iter().enumerate() {
131        let prefixes = validator.prefixes();
132        if prefixes.is_empty() {
133            issues.push(QualityIssue::Error(format!(
134                "validators[{index}].prefixes must not be empty"
135            )));
136        }
137        for prefix in prefixes {
138            if prefix.is_empty() || !prefix.is_ascii() {
139                issues.push(QualityIssue::Error(format!(
140                    "validators[{index}] prefix {prefix:?} must be non-empty ASCII"
141                )));
142            }
143            if !claimed_prefixes.insert(prefix) {
144                issues.push(QualityIssue::Error(format!(
145                    "detector validators claim prefix {prefix:?} more than once"
146                )));
147            }
148        }
149
150        if let Some(floor) = validator.confidence_floor() {
151            if !floor.is_finite() || !(0.0..=1.0).contains(&floor) {
152                issues.push(QualityIssue::Error(format!(
153                    "validators[{index}].confidence_floor must be finite and in [0.0, 1.0], found {floor}"
154                )));
155            }
156        }
157
158        match validator {
159            crate::DetectorValidatorSpec::Crc32Base62 {
160                entropy_len,
161                checksum_len,
162                ..
163            } => {
164                if *entropy_len == 0 || *checksum_len == 0 {
165                    issues.push(QualityIssue::Error(format!(
166                        "validators[{index}] CRC32 entropy_len and checksum_len must both be greater than zero"
167                    )));
168                }
169            }
170            crate::DetectorValidatorSpec::GithubFineGrainedCrc32 {
171                left_len,
172                right_len,
173                checksum_len,
174                ..
175            } => {
176                if *left_len == 0 || *checksum_len == 0 || *right_len <= *checksum_len {
177                    issues.push(QualityIssue::Error(format!(
178                        "validators[{index}] fine-grained lengths require left_len > 0 and right_len > checksum_len > 0"
179                    )));
180                }
181            }
182            crate::DetectorValidatorSpec::Base64Payload {
183                min_encoded_len,
184                max_encoded_len,
185                min_decoded_len,
186                ..
187            } => {
188                if *min_encoded_len == 0
189                    || *max_encoded_len < *min_encoded_len
190                    || *min_decoded_len == 0
191                {
192                    issues.push(QualityIssue::Error(format!(
193                        "validators[{index}] base64 lengths require 0 < min_encoded_len <= max_encoded_len and min_decoded_len > 0"
194                    )));
195                }
196            }
197            crate::DetectorValidatorSpec::PatternShape { .. } => {
198                if spec.patterns.is_empty() {
199                    issues.push(QualityIssue::Error(format!(
200                        "validators[{index}] pattern-shape requires at least one detector pattern"
201                    )));
202                }
203            }
204        }
205    }
206}
207
208fn validate_identity(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
209    if spec.id.is_empty() {
210        issues.push(QualityIssue::Error(
211            "detector.id must not be empty; assign a stable detector identifier".to_string(),
212        ));
213    } else if spec.id.trim() != spec.id {
214        issues.push(QualityIssue::Error(
215            "detector.id must not contain leading or trailing whitespace; remove the padding"
216                .to_string(),
217        ));
218    }
219}
220
221fn validate_decoded_hex_key_material_lengths(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
222    if spec.decoded_hex_key_material_lengths.is_empty() {
223        return;
224    }
225    if spec.kind != DetectorKind::Phase2Generic {
226        issues.push(QualityIssue::Error(
227            "decoded_hex_key_material_lengths is only valid for kind = \"phase2-generic\"".into(),
228        ));
229    }
230    let mut seen = std::collections::HashSet::new();
231    for &length in &spec.decoded_hex_key_material_lengths {
232        if length < 16 || length % 2 != 0 {
233            issues.push(QualityIssue::Error(format!(
234                "decoded_hex_key_material_lengths value {length} must be an even character count of at least 16"
235            )));
236        }
237        if !seen.insert(length) {
238            issues.push(QualityIssue::Error(format!(
239                "decoded_hex_key_material_lengths contains duplicate length {length}"
240            )));
241        }
242    }
243}
244
245fn validate_canonical_hex_key_material(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
246    if spec.canonical_hex_key_material.is_empty() {
247        return;
248    }
249    let generic_policy = spec.kind == DetectorKind::Phase2Generic;
250    let has_assignment_scope = |policy: &CanonicalHexKeyMaterialSpec| {
251        !policy.keywords.is_empty()
252            || !policy.suffixes.is_empty()
253            || !policy.excluded_keywords.is_empty()
254    };
255    if !generic_policy
256        && spec
257            .canonical_hex_key_material
258            .iter()
259            .any(has_assignment_scope)
260    {
261        issues.push(QualityIssue::Error(
262            "keyword- or suffix-scoped canonical_hex_key_material is only valid for kind = \"phase2-generic\"; regex detectors must declare length-only entries because the matched pattern is their anchor".into(),
263        ));
264    }
265
266    let owned_keywords: std::collections::HashSet<String> = spec
267        .keywords
268        .iter()
269        .filter_map(|keyword| normalize_detector_keyword(keyword))
270        .collect();
271    let mut seen_pairs = std::collections::HashSet::new();
272    let mut seen_regex_lengths = std::collections::HashSet::new();
273    for (policy_index, policy) in spec.canonical_hex_key_material.iter().enumerate() {
274        if policy.lengths.is_empty() {
275            issues.push(QualityIssue::Error(format!(
276                "canonical_hex_key_material[{policy_index}].lengths must not be empty"
277            )));
278        }
279        if generic_policy && policy.keywords.is_empty() && policy.suffixes.is_empty() {
280            issues.push(QualityIssue::Error(format!(
281                "phase2-generic canonical_hex_key_material[{policy_index}] must declare keywords or suffixes"
282            )));
283        }
284        let mut seen_lengths = std::collections::HashSet::new();
285        for &length in &policy.lengths {
286            if length < 16 || length % 2 != 0 {
287                issues.push(QualityIssue::Error(format!(
288                    "canonical_hex_key_material[{policy_index}] length {length} must be an even character count of at least 16"
289                )));
290            }
291            if !seen_lengths.insert(length) {
292                issues.push(QualityIssue::Error(format!(
293                    "canonical_hex_key_material[{policy_index}] contains duplicate length {length}"
294                )));
295            }
296            if !generic_policy && !seen_regex_lengths.insert(length) {
297                issues.push(QualityIssue::Error(format!(
298                    "canonical_hex_key_material repeats regex-detector length {length} across policies"
299                )));
300            }
301        }
302        let mut seen_keywords = std::collections::HashSet::new();
303        for keyword in &policy.keywords {
304            let Some(normalized) = normalize_detector_keyword(keyword) else {
305                issues.push(QualityIssue::Error(format!(
306                    "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
307                )));
308                continue;
309            };
310            if !seen_keywords.insert(normalized.clone()) {
311                issues.push(QualityIssue::Error(format!(
312                    "canonical_hex_key_material[{policy_index}] contains duplicate normalized keyword {normalized:?}"
313                )));
314            }
315            if !owned_keywords.contains(&normalized) {
316                issues.push(QualityIssue::Error(format!(
317                    "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must also appear in detector.keywords"
318                )));
319            }
320            for &length in &policy.lengths {
321                if !seen_pairs.insert((normalized.clone(), length)) {
322                    issues.push(QualityIssue::Error(format!(
323                        "canonical_hex_key_material repeats keyword {keyword:?} at length {length} across policies"
324                    )));
325                }
326            }
327        }
328        let mut seen_suffixes = std::collections::HashSet::new();
329        for suffix in &policy.suffixes {
330            let Some(normalized) = normalize_detector_keyword(suffix) else {
331                issues.push(QualityIssue::Error(format!(
332                    "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
333                )));
334                continue;
335            };
336            if normalized.is_empty() {
337                issues.push(QualityIssue::Error(format!(
338                    "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must not be empty"
339                )));
340            }
341            if !seen_suffixes.insert(normalized) {
342                issues.push(QualityIssue::Error(format!(
343                    "canonical_hex_key_material[{policy_index}] contains duplicate normalized suffix {suffix:?}"
344                )));
345            }
346        }
347        let mut seen_exclusions = std::collections::HashSet::new();
348        for excluded in &policy.excluded_keywords {
349            let Some(normalized) = normalize_detector_keyword(excluded) else {
350                issues.push(QualityIssue::Error(format!(
351                    "canonical_hex_key_material[{policy_index}] excluded keyword {excluded:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
352                )));
353                continue;
354            };
355            if !seen_exclusions.insert(normalized) {
356                issues.push(QualityIssue::Error(format!(
357                    "canonical_hex_key_material[{policy_index}] contains duplicate excluded keyword {excluded:?}"
358                )));
359            }
360        }
361    }
362}
363
364fn normalize_detector_keyword(keyword: &str) -> Option<String> {
365    let mut normalized = String::with_capacity(keyword.len());
366    for byte in keyword.bytes() {
367        if byte.is_ascii_alphanumeric() {
368            normalized.push(byte.to_ascii_lowercase() as char);
369        } else if !matches!(byte, b'_' | b'-' | b'.') {
370            return None;
371        }
372    }
373    (!normalized.is_empty()).then_some(normalized)
374}
375
376fn validate_simdsieve_prefixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
377    let mut seen = std::collections::HashSet::new();
378    for (index, prefix) in spec.simdsieve_prefixes.iter().enumerate() {
379        if prefix.is_empty() {
380            issues.push(QualityIssue::Error(format!(
381                "simdsieve_prefixes[{index}] must not be empty"
382            )));
383        } else if !prefix.is_ascii() {
384            issues.push(QualityIssue::Error(format!(
385                "simdsieve_prefixes[{index}] must be ASCII because simdsieve performs byte-prefix matching"
386            )));
387        }
388        if !seen.insert(prefix) {
389            issues.push(QualityIssue::Error(format!(
390                "simdsieve_prefixes contains duplicate literal {prefix:?}"
391            )));
392        }
393    }
394}
395
396/// `min_confidence` is a probability in `[0.0, 1.0]`. It is a bare `Option<f64>`
397/// with no serde bound, so a typo'd value parses cleanly and then silently
398/// breaks the gate: `< 0.0` always clears the confidence floor (every candidate
399/// surfaces), `> 1.0` can never clear it (the detector never fires), and `NaN`
400/// makes every comparison false. Reject anything outside the closed unit range
401/// (a `RangeInclusive::contains` check is false for `NaN`, so NaN is caught too).
402fn validate_thresholds(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
403    if !(0.0..=1.0).contains(&spec.ml.weight) {
404        issues.push(QualityIssue::Error(format!(
405            "ml.weight {} is out of range; detector model weight must be finite and in [0.0, 1.0]",
406            spec.ml.weight
407        )));
408    }
409    if spec.ml.context_radius_lines > 64 {
410        issues.push(QualityIssue::Error(format!(
411            "ml.context_radius_lines {} exceeds the bounded maximum of 64",
412            spec.ml.context_radius_lines
413        )));
414    }
415    let owns_entropy = spec.owns_entropy_policy();
416    match spec.match_confidence {
417        None => issues.push(QualityIssue::Error(
418            "detector must declare match_confidence; scanner-wide match scoring defaults are not permitted"
419                .into(),
420        )),
421        Some(confidence) => {
422            if let Err(error) = confidence.validate() {
423                issues.push(QualityIssue::Error(format!(
424                    "match_confidence is invalid: {error}"
425                )));
426            }
427            if owns_entropy {
428                if confidence.named_anchor_floor.is_some() {
429                    issues.push(QualityIssue::Error(
430                        "generic entropy owners must omit match_confidence.named_anchor_floor because their regex candidates do not receive the named-detector lift"
431                            .into(),
432                    ));
433                }
434                if confidence.low_promise_confidence.is_none() {
435                    issues.push(QualityIssue::Error(
436                        "generic entropy owners must declare match_confidence.low_promise_confidence"
437                            .into(),
438                    ));
439                }
440            } else {
441                if confidence.named_anchor_floor.is_none() {
442                    issues.push(QualityIssue::Error(
443                        "named detectors must declare match_confidence.named_anchor_floor"
444                            .into(),
445                    ));
446                }
447                if confidence.low_promise_confidence.is_some() {
448                    issues.push(QualityIssue::Error(
449                        "named detectors must omit match_confidence.low_promise_confidence because the promise gate cannot replace service-owned evidence"
450                            .into(),
451                    ));
452                }
453            }
454        }
455    }
456    if owns_entropy && spec.ml.entropy_mode == crate::DetectorMlMode::Disabled {
457        issues.push(QualityIssue::Error(
458            "an active entropy-policy owner must declare a non-disabled ml.entropy_mode"
459                .to_string(),
460        ));
461    }
462    if !owns_entropy && spec.ml.entropy_mode != crate::DetectorMlMode::Disabled {
463        issues.push(QualityIssue::Error(
464            "ml.entropy_mode is only valid for a detector that owns entropy policy".to_string(),
465        ));
466    }
467    for (name, value) in [
468        ("min_len", spec.min_len),
469        ("max_len", spec.max_len),
470        ("keyword_free_min_len", spec.keyword_free_min_len),
471    ] {
472        if value == Some(0) {
473            issues.push(QualityIssue::Error(format!(
474                "{name} must be greater than 0 when present; use omission to inherit the path default"
475            )));
476        }
477    }
478    if let (Some(min_len), Some(max_len)) = (spec.min_len, spec.max_len) {
479        if min_len > max_len {
480            issues.push(QualityIssue::Error(format!(
481                "min_len {min_len} exceeds max_len {max_len}"
482            )));
483        }
484    }
485    if spec.max_len.is_some_and(|max_len| max_len < 8) {
486        issues.push(QualityIssue::Error(
487            "max_len must be at least the generic assignment path minimum of 8".to_string(),
488        ));
489    }
490    if spec.max_len.is_some() && !spec.owns_entropy_policy() {
491        issues.push(QualityIssue::Error(
492            "max_len is only valid for detectors that own generic entropy policy".to_string(),
493        ));
494    }
495    if let Some(mc) = spec.min_confidence {
496        if !(0.0..=1.0).contains(&mc) {
497            issues.push(QualityIssue::Error(format!(
498                "min_confidence {mc} is out of range; confidence is a probability in [0.0, 1.0] \
499                 (outside it silently breaks the gate: < 0 always passes, > 1 never fires, NaN is undefined)"
500            )));
501        }
502    }
503    if let Some(bound) = spec.bpe_max_bytes_per_token {
504        if !bound.is_finite() || bound <= 0.0 {
505            issues.push(QualityIssue::Error(format!(
506                "bpe_max_bytes_per_token {bound} must be finite and greater than 0; \
507                 zero or a negative value suppresses every candidate and NaN/inf makes the gate undefined"
508            )));
509        }
510    }
511    if spec.bpe_enabled == Some(false) && spec.bpe_max_bytes_per_token.is_some() {
512        issues.push(QualityIssue::Error(
513            "bpe_enabled = false conflicts with bpe_max_bytes_per_token; remove the ceiling when token efficiency is disabled"
514                .into(),
515        ));
516    }
517    if !spec.entropy_roles.is_empty() && !spec.owns_entropy_policy() {
518        issues.push(QualityIssue::Error(
519            "entropy_roles require a detector that owns a complete entropy policy".into(),
520        ));
521    }
522    let mut entropy_roles = std::collections::HashSet::new();
523    for role in &spec.entropy_roles {
524        if !entropy_roles.insert(*role) {
525            issues.push(QualityIssue::Error(format!(
526                "entropy_roles contains duplicate role {:?}",
527                role.as_str()
528            )));
529        }
530    }
531    for (name, value) in [
532        ("entropy_high", spec.entropy_high),
533        ("entropy_low", spec.entropy_low),
534        ("entropy_very_high", spec.entropy_very_high),
535        (
536            "sensitive_path_entropy_very_high",
537            spec.sensitive_path_entropy_very_high,
538        ),
539    ] {
540        let Some(score) = value else {
541            continue;
542        };
543        if !score.is_finite() || !(0.0..=8.0).contains(&score) {
544            issues.push(QualityIssue::Error(format!(
545                "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
546            )));
547        }
548    }
549    if let (Some(low), Some(high)) = (spec.entropy_low, spec.entropy_high) {
550        if low > high {
551            issues.push(QualityIssue::Error(format!(
552                "entropy_low {low} must not exceed entropy_high {high}"
553            )));
554        }
555    }
556    if let (Some(high), Some(very_high)) = (spec.entropy_high, spec.entropy_very_high) {
557        if high > very_high {
558            issues.push(QualityIssue::Error(format!(
559                "entropy_high {high} must not exceed entropy_very_high {very_high}"
560            )));
561        }
562    }
563    if let Some(plausibility) = spec.plausibility {
564        for (name, score) in [
565            (
566                "plausibility.mixed_alnum_floor",
567                plausibility.mixed_alnum_floor,
568            ),
569            (
570                "plausibility.symbolic_entropy_floor",
571                plausibility.symbolic_entropy_floor,
572            ),
573            (
574                "plausibility.second_half_entropy_floor",
575                plausibility.second_half_entropy_floor,
576            ),
577            (
578                "plausibility.isolated_mixed_entropy_floor",
579                plausibility.isolated_mixed_entropy_floor,
580            ),
581            (
582                "plausibility.leading_slash_base64_entropy_floor",
583                plausibility.leading_slash_base64_entropy_floor,
584            ),
585        ] {
586            if !score.is_finite() || !(0.0..=8.0).contains(&score) {
587                issues.push(QualityIssue::Error(format!(
588                    "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
589                )));
590            }
591        }
592        if let Some(margin) = plausibility.keyword_free_operator_margin {
593            if !margin.is_finite() || !(0.0..=8.0).contains(&margin) {
594                issues.push(QualityIssue::Error(format!(
595                    "plausibility.keyword_free_operator_margin must be finite and in [0.0, 8.0], found {margin}"
596                )));
597            }
598        }
599        if plausibility.mixed_alnum_min_len == 0 {
600            issues.push(QualityIssue::Error(
601                "plausibility.mixed_alnum_min_len must be greater than zero".into(),
602            ));
603        }
604        for (name, length) in [
605            (
606                "plausibility.second_half_min_len",
607                plausibility.second_half_min_len,
608            ),
609            (
610                "plausibility.unique_chars_min_len",
611                plausibility.unique_chars_min_len,
612            ),
613            (
614                "plausibility.min_unique_chars",
615                plausibility.min_unique_chars,
616            ),
617            (
618                "plausibility.unanchored_hex_max_len",
619                plausibility.unanchored_hex_max_len,
620            ),
621            (
622                "plausibility.identical_char_max_len",
623                plausibility.identical_char_max_len,
624            ),
625            (
626                "plausibility.structured_dotted_min_len",
627                plausibility.structured_dotted_min_len,
628            ),
629            (
630                "plausibility.isolated_symbolic_min_len",
631                plausibility.isolated_symbolic_min_len,
632            ),
633            (
634                "plausibility.isolated_symbolic_min_symbols",
635                plausibility.isolated_symbolic_min_symbols,
636            ),
637            (
638                "plausibility.isolated_alpha_only_min_symbols",
639                plausibility.isolated_alpha_only_min_symbols,
640            ),
641            (
642                "plausibility.source_type_name_max_len",
643                plausibility.source_type_name_max_len,
644            ),
645            (
646                "plausibility.source_type_name_min_uppercase",
647                plausibility.source_type_name_min_uppercase,
648            ),
649            (
650                "plausibility.url_path_high_entropy_min_len",
651                plausibility.url_path_high_entropy_min_len,
652            ),
653            (
654                "plausibility.isolated_colon_left_min_len",
655                plausibility.isolated_colon_left_min_len,
656            ),
657            (
658                "plausibility.isolated_colon_right_min_len",
659                plausibility.isolated_colon_right_min_len,
660            ),
661            (
662                "plausibility.leading_slash_base64_min_len",
663                plausibility.leading_slash_base64_min_len,
664            ),
665        ] {
666            if length == 0 {
667                issues.push(QualityIssue::Error(format!(
668                    "{name} must be greater than zero"
669                )));
670            }
671        }
672        if !plausibility.isolated_alpha_only_min_alpha_ratio.is_finite()
673            || !(0.0..=1.0).contains(&plausibility.isolated_alpha_only_min_alpha_ratio)
674            || plausibility.isolated_alpha_only_min_alpha_ratio == 0.0
675        {
676            issues.push(QualityIssue::Error(format!(
677                "plausibility.isolated_alpha_only_min_alpha_ratio must be finite and in (0.0, 1.0], found {}",
678                plausibility.isolated_alpha_only_min_alpha_ratio
679            )));
680        }
681        if !plausibility.min_alnum_ratio.is_finite()
682            || !(0.0..=1.0).contains(&plausibility.min_alnum_ratio)
683            || plausibility.min_alnum_ratio == 0.0
684        {
685            issues.push(QualityIssue::Error(format!(
686                "plausibility.min_alnum_ratio must be finite and in (0.0, 1.0], found {}",
687                plausibility.min_alnum_ratio
688            )));
689        }
690        if plausibility.source_type_name_min_uppercase > plausibility.source_type_name_max_len {
691            issues.push(QualityIssue::Error(format!(
692                "plausibility.source_type_name_min_uppercase ({}) must not exceed plausibility.source_type_name_max_len ({})",
693                plausibility.source_type_name_min_uppercase,
694                plausibility.source_type_name_max_len
695            )));
696        }
697        if plausibility.min_unique_chars > plausibility.unique_chars_min_len {
698            issues.push(QualityIssue::Error(format!(
699                "plausibility.min_unique_chars ({}) must not exceed plausibility.unique_chars_min_len ({})",
700                plausibility.min_unique_chars, plausibility.unique_chars_min_len
701            )));
702        }
703    }
704    if let (Some(very_high), Some(sensitive)) = (
705        spec.entropy_very_high,
706        spec.sensitive_path_entropy_very_high,
707    ) {
708        if sensitive > very_high {
709            issues.push(QualityIssue::Error(format!(
710                "sensitive_path_entropy_very_high {sensitive} must not exceed entropy_very_high {very_high}; sensitive paths may lower the keyword-free bar, never raise it"
711            )));
712        }
713    }
714    let entropy_owner = spec.owns_entropy_policy();
715    let has_weak_pattern = spec.patterns.iter().any(|pattern| pattern.weak_anchor);
716    if spec.weak_anchor && has_weak_pattern {
717        issues.push(QualityIssue::Error(
718            "detector weak_anchor=true already applies to every pattern; remove redundant pattern weak_anchor flags"
719                .into(),
720        ));
721    }
722    if spec.weak_anchor || has_weak_pattern {
723        if spec.entropy_high.is_none() {
724            issues.push(QualityIssue::Error(
725                "weak_anchor detectors and patterns must declare entropy_high in their own detector TOML".into(),
726            ));
727        }
728        if spec.entropy_floor.is_empty() {
729            issues.push(QualityIssue::Error(
730                "weak_anchor detectors and patterns must declare entropy_floor in their own detector TOML"
731                    .into(),
732            ));
733        }
734    }
735    if entropy_owner {
736        for (field, present) in [
737            ("entropy_high", spec.entropy_high.is_some()),
738            ("entropy_low", spec.entropy_low.is_some()),
739            ("entropy_very_high", spec.entropy_very_high.is_some()),
740            (
741                "sensitive_path_entropy_very_high",
742                spec.sensitive_path_entropy_very_high.is_some(),
743            ),
744            ("[detector.plausibility]", spec.plausibility.is_some()),
745            ("keyword_free_min_len", spec.keyword_free_min_len.is_some()),
746            ("min_len", spec.min_len.is_some()),
747            ("max_len", spec.max_len.is_some()),
748            (
749                "entropy_policy_priority",
750                spec.entropy_policy_priority.is_some(),
751            ),
752        ] {
753            if !present {
754                issues.push(QualityIssue::Error(format!(
755                    "active entropy owner must declare {field} in its detector TOML; runtime fallback policy is forbidden"
756                )));
757            }
758        }
759        if spec.entropy_shapes.is_empty() {
760            issues.push(QualityIssue::Error(
761                "active entropy owner must declare detector.entropy_shapes in its detector TOML"
762                    .into(),
763            ));
764        }
765        if spec.entropy_floor.is_empty() {
766            issues.push(QualityIssue::Error(
767                "active entropy owner must declare entropy_floor in its detector TOML".into(),
768            ));
769        }
770        if spec.bpe_enabled.is_none() {
771            issues.push(QualityIssue::Error(
772                "active entropy owner must declare bpe_enabled in its detector TOML".into(),
773            ));
774        }
775        if spec.bpe_enabled != Some(false) && spec.bpe_max_bytes_per_token.is_none() {
776            issues.push(QualityIssue::Error(
777                "active entropy owner must declare bpe_max_bytes_per_token or bpe_enabled = false in its detector TOML"
778                    .into(),
779            ));
780        }
781    }
782    let owns_keyword_free = spec
783        .entropy_roles
784        .contains(&crate::EntropyDetectionRole::KeywordFree);
785    let keyword_free_operator_margin = spec
786        .plausibility
787        .and_then(|policy| policy.keyword_free_operator_margin);
788    match (owns_keyword_free, keyword_free_operator_margin) {
789        (true, None) => issues.push(QualityIssue::Error(
790            "the detector claiming entropy role `keyword-free` must declare plausibility.keyword_free_operator_margin"
791                .into(),
792        )),
793        (false, Some(_)) => issues.push(QualityIssue::Error(
794            "plausibility.keyword_free_operator_margin is valid only on the detector claiming entropy role `keyword-free`"
795                .into(),
796        )),
797        _ => {}
798    }
799    if entropy_owner && spec.entropy_fallback.is_none() {
800        issues.push(QualityIssue::Error(
801            "active entropy owner must declare entropy_fallback metadata; omission would make synthetic finding identity ambiguous".into(),
802        ));
803    }
804    if entropy_owner && spec.entropy_fallback_confidence.is_none() {
805        issues.push(QualityIssue::Error(
806            "active entropy owner must declare entropy_fallback_confidence; omission would leave detector confidence in scanner literals".into(),
807        ));
808    }
809    if entropy_owner && spec.generic_assignment_confidence.is_none() {
810        issues.push(QualityIssue::Error(
811            "active entropy owner must declare generic_assignment_confidence; omission would leave generic assignment scoring in scanner literals".into(),
812        ));
813    }
814    if let Some(confidence) = spec.entropy_fallback_confidence {
815        if !entropy_owner {
816            issues.push(QualityIssue::Error(
817                "entropy_fallback_confidence requires an active detector-owned entropy policy"
818                    .into(),
819            ));
820        }
821        if let Err(error) = confidence.validate() {
822            issues.push(QualityIssue::Error(format!(
823                "entropy_fallback_confidence is invalid: {error}"
824            )));
825        }
826    }
827    if let Some(confidence) = spec.generic_assignment_confidence {
828        if !entropy_owner {
829            issues.push(QualityIssue::Error(
830                "generic_assignment_confidence requires an active detector-owned entropy policy"
831                    .into(),
832            ));
833        }
834        if let Err(error) = confidence.validate() {
835            issues.push(QualityIssue::Error(format!(
836                "generic_assignment_confidence is invalid: {error}"
837            )));
838        }
839    }
840    if let Some(metadata) = &spec.entropy_fallback {
841        if !entropy_owner {
842            issues.push(QualityIssue::Error(
843                "entropy_fallback requires an active detector-owned entropy policy".into(),
844            ));
845        }
846        if !metadata.id.strip_prefix("entropy-").is_some_and(|suffix| {
847            !suffix.is_empty()
848                && suffix
849                    .bytes()
850                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
851        }) {
852            issues.push(QualityIssue::Error(format!(
853                "entropy_fallback.id {:?} must use a lowercase entropy- namespace id",
854                metadata.id
855            )));
856        }
857        if metadata.name.trim().is_empty() {
858            issues.push(QualityIssue::Error(
859                "entropy_fallback.name must not be empty".into(),
860            ));
861        }
862        if metadata.service.trim().is_empty() {
863            issues.push(QualityIssue::Error(
864                "entropy_fallback.service must not be empty".into(),
865            ));
866        }
867    }
868    if !spec.entropy_shapes.is_empty() && !entropy_owner {
869        issues.push(QualityIssue::Error(
870            "entropy_shapes require an active detector-owned entropy policy".into(),
871        ));
872    }
873    if spec.entropy_shapes.len() > 1 {
874        issues.push(QualityIssue::Error(format!(
875            "active entropy policy accepts exactly one detector.entropy_shapes entry, found {}",
876            spec.entropy_shapes.len()
877        )));
878    }
879    let mut shape_signatures: Vec<(crate::spec::ShapeCharset, Option<(usize, usize, char)>)> =
880        Vec::new();
881    for (index, shape) in spec.entropy_shapes.iter().enumerate() {
882        let signature = (
883            shape.charset,
884            shape
885                .grouping
886                .map(|g| (g.group_count, g.group_length, g.separator)),
887        );
888        if shape_signatures.contains(&signature) {
889            issues.push(QualityIssue::Error(format!(
890                "entropy_shapes[{index}] duplicates an earlier shape's charset and grouping"
891            )));
892        }
893        shape_signatures.push(signature);
894        if !shape.entropy_floor.is_finite() || !(0.0..=8.0).contains(&shape.entropy_floor) {
895            issues.push(QualityIssue::Error(format!(
896                "entropy_shapes[{index}].entropy_floor must be finite and in [0.0, 8.0], found {}",
897                shape.entropy_floor
898            )));
899        }
900        if shape.special_min_length == 0 {
901            issues.push(QualityIssue::Error(format!(
902                "entropy_shapes[{index}].special_min_length must be greater than 0"
903            )));
904        }
905        if shape.require_mixed_case && shape.charset == crate::spec::ShapeCharset::LowerAlnum {
906            issues.push(QualityIssue::Error(format!(
907                "entropy_shapes[{index}].require_mixed_case is impossible with charset lower-alnum"
908            )));
909        }
910        if shape.require_non_hex_alpha && shape.charset == crate::spec::ShapeCharset::Hex {
911            issues.push(QualityIssue::Error(format!(
912                "entropy_shapes[{index}].require_non_hex_alpha is impossible with charset hex"
913            )));
914        }
915        if shape.require_group_alpha_digit && shape.grouping.is_none() {
916            issues.push(QualityIssue::Error(format!(
917                "entropy_shapes[{index}].require_group_alpha_digit requires grouping"
918            )));
919        }
920        if let Some(grouping) = shape.grouping {
921            if grouping.group_count == 0 || grouping.group_length == 0 {
922                issues.push(QualityIssue::Error(format!(
923                    "entropy_shapes[{index}] grouping.group_count and group_length must both be greater than 0"
924                )));
925                continue;
926            }
927            let derived_length = grouping
928                .group_count
929                .checked_mul(grouping.group_length)
930                .and_then(|length| {
931                    length.checked_add(
932                        grouping
933                            .group_count
934                            .saturating_sub(1)
935                            .saturating_mul(grouping.separator.len_utf8()),
936                    )
937                });
938            let Some(derived_length) = derived_length else {
939                issues.push(QualityIssue::Error(format!(
940                    "entropy_shapes[{index}] grouping overflows the derived candidate length"
941                )));
942                continue;
943            };
944            if shape.special_min_length > derived_length {
945                issues.push(QualityIssue::Error(format!(
946                    "entropy_shapes[{index}].special_min_length must be in 1..={derived_length}, found {}",
947                    shape.special_min_length
948                )));
949            }
950        }
951    }
952}
953
954fn validate_entropy_floor(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
955    if spec.entropy_floor.is_empty() {
956        return;
957    }
958    let last = spec.entropy_floor.len() - 1;
959    let mut previous_max = 0usize;
960    for (index, bucket) in spec.entropy_floor.iter().enumerate() {
961        if !bucket.floor.is_finite() || !(0.0..=8.0).contains(&bucket.floor) {
962            issues.push(QualityIssue::Error(format!(
963                "entropy_floor bucket {index} floor must be finite and in [0.0, 8.0], found {}",
964                bucket.floor
965            )));
966        }
967        if index < last && bucket.max_len.is_none() {
968            issues.push(QualityIssue::Error(format!(
969                "entropy_floor bucket {index} is an early catch-all; only the final bucket may omit max_len"
970            )));
971        }
972        if index == last && bucket.max_len.is_some() {
973            issues.push(QualityIssue::Error(
974                "entropy_floor final bucket must omit max_len so longer candidates cannot bypass the floor"
975                    .into(),
976            ));
977        }
978        if let Some(max_len) = bucket.max_len {
979            if max_len <= previous_max {
980                issues.push(QualityIssue::Error(format!(
981                    "entropy_floor max_len values must strictly increase from a positive length; found {max_len} after {previous_max}"
982                )));
983            }
984            previous_max = max_len;
985        }
986    }
987}
988
989fn validate_credential_shape(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
990    if let Some(shape) = &spec.credential_shape {
991        if let Err(error) = shape.validate(&spec.id) {
992            issues.push(QualityIssue::Error(error));
993        }
994    }
995}
996
997fn validate_detector_allowlists(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
998    for (field, patterns) in [
999        ("allowlist_paths", &spec.allowlist_paths),
1000        ("allowlist_values", &spec.allowlist_values),
1001    ] {
1002        let mut first_index_by_pattern = HashMap::new();
1003        for (index, pattern) in patterns.iter().enumerate() {
1004            if pattern.trim().is_empty() {
1005                issues.push(QualityIssue::Error(format!(
1006                    "detector {:?} {field}[{index}] must not be empty or whitespace-only",
1007                    spec.id
1008                )));
1009                continue;
1010            }
1011            match first_index_by_pattern.entry(pattern.as_str()) {
1012                Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1013                    "detector {:?} {field}[{index}] duplicates {field}[{}]",
1014                    spec.id,
1015                    first.get()
1016                ))),
1017                Entry::Vacant(slot) => {
1018                    slot.insert(index);
1019                }
1020            }
1021            if let Err(error) = regex::Regex::new(pattern) {
1022                issues.push(QualityIssue::Error(format!(
1023                    "detector {:?} {field}[{index}] is not a valid regex ({pattern:?}): {error}",
1024                    spec.id
1025                )));
1026            }
1027        }
1028    }
1029
1030    let mut first_index_by_stopword = HashMap::new();
1031    for (index, stopword) in spec.stopwords.iter().enumerate() {
1032        if stopword.trim().is_empty() {
1033            issues.push(QualityIssue::Error(format!(
1034                "detector {:?} stopwords[{index}] must not be empty or whitespace-only",
1035                spec.id
1036            )));
1037            continue;
1038        }
1039        let normalized = stopword.to_ascii_lowercase();
1040        match first_index_by_stopword.entry(normalized) {
1041            Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1042                "detector {:?} stopwords[{index}] duplicates stopwords[{}] under case-insensitive matching",
1043                spec.id,
1044                first.get()
1045            ))),
1046            Entry::Vacant(slot) => {
1047                slot.insert(index);
1048            }
1049        }
1050    }
1051    let mut first_marker_index = HashMap::new();
1052    for (index, marker) in spec.public_identifier_assignment_markers.iter().enumerate() {
1053        if marker.is_empty()
1054            || !marker.is_ascii()
1055            || marker.bytes().any(|byte| byte.is_ascii_lowercase())
1056        {
1057            issues.push(QualityIssue::Error(format!(
1058                "detector {:?} public_identifier_assignment_markers[{index}] must be non-empty uppercase ASCII because runtime matching is allocation-free ASCII-insensitive",
1059                spec.id
1060            )));
1061            continue;
1062        }
1063        match first_marker_index.entry(marker.as_str()) {
1064            Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1065                "detector {:?} public_identifier_assignment_markers[{index}] duplicates public_identifier_assignment_markers[{}]",
1066                spec.id,
1067                first.get()
1068            ))),
1069            Entry::Vacant(slot) => {
1070                slot.insert(index);
1071            }
1072        }
1073    }
1074}
1075
1076fn validate_patterns_present(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1077    match spec.kind {
1078        // A phase-1 regex detector is defined by its anchors (no patterns is an error).
1079        DetectorKind::Regex => {
1080            if spec.patterns.is_empty() {
1081                issues.push(QualityIssue::Error("no patterns defined".into()));
1082            }
1083        }
1084        // A phase-2 generic bridge is defined by keywords + entropy_floor.
1085        // Optional patterns add strongly structured envelopes without creating
1086        // a duplicate detector owner; keywords remain required for the
1087        // shapeless phase-2 path.
1088        DetectorKind::Phase2Generic => {
1089            if spec.keywords.is_empty() {
1090                issues.push(QualityIssue::Error(
1091                    "phase2-generic detector must define keywords (its only pre-filter)".into(),
1092                ));
1093            }
1094        }
1095    }
1096}
1097
1098fn validate_regexes<'a>(
1099    spec: &'a DetectorSpec,
1100    issues: &mut Vec<QualityIssue>,
1101    regex_cache: &mut RegexAstCache<'a>,
1102) {
1103    for (i, pat) in spec.patterns.iter().enumerate() {
1104        validate_regex_definition(RegexKind::Pattern, i, &pat.regex, issues, regex_cache);
1105    }
1106}
1107
1108fn validate_keywords(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1109    if spec.keywords.is_empty() {
1110        issues.push(QualityIssue::Warning(
1111            "no keywords defined - pattern may produce false positives".into(),
1112        ));
1113        return;
1114    }
1115    for (index, keyword) in spec.keywords.iter().enumerate() {
1116        if keyword.is_empty() {
1117            issues.push(QualityIssue::Error(format!(
1118                "keyword {index} is empty; remove it or declare a non-empty detector-owned context literal"
1119            )));
1120        }
1121    }
1122}
1123
1124fn validate_pattern_groups<'a>(
1125    spec: &'a DetectorSpec,
1126    issues: &mut Vec<QualityIssue>,
1127    regex_cache: &mut RegexAstCache<'a>,
1128) {
1129    for (i, pat) in spec.patterns.iter().enumerate() {
1130        let Some(group) = pat.group else {
1131            continue;
1132        };
1133        let Ok(ast) = regex_cache.parse(&pat.regex) else {
1134            continue; // LAW10: invalid regex already emits a QualityIssue::Error; detector load fails closed, recall-safe
1135        };
1136        let captures = ast_captures_len(ast);
1137        if group >= captures {
1138            issues.push(QualityIssue::Error(format!(
1139                "pattern {i} capture group {group} is out of range; regex has {} capture groups \
1140                 (valid group indexes are 0..{})",
1141                captures.saturating_sub(1),
1142                captures.saturating_sub(1)
1143            )));
1144        }
1145    }
1146}
1147
1148fn validate_pattern_specificity<'a>(
1149    spec: &'a DetectorSpec,
1150    issues: &mut Vec<QualityIssue>,
1151    regex_cache: &mut RegexAstCache<'a>,
1152) {
1153    for (i, pat) in spec.patterns.iter().enumerate() {
1154        let has_prefix = has_literal_prefix(regex_cache, &pat.regex, 3);
1155        let has_group = pat.group.is_some();
1156        let is_pure_charclass = is_pure_character_class(regex_cache, &pat.regex);
1157
1158        if is_pure_charclass && !has_group {
1159            issues.push(QualityIssue::Error(format!(
1160                "pattern {} is a pure character class ({}) - too broad without context anchoring. \
1161                 Use a capture group or add a literal prefix.",
1162                i, pat.regex
1163            )));
1164        } else if !has_prefix && !has_group && spec.keywords.is_empty() {
1165            issues.push(QualityIssue::Warning(format!(
1166                "pattern {} has no literal prefix and no capture group - may false-positive",
1167                i
1168            )));
1169        }
1170    }
1171}
1172
1173fn validate_companions<'a>(
1174    spec: &'a DetectorSpec,
1175    issues: &mut Vec<QualityIssue>,
1176    regex_cache: &mut RegexAstCache<'a>,
1177) {
1178    for (i, companion) in spec.companions.iter().enumerate() {
1179        if companion.name.trim().is_empty() {
1180            issues.push(QualityIssue::Error(format!(
1181                "companion {} name must not be empty",
1182                i
1183            )));
1184        }
1185        if companion.within_lines > MAX_COMPANION_WITHIN_LINES {
1186            issues.push(QualityIssue::Error(format!(
1187                "companion {} within_lines={} exceeds {} search-window limit",
1188                i, companion.within_lines, MAX_COMPANION_WITHIN_LINES
1189            )));
1190        }
1191        validate_regex_definition(
1192            RegexKind::Companion,
1193            i,
1194            &companion.regex,
1195            issues,
1196            regex_cache,
1197        );
1198        // A "pure character class" companion (e.g. `[A-Z0-9]{10}` for an
1199        // Algolia application_id) is acceptable when `within_lines` is small:
1200        // the positional constraint is itself the contextual anchor. Reject
1201        // only when the companion permits a wide search radius - at that
1202        // point the lack of textual context really does over-fire.
1203        if is_pure_character_class(regex_cache, &companion.regex) {
1204            if companion.within_lines <= TIGHT_COMPANION_RADIUS {
1205                issues.push(QualityIssue::Warning(format!(
1206                    "companion {} regex '{}' is a pure character class; \
1207                     allowed because within_lines={} ≤ {} (positional anchoring).",
1208                    i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1209                )));
1210            } else {
1211                issues.push(QualityIssue::Error(format!(
1212                    "companion {} regex '{}' is a pure character class with within_lines={} \
1213                     (> {}) - the wide search radius needs a literal context anchor",
1214                    i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1215                )));
1216            }
1217        } else if !has_substantial_literal(regex_cache, &companion.regex, 3) {
1218            issues.push(QualityIssue::Warning(format!(
1219                "companion {} regex '{}' is too broad - may produce false positives. \
1220                 Add a context anchor like 'KEY_NAME='.",
1221                i, companion.regex
1222            )));
1223        }
1224    }
1225}
1226
1227/// Companion search radius (in lines) below which a pure character-class
1228/// regex is acceptable. The positional bound provides the context anchor.
1229const TIGHT_COMPANION_RADIUS: usize = 5;
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1232enum RegexKind {
1233    Pattern,
1234    Companion,
1235}
1236
1237impl RegexKind {
1238    fn label(self) -> &'static str {
1239        match self {
1240            Self::Pattern => "pattern",
1241            Self::Companion => "companion",
1242        }
1243    }
1244}
1245
1246#[derive(Default)]
1247struct RegexAstCache<'a> {
1248    parsed: HashMap<&'a str, Result<ast::Ast, String>>,
1249}
1250
1251impl<'a> RegexAstCache<'a> {
1252    fn parse(&mut self, regex: &'a str) -> Result<&ast::Ast, &str> {
1253        let parsed = match self.parsed.entry(regex) {
1254            Entry::Occupied(entry) => entry.into_mut(),
1255            Entry::Vacant(entry) => entry.insert(
1256                ast::parse::Parser::new()
1257                    .parse(regex)
1258                    .map_err(|error| error.to_string()),
1259            ),
1260        };
1261        parsed.as_ref().map_err(String::as_str)
1262    }
1263}
1264
1265fn validate_regex_definition<'a>(
1266    kind: RegexKind,
1267    index: usize,
1268    regex: &'a str,
1269    issues: &mut Vec<QualityIssue>,
1270    regex_cache: &mut RegexAstCache<'a>,
1271) {
1272    let kind = kind.label();
1273    // An empty regex is VALID syntax, it parses cleanly and matches the empty
1274    // string at EVERY position, so a detector carrying one fires on every byte
1275    // of every file: a catastrophic false-positive flood that the parse check
1276    // below cannot catch (it compiles fine). Reject it up front, fail closed.
1277    if regex.is_empty() {
1278        issues.push(QualityIssue::Error(format!(
1279            "{kind} {index} regex is empty; an empty pattern matches at every position \
1280             (a catastrophic false-positive flood), define a real anchor or remove the pattern"
1281        )));
1282        return;
1283    }
1284    if regex.len() > MAX_REGEX_PATTERN_LEN {
1285        issues.push(QualityIssue::Error(format!(
1286            "{kind} {index} regex is too large ({} bytes > {} byte limit)",
1287            regex.len(),
1288            MAX_REGEX_PATTERN_LEN
1289        )));
1290        return;
1291    }
1292
1293    match regex_cache.parse(regex) {
1294        Ok(ast) => validate_regex_complexity(kind, index, ast, issues),
1295        Err(error) => issues.push(QualityIssue::Error(format!(
1296            "{kind} {index} regex does not compile: {error}"
1297        ))),
1298    }
1299}
1300
1301fn has_substantial_literal<'a>(
1302    regex_cache: &mut RegexAstCache<'a>,
1303    pattern: &'a str,
1304    min_len: usize,
1305) -> bool {
1306    match regex_cache.parse(pattern) {
1307        Ok(ast) => ast_literal_runs(ast).max >= min_len,
1308        Err(_) => false, // LAW10: invalid regex already emits a QualityIssue::Error; no recall impact
1309    }
1310}
1311
1312fn validate_verify_spec(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1313    if let Some(ref verify) = spec.verify {
1314        validate_verify_urls(spec, verify, issues);
1315        validate_verify_success_statuses(verify, issues);
1316        validate_provider_evidence(verify, issues);
1317        issues.extend(
1318            crate::json_selector::validate_detector_response_selectors(spec)
1319                .into_iter()
1320                .map(QualityIssue::Error),
1321        );
1322        check_oob_consistency(verify, issues);
1323    }
1324    check_reserved_companion_names(spec, issues);
1325}
1326
1327fn validate_provider_evidence(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1328    let mut roles = std::collections::HashSet::new();
1329    for (index, field) in verify.metadata.iter().enumerate() {
1330        let Some(role) = super::ProviderEvidenceRole::from_metadata_name(&field.name) else {
1331            issues.push(QualityIssue::Error(format!(
1332                "verify.metadata[{index}].name {:?} is not a supported provider evidence role; use a reviewed provider-neutral role such as account_id, email, scope, team_id, or user_id",
1333                field.name
1334            )));
1335            continue;
1336        };
1337        if !roles.insert(role) {
1338            issues.push(QualityIssue::Error(format!(
1339                "verify.metadata[{index}] repeats provider evidence role {:?}; each report role must have one detector-owned selector",
1340                role.as_str()
1341            )));
1342        }
1343    }
1344}
1345
1346fn validate_verify_success_statuses(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1347    if let Some(success) = &verify.success {
1348        validate_success_status("verify.success", success, issues);
1349    }
1350    for (step_index, step) in verify.steps.iter().enumerate() {
1351        validate_success_status(
1352            &format!("verify.steps[{step_index}].success"),
1353            &step.success,
1354            issues,
1355        );
1356    }
1357}
1358
1359fn validate_success_status(
1360    scope: &str,
1361    success: &super::SuccessSpec,
1362    issues: &mut Vec<QualityIssue>,
1363) {
1364    validate_http_status(scope, "status", success.status, issues);
1365    validate_http_status(scope, "status_not", success.status_not, issues);
1366}
1367
1368fn validate_http_status(
1369    scope: &str,
1370    field: &str,
1371    status: Option<u16>,
1372    issues: &mut Vec<QualityIssue>,
1373) {
1374    let Some(status) = status else {
1375        return;
1376    };
1377    if !(MIN_HTTP_STATUS..=MAX_HTTP_STATUS).contains(&status) {
1378        issues.push(QualityIssue::Error(format!(
1379            "{scope}.{field}={status} is outside valid HTTP status range {MIN_HTTP_STATUS}..={MAX_HTTP_STATUS}"
1380        )));
1381    }
1382}
1383
1384fn validate_verify_urls(
1385    detector: &DetectorSpec,
1386    verify: &VerifySpec,
1387    issues: &mut Vec<QualityIssue>,
1388) {
1389    for (index, domain) in verify.allowed_domains.iter().enumerate() {
1390        if crate::verification_domain::normalize_allowlist_entry(domain).is_none() {
1391            issues.push(QualityIssue::Error(format!(
1392                "verify.allowed_domains[{index}] is not a bare domain or host-only URL: {domain:?}"
1393            )));
1394        }
1395    }
1396
1397    if verify.steps.is_empty() {
1398        if let Some(url) = verify.url.as_deref() {
1399            validate_selected_verify_url("verify.url", url, &detector.service, verify, issues);
1400        } else {
1401            issues.push(QualityIssue::Error(
1402                "verify spec has no steps and no default URL".into(),
1403            ));
1404        }
1405    } else {
1406        for (index, step) in verify.steps.iter().enumerate() {
1407            validate_selected_verify_url(
1408                &format!("verify.steps[{index}].url"),
1409                &step.url,
1410                &detector.service,
1411                verify,
1412                issues,
1413            );
1414        }
1415    }
1416}
1417
1418fn validate_selected_verify_url(
1419    field: &str,
1420    raw_url: &str,
1421    detector_service: &str,
1422    verify: &VerifySpec,
1423    issues: &mut Vec<QualityIssue>,
1424) {
1425    validate_url(raw_url, issues);
1426    check_url_exfil_risk(raw_url, &verify.allowed_domains, issues);
1427    if url_authority_is_templated(raw_url) {
1428        return;
1429    }
1430    let parsed = match url::Url::parse(raw_url) {
1431        Ok(parsed) => parsed,
1432        Err(error) => {
1433            issues.push(QualityIssue::Error(format!(
1434                "{field} is not a valid absolute URL: {error}"
1435            )));
1436            return;
1437        }
1438    };
1439    let Some(host) = parsed.host_str() else {
1440        issues.push(QualityIssue::Error(format!(
1441            "{field} has no host; use an absolute service URL"
1442        )));
1443        return;
1444    };
1445    let Some(allowlist) =
1446        crate::verification_domain::effective_allowlist(verify, Some(detector_service))
1447    else {
1448        issues.push(QualityIssue::Error(format!(
1449            "{field} host {host:?} has no domain policy; set verify.service to a known service or declare verify.allowed_domains"
1450        )));
1451        return;
1452    };
1453    if !crate::verification_domain::host_is_allowed(host, &allowlist) {
1454        let policy_service = if verify.service.trim().is_empty() {
1455            detector_service
1456        } else {
1457            verify.service.as_str()
1458        };
1459        issues.push(QualityIssue::Error(format!(
1460            "{field} host {host:?} is outside verify.allowed_domains for service {:?} (allowed: {})",
1461            policy_service,
1462            allowlist.join(", ")
1463        )));
1464    }
1465}
1466
1467fn url_authority_is_templated(raw_url: &str) -> bool {
1468    let trimmed = raw_url.trim();
1469    let authority = trimmed
1470        .split_once("://")
1471        .map_or(trimmed, |(_, remainder)| remainder)
1472        .split(['/', '?', '#'])
1473        .next()
1474        .unwrap_or_default(); // LAW10: infallible split iterator; the first authority segment always exists, including the documented empty value.
1475    authority.contains(['{', '}'])
1476}
1477
1478/// Reserved synthetic companion-map keys used by the OOB interpolator. A
1479/// detector that names a companion `__keyhog_oob_*` would either be
1480/// shadowed by the OOB injector or shadow it - either way, the verify
1481/// templates would resolve to surprising values. Reject the names so a
1482/// future detector author gets a clear error instead of a debugging
1483/// nightmare.
1484const RESERVED_COMPANION_NAMES: &[&str] =
1485    &["__keyhog_oob_url", "__keyhog_oob_host", "__keyhog_oob_id"];
1486
1487fn check_reserved_companion_names(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1488    for (i, c) in spec.companions.iter().enumerate() {
1489        if RESERVED_COMPANION_NAMES.contains(&c.name.as_str()) {
1490            issues.push(QualityIssue::Error(format!(
1491                "companion {} name '{}' is reserved for the OOB interpolator. \
1492                 Pick a different name; this collision would corrupt verify templates.",
1493                i, c.name,
1494            )));
1495        }
1496    }
1497}
1498
1499/// Check that `[detector.verify.oob]` and `{{interactsh}}` template tokens
1500/// are configured consistently:
1501///
1502/// - `oob` set but no `{{interactsh*}}` token anywhere in the verify
1503///   templates → the wait_for parks for nothing; the probe never embeds
1504///   the callback URL so the service can't reach our collector.
1505/// - `{{interactsh*}}` token present but `oob` unset → the token resolves
1506///   to an empty string at runtime, sending malformed requests (e.g.
1507///   `https:///x` or a JSON body with `"target":""`).
1508///
1509/// Both are misconfigurations that load successfully but produce
1510/// silently-wrong verify behavior. Fail-closed at the validator instead.
1511fn check_oob_consistency(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1512    let mut interactsh_referenced = false;
1513    visit_verify_template_fields(verify, |value| {
1514        if value.contains("{{interactsh") {
1515            interactsh_referenced = true;
1516        }
1517    });
1518    let oob_configured = verify.oob.is_some();
1519    if oob_configured && !verify.steps.is_empty() {
1520        issues.push(QualityIssue::Error(
1521            "verify.oob cannot be combined with multi-step verification: the \
1522             runtime must bind each interactsh callback to a concrete request \
1523             step, and this detector shape cannot be evaluated honestly. Use a \
1524             single request verifier for the OOB probe or split the detector."
1525                .into(),
1526        ));
1527    }
1528    match (oob_configured, interactsh_referenced) {
1529        (true, false) => issues.push(QualityIssue::Error(
1530            "verify.oob is set but no `{{interactsh}}` / `{{interactsh.host}}` / \
1531             `{{interactsh.url}}` / `{{interactsh.id}}` token appears in any verify \
1532             template - the OOB callback URL has nowhere to land, so the wait_for \
1533             would always time out. Either embed an interactsh token in the body, \
1534             URL, or a header - or remove the [detector.verify.oob] block."
1535                .into(),
1536        )),
1537        (false, true) => issues.push(QualityIssue::Error(
1538            "an `{{interactsh*}}` token is referenced in a verify template but no \
1539             [detector.verify.oob] block is set - the token will resolve to an empty \
1540             string at runtime and ship a malformed request to the service. Either \
1541             add a [detector.verify.oob] block or remove the token."
1542                .into(),
1543        )),
1544        _ => {}
1545    }
1546}
1547
1548fn visit_verify_template_fields(verify: &VerifySpec, mut visit: impl FnMut(&str)) {
1549    if let Some(ref url) = verify.url {
1550        visit(url);
1551    }
1552    if let Some(ref body) = verify.body {
1553        visit(body);
1554    }
1555    for header in &verify.headers {
1556        visit(&header.value);
1557    }
1558    for step in &verify.steps {
1559        visit(&step.url);
1560        if let Some(ref body) = step.body {
1561            visit(body);
1562        }
1563        for header in &step.headers {
1564            visit(&header.value);
1565        }
1566    }
1567}
1568
1569/// Catch detectors whose `verify.url` is built from interpolation tokens
1570/// without a fixed authoritative host AND without an explicit
1571/// `allowed_domains` list. The verifier's runtime domain allowlist
1572/// catches these at request time, but flagging at load time gives the
1573/// detector author actionable feedback before the rule ships.
1574/// kimi-wave3 §1 + §1.HIGH (single-brace `{var}` and `{{shop}}` cases).
1575fn check_url_exfil_risk(url: &str, allowed_domains: &[String], issues: &mut Vec<QualityIssue>) {
1576    // Detect `{{match}}` or `{{companion.*}}` taking the place of the
1577    // authority component of the URL. Conservative match: anything that
1578    // starts with the templated host (e.g. `https://{{...}}`, plain
1579    // `{{match}}`, `https://{{...}}/path`).
1580    let trimmed = url.trim();
1581    let after_scheme = trimmed
1582        .strip_prefix("https://")
1583        .or_else(|| trimmed.strip_prefix("http://"))
1584        .unwrap_or(trimmed); // LAW10: no scheme to strip -> analyze the whole URL; deterministic, not a failure
1585    let host_starts_with_template =
1586        after_scheme.starts_with("{{") || after_scheme.starts_with("{") || trimmed == "{{match}}";
1587    if host_starts_with_template && allowed_domains.is_empty() {
1588        issues.push(QualityIssue::Error(
1589            "verify URL host is templated and no `allowed_domains` is set - \
1590             attacker-controlled interpolation could exfil credentials. \
1591             Either hardcode the authoritative host in the URL or set \
1592             `allowed_domains` explicitly. See kimi-wave3 §1."
1593                .into(),
1594        ));
1595    }
1596    // Single-brace `{name}` is a common author error - interpolate.rs
1597    // only handles `{{...}}`, so `{name}` lands in the URL literally.
1598    if url.contains('{') && !url.contains("{{") {
1599        issues.push(QualityIssue::Error(
1600            "verify URL uses single-brace `{var}` template syntax which the \
1601             interpolator does NOT honor (only `{{var}}` works); the URL will \
1602             be sent to a literal-string host. Use `{{companion.var}}`."
1603                .into(),
1604        ));
1605    }
1606}
1607
1608fn validate_url(url: &str, issues: &mut Vec<QualityIssue>) {
1609    if url.is_empty() {
1610        issues.push(QualityIssue::Error("verify URL is empty".into()));
1611    }
1612    if url.starts_with("http://") && !is_loopback_http_host(url) {
1613        issues.push(QualityIssue::Warning(
1614            "verify URL uses HTTP instead of HTTPS".into(),
1615        ));
1616    }
1617}
1618
1619/// True when the `http://` URL's authority HOST is a loopback address
1620/// (`localhost` / `127.0.0.1` / `[::1]`), for which plaintext HTTP carries no
1621/// exfil risk. Matches the parsed host, not any occurrence of the literal, so
1622/// `http://evil.example.com/callback?host=localhost` is NOT exempt.
1623fn is_loopback_http_host(url: &str) -> bool {
1624    let Some(after_scheme) = url.strip_prefix("http://") else {
1625        return false;
1626    };
1627    let authority = after_scheme
1628        .split(['/', '?', '#'])
1629        .next()
1630        .map_or(after_scheme, |authority| authority);
1631    let host_port = authority
1632        .rsplit_once('@')
1633        .map_or(authority, |(_, host)| host);
1634    let host = if let Some(rest) = host_port.strip_prefix('[') {
1635        // IPv6 literal `[::1]:port` -> `::1`
1636        match rest.split_once(']') {
1637            Some((inner, _)) => inner,
1638            None => return false,
1639        }
1640    } else {
1641        host_port.split(':').next().map_or(host_port, |host| host)
1642    };
1643    matches!(host, "localhost" | "127.0.0.1" | "::1")
1644}
1645
1646fn has_literal_prefix<'a>(
1647    regex_cache: &mut RegexAstCache<'a>,
1648    pattern: &'a str,
1649    min_len: usize,
1650) -> bool {
1651    match regex_cache.parse(pattern) {
1652        Ok(ast) => ast_literal_runs(ast).prefix >= min_len,
1653        Err(_) => false, // LAW10: invalid regex already emits a QualityIssue::Error; no recall impact
1654    }
1655}
1656
1657fn ast_captures_len(ast: &ast::Ast) -> usize {
1658    ast_max_capture_index(ast)
1659        .map(|index| index as usize + 1)
1660        .unwrap_or(1) // LAW10: no explicit capture groups still leaves regex capture group 0; this is the same captures_len contract, not a fallback.
1661}
1662
1663fn ast_max_capture_index(ast: &ast::Ast) -> Option<u32> {
1664    let mut max_capture = None;
1665    let mut stack = vec![ast];
1666    while let Some(node) = stack.pop() {
1667        match node {
1668            ast::Ast::Group(group) => {
1669                max_capture = max_capture.max(group.capture_index());
1670                stack.push(&group.ast);
1671            }
1672            ast::Ast::Concat(concat) => stack.extend(concat.asts.iter()),
1673            ast::Ast::Alternation(alternation) => stack.extend(alternation.asts.iter()),
1674            ast::Ast::Repetition(repetition) => stack.push(&repetition.ast),
1675            ast::Ast::Empty(_)
1676            | ast::Ast::Flags(_)
1677            | ast::Ast::Literal(_)
1678            | ast::Ast::Dot(_)
1679            | ast::Ast::Assertion(_)
1680            | ast::Ast::ClassUnicode(_)
1681            | ast::Ast::ClassPerl(_)
1682            | ast::Ast::ClassBracketed(_) => {}
1683        }
1684    }
1685    max_capture
1686}
1687
1688#[derive(Clone, Copy)]
1689struct LiteralRunStats {
1690    prefix: usize,
1691    suffix: usize,
1692    max: usize,
1693    all_literal: bool,
1694}
1695
1696impl LiteralRunStats {
1697    fn empty() -> Self {
1698        Self {
1699            prefix: 0,
1700            suffix: 0,
1701            max: 0,
1702            all_literal: true,
1703        }
1704    }
1705
1706    fn literal(len: usize) -> Self {
1707        Self {
1708            prefix: len,
1709            suffix: len,
1710            max: len,
1711            all_literal: true,
1712        }
1713    }
1714}
1715
1716fn ast_literal_runs(ast: &ast::Ast) -> LiteralRunStats {
1717    enum LiteralFrame<'a> {
1718        Visit(&'a ast::Ast),
1719        FinishConcat(usize),
1720        FinishAlternation(usize),
1721        FinishRepetition(&'a ast::RepetitionKind),
1722    }
1723
1724    let mut frames = vec![LiteralFrame::Visit(ast)];
1725    let mut results = Vec::new();
1726    while let Some(frame) = frames.pop() {
1727        match frame {
1728            LiteralFrame::Visit(node) => match node {
1729                ast::Ast::Literal(_) => results.push(LiteralRunStats::literal(1)),
1730                ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_) => {
1731                    results.push(LiteralRunStats::empty());
1732                }
1733                ast::Ast::Group(group) => frames.push(LiteralFrame::Visit(&group.ast)),
1734                ast::Ast::Concat(concat) => {
1735                    frames.push(LiteralFrame::FinishConcat(concat.asts.len()));
1736                    for child in concat.asts.iter().rev() {
1737                        frames.push(LiteralFrame::Visit(child));
1738                    }
1739                }
1740                ast::Ast::Alternation(alternation) => {
1741                    frames.push(LiteralFrame::FinishAlternation(alternation.asts.len()));
1742                    for child in alternation.asts.iter().rev() {
1743                        frames.push(LiteralFrame::Visit(child));
1744                    }
1745                }
1746                ast::Ast::Repetition(repetition) => {
1747                    frames.push(LiteralFrame::FinishRepetition(&repetition.op.kind));
1748                    frames.push(LiteralFrame::Visit(&repetition.ast));
1749                }
1750                ast::Ast::Dot(_)
1751                | ast::Ast::ClassUnicode(_)
1752                | ast::Ast::ClassPerl(_)
1753                | ast::Ast::ClassBracketed(_) => results.push(LiteralRunStats {
1754                    prefix: 0,
1755                    suffix: 0,
1756                    max: 0,
1757                    all_literal: false,
1758                }),
1759            },
1760            LiteralFrame::FinishConcat(child_count) => {
1761                let children = results.split_off(results.len() - child_count);
1762                let combined = children
1763                    .into_iter()
1764                    .fold(LiteralRunStats::empty(), combine_literal_runs);
1765                results.push(combined);
1766            }
1767            LiteralFrame::FinishAlternation(child_count) => {
1768                let children = results.split_off(results.len() - child_count);
1769                let max = match children.into_iter().map(|child| child.max).max() {
1770                    Some(max) => max,
1771                    None => 0,
1772                };
1773                results.push(LiteralRunStats {
1774                    max,
1775                    prefix: 0,
1776                    suffix: 0,
1777                    all_literal: false,
1778                });
1779            }
1780            LiteralFrame::FinishRepetition(kind) => {
1781                let inner = match results.pop() {
1782                    Some(inner) => inner,
1783                    None => LiteralRunStats::empty(),
1784                };
1785                results.push(repeated_literal_runs(
1786                    inner,
1787                    repetition_min(kind),
1788                    repetition_is_exact(kind),
1789                ));
1790            }
1791        }
1792    }
1793    match results.pop() {
1794        Some(stats) => stats,
1795        None => LiteralRunStats::empty(),
1796    }
1797}
1798
1799fn combine_literal_runs(left: LiteralRunStats, right: LiteralRunStats) -> LiteralRunStats {
1800    LiteralRunStats {
1801        prefix: if left.all_literal {
1802            left.prefix.saturating_add(right.prefix)
1803        } else {
1804            left.prefix
1805        },
1806        suffix: if right.all_literal {
1807            left.suffix.saturating_add(right.suffix)
1808        } else {
1809            right.suffix
1810        },
1811        max: left
1812            .max
1813            .max(right.max)
1814            .max(left.suffix.saturating_add(right.prefix)),
1815        all_literal: left.all_literal && right.all_literal,
1816    }
1817}
1818
1819fn repeated_literal_runs(
1820    inner: LiteralRunStats,
1821    min_repetitions: usize,
1822    exact_repetition: bool,
1823) -> LiteralRunStats {
1824    if min_repetitions == 0 {
1825        return LiteralRunStats {
1826            prefix: 0,
1827            suffix: 0,
1828            max: inner.max,
1829            all_literal: false,
1830        };
1831    }
1832
1833    if inner.all_literal {
1834        let repeated_len = inner.max.saturating_mul(min_repetitions);
1835        return LiteralRunStats {
1836            prefix: repeated_len,
1837            suffix: repeated_len,
1838            max: repeated_len,
1839            all_literal: exact_repetition,
1840        };
1841    }
1842
1843    LiteralRunStats {
1844        prefix: inner.prefix,
1845        suffix: inner.suffix,
1846        max: inner.max,
1847        all_literal: false,
1848    }
1849}
1850
1851fn repetition_min(kind: &ast::RepetitionKind) -> usize {
1852    match kind {
1853        ast::RepetitionKind::ZeroOrOne | ast::RepetitionKind::ZeroOrMore => 0,
1854        ast::RepetitionKind::OneOrMore => 1,
1855        ast::RepetitionKind::Range(ast::RepetitionRange::Exactly(min))
1856        | ast::RepetitionKind::Range(ast::RepetitionRange::AtLeast(min))
1857        | ast::RepetitionKind::Range(ast::RepetitionRange::Bounded(min, _)) => *min as usize,
1858    }
1859}
1860
1861fn repetition_is_exact(kind: &ast::RepetitionKind) -> bool {
1862    matches!(
1863        kind,
1864        ast::RepetitionKind::Range(ast::RepetitionRange::Exactly(_))
1865    )
1866}
1867
1868fn is_pure_character_class<'a>(regex_cache: &mut RegexAstCache<'a>, pattern: &'a str) -> bool {
1869    match regex_cache.parse(pattern) {
1870        Ok(ast) => pure_character_class_ast(ast).is_some(),
1871        Err(_) => false, // LAW10: invalid regex already emits a QualityIssue::Error; no recall impact
1872    }
1873}
1874
1875fn pure_character_class_ast(ast: &ast::Ast) -> Option<()> {
1876    enum PureFrame<'a> {
1877        Visit(&'a ast::Ast),
1878        FinishAllNonempty(usize),
1879    }
1880
1881    let mut frames = vec![PureFrame::Visit(ast)];
1882    let mut results = Vec::new();
1883    while let Some(frame) = frames.pop() {
1884        match frame {
1885            PureFrame::Visit(node) => match node {
1886                ast::Ast::ClassBracketed(_) => results.push(Some(())),
1887                ast::Ast::Group(group) => frames.push(PureFrame::Visit(&group.ast)),
1888                ast::Ast::Repetition(repetition) => frames.push(PureFrame::Visit(&repetition.ast)),
1889                ast::Ast::Alternation(alternation) => {
1890                    frames.push(PureFrame::FinishAllNonempty(alternation.asts.len()));
1891                    for child in alternation.asts.iter().rev() {
1892                        frames.push(PureFrame::Visit(child));
1893                    }
1894                }
1895                ast::Ast::Concat(concat) => {
1896                    let children = concat
1897                        .asts
1898                        .iter()
1899                        .filter(|child| !is_regex_metadata_node(child))
1900                        .collect::<Vec<_>>();
1901                    frames.push(PureFrame::FinishAllNonempty(children.len()));
1902                    for child in children.into_iter().rev() {
1903                        frames.push(PureFrame::Visit(child));
1904                    }
1905                }
1906                ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_) => {
1907                    results.push(None);
1908                }
1909                ast::Ast::Literal(_)
1910                | ast::Ast::Dot(_)
1911                | ast::Ast::ClassUnicode(_)
1912                | ast::Ast::ClassPerl(_) => results.push(None),
1913            },
1914            PureFrame::FinishAllNonempty(child_count) => {
1915                if child_count == 0 {
1916                    results.push(None);
1917                    continue;
1918                }
1919                let children = results.split_off(results.len() - child_count);
1920                results.push(
1921                    children
1922                        .into_iter()
1923                        .all(|child| child.is_some())
1924                        .then_some(()),
1925                );
1926            }
1927        }
1928    }
1929    results.pop().flatten()
1930}
1931
1932fn is_regex_metadata_node(ast: &ast::Ast) -> bool {
1933    matches!(
1934        ast,
1935        ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_)
1936    )
1937}
1938
1939mod regex_complexity;
1940use regex_complexity::validate_regex_complexity;