1#![allow(clippy::result_large_err)] use std::path::{Path, PathBuf};
6
7use rayon::prelude::*;
8use thiserror::Error;
9
10use super::{validate_detector, DetectorFile, DetectorSpec, QualityIssue};
11pub use crate::detector_file_io::{read_detector_toml_file, DETECTOR_TOML_FILE_BYTES};
12
13#[derive(Debug, Error)]
15#[allow(clippy::result_large_err)] pub enum SpecError {
17 #[error(
18 "failed to read detector path {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
19 )]
20 ReadFile {
21 path: String,
22 source: std::io::Error,
23 },
24 #[error(
25 "invalid TOML in detector {path}: {source}. Fix: repair the TOML syntax in the detector file"
26 )]
27 InvalidToml {
28 path: PathBuf,
29 source: toml::de::Error,
30 },
31 #[error(
32 "{failed_count} of {total} embedded detector(s) failed to parse, the binary \
33 baked in a CORRUPT detector set, so its recall is silently degraded. This is \
34 a build/source bug, not a runtime condition: the embedded corpus is compiled \
35 in and cannot have been edited at runtime. Offending detector(s):\n{detail}\n\
36 Fix: repair the named TOML(s) under `detectors/` (the toml error names the \
37 line/column) and rebuild keyhog so build.rs re-embeds a valid set."
38 )]
39 EmbeddedCorpusCorrupt {
40 failed_count: usize,
41 total: usize,
42 detail: String,
43 },
44 #[error(
45 "{failed_count} of {total} detector file(s) from {dir} failed to load, \
46 pass the quality gate, or exist at all, that is a partial detector \
47 corpus, so keyhog is refusing to scan without a complete detector \
48 corpus (a partial corpus silently drops recall). \
49 Offending detector(s):\n{detail}\nFix: repair the named TOML file(s) \
50 or add at least one valid `*.toml` detector spec, then rerun the scan."
51 )]
52 DetectorCorpusRejected {
53 dir: String,
54 failed_count: usize,
55 total: usize,
56 detail: String,
57 },
58}
59
60pub fn load_detectors(dir: &Path) -> Result<Vec<DetectorSpec>, SpecError> {
76 load_detectors_with_gate(dir, true)
77}
78
79pub(crate) fn load_detectors_with_gate(
95 dir: &Path,
96 enforce_gate: bool,
97) -> Result<Vec<DetectorSpec>, SpecError> {
98 let toml_paths = discover_detector_tomls(dir, enforce_gate)?;
99 let parsed = parse_detector_files(&toml_paths);
100 assemble_detector_load(dir, enforce_gate, toml_paths.len(), parsed)
101}
102
103fn discover_detector_tomls(dir: &Path, enforce_gate: bool) -> Result<Vec<PathBuf>, SpecError> {
104 let entries = std::fs::read_dir(dir).map_err(|e| SpecError::ReadFile {
105 path: dir.display().to_string(),
106 source: e,
107 })?;
108 let mut toml_paths = Vec::new();
109 for entry in entries {
110 let entry = entry.map_err(|e| SpecError::ReadFile {
111 path: format!("directory entry under {}", dir.display()),
112 source: e,
113 })?;
114 let path = entry.path();
115 if path.extension().is_some_and(|ext| ext == "toml") {
116 toml_paths.push(path);
117 }
118 }
119
120 if enforce_gate && toml_paths.is_empty() {
121 return Err(SpecError::DetectorCorpusRejected {
122 dir: dir.display().to_string(),
123 failed_count: 0,
124 total: 0,
125 detail:
126 " - no detector TOML files found; add at least one valid `*.toml` detector spec"
127 .to_string(),
128 });
129 }
130 Ok(toml_paths)
131}
132
133fn parse_detector_files(toml_paths: &[PathBuf]) -> Vec<ReadDetectorOutcome> {
134 toml_paths
135 .par_iter()
136 .map(|path| read_detector_file(path))
137 .collect()
138}
139
140fn assemble_detector_load(
141 dir: &Path,
142 enforce_gate: bool,
143 total: usize,
144 parsed: Vec<ReadDetectorOutcome>,
145) -> Result<Vec<DetectorSpec>, SpecError> {
146 let mut load_state = DetectorLoadState::default();
147 let mut detectors = Vec::with_capacity(parsed.len());
148
149 for outcome in parsed {
150 match outcome {
151 ReadDetectorOutcome::Loaded { path, spec } => {
152 if should_reject_detector(
153 &spec,
154 &path,
155 enforce_gate,
156 &mut load_state.gate_rejected,
157 &mut load_state.gate_errors,
158 &mut load_state.total_warnings,
159 ) {
160 continue;
161 }
162 detectors.push(*spec);
163 }
164 ReadDetectorOutcome::Skipped { message } => {
165 load_state.skipped += 1;
166 load_state.load_errors.push(message);
167 }
168 }
169 }
170
171 detectors.sort_by(|a, b| a.id.cmp(&b.id));
179 let mut duplicate_ids: Vec<&str> = detectors
180 .windows(2)
181 .filter(|w| w[0].id == w[1].id)
182 .map(|w| w[0].id.as_str())
183 .collect();
184 duplicate_ids.dedup();
185 if !duplicate_ids.is_empty() {
186 load_state.gate_rejected += duplicate_ids.len();
187 for id in duplicate_ids {
188 load_state.gate_errors.push(format!(
189 "duplicate detector id `{id}` (a later spec would shadow the earlier)"
190 ));
191 }
192 }
193
194 log_load_summary(&load_state);
195 if enforce_gate && load_state.has_failures() {
196 return Err(load_state.into_rejected_error(dir, total));
197 }
198 Ok(detectors)
199}
200
201#[derive(Default)]
202struct DetectorLoadState {
203 skipped: usize,
204 load_errors: Vec<String>,
205 gate_rejected: usize,
206 gate_errors: Vec<String>,
207 total_warnings: usize,
208}
209
210impl DetectorLoadState {
211 fn has_failures(&self) -> bool {
212 self.skipped > 0 || self.gate_rejected > 0
213 }
214
215 fn into_rejected_error(self, dir: &Path, total: usize) -> SpecError {
216 let mut details = self.load_errors;
217 details.extend(self.gate_errors);
218 let detail = details
219 .into_iter()
220 .map(|line| format!(" - {line}"))
221 .collect::<Vec<_>>()
222 .join("\n");
223 SpecError::DetectorCorpusRejected {
224 dir: dir.display().to_string(),
225 failed_count: self.skipped + self.gate_rejected,
226 total,
227 detail,
228 }
229 }
230}
231
232fn log_load_summary(state: &DetectorLoadState) {
233 if state.skipped > 0 {
234 let version_skew = state
239 .load_errors
240 .iter()
241 .filter(|error| error.contains("unknown field"))
242 .count();
243 let examples = state
244 .load_errors
245 .iter()
246 .take(3)
247 .map(String::as_str)
248 .collect::<Vec<_>>()
249 .join(" | ");
250 if version_skew > 0 {
251 tracing::warn!(
252 "skipped {} detector file(s); {} declare a field this keyhog version \
253 does not understand (the detector corpus is newer than the binary) - \
254 run `keyhog update`. Examples: {examples}",
255 state.skipped,
256 version_skew
257 );
258 } else {
259 tracing::warn!(
260 "skipped {} malformed/unreadable detector file(s) - run \
261 `keyhog detectors --detectors <DIR>` or -vv for the full list. \
262 Examples: {examples}",
263 state.skipped
264 );
265 }
266 }
267 if state.gate_rejected > 0 {
268 tracing::warn!(
273 "quality gate rejected {} detectors (see per-detector warnings above)",
274 state.gate_rejected
275 );
276 }
277 if state.total_warnings > 0 {
278 tracing::debug!("quality gate: {} advisory warnings", state.total_warnings);
289 }
290}
291
292enum ReadDetectorOutcome {
293 Loaded {
294 path: PathBuf,
295 spec: Box<DetectorSpec>,
296 },
297 Skipped {
298 message: String,
299 },
300}
301
302fn read_detector_file(path: &Path) -> ReadDetectorOutcome {
303 let contents = match read_detector_toml_file(path) {
304 Ok(contents) => contents,
305 Err(error) => {
306 let message = format!("failed to read {}: {}", path.display(), error);
312 tracing::debug!(
313 detector_path = %path.display(),
314 error = %error,
315 "skipping detector - unreadable file" );
317 return ReadDetectorOutcome::Skipped { message };
318 }
319 };
320
321 match toml::from_str::<DetectorFile>(&contents) {
322 Ok(file) => ReadDetectorOutcome::Loaded {
323 path: path.to_path_buf(),
324 spec: Box::new(file.detector),
325 },
326 Err(error) => {
327 let message = format!("failed to parse {}: {}", path.display(), error);
331 tracing::debug!(
332 detector_path = %path.display(),
333 error = %error,
334 "skipping detector - TOML parse failed" );
336 ReadDetectorOutcome::Skipped { message }
337 }
338 }
339}
340
341fn should_reject_detector(
342 spec: &DetectorSpec,
343 path: &Path,
344 enforce_gate: bool,
345 gate_rejected: &mut usize,
346 gate_errors: &mut Vec<String>,
347 total_warnings: &mut usize,
348) -> bool {
349 let mut has_errors = false;
350 let mut detector_errors = Vec::new();
351 for issue in validate_detector(spec) {
352 match issue {
353 QualityIssue::Warning(warning) => {
354 tracing::debug!(detector_path = %path.display(), "quality: {} - {}", spec.id, warning);
359 *total_warnings += 1;
360 }
361 QualityIssue::Error(error) => {
362 tracing::warn!(
367 detector_path = %path.display(),
368 "detector quality error: {}: {}",
369 spec.id,
370 error
371 );
372 detector_errors.push(format!("{}: {}: {}", path.display(), spec.id, error));
373 has_errors = true;
374 }
375 }
376 }
377
378 if has_errors && enforce_gate {
379 *gate_rejected += 1;
380 gate_errors.extend(detector_errors);
381 return true;
382 }
383
384 false
385}
386
387pub(crate) fn load_detectors_from_str(toml_str: &str) -> Result<Vec<DetectorSpec>, SpecError> {
392 let file: DetectorFile = toml::from_str(toml_str).map_err(|e| SpecError::InvalidToml {
393 path: PathBuf::from("<string>"),
394 source: e,
395 })?;
396 Ok(vec![file.detector])
397}