Skip to main content

keyhog_core/spec/
validate.rs

1//! Detector quality gate validation rules used while loading TOML specs.
2
3use super::{
4    CanonicalHexKeyMaterialSpec, DetectorKind, DetectorRelationKind, DetectorSpec,
5    EvidenceRequirement, EvidenceScope,
6};
7use serde::Serialize;
8use std::collections::{hash_map::Entry, HashMap, HashSet};
9
10const MAX_REGEX_PATTERN_LEN: usize = 4096;
11const MAX_COMPANION_WITHIN_LINES: usize = 100;
12const MAX_COMPANION_WITHIN_BYTES: usize = 1_048_576;
13const MIN_HTTP_STATUS: u16 = 100;
14const MAX_HTTP_STATUS: u16 = 599;
15// MAX_REGEX_AST_NODES / MAX_REGEX_ALTERNATION_BRANCHES /
16// MAX_REGEX_REPEAT_BOUND were originally defined here too but are the
17// canonical constants in `validate/regex_complexity.rs` (which is where
18// they're actually consumed). Duplicates here had no consumers - clippy
19// `dead_code` flagged them. Re-imports happen via the `use
20// regex_complexity::validate_regex_complexity;` below.
21
22/// Quality issue found in a detector spec.
23///
24/// # Examples
25///
26/// ```rust
27/// use keyhog_core::QualityIssue;
28///
29/// let issue = QualityIssue::Warning("add keywords".into());
30/// assert!(matches!(issue, QualityIssue::Warning(_)));
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub enum QualityIssue {
34    /// A gate violation that makes the detector unloadable.
35    Error(String),
36    /// A gate violation that is reported but does not block loading.
37    Warning(String),
38}
39
40/// Validate a detector spec against the quality gate.
41///
42/// # Examples
43///
44/// ```rust
45/// use keyhog_core::{detector_spec_by_id, validate_detector};
46///
47/// let detector = detector_spec_by_id("aws-access-key")
48///     .expect("the embedded detector corpus contains AWS access keys");
49///
50/// let issues = validate_detector(&detector);
51/// assert!(issues.is_empty(), "{issues:?}");
52/// ```
53pub fn validate_detector(spec: &DetectorSpec) -> Vec<QualityIssue> {
54    let mut issues = Vec::new();
55    let mut regex_cache = RegexAstCache::default();
56    validate_identity(spec, &mut issues);
57    validate_patterns_present(spec, &mut issues);
58    validate_regexes(spec, &mut issues, &mut regex_cache);
59    validate_required_literals(spec, &mut issues);
60    validate_pattern_groups(spec, &mut issues, &mut regex_cache);
61    validate_keywords(spec, &mut issues);
62    validate_simdsieve_prefixes(spec, &mut issues);
63    validate_offline_validators(spec, &mut issues);
64    validate_decode_transforms(spec, &mut issues);
65    validate_pattern_specificity(spec, &mut issues, &mut regex_cache);
66    validate_companions(spec, &mut issues, &mut regex_cache);
67    validate_detector_relations(spec, &mut issues);
68    validate_verify_spec(spec, &mut issues);
69    validate_thresholds(spec, &mut issues);
70    validate_entropy_floor(spec, &mut issues);
71    validate_decoded_hex_key_material_lengths(spec, &mut issues);
72    validate_canonical_hex_key_material(spec, &mut issues);
73    validate_credential_shape(spec, &mut issues);
74    validate_generic_assignment_suffixes(spec, &mut issues);
75    validate_detector_allowlists(spec, &mut issues);
76    issues
77}
78fn validate_generic_assignment_suffixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
79    for (field, suffixes) in [
80        ("generic_vendor_suffixes", &spec.generic_vendor_suffixes),
81        (
82            "generic_assignment_tail_suffixes",
83            &spec.generic_assignment_tail_suffixes,
84        ),
85    ] {
86        if !suffixes.is_empty() && spec.kind != crate::DetectorKind::Phase2Generic {
87            issues.push(QualityIssue::Error(format!(
88                "{field} is only valid for a phase2-generic detector"
89            )));
90        }
91        let mut seen = std::collections::BTreeSet::new();
92        for suffix in suffixes {
93            if suffix.is_empty()
94                || suffix != &suffix.to_ascii_lowercase()
95                || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
96            {
97                issues.push(QualityIssue::Error(format!(
98                    "{field} entry {suffix:?} must be non-empty lowercase ASCII alphanumeric"
99                )));
100            } else if !seen.insert(suffix.as_str()) {
101                issues.push(QualityIssue::Error(format!(
102                    "{field} contains duplicate suffix {suffix:?}"
103                )));
104            }
105        }
106    }
107}
108
109fn validate_decode_transforms(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
110    for issue in spec.decode_transforms.validate() {
111        issues.push(QualityIssue::Error(format!("decode_transforms.{issue}")));
112    }
113}
114
115fn validate_required_literals(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
116    for (index, pattern) in spec.patterns.iter().enumerate() {
117        if let Err(reason) = pattern.validate_required_literals() {
118            issues.push(QualityIssue::Error(format!(
119                "patterns[{index}].required_literals: {reason}"
120            )));
121        }
122    }
123}
124
125fn validate_offline_validators(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
126    let mut claimed_prefixes = std::collections::HashSet::new();
127    for (index, validator) in spec.validators.iter().enumerate() {
128        let prefixes = validator.prefixes();
129        if prefixes.is_empty() {
130            issues.push(QualityIssue::Error(format!(
131                "validators[{index}].prefixes must not be empty"
132            )));
133        }
134        for prefix in prefixes {
135            if prefix.is_empty() || !prefix.is_ascii() {
136                issues.push(QualityIssue::Error(format!(
137                    "validators[{index}] prefix {prefix:?} must be non-empty ASCII"
138                )));
139            }
140            if !claimed_prefixes.insert(prefix) {
141                issues.push(QualityIssue::Error(format!(
142                    "detector validators claim prefix {prefix:?} more than once"
143                )));
144            }
145        }
146
147        if let Some(floor) = validator.confidence_floor() {
148            if !floor.is_finite() || !(0.0..=1.0).contains(&floor) {
149                issues.push(QualityIssue::Error(format!(
150                    "validators[{index}].confidence_floor must be finite and in [0.0, 1.0], found {floor}"
151                )));
152            }
153        }
154
155        match validator {
156            crate::DetectorValidatorSpec::Crc32Base62 {
157                entropy_len,
158                checksum_len,
159                ..
160            } => {
161                if *entropy_len == 0 || *checksum_len == 0 {
162                    issues.push(QualityIssue::Error(format!(
163                        "validators[{index}] CRC32 entropy_len and checksum_len must both be greater than zero"
164                    )));
165                }
166            }
167            crate::DetectorValidatorSpec::GithubFineGrainedCrc32 {
168                left_len,
169                right_len,
170                checksum_len,
171                ..
172            } => {
173                if *left_len == 0 || *checksum_len == 0 || *right_len <= *checksum_len {
174                    issues.push(QualityIssue::Error(format!(
175                        "validators[{index}] fine-grained lengths require left_len > 0 and right_len > checksum_len > 0"
176                    )));
177                }
178            }
179            crate::DetectorValidatorSpec::Base64Payload {
180                min_encoded_len,
181                max_encoded_len,
182                min_decoded_len,
183                ..
184            } => {
185                if *min_encoded_len == 0
186                    || *max_encoded_len < *min_encoded_len
187                    || *min_decoded_len == 0
188                {
189                    issues.push(QualityIssue::Error(format!(
190                        "validators[{index}] base64 lengths require 0 < min_encoded_len <= max_encoded_len and min_decoded_len > 0"
191                    )));
192                }
193            }
194            crate::DetectorValidatorSpec::PatternShape { .. } => {
195                if spec.patterns.is_empty() {
196                    issues.push(QualityIssue::Error(format!(
197                        "validators[{index}] pattern-shape requires at least one detector pattern"
198                    )));
199                }
200            }
201        }
202    }
203}
204
205fn validate_identity(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
206    if spec.id.is_empty() {
207        issues.push(QualityIssue::Error(
208            "detector.id must not be empty; assign a stable detector identifier".to_string(),
209        ));
210    } else if spec.id.trim() != spec.id {
211        issues.push(QualityIssue::Error(
212            "detector.id must not contain leading or trailing whitespace; remove the padding"
213                .to_string(),
214        ));
215    }
216}
217
218fn validate_decoded_hex_key_material_lengths(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
219    if spec.decoded_hex_key_material_lengths.is_empty() {
220        return;
221    }
222    if spec.kind != DetectorKind::Phase2Generic {
223        issues.push(QualityIssue::Error(
224            "decoded_hex_key_material_lengths is only valid for kind = \"phase2-generic\"".into(),
225        ));
226    }
227    let mut seen = std::collections::HashSet::new();
228    for &length in &spec.decoded_hex_key_material_lengths {
229        if length < 16 || length % 2 != 0 {
230            issues.push(QualityIssue::Error(format!(
231                "decoded_hex_key_material_lengths value {length} must be an even character count of at least 16"
232            )));
233        }
234        if !seen.insert(length) {
235            issues.push(QualityIssue::Error(format!(
236                "decoded_hex_key_material_lengths contains duplicate length {length}"
237            )));
238        }
239    }
240}
241
242fn validate_canonical_hex_key_material(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
243    if spec.canonical_hex_key_material.is_empty() {
244        return;
245    }
246    let generic_policy = spec.kind == DetectorKind::Phase2Generic;
247    let has_assignment_scope = |policy: &CanonicalHexKeyMaterialSpec| {
248        !policy.keywords.is_empty()
249            || !policy.suffixes.is_empty()
250            || !policy.excluded_keywords.is_empty()
251    };
252    if !generic_policy
253        && spec
254            .canonical_hex_key_material
255            .iter()
256            .any(has_assignment_scope)
257    {
258        issues.push(QualityIssue::Error(
259            "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(),
260        ));
261    }
262
263    let owned_keywords: std::collections::HashSet<String> = spec
264        .keywords
265        .iter()
266        .filter_map(|keyword| normalize_detector_keyword(keyword))
267        .collect();
268    let mut seen_pairs = std::collections::HashSet::new();
269    let mut seen_regex_lengths = std::collections::HashSet::new();
270    for (policy_index, policy) in spec.canonical_hex_key_material.iter().enumerate() {
271        if policy.lengths.is_empty() {
272            issues.push(QualityIssue::Error(format!(
273                "canonical_hex_key_material[{policy_index}].lengths must not be empty"
274            )));
275        }
276        if generic_policy && policy.keywords.is_empty() && policy.suffixes.is_empty() {
277            issues.push(QualityIssue::Error(format!(
278                "phase2-generic canonical_hex_key_material[{policy_index}] must declare keywords or suffixes"
279            )));
280        }
281        let mut seen_lengths = std::collections::HashSet::new();
282        for &length in &policy.lengths {
283            if length < 16 || length % 2 != 0 {
284                issues.push(QualityIssue::Error(format!(
285                    "canonical_hex_key_material[{policy_index}] length {length} must be an even character count of at least 16"
286                )));
287            }
288            if !seen_lengths.insert(length) {
289                issues.push(QualityIssue::Error(format!(
290                    "canonical_hex_key_material[{policy_index}] contains duplicate length {length}"
291                )));
292            }
293            if !generic_policy && !seen_regex_lengths.insert(length) {
294                issues.push(QualityIssue::Error(format!(
295                    "canonical_hex_key_material repeats regex-detector length {length} across policies"
296                )));
297            }
298        }
299        let mut seen_keywords = std::collections::HashSet::new();
300        for keyword in &policy.keywords {
301            let Some(normalized) = normalize_detector_keyword(keyword) else {
302                issues.push(QualityIssue::Error(format!(
303                    "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
304                )));
305                continue;
306            };
307            if !seen_keywords.insert(normalized.clone()) {
308                issues.push(QualityIssue::Error(format!(
309                    "canonical_hex_key_material[{policy_index}] contains duplicate normalized keyword {normalized:?}"
310                )));
311            }
312            if !owned_keywords.contains(&normalized) {
313                issues.push(QualityIssue::Error(format!(
314                    "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must also appear in detector.keywords"
315                )));
316            }
317            for &length in &policy.lengths {
318                if !seen_pairs.insert((normalized.clone(), length)) {
319                    issues.push(QualityIssue::Error(format!(
320                        "canonical_hex_key_material repeats keyword {keyword:?} at length {length} across policies"
321                    )));
322                }
323            }
324        }
325        let mut seen_suffixes = std::collections::HashSet::new();
326        for suffix in &policy.suffixes {
327            let Some(normalized) = normalize_detector_keyword(suffix) else {
328                issues.push(QualityIssue::Error(format!(
329                    "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
330                )));
331                continue;
332            };
333            if normalized.is_empty() {
334                issues.push(QualityIssue::Error(format!(
335                    "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must not be empty"
336                )));
337            }
338            if !seen_suffixes.insert(normalized) {
339                issues.push(QualityIssue::Error(format!(
340                    "canonical_hex_key_material[{policy_index}] contains duplicate normalized suffix {suffix:?}"
341                )));
342            }
343        }
344        let mut seen_exclusions = std::collections::HashSet::new();
345        for excluded in &policy.excluded_keywords {
346            let Some(normalized) = normalize_detector_keyword(excluded) else {
347                issues.push(QualityIssue::Error(format!(
348                    "canonical_hex_key_material[{policy_index}] excluded keyword {excluded:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
349                )));
350                continue;
351            };
352            if !seen_exclusions.insert(normalized) {
353                issues.push(QualityIssue::Error(format!(
354                    "canonical_hex_key_material[{policy_index}] contains duplicate excluded keyword {excluded:?}"
355                )));
356            }
357        }
358    }
359}
360
361fn normalize_detector_keyword(keyword: &str) -> Option<String> {
362    let mut normalized = String::with_capacity(keyword.len());
363    for byte in keyword.bytes() {
364        if byte.is_ascii_alphanumeric() {
365            normalized.push(byte.to_ascii_lowercase() as char);
366        } else if !matches!(byte, b'_' | b'-' | b'.') {
367            return None;
368        }
369    }
370    (!normalized.is_empty()).then_some(normalized)
371}
372
373fn validate_simdsieve_prefixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
374    let mut seen = std::collections::HashSet::new();
375    for (index, prefix) in spec.simdsieve_prefixes.iter().enumerate() {
376        if prefix.is_empty() {
377            issues.push(QualityIssue::Error(format!(
378                "simdsieve_prefixes[{index}] must not be empty"
379            )));
380        } else if !prefix.is_ascii() {
381            issues.push(QualityIssue::Error(format!(
382                "simdsieve_prefixes[{index}] must be ASCII because simdsieve performs byte-prefix matching"
383            )));
384        }
385        if !seen.insert(prefix) {
386            issues.push(QualityIssue::Error(format!(
387                "simdsieve_prefixes contains duplicate literal {prefix:?}"
388            )));
389        }
390    }
391}
392
393/// `min_confidence` is a probability in `[0.0, 1.0]`. It is a bare `Option<f64>`
394/// with no serde bound, so a typo'd value parses cleanly and then silently
395/// breaks the gate: `< 0.0` always clears the confidence floor (every candidate
396/// surfaces), `> 1.0` can never clear it (the detector never fires), and `NaN`
397/// makes every comparison false. Reject anything outside the closed unit range
398/// (a `RangeInclusive::contains` check is false for `NaN`, so NaN is caught too).
399fn validate_thresholds(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
400    if !(0.0..=1.0).contains(&spec.ml.weight) {
401        issues.push(QualityIssue::Error(format!(
402            "ml.weight {} is out of range; detector model weight must be finite and in [0.0, 1.0]",
403            spec.ml.weight
404        )));
405    }
406    if spec.ml.context_radius_lines > 64 {
407        issues.push(QualityIssue::Error(format!(
408            "ml.context_radius_lines {} exceeds the bounded maximum of 64",
409            spec.ml.context_radius_lines
410        )));
411    }
412    let owns_entropy = spec.owns_entropy_policy();
413    match spec.match_confidence {
414        None => issues.push(QualityIssue::Error(
415            "detector must declare match_confidence; scanner-wide match scoring defaults are not permitted"
416                .into(),
417        )),
418        Some(confidence) => {
419            if let Err(error) = confidence.validate() {
420                issues.push(QualityIssue::Error(format!(
421                    "match_confidence is invalid: {error}"
422                )));
423            }
424            if owns_entropy {
425                if confidence.named_anchor_floor.is_some() {
426                    issues.push(QualityIssue::Error(
427                        "generic entropy owners must omit match_confidence.named_anchor_floor because their regex candidates do not receive the named-detector lift"
428                            .into(),
429                    ));
430                }
431                if confidence.low_promise_confidence.is_none() {
432                    issues.push(QualityIssue::Error(
433                        "generic entropy owners must declare match_confidence.low_promise_confidence"
434                            .into(),
435                    ));
436                }
437            } else {
438                if confidence.named_anchor_floor.is_none() {
439                    issues.push(QualityIssue::Error(
440                        "named detectors must declare match_confidence.named_anchor_floor"
441                            .into(),
442                    ));
443                }
444                if confidence.low_promise_confidence.is_some() {
445                    issues.push(QualityIssue::Error(
446                        "named detectors must omit match_confidence.low_promise_confidence because the promise gate cannot replace service-owned evidence"
447                            .into(),
448                    ));
449                }
450            }
451        }
452    }
453    if owns_entropy && spec.ml.entropy_mode == crate::DetectorMlMode::Disabled {
454        issues.push(QualityIssue::Error(
455            "an active entropy-policy owner must declare a non-disabled ml.entropy_mode"
456                .to_string(),
457        ));
458    }
459    if !owns_entropy && spec.ml.entropy_mode != crate::DetectorMlMode::Disabled {
460        issues.push(QualityIssue::Error(
461            "ml.entropy_mode is only valid for a detector that owns entropy policy".to_string(),
462        ));
463    }
464    for (name, value) in [
465        ("min_len", spec.min_len),
466        ("max_len", spec.max_len),
467        ("keyword_free_min_len", spec.keyword_free_min_len),
468    ] {
469        if value == Some(0) {
470            issues.push(QualityIssue::Error(format!(
471                "{name} must be greater than 0 when present; use omission to inherit the path default"
472            )));
473        }
474    }
475    if let (Some(min_len), Some(max_len)) = (spec.min_len, spec.max_len) {
476        if min_len > max_len {
477            issues.push(QualityIssue::Error(format!(
478                "min_len {min_len} exceeds max_len {max_len}"
479            )));
480        }
481    }
482    if spec.max_len.is_some_and(|max_len| max_len < 8) {
483        issues.push(QualityIssue::Error(
484            "max_len must be at least the generic assignment path minimum of 8".to_string(),
485        ));
486    }
487    if spec.max_len.is_some() && !spec.owns_entropy_policy() {
488        issues.push(QualityIssue::Error(
489            "max_len is only valid for detectors that own generic entropy policy".to_string(),
490        ));
491    }
492    if let Some(mc) = spec.min_confidence {
493        if !(0.0..=1.0).contains(&mc) {
494            issues.push(QualityIssue::Error(format!(
495                "min_confidence {mc} is out of range; confidence is a probability in [0.0, 1.0] \
496                 (outside it silently breaks the gate: < 0 always passes, > 1 never fires, NaN is undefined)"
497            )));
498        }
499    }
500    if let Some(bound) = spec.bpe_max_bytes_per_token {
501        if !bound.is_finite() || bound <= 0.0 {
502            issues.push(QualityIssue::Error(format!(
503                "bpe_max_bytes_per_token {bound} must be finite and greater than 0; \
504                 zero or a negative value suppresses every candidate and NaN/inf makes the gate undefined"
505            )));
506        }
507    }
508    if spec.bpe_enabled == Some(false) && spec.bpe_max_bytes_per_token.is_some() {
509        issues.push(QualityIssue::Error(
510            "bpe_enabled = false conflicts with bpe_max_bytes_per_token; remove the ceiling when token efficiency is disabled"
511                .into(),
512        ));
513    }
514    if !spec.entropy_roles.is_empty() && !spec.owns_entropy_policy() {
515        issues.push(QualityIssue::Error(
516            "entropy_roles require a detector that owns a complete entropy policy".into(),
517        ));
518    }
519    let mut entropy_roles = std::collections::HashSet::new();
520    for role in &spec.entropy_roles {
521        if !entropy_roles.insert(*role) {
522            issues.push(QualityIssue::Error(format!(
523                "entropy_roles contains duplicate role {:?}",
524                role.as_str()
525            )));
526        }
527    }
528    for (name, value) in [
529        ("entropy_high", spec.entropy_high),
530        ("entropy_low", spec.entropy_low),
531        ("entropy_very_high", spec.entropy_very_high),
532        (
533            "sensitive_path_entropy_very_high",
534            spec.sensitive_path_entropy_very_high,
535        ),
536    ] {
537        let Some(score) = value else {
538            continue;
539        };
540        if !score.is_finite() || !(0.0..=8.0).contains(&score) {
541            issues.push(QualityIssue::Error(format!(
542                "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
543            )));
544        }
545    }
546    if let (Some(low), Some(high)) = (spec.entropy_low, spec.entropy_high) {
547        if low > high {
548            issues.push(QualityIssue::Error(format!(
549                "entropy_low {low} must not exceed entropy_high {high}"
550            )));
551        }
552    }
553    if let (Some(high), Some(very_high)) = (spec.entropy_high, spec.entropy_very_high) {
554        if high > very_high {
555            issues.push(QualityIssue::Error(format!(
556                "entropy_high {high} must not exceed entropy_very_high {very_high}"
557            )));
558        }
559    }
560    if let Some(plausibility) = spec.plausibility {
561        for (name, score) in [
562            (
563                "plausibility.mixed_alnum_floor",
564                plausibility.mixed_alnum_floor,
565            ),
566            (
567                "plausibility.symbolic_entropy_floor",
568                plausibility.symbolic_entropy_floor,
569            ),
570            (
571                "plausibility.second_half_entropy_floor",
572                plausibility.second_half_entropy_floor,
573            ),
574            (
575                "plausibility.isolated_mixed_entropy_floor",
576                plausibility.isolated_mixed_entropy_floor,
577            ),
578            (
579                "plausibility.leading_slash_base64_entropy_floor",
580                plausibility.leading_slash_base64_entropy_floor,
581            ),
582        ] {
583            if !score.is_finite() || !(0.0..=8.0).contains(&score) {
584                issues.push(QualityIssue::Error(format!(
585                    "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
586                )));
587            }
588        }
589        if let Some(margin) = plausibility.keyword_free_operator_margin {
590            if !margin.is_finite() || !(0.0..=8.0).contains(&margin) {
591                issues.push(QualityIssue::Error(format!(
592                    "plausibility.keyword_free_operator_margin must be finite and in [0.0, 8.0], found {margin}"
593                )));
594            }
595        }
596        if plausibility.mixed_alnum_min_len == 0 {
597            issues.push(QualityIssue::Error(
598                "plausibility.mixed_alnum_min_len must be greater than zero".into(),
599            ));
600        }
601        for (name, length) in [
602            (
603                "plausibility.second_half_min_len",
604                plausibility.second_half_min_len,
605            ),
606            (
607                "plausibility.unique_chars_min_len",
608                plausibility.unique_chars_min_len,
609            ),
610            (
611                "plausibility.min_unique_chars",
612                plausibility.min_unique_chars,
613            ),
614            (
615                "plausibility.unanchored_hex_max_len",
616                plausibility.unanchored_hex_max_len,
617            ),
618            (
619                "plausibility.identical_char_max_len",
620                plausibility.identical_char_max_len,
621            ),
622            (
623                "plausibility.structured_dotted_min_len",
624                plausibility.structured_dotted_min_len,
625            ),
626            (
627                "plausibility.isolated_symbolic_min_len",
628                plausibility.isolated_symbolic_min_len,
629            ),
630            (
631                "plausibility.isolated_symbolic_min_symbols",
632                plausibility.isolated_symbolic_min_symbols,
633            ),
634            (
635                "plausibility.isolated_alpha_only_min_symbols",
636                plausibility.isolated_alpha_only_min_symbols,
637            ),
638            (
639                "plausibility.source_type_name_max_len",
640                plausibility.source_type_name_max_len,
641            ),
642            (
643                "plausibility.source_type_name_min_uppercase",
644                plausibility.source_type_name_min_uppercase,
645            ),
646            (
647                "plausibility.url_path_high_entropy_min_len",
648                plausibility.url_path_high_entropy_min_len,
649            ),
650            (
651                "plausibility.isolated_colon_left_min_len",
652                plausibility.isolated_colon_left_min_len,
653            ),
654            (
655                "plausibility.isolated_colon_right_min_len",
656                plausibility.isolated_colon_right_min_len,
657            ),
658            (
659                "plausibility.leading_slash_base64_min_len",
660                plausibility.leading_slash_base64_min_len,
661            ),
662        ] {
663            if length == 0 {
664                issues.push(QualityIssue::Error(format!(
665                    "{name} must be greater than zero"
666                )));
667            }
668        }
669        if !plausibility.isolated_alpha_only_min_alpha_ratio.is_finite()
670            || !(0.0..=1.0).contains(&plausibility.isolated_alpha_only_min_alpha_ratio)
671            || plausibility.isolated_alpha_only_min_alpha_ratio == 0.0
672        {
673            issues.push(QualityIssue::Error(format!(
674                "plausibility.isolated_alpha_only_min_alpha_ratio must be finite and in (0.0, 1.0], found {}",
675                plausibility.isolated_alpha_only_min_alpha_ratio
676            )));
677        }
678        if !plausibility.min_alnum_ratio.is_finite()
679            || !(0.0..=1.0).contains(&plausibility.min_alnum_ratio)
680            || plausibility.min_alnum_ratio == 0.0
681        {
682            issues.push(QualityIssue::Error(format!(
683                "plausibility.min_alnum_ratio must be finite and in (0.0, 1.0], found {}",
684                plausibility.min_alnum_ratio
685            )));
686        }
687        if plausibility.source_type_name_min_uppercase > plausibility.source_type_name_max_len {
688            issues.push(QualityIssue::Error(format!(
689                "plausibility.source_type_name_min_uppercase ({}) must not exceed plausibility.source_type_name_max_len ({})",
690                plausibility.source_type_name_min_uppercase,
691                plausibility.source_type_name_max_len
692            )));
693        }
694        if plausibility.min_unique_chars > plausibility.unique_chars_min_len {
695            issues.push(QualityIssue::Error(format!(
696                "plausibility.min_unique_chars ({}) must not exceed plausibility.unique_chars_min_len ({})",
697                plausibility.min_unique_chars, plausibility.unique_chars_min_len
698            )));
699        }
700    }
701    if let (Some(very_high), Some(sensitive)) = (
702        spec.entropy_very_high,
703        spec.sensitive_path_entropy_very_high,
704    ) {
705        if sensitive > very_high {
706            issues.push(QualityIssue::Error(format!(
707                "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"
708            )));
709        }
710    }
711    let entropy_owner = spec.owns_entropy_policy();
712    let has_weak_pattern = spec.patterns.iter().any(|pattern| pattern.weak_anchor);
713    if spec.weak_anchor && has_weak_pattern {
714        issues.push(QualityIssue::Error(
715            "detector weak_anchor=true already applies to every pattern; remove redundant pattern weak_anchor flags"
716                .into(),
717        ));
718    }
719    if spec.weak_anchor || has_weak_pattern {
720        if spec.entropy_high.is_none() {
721            issues.push(QualityIssue::Error(
722                "weak_anchor detectors and patterns must declare entropy_high in their own detector TOML".into(),
723            ));
724        }
725        if spec.entropy_floor.is_empty() {
726            issues.push(QualityIssue::Error(
727                "weak_anchor detectors and patterns must declare entropy_floor in their own detector TOML"
728                    .into(),
729            ));
730        }
731    }
732    if entropy_owner {
733        for (field, present) in [
734            ("entropy_high", spec.entropy_high.is_some()),
735            ("entropy_low", spec.entropy_low.is_some()),
736            ("entropy_very_high", spec.entropy_very_high.is_some()),
737            (
738                "sensitive_path_entropy_very_high",
739                spec.sensitive_path_entropy_very_high.is_some(),
740            ),
741            ("[detector.plausibility]", spec.plausibility.is_some()),
742            ("keyword_free_min_len", spec.keyword_free_min_len.is_some()),
743            ("min_len", spec.min_len.is_some()),
744            ("max_len", spec.max_len.is_some()),
745            (
746                "entropy_policy_priority",
747                spec.entropy_policy_priority.is_some(),
748            ),
749        ] {
750            if !present {
751                issues.push(QualityIssue::Error(format!(
752                    "active entropy owner must declare {field} in its detector TOML; runtime fallback policy is forbidden"
753                )));
754            }
755        }
756        if spec.entropy_shapes.is_empty() {
757            issues.push(QualityIssue::Error(
758                "active entropy owner must declare detector.entropy_shapes in its detector TOML"
759                    .into(),
760            ));
761        }
762        if spec.entropy_floor.is_empty() {
763            issues.push(QualityIssue::Error(
764                "active entropy owner must declare entropy_floor in its detector TOML".into(),
765            ));
766        }
767        if spec.bpe_enabled.is_none() {
768            issues.push(QualityIssue::Error(
769                "active entropy owner must declare bpe_enabled in its detector TOML".into(),
770            ));
771        }
772        if spec.bpe_enabled != Some(false) && spec.bpe_max_bytes_per_token.is_none() {
773            issues.push(QualityIssue::Error(
774                "active entropy owner must declare bpe_max_bytes_per_token or bpe_enabled = false in its detector TOML"
775                    .into(),
776            ));
777        }
778    }
779    let owns_keyword_free = spec
780        .entropy_roles
781        .contains(&crate::EntropyDetectionRole::KeywordFree);
782    let keyword_free_operator_margin = spec
783        .plausibility
784        .and_then(|policy| policy.keyword_free_operator_margin);
785    match (owns_keyword_free, keyword_free_operator_margin) {
786        (true, None) => issues.push(QualityIssue::Error(
787            "the detector claiming entropy role `keyword-free` must declare plausibility.keyword_free_operator_margin"
788                .into(),
789        )),
790        (false, Some(_)) => issues.push(QualityIssue::Error(
791            "plausibility.keyword_free_operator_margin is valid only on the detector claiming entropy role `keyword-free`"
792                .into(),
793        )),
794        _ => {}
795    }
796    if entropy_owner && spec.entropy_fallback.is_none() {
797        issues.push(QualityIssue::Error(
798            "active entropy owner must declare entropy_fallback metadata; omission would make synthetic finding identity ambiguous".into(),
799        ));
800    }
801    if entropy_owner && spec.entropy_fallback_confidence.is_none() {
802        issues.push(QualityIssue::Error(
803            "active entropy owner must declare entropy_fallback_confidence; omission would leave detector confidence in scanner literals".into(),
804        ));
805    }
806    if entropy_owner && spec.generic_assignment_confidence.is_none() {
807        issues.push(QualityIssue::Error(
808            "active entropy owner must declare generic_assignment_confidence; omission would leave generic assignment scoring in scanner literals".into(),
809        ));
810    }
811    if let Some(confidence) = spec.entropy_fallback_confidence {
812        if !entropy_owner {
813            issues.push(QualityIssue::Error(
814                "entropy_fallback_confidence requires an active detector-owned entropy policy"
815                    .into(),
816            ));
817        }
818        if let Err(error) = confidence.validate() {
819            issues.push(QualityIssue::Error(format!(
820                "entropy_fallback_confidence is invalid: {error}"
821            )));
822        }
823    }
824    if let Some(confidence) = spec.generic_assignment_confidence {
825        if !entropy_owner {
826            issues.push(QualityIssue::Error(
827                "generic_assignment_confidence requires an active detector-owned entropy policy"
828                    .into(),
829            ));
830        }
831        if let Err(error) = confidence.validate() {
832            issues.push(QualityIssue::Error(format!(
833                "generic_assignment_confidence is invalid: {error}"
834            )));
835        }
836    }
837    if let Some(metadata) = &spec.entropy_fallback {
838        if !entropy_owner {
839            issues.push(QualityIssue::Error(
840                "entropy_fallback requires an active detector-owned entropy policy".into(),
841            ));
842        }
843        if !metadata.id.strip_prefix("entropy-").is_some_and(|suffix| {
844            !suffix.is_empty()
845                && suffix
846                    .bytes()
847                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
848        }) {
849            issues.push(QualityIssue::Error(format!(
850                "entropy_fallback.id {:?} must use a lowercase entropy- namespace id",
851                metadata.id
852            )));
853        }
854        if metadata.name.trim().is_empty() {
855            issues.push(QualityIssue::Error(
856                "entropy_fallback.name must not be empty".into(),
857            ));
858        }
859        if metadata.service.trim().is_empty() {
860            issues.push(QualityIssue::Error(
861                "entropy_fallback.service must not be empty".into(),
862            ));
863        }
864    }
865    if !spec.entropy_shapes.is_empty() && !entropy_owner {
866        issues.push(QualityIssue::Error(
867            "entropy_shapes require an active detector-owned entropy policy".into(),
868        ));
869    }
870    if spec.entropy_shapes.len() > 1 {
871        issues.push(QualityIssue::Error(format!(
872            "active entropy policy accepts exactly one detector.entropy_shapes entry, found {}",
873            spec.entropy_shapes.len()
874        )));
875    }
876    let mut shape_signatures: Vec<(crate::spec::ShapeCharset, Option<(usize, usize, char)>)> =
877        Vec::new();
878    for (index, shape) in spec.entropy_shapes.iter().enumerate() {
879        let signature = (
880            shape.charset,
881            shape
882                .grouping
883                .map(|g| (g.group_count, g.group_length, g.separator)),
884        );
885        if shape_signatures.contains(&signature) {
886            issues.push(QualityIssue::Error(format!(
887                "entropy_shapes[{index}] duplicates an earlier shape's charset and grouping"
888            )));
889        }
890        shape_signatures.push(signature);
891        if !shape.entropy_floor.is_finite() || !(0.0..=8.0).contains(&shape.entropy_floor) {
892            issues.push(QualityIssue::Error(format!(
893                "entropy_shapes[{index}].entropy_floor must be finite and in [0.0, 8.0], found {}",
894                shape.entropy_floor
895            )));
896        }
897        if shape.special_min_length == 0 {
898            issues.push(QualityIssue::Error(format!(
899                "entropy_shapes[{index}].special_min_length must be greater than 0"
900            )));
901        }
902        if shape.require_mixed_case && shape.charset == crate::spec::ShapeCharset::LowerAlnum {
903            issues.push(QualityIssue::Error(format!(
904                "entropy_shapes[{index}].require_mixed_case is impossible with charset lower-alnum"
905            )));
906        }
907        if shape.require_non_hex_alpha && shape.charset == crate::spec::ShapeCharset::Hex {
908            issues.push(QualityIssue::Error(format!(
909                "entropy_shapes[{index}].require_non_hex_alpha is impossible with charset hex"
910            )));
911        }
912        if shape.require_group_alpha_digit && shape.grouping.is_none() {
913            issues.push(QualityIssue::Error(format!(
914                "entropy_shapes[{index}].require_group_alpha_digit requires grouping"
915            )));
916        }
917        if let Some(grouping) = shape.grouping {
918            if grouping.group_count == 0 || grouping.group_length == 0 {
919                issues.push(QualityIssue::Error(format!(
920                    "entropy_shapes[{index}] grouping.group_count and group_length must both be greater than 0"
921                )));
922                continue;
923            }
924            let derived_length = grouping
925                .group_count
926                .checked_mul(grouping.group_length)
927                .and_then(|length| {
928                    length.checked_add(
929                        grouping
930                            .group_count
931                            .saturating_sub(1)
932                            .saturating_mul(grouping.separator.len_utf8()),
933                    )
934                });
935            let Some(derived_length) = derived_length else {
936                issues.push(QualityIssue::Error(format!(
937                    "entropy_shapes[{index}] grouping overflows the derived candidate length"
938                )));
939                continue;
940            };
941            if shape.special_min_length > derived_length {
942                issues.push(QualityIssue::Error(format!(
943                    "entropy_shapes[{index}].special_min_length must be in 1..={derived_length}, found {}",
944                    shape.special_min_length
945                )));
946            }
947        }
948    }
949}
950
951fn validate_entropy_floor(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
952    if spec.entropy_floor.is_empty() {
953        return;
954    }
955    let last = spec.entropy_floor.len() - 1;
956    let mut previous_max = 0usize;
957    for (index, bucket) in spec.entropy_floor.iter().enumerate() {
958        if !bucket.floor.is_finite() || !(0.0..=8.0).contains(&bucket.floor) {
959            issues.push(QualityIssue::Error(format!(
960                "entropy_floor bucket {index} floor must be finite and in [0.0, 8.0], found {}",
961                bucket.floor
962            )));
963        }
964        if index < last && bucket.max_len.is_none() {
965            issues.push(QualityIssue::Error(format!(
966                "entropy_floor bucket {index} is an early catch-all; only the final bucket may omit max_len"
967            )));
968        }
969        if index == last && bucket.max_len.is_some() {
970            issues.push(QualityIssue::Error(
971                "entropy_floor final bucket must omit max_len so longer candidates cannot bypass the floor"
972                    .into(),
973            ));
974        }
975        if let Some(max_len) = bucket.max_len {
976            if max_len <= previous_max {
977                issues.push(QualityIssue::Error(format!(
978                    "entropy_floor max_len values must strictly increase from a positive length; found {max_len} after {previous_max}"
979                )));
980            }
981            previous_max = max_len;
982        }
983    }
984}
985
986fn validate_credential_shape(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
987    if let Some(shape) = &spec.credential_shape {
988        if let Err(error) = shape.validate(&spec.id) {
989            issues.push(QualityIssue::Error(error));
990        }
991    }
992}
993
994fn validate_detector_allowlists(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
995    for (field, patterns) in [
996        ("allowlist_paths", &spec.allowlist_paths),
997        ("allowlist_values", &spec.allowlist_values),
998        (
999            "source_admission.path_patterns",
1000            &spec.source_admission.path_patterns,
1001        ),
1002    ] {
1003        let mut first_index_by_pattern = HashMap::new();
1004        for (index, pattern) in patterns.iter().enumerate() {
1005            if pattern.trim().is_empty() {
1006                issues.push(QualityIssue::Error(format!(
1007                    "detector {:?} {field}[{index}] must not be empty or whitespace-only",
1008                    spec.id
1009                )));
1010                continue;
1011            }
1012            match first_index_by_pattern.entry(pattern.as_str()) {
1013                Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1014                    "detector {:?} {field}[{index}] duplicates {field}[{}]",
1015                    spec.id,
1016                    first.get()
1017                ))),
1018                Entry::Vacant(slot) => {
1019                    slot.insert(index);
1020                }
1021            }
1022            if let Err(error) = regex::Regex::new(pattern) {
1023                issues.push(QualityIssue::Error(format!(
1024                    "detector {:?} {field}[{index}] is not a valid regex ({pattern:?}): {error}",
1025                    spec.id
1026                )));
1027            }
1028        }
1029    }
1030
1031    let mut first_index_by_stopword = HashMap::new();
1032    for (index, stopword) in spec.stopwords.iter().enumerate() {
1033        if stopword.trim().is_empty() {
1034            issues.push(QualityIssue::Error(format!(
1035                "detector {:?} stopwords[{index}] must not be empty or whitespace-only",
1036                spec.id
1037            )));
1038            continue;
1039        }
1040        let normalized = stopword.to_ascii_lowercase();
1041        match first_index_by_stopword.entry(normalized) {
1042            Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1043                "detector {:?} stopwords[{index}] duplicates stopwords[{}] under case-insensitive matching",
1044                spec.id,
1045                first.get()
1046            ))),
1047            Entry::Vacant(slot) => {
1048                slot.insert(index);
1049            }
1050        }
1051    }
1052    let mut first_marker_index = HashMap::new();
1053    for (index, marker) in spec.public_identifier_assignment_markers.iter().enumerate() {
1054        if marker.is_empty()
1055            || !marker.is_ascii()
1056            || marker.bytes().any(|byte| byte.is_ascii_lowercase())
1057        {
1058            issues.push(QualityIssue::Error(format!(
1059                "detector {:?} public_identifier_assignment_markers[{index}] must be non-empty uppercase ASCII because runtime matching is allocation-free ASCII-insensitive",
1060                spec.id
1061            )));
1062            continue;
1063        }
1064        match first_marker_index.entry(marker.as_str()) {
1065            Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1066                "detector {:?} public_identifier_assignment_markers[{index}] duplicates public_identifier_assignment_markers[{}]",
1067                spec.id,
1068                first.get()
1069            ))),
1070            Entry::Vacant(slot) => {
1071                slot.insert(index);
1072            }
1073        }
1074    }
1075    let mut source_types = HashSet::new();
1076    for (index, source_type) in spec.source_admission.source_types.iter().enumerate() {
1077        if source_type.trim().is_empty() {
1078            issues.push(QualityIssue::Error(format!(
1079                "detector {:?} source_admission.source_types[{index}] must not be empty",
1080                spec.id
1081            )));
1082        } else if !source_types.insert(source_type) {
1083            issues.push(QualityIssue::Error(format!(
1084                "detector {:?} source_admission.source_types[{index}] is duplicated",
1085                spec.id
1086            )));
1087        }
1088    }
1089    let mut extensions = HashSet::new();
1090    for (index, extension) in spec.source_admission.file_extensions.iter().enumerate() {
1091        if extension.is_empty()
1092            || !extension.is_ascii()
1093            || extension.starts_with('.')
1094            || extension.bytes().any(|byte| byte.is_ascii_uppercase())
1095        {
1096            issues.push(QualityIssue::Error(format!(
1097                "detector {:?} source_admission.file_extensions[{index}] must be lowercase ASCII without a leading dot",
1098                spec.id
1099            )));
1100        } else if !extensions.insert(extension) {
1101            issues.push(QualityIssue::Error(format!(
1102                "detector {:?} source_admission.file_extensions[{index}] is duplicated",
1103                spec.id
1104            )));
1105        }
1106    }
1107}
1108
1109fn validate_patterns_present(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1110    match spec.kind {
1111        // A phase-1 regex detector is defined by its anchors (no patterns is an error).
1112        DetectorKind::Regex => {
1113            if spec.patterns.is_empty() {
1114                issues.push(QualityIssue::Error("no patterns defined".into()));
1115            }
1116        }
1117        // A phase-2 generic bridge is defined by keywords + entropy_floor.
1118        // Optional patterns add strongly structured envelopes without creating
1119        // a duplicate detector owner; keywords remain required for the
1120        // shapeless phase-2 path.
1121        DetectorKind::Phase2Generic => {
1122            if spec.keywords.is_empty() {
1123                issues.push(QualityIssue::Error(
1124                    "phase2-generic detector must define keywords (its only pre-filter)".into(),
1125                ));
1126            }
1127        }
1128    }
1129}
1130
1131fn validate_regexes<'a>(
1132    spec: &'a DetectorSpec,
1133    issues: &mut Vec<QualityIssue>,
1134    regex_cache: &mut RegexAstCache<'a>,
1135) {
1136    for (i, pat) in spec.patterns.iter().enumerate() {
1137        validate_regex_definition(RegexKind::Pattern, i, &pat.regex, issues, regex_cache);
1138    }
1139}
1140
1141fn validate_keywords(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1142    if spec.keywords.is_empty() {
1143        issues.push(QualityIssue::Warning(
1144            "no keywords defined - pattern may produce false positives".into(),
1145        ));
1146        return;
1147    }
1148    for (index, keyword) in spec.keywords.iter().enumerate() {
1149        if keyword.is_empty() {
1150            issues.push(QualityIssue::Error(format!(
1151                "keyword {index} is empty; remove it or declare a non-empty detector-owned context literal"
1152            )));
1153        }
1154    }
1155}
1156
1157fn validate_pattern_groups<'a>(
1158    spec: &'a DetectorSpec,
1159    issues: &mut Vec<QualityIssue>,
1160    regex_cache: &mut RegexAstCache<'a>,
1161) {
1162    for (i, pat) in spec.patterns.iter().enumerate() {
1163        let Some(group) = pat.group else {
1164            continue;
1165        };
1166        let Ok(ast) = regex_cache.parse(&pat.regex) else {
1167            continue; // LAW10: invalid regex already emits a QualityIssue::Error; detector load fails closed, recall-safe
1168        };
1169        let captures = ast_captures_len(ast);
1170        if group >= captures {
1171            issues.push(QualityIssue::Error(format!(
1172                "pattern {i} capture group {group} is out of range; regex has {} capture groups \
1173                 (valid group indexes are 0..{})",
1174                captures.saturating_sub(1),
1175                captures.saturating_sub(1)
1176            )));
1177        }
1178    }
1179}
1180
1181fn validate_pattern_specificity<'a>(
1182    spec: &'a DetectorSpec,
1183    issues: &mut Vec<QualityIssue>,
1184    regex_cache: &mut RegexAstCache<'a>,
1185) {
1186    for (i, pat) in spec.patterns.iter().enumerate() {
1187        let has_prefix = has_literal_prefix(regex_cache, &pat.regex, 3);
1188        let has_group = pat.group.is_some();
1189        let is_pure_charclass = is_pure_character_class(regex_cache, &pat.regex);
1190
1191        if is_pure_charclass && !has_group {
1192            issues.push(QualityIssue::Error(format!(
1193                "pattern {} is a pure character class ({}) - too broad without context anchoring. \
1194                 Use a capture group or add a literal prefix.",
1195                i, pat.regex
1196            )));
1197        } else if !has_prefix && !has_group && spec.keywords.is_empty() {
1198            issues.push(QualityIssue::Warning(format!(
1199                "pattern {} has no literal prefix and no capture group - may false-positive",
1200                i
1201            )));
1202        }
1203    }
1204}
1205
1206fn validate_companions<'a>(
1207    spec: &'a DetectorSpec,
1208    issues: &mut Vec<QualityIssue>,
1209    regex_cache: &mut RegexAstCache<'a>,
1210) {
1211    for (i, companion) in spec.companions.iter().enumerate() {
1212        if companion.name.trim().is_empty() {
1213            issues.push(QualityIssue::Error(format!(
1214                "companion {} name must not be empty",
1215                i
1216            )));
1217        }
1218        if companion.within_lines > MAX_COMPANION_WITHIN_LINES {
1219            issues.push(QualityIssue::Error(format!(
1220                "companion {} within_lines={} exceeds {} search-window limit",
1221                i, companion.within_lines, MAX_COMPANION_WITHIN_LINES
1222            )));
1223        }
1224        if let Some(within_bytes) = companion.within_bytes {
1225            if within_bytes == 0 || within_bytes > MAX_COMPANION_WITHIN_BYTES {
1226                issues.push(QualityIssue::Error(format!(
1227                    "companion {i} within_bytes={within_bytes} must be in 1..={MAX_COMPANION_WITHIN_BYTES}"
1228                )));
1229            }
1230        }
1231        if companion.scope == EvidenceScope::SameLine && companion.within_lines != 0 {
1232            issues.push(QualityIssue::Error(format!(
1233                "companion {i} scope=same-line requires within_lines=0, found {}",
1234                companion.within_lines
1235            )));
1236        }
1237        if companion.required && companion.requirement != EvidenceRequirement::Reinforcing {
1238            issues.push(QualityIssue::Error(format!(
1239                "companion {i} mixes schema-v2 required=true with typed requirement={:?}; \
1240                 remove required and keep only the typed requirement",
1241                companion.requirement
1242            )));
1243        }
1244        if let Some(group) = companion.capture_group {
1245            if let Ok(regex) = regex::Regex::new(&companion.regex) {
1246                // LAW10: malformed input fails closed in validation below; this reporting-only branch adds a capture-group diagnostic.
1247                if group >= regex.captures_len() {
1248                    issues.push(QualityIssue::Error(format!(
1249                        "companion {i} capture_group={group} does not exist; regex exposes groups 0..{}",
1250                        regex.captures_len().saturating_sub(1)
1251                    )));
1252                }
1253            }
1254        }
1255        validate_regex_definition(
1256            RegexKind::Companion,
1257            i,
1258            &companion.regex,
1259            issues,
1260            regex_cache,
1261        );
1262        // A "pure character class" companion (e.g. `[A-Z0-9]{10}` for an
1263        // Algolia application_id) is acceptable when `within_lines` is small:
1264        // the positional constraint is itself the contextual anchor. Reject
1265        // only when the companion permits a wide search radius - at that
1266        // point the lack of textual context really does over-fire.
1267        if is_pure_character_class(regex_cache, &companion.regex) {
1268            if companion.within_lines <= TIGHT_COMPANION_RADIUS {
1269                issues.push(QualityIssue::Warning(format!(
1270                    "companion {} regex '{}' is a pure character class; \
1271                     allowed because within_lines={} ≤ {} (positional anchoring).",
1272                    i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1273                )));
1274            } else {
1275                issues.push(QualityIssue::Error(format!(
1276                    "companion {} regex '{}' is a pure character class with within_lines={} \
1277                     (> {}) - the wide search radius needs a literal context anchor",
1278                    i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1279                )));
1280            }
1281        } else if !has_substantial_literal(regex_cache, &companion.regex, 3) {
1282            issues.push(QualityIssue::Warning(format!(
1283                "companion {} regex '{}' is too broad - may produce false positives. \
1284                 Add a context anchor like 'KEY_NAME='.",
1285                i, companion.regex
1286            )));
1287        }
1288    }
1289}
1290
1291fn validate_detector_relations(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1292    let mut first_by_target: HashMap<&str, (usize, DetectorRelationKind)> = HashMap::new();
1293    for (index, relation) in spec.detector_relations.iter().enumerate() {
1294        let target = relation.detector_id.trim();
1295        if target.is_empty() {
1296            issues.push(QualityIssue::Error(format!(
1297                "detector relation {index} target detector_id must not be empty"
1298            )));
1299        }
1300        if target == spec.id {
1301            issues.push(QualityIssue::Error(format!(
1302                "detector relation {index} cannot target its owning detector {:?}",
1303                spec.id
1304            )));
1305        }
1306        if relation.within_lines > MAX_COMPANION_WITHIN_LINES {
1307            issues.push(QualityIssue::Error(format!(
1308                "detector relation {index} within_lines={} exceeds {} search-window limit",
1309                relation.within_lines, MAX_COMPANION_WITHIN_LINES
1310            )));
1311        }
1312        if let Some(within_bytes) = relation.within_bytes {
1313            if within_bytes > MAX_COMPANION_WITHIN_BYTES {
1314                issues.push(QualityIssue::Error(format!(
1315                    "detector relation {index} within_bytes={within_bytes} must be in 0..={MAX_COMPANION_WITHIN_BYTES}"
1316                )));
1317            }
1318        }
1319        if let Some((first_index, first_kind)) =
1320            first_by_target.insert(target, (index, relation.kind))
1321        {
1322            let detail = if first_kind == relation.kind {
1323                "duplicates"
1324            } else {
1325                "contradicts"
1326            };
1327            issues.push(QualityIssue::Error(format!(
1328                "detector relation {index} {detail} relation {first_index} for target {target:?}; \
1329                 declare one operation per detector pair"
1330            )));
1331        }
1332    }
1333}
1334
1335/// Companion search radius (in lines) below which a pure character-class
1336/// regex is acceptable. The positional bound provides the context anchor.
1337const TIGHT_COMPANION_RADIUS: usize = 5;
1338
1339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1340enum RegexKind {
1341    Pattern,
1342    Companion,
1343}
1344
1345impl RegexKind {
1346    fn label(self) -> &'static str {
1347        match self {
1348            Self::Pattern => "pattern",
1349            Self::Companion => "companion",
1350        }
1351    }
1352}
1353
1354fn validate_regex_definition<'a>(
1355    kind: RegexKind,
1356    index: usize,
1357    regex: &'a str,
1358    issues: &mut Vec<QualityIssue>,
1359    regex_cache: &mut RegexAstCache<'a>,
1360) {
1361    let kind = kind.label();
1362    // An empty regex is VALID syntax, it parses cleanly and matches the empty
1363    // string at EVERY position, so a detector carrying one fires on every byte
1364    // of every file: a catastrophic false-positive flood that the parse check
1365    // below cannot catch (it compiles fine). Reject it up front, fail closed.
1366    if regex.is_empty() {
1367        issues.push(QualityIssue::Error(format!(
1368            "{kind} {index} regex is empty; an empty pattern matches at every position \
1369             (a catastrophic false-positive flood), define a real anchor or remove the pattern"
1370        )));
1371        return;
1372    }
1373    if regex.len() > MAX_REGEX_PATTERN_LEN {
1374        issues.push(QualityIssue::Error(format!(
1375            "{kind} {index} regex is too large ({} bytes > {} byte limit)",
1376            regex.len(),
1377            MAX_REGEX_PATTERN_LEN
1378        )));
1379        return;
1380    }
1381
1382    match regex_cache.parse(regex) {
1383        Ok(ast) => validate_regex_complexity(kind, index, ast, issues),
1384        Err(error) => issues.push(QualityIssue::Error(format!(
1385            "{kind} {index} regex does not compile: {error}"
1386        ))),
1387    }
1388}
1389
1390mod regex_ast;
1391mod regex_complexity;
1392mod verify;
1393
1394use regex_ast::{
1395    ast_captures_len, has_literal_prefix, has_substantial_literal, is_pure_character_class,
1396    RegexAstCache,
1397};
1398use regex_complexity::validate_regex_complexity;
1399use verify::validate_verify_spec;