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