1#![allow(clippy::result_large_err)] use std::path::{Path, PathBuf};
6
7use rayon::prelude::*;
8use thiserror::Error;
9
10use super::{
11 migrate_legacy_success_policies, validate_detector, DetectorCorpusManifest, DetectorFile,
12 DetectorSpec, QualityIssue, DETECTOR_CORPUS_MANIFEST_FILE,
13 DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION, DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
14 DETECTOR_CORPUS_SCHEMA_VERSION,
15};
16pub use crate::detector_file_io::{read_detector_toml_file, DETECTOR_TOML_FILE_BYTES};
17
18#[derive(Debug, Error)]
20#[allow(clippy::result_large_err)] pub enum SpecError {
22 #[error(
23 "failed to read detector path {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
24 )]
25 ReadFile {
26 path: String,
27 source: std::io::Error,
28 },
29 #[error(
30 "invalid TOML in detector {path}: {source}. Fix: repair the TOML syntax in the detector file"
31 )]
32 InvalidToml {
33 path: PathBuf,
34 source: toml::de::Error,
35 },
36 #[error(
37 "invalid detector corpus manifest {path}: {source}. Fix: set `schema_version` \
38 to an integer supported by this keyhog binary and remove misspelled manifest fields"
39 )]
40 InvalidCorpusManifest {
41 path: PathBuf,
42 source: toml::de::Error,
43 },
44 #[error(
45 "unsupported detector corpus schema {found} declared by {path}; this binary \
46 supports schema {current} and bounded forward compatibility through schema \
47 {max_forward}. Fix: use a compatible detector corpus or update keyhog"
48 )]
49 UnsupportedCorpusSchema {
50 path: PathBuf,
51 found: u32,
52 current: u32,
53 max_forward: u32,
54 },
55 #[error(
56 "detector corpus {dir} declares supported forward schema {declared_schema}, \
57 while this binary owns schema {supported_schema}; {skipped_count} of {total} \
58 detector file(s) use fields this binary cannot interpret. Keyhog refuses to \
59 scan under newer parsing semantics or with a partial corpus because either \
60 would invalidate corpus identity and could silently drop recall. \
61 Compatibility detail:\n{detail}\nFix: update keyhog to load the complete \
62 detector corpus"
63 )]
64 ForwardIncompatibleCorpus {
65 dir: String,
66 declared_schema: u32,
67 supported_schema: u32,
68 skipped_count: usize,
69 total: usize,
70 detail: String,
71 },
72 #[error(
73 "{failed_count} of {total} embedded detector(s) failed to parse, the binary \
74 baked in a CORRUPT detector set, so its recall is silently degraded. This is \
75 a build/source bug, not a runtime condition: the embedded corpus is compiled \
76 in and cannot have been edited at runtime. Offending detector(s):\n{detail}\n\
77 Fix: repair the named TOML(s) under `detectors/` (the toml error names the \
78 line/column) and rebuild keyhog so build.rs re-embeds a valid set."
79 )]
80 EmbeddedCorpusCorrupt {
81 failed_count: usize,
82 total: usize,
83 detail: String,
84 },
85 #[error(
86 "{failed_count} of {total} detector file(s) from {dir} failed to load, \
87 pass the quality gate, or exist at all, that is a partial detector \
88 corpus, so keyhog is refusing to scan without a complete detector \
89 corpus (a partial corpus silently drops recall). \
90 Offending detector(s):\n{detail}\nFix: repair the named TOML file(s) \
91 or add at least one valid `*.toml` detector spec, then rerun the scan."
92 )]
93 DetectorCorpusRejected {
94 dir: String,
95 failed_count: usize,
96 total: usize,
97 detail: String,
98 },
99}
100
101#[derive(Debug)]
109pub struct LoadedDetectorCorpus {
110 pub specs: Vec<DetectorSpec>,
112 pub schema_version: u32,
114}
115
116impl LoadedDetectorCorpus {
117 pub fn compute_digest(&self) -> Result<[u8; 32], serde_json::Error> {
119 crate::compute_detector_corpus_digest_for_schema(&self.specs, self.schema_version)
120 }
121}
122
123pub fn load_detectors(dir: &Path) -> Result<Vec<DetectorSpec>, SpecError> {
139 Ok(load_detector_corpus(dir)?.specs)
140}
141
142pub fn load_detector_corpus(dir: &Path) -> Result<LoadedDetectorCorpus, SpecError> {
145 load_detector_corpus_with_gate(dir, true)
146}
147
148#[derive(Clone, Copy)]
164struct CorpusCompatibility {
165 schema_version: u32,
166 permits_forward_unknown_fields: bool,
167}
168
169pub(crate) fn load_detectors_with_gate(
170 dir: &Path,
171 enforce_gate: bool,
172) -> Result<Vec<DetectorSpec>, SpecError> {
173 Ok(load_detector_corpus_with_gate(dir, enforce_gate)?.specs)
174}
175
176fn load_detector_corpus_with_gate(
177 dir: &Path,
178 enforce_gate: bool,
179) -> Result<LoadedDetectorCorpus, SpecError> {
180 let compatibility = read_corpus_compatibility(dir)?;
181 let toml_paths = discover_detector_tomls(dir, enforce_gate)?;
182 let parsed = parse_detector_files(&toml_paths, compatibility);
183 let specs = assemble_detector_load(dir, enforce_gate, compatibility, toml_paths.len(), parsed)?;
184 Ok(LoadedDetectorCorpus {
185 specs,
186 schema_version: compatibility.schema_version,
187 })
188}
189
190fn read_corpus_compatibility(dir: &Path) -> Result<CorpusCompatibility, SpecError> {
191 let path = dir.join(DETECTOR_CORPUS_MANIFEST_FILE);
192 let contents = match std::fs::read_to_string(&path) {
193 Ok(contents) => contents,
194 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
195 return Ok(CorpusCompatibility {
196 schema_version: DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
197 permits_forward_unknown_fields: false,
198 });
199 }
200 Err(source) => {
201 return Err(SpecError::ReadFile {
202 path: path.display().to_string(),
203 source,
204 });
205 }
206 };
207 let manifest: DetectorCorpusManifest =
208 toml::from_str(&contents).map_err(|source| SpecError::InvalidCorpusManifest {
209 path: path.clone(),
210 source,
211 })?;
212 if !(DETECTOR_CORPUS_MIN_SCHEMA_VERSION..=DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION)
213 .contains(&manifest.schema_version)
214 {
215 return Err(SpecError::UnsupportedCorpusSchema {
216 path,
217 found: manifest.schema_version,
218 current: DETECTOR_CORPUS_SCHEMA_VERSION,
219 max_forward: DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION,
220 });
221 }
222 Ok(CorpusCompatibility {
223 schema_version: manifest.schema_version,
224 permits_forward_unknown_fields: manifest.schema_version > DETECTOR_CORPUS_SCHEMA_VERSION,
225 })
226}
227
228fn discover_detector_tomls(dir: &Path, enforce_gate: bool) -> Result<Vec<PathBuf>, SpecError> {
229 let entries = std::fs::read_dir(dir).map_err(|e| SpecError::ReadFile {
230 path: dir.display().to_string(),
231 source: e,
232 })?;
233 let mut toml_paths = Vec::new();
234 for entry in entries {
235 let entry = entry.map_err(|e| SpecError::ReadFile {
236 path: format!("directory entry under {}", dir.display()),
237 source: e,
238 })?;
239 let path = entry.path();
240 if path.extension().is_some_and(|ext| ext == "toml")
241 && path
242 .file_name()
243 .is_none_or(|name| name != DETECTOR_CORPUS_MANIFEST_FILE)
244 {
245 toml_paths.push(path);
246 }
247 }
248
249 if enforce_gate && toml_paths.is_empty() {
250 return Err(SpecError::DetectorCorpusRejected {
251 dir: dir.display().to_string(),
252 failed_count: 0,
253 total: 0,
254 detail:
255 " - no detector TOML files found; add at least one valid `*.toml` detector spec"
256 .to_string(),
257 });
258 }
259 Ok(toml_paths)
260}
261
262fn parse_detector_files(
263 toml_paths: &[PathBuf],
264 compatibility: CorpusCompatibility,
265) -> Vec<ReadDetectorOutcome> {
266 toml_paths
267 .par_iter()
268 .map(|path| read_detector_file(path, compatibility))
269 .collect()
270}
271
272fn assemble_detector_load(
273 dir: &Path,
274 enforce_gate: bool,
275 compatibility: CorpusCompatibility,
276 total: usize,
277 parsed: Vec<ReadDetectorOutcome>,
278) -> Result<Vec<DetectorSpec>, SpecError> {
279 let mut load_state = DetectorLoadState::default();
280 let mut detectors = Vec::with_capacity(parsed.len());
281
282 for outcome in parsed {
283 match outcome {
284 ReadDetectorOutcome::Loaded {
285 path,
286 spec,
287 legacy_migrations,
288 } => {
289 load_state.legacy_migrations += legacy_migrations;
290 if should_reject_detector(
291 &spec,
292 &path,
293 enforce_gate,
294 &mut load_state.gate_rejected,
295 &mut load_state.gate_errors,
296 &mut load_state.total_warnings,
297 ) {
298 continue;
299 }
300 detectors.push(*spec);
301 }
302 ReadDetectorOutcome::ForwardSkipped { message } => {
303 load_state.forward_skipped += 1;
304 load_state.forward_errors.push(message);
305 }
306 ReadDetectorOutcome::Skipped { message } => {
307 load_state.skipped += 1;
308 load_state.load_errors.push(message);
309 }
310 }
311 }
312
313 detectors.sort_by(|a, b| a.id.cmp(&b.id));
321 let mut duplicate_ids: Vec<&str> = detectors
322 .windows(2)
323 .filter(|w| w[0].id == w[1].id)
324 .map(|w| w[0].id.as_str())
325 .collect();
326 duplicate_ids.dedup();
327 if !duplicate_ids.is_empty() {
328 load_state.gate_rejected += duplicate_ids.len();
329 for id in duplicate_ids {
330 load_state.gate_errors.push(format!(
331 "duplicate detector id `{id}` (a later spec would shadow the earlier)"
332 ));
333 }
334 }
335
336 log_load_summary(&load_state);
337 if enforce_gate && compatibility.permits_forward_unknown_fields {
338 return Err(load_state.into_forward_error(dir, total, compatibility.schema_version));
339 }
340 if enforce_gate && load_state.has_failures() {
341 return Err(load_state.into_rejected_error(dir, total));
342 }
343 Ok(detectors)
344}
345
346#[derive(Default)]
347struct DetectorLoadState {
348 skipped: usize,
349 load_errors: Vec<String>,
350 forward_skipped: usize,
351 forward_errors: Vec<String>,
352 legacy_migrations: usize,
353 gate_rejected: usize,
354 gate_errors: Vec<String>,
355 total_warnings: usize,
356}
357
358impl DetectorLoadState {
359 fn has_failures(&self) -> bool {
360 self.skipped > 0 || self.forward_skipped > 0 || self.gate_rejected > 0
361 }
362
363 fn into_rejected_error(self, dir: &Path, total: usize) -> SpecError {
364 let mut details = self.load_errors;
365 details.extend(self.gate_errors);
366 let detail = details
367 .into_iter()
368 .map(|line| format!(" - {line}"))
369 .collect::<Vec<_>>()
370 .join("\n");
371 SpecError::DetectorCorpusRejected {
372 dir: dir.display().to_string(),
373 failed_count: self.skipped + self.gate_rejected,
374 total,
375 detail,
376 }
377 }
378 fn into_forward_error(self, dir: &Path, total: usize, declared_schema: u32) -> SpecError {
379 let detail = if self.forward_errors.is_empty() {
380 format!(
381 " - {} declares schema {}; schema metadata is part of effective \
382 corpus identity and cannot be interpreted as schema {}",
383 dir.join(DETECTOR_CORPUS_MANIFEST_FILE).display(),
384 declared_schema,
385 DETECTOR_CORPUS_SCHEMA_VERSION
386 )
387 } else {
388 self.forward_errors
389 .into_iter()
390 .map(|line| format!(" - {line}"))
391 .collect::<Vec<_>>()
392 .join("\n")
393 };
394 SpecError::ForwardIncompatibleCorpus {
395 dir: dir.display().to_string(),
396 declared_schema,
397 supported_schema: DETECTOR_CORPUS_SCHEMA_VERSION,
398 skipped_count: self.forward_skipped,
399 total,
400 detail,
401 }
402 }
403}
404
405fn log_load_summary(state: &DetectorLoadState) {
406 if state.skipped > 0 {
407 let version_skew = state
412 .load_errors
413 .iter()
414 .filter(|error| error.contains("unknown field"))
415 .count();
416 let examples = state
417 .load_errors
418 .iter()
419 .take(3)
420 .map(String::as_str)
421 .collect::<Vec<_>>()
422 .join(" | ");
423 if version_skew > 0 {
424 tracing::warn!(
425 "skipped {} detector file(s); {} contain unknown fields while the corpus \
426 is using the current/legacy strict schema. Fix field typos, or add a \
427 supported newer `{}` declaration when the fields are intentional. \
428 Examples: {examples}",
429 state.skipped,
430 version_skew,
431 DETECTOR_CORPUS_MANIFEST_FILE
432 );
433 } else {
434 tracing::warn!(
435 "skipped {} malformed/unreadable detector file(s) - run \
436 `keyhog detectors --detectors <DIR>` or -vv for the full list. \
437 Examples: {examples}",
438 state.skipped
439 );
440 }
441 }
442 if state.forward_skipped > 0 {
443 let examples = state
444 .forward_errors
445 .iter()
446 .take(3)
447 .map(String::as_str)
448 .collect::<Vec<_>>()
449 .join(" | ");
450 tracing::warn!(
451 "detector corpus declared a supported forward schema; skipped {} detector \
452 file(s) that use newer fields rather than silently dropping those fields. \
453 Update keyhog for full recall. Examples: {examples}",
454 state.forward_skipped
455 );
456 }
457 if state.legacy_migrations > 0 {
458 tracing::warn!(
459 "migrated {} legacy schema-{} verifier success contract(s) to \
460 status_with_error_backstop; add an explicit success policy and a \
461 schema-{} corpus manifest",
462 state.legacy_migrations,
463 DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
464 DETECTOR_CORPUS_SCHEMA_VERSION
465 );
466 }
467 if state.gate_rejected > 0 {
468 tracing::warn!(
473 "quality gate rejected {} detectors (see per-detector warnings above)",
474 state.gate_rejected
475 );
476 }
477 if state.total_warnings > 0 {
478 tracing::debug!("quality gate: {} advisory warnings", state.total_warnings);
489 }
490}
491
492enum ReadDetectorOutcome {
493 Loaded {
494 path: PathBuf,
495 spec: Box<DetectorSpec>,
496 legacy_migrations: usize,
497 },
498 ForwardSkipped {
499 message: String,
500 },
501 Skipped {
502 message: String,
503 },
504}
505
506fn read_detector_file(path: &Path, compatibility: CorpusCompatibility) -> ReadDetectorOutcome {
507 let contents = match read_detector_toml_file(path) {
508 Ok(contents) => contents,
509 Err(error) => {
510 let message = format!("failed to read {}: {}", path.display(), error);
516 tracing::debug!(
517 detector_path = %path.display(),
518 error = %error,
519 "skipping detector - unreadable file" );
521 return ReadDetectorOutcome::Skipped { message };
522 }
523 };
524
525 match toml::from_str::<DetectorFile>(&contents) {
526 Ok(mut file) => {
527 let legacy_migrations =
528 if compatibility.schema_version == DETECTOR_CORPUS_MIN_SCHEMA_VERSION {
529 migrate_legacy_success_policies(&mut file.detector)
530 } else {
531 0
532 };
533 ReadDetectorOutcome::Loaded {
534 path: path.to_path_buf(),
535 spec: Box::new(file.detector),
536 legacy_migrations,
537 }
538 }
539 Err(error) => {
540 let unknown_field = error.to_string().contains("unknown field");
541 if compatibility.permits_forward_unknown_fields && unknown_field {
542 let message = format!(
543 "skipped {} under declared detector corpus schema {} because it uses \
544 a field unknown to schema {}: {}. Fix: update keyhog to load this detector",
545 path.display(),
546 compatibility.schema_version,
547 DETECTOR_CORPUS_SCHEMA_VERSION,
548 error
549 );
550 tracing::warn!(
551 detector_path = %path.display(),
552 declared_schema = compatibility.schema_version,
553 supported_schema = DETECTOR_CORPUS_SCHEMA_VERSION,
554 error = %error,
555 "skipping forward-schema detector without dropping unknown fields"
556 );
557 return ReadDetectorOutcome::ForwardSkipped { message };
558 }
559 let message = format!(
560 "failed to parse {} under detector corpus schema {}: {}. Fix: correct \
561 misspelled or invalid detector fields; only a corpus manifest declaring \
562 a supported newer schema permits an unknown future field",
563 path.display(),
564 compatibility.schema_version,
565 error
566 );
567 tracing::debug!(
570 detector_path = %path.display(),
571 schema_version = compatibility.schema_version,
572 error = %error,
573 "skipping detector - TOML parse failed"
574 );
575 ReadDetectorOutcome::Skipped { message }
576 }
577 }
578}
579
580fn should_reject_detector(
581 spec: &DetectorSpec,
582 path: &Path,
583 enforce_gate: bool,
584 gate_rejected: &mut usize,
585 gate_errors: &mut Vec<String>,
586 total_warnings: &mut usize,
587) -> bool {
588 let mut has_errors = false;
589 let mut detector_errors = Vec::new();
590 for issue in validate_detector(spec) {
591 match issue {
592 QualityIssue::Warning(warning) => {
593 tracing::debug!(detector_path = %path.display(), "quality: {} - {}", spec.id, warning);
598 *total_warnings += 1;
599 }
600 QualityIssue::Error(error) => {
601 tracing::warn!(
606 detector_path = %path.display(),
607 "detector quality error: {}: {}",
608 spec.id,
609 error
610 );
611 detector_errors.push(format!("{}: {}: {}", path.display(), spec.id, error));
612 has_errors = true;
613 }
614 }
615 }
616
617 if has_errors && enforce_gate {
618 *gate_rejected += 1;
619 gate_errors.extend(detector_errors);
620 return true;
621 }
622
623 false
624}
625
626pub(crate) fn load_detectors_from_str(toml_str: &str) -> Result<Vec<DetectorSpec>, SpecError> {
631 let file: DetectorFile = toml::from_str(toml_str).map_err(|e| SpecError::InvalidToml {
632 path: PathBuf::from("<string>"),
633 source: e,
634 })?;
635 Ok(vec![file.detector])
636}