Skip to main content

keyhog_core/spec/
load.rs

1//! Detector loading pipeline: read TOML files and run the quality gate.
2
3#![allow(clippy::result_large_err)] // SpecError carries a 128-byte toml::de::Error; boxing it would be a breaking API change.
4
5use 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/// Errors returned while loading or validating detector specifications.
14#[derive(Debug, Error)]
15#[allow(clippy::result_large_err)] // SpecError variants include 128-byte toml::de::Error; boxing would be a breaking API change.
16pub 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
60/// Load all detector specs from a directory of TOML files.
61/// Runs the quality gate on each detector and fails closed if any detector
62/// cannot be read, parsed, or accepted by the gate.
63///
64/// # Examples
65///
66/// ```rust,no_run
67/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
68/// use keyhog_core::load_detectors;
69/// use std::path::Path;
70///
71/// let detectors = load_detectors(Path::new("detectors"))?;
72/// assert!(!detectors.is_empty());
73/// # Ok(()) }
74/// ```
75pub fn load_detectors(dir: &Path) -> Result<Vec<DetectorSpec>, SpecError> {
76    load_detectors_with_gate(dir, true)
77}
78
79/// Load detectors with optional quality gate enforcement.
80/// When `enforce_gate` is `true`, detector read/parse/quality errors reject
81/// the entire corpus instead of returning a partial detector set.
82///
83/// # Examples
84///
85/// ```ignore
86/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
87/// // Crate-internal hook for tests and CLI detector-cache owner code.
88/// use keyhog_core::spec::load::load_detectors_with_gate;
89/// use std::path::Path;
90///
91/// let _detectors = load_detectors_with_gate(Path::new("detectors"), true)?;
92/// # Ok(()) }
93/// ```
94pub(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    // Sort before the duplicate-id scan so identical ids are adjacent and one
172    // linear pass finds them. A detector id is a unique key, it selects the
173    // checksum validator, suppression rules, and finding attribution, so two
174    // detectors sharing an id silently shadow each other (the loser's
175    // patterns/companions never fire). Law 10: surface it, don't let it pass.
176    // Folded into the SAME gate as other corpus-integrity failures (fail closed
177    // under the gate, logged otherwise) rather than a bespoke rejection path.
178    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        // Aggregate into ONE actionable line instead of one warn! per file.
235        // An "unknown field" parse error means the detector TOML declares a
236        // field this binary's schema does not know, i.e. the corpus is newer
237        // than the binary; the fix is `keyhog update`, not editing the file.
238        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        // Law 10: quality-gate rejections are not silent. The per-detector
269        // causes are logged at warn! below; the aggregate is surfaced at
270        // the default level so operators see why the detector set would have
271        // been smaller than expected.
272        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        // Advisory (non-rejecting) quality warnings describe detector-AUTHORING
279        // nits on the already-validated, shipped detector set (e.g. "companion
280        // regex is a pure character class; ALLOWED because within_lines <= 5").
281        // They are build-time/authoring feedback, not an operator signal: the
282        // bundled detectors passed the gate, so re-announcing their advisories
283        // on every user command that loads detectors (`explain`, `detectors`,
284        // a custom `--detectors` dir) is noise that drowns out the real
285        // rejections above. Keep them at debug! (visible with `-vv` /
286        // RUST_LOG=keyhog=debug for authors); errors and gate REJECTIONS stay
287        // loud above (Law 10).
288        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            // LAW10: reporting-only; per-file detail stays at debug! (visible
307            // with -vv), while `log_load_summary` warns and gated loads reject.
308            // One warn! per skipped file floods stderr on a version-skewed or
309            // partly-broken corpus (dozens of near-identical lines before any
310            // finding), which is the opposite of actionable.
311            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" // LAW10: aggregate warning surfaces the skipped file count and examples; gated loads reject
316            );
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            // LAW10: reporting-only; per-file TOML detail stays at debug!,
328            // while the summary warns with examples and gated loads reject the
329            // incomplete corpus.
330            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" // LAW10: aggregate warning surfaces the skipped file count and examples; gated loads reject
335            );
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                // Advisory only - the detector still loads and scans. This is
355                // authoring feedback (see the aggregate at debug! in
356                // `log_load_summary`), so keep it at debug! to stay out of
357                // user-facing command output; errors below stay loud (Law 10).
358                tracing::debug!(detector_path = %path.display(), "quality: {} - {}", spec.id, warning);
359                *total_warnings += 1;
360            }
361            QualityIssue::Error(error) => {
362                // Law 10: a detector that fails the quality gate must not be
363                // silently loaded. The warning names the detector and the
364                // issue so the author can fix it; when enforce_gate is true
365                // the detector is rejected below.
366                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
387/// Load a set of detectors from a TOML string.
388///
389/// This is primarily used for dynamic detector injection and tests that need
390/// an in-memory detector corpus.
391pub(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}