1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub enum QualityIssue {
34 Error(String),
36 Warning(String),
38}
39
40pub 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
393fn 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 DetectorKind::Regex => {
1113 if spec.patterns.is_empty() {
1114 issues.push(QualityIssue::Error("no patterns defined".into()));
1115 }
1116 }
1117 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; };
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 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 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
1335const 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 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;