#![allow(clippy::result_large_err)]
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use thiserror::Error;
use super::{validate_detector, DetectorFile, DetectorSpec, QualityIssue};
pub use crate::detector_file_io::{read_detector_toml_file, DETECTOR_TOML_FILE_BYTES};
#[derive(Debug, Error)]
#[allow(clippy::result_large_err)] pub enum SpecError {
#[error(
"failed to read detector path {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
)]
ReadFile {
path: String,
source: std::io::Error,
},
#[error(
"invalid TOML in detector {path}: {source}. Fix: repair the TOML syntax in the detector file"
)]
InvalidToml {
path: PathBuf,
source: toml::de::Error,
},
#[error(
"{failed_count} of {total} embedded detector(s) failed to parse, the binary \
baked in a CORRUPT detector set, so its recall is silently degraded. This is \
a build/source bug, not a runtime condition: the embedded corpus is compiled \
in and cannot have been edited at runtime. Offending detector(s):\n{detail}\n\
Fix: repair the named TOML(s) under `detectors/` (the toml error names the \
line/column) and rebuild keyhog so build.rs re-embeds a valid set."
)]
EmbeddedCorpusCorrupt {
failed_count: usize,
total: usize,
detail: String,
},
#[error(
"{failed_count} of {total} detector file(s) from {dir} failed to load, \
pass the quality gate, or exist at all, that is a partial detector \
corpus, so keyhog is refusing to scan without a complete detector \
corpus (a partial corpus silently drops recall). \
Offending detector(s):\n{detail}\nFix: repair the named TOML file(s) \
or add at least one valid `*.toml` detector spec, then rerun the scan."
)]
DetectorCorpusRejected {
dir: String,
failed_count: usize,
total: usize,
detail: String,
},
}
pub fn load_detectors(dir: &Path) -> Result<Vec<DetectorSpec>, SpecError> {
load_detectors_with_gate(dir, true)
}
pub(crate) fn load_detectors_with_gate(
dir: &Path,
enforce_gate: bool,
) -> Result<Vec<DetectorSpec>, SpecError> {
let toml_paths = discover_detector_tomls(dir, enforce_gate)?;
let parsed = parse_detector_files(&toml_paths);
assemble_detector_load(dir, enforce_gate, toml_paths.len(), parsed)
}
fn discover_detector_tomls(dir: &Path, enforce_gate: bool) -> Result<Vec<PathBuf>, SpecError> {
let entries = std::fs::read_dir(dir).map_err(|e| SpecError::ReadFile {
path: dir.display().to_string(),
source: e,
})?;
let mut toml_paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| SpecError::ReadFile {
path: format!("directory entry under {}", dir.display()),
source: e,
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
toml_paths.push(path);
}
}
if enforce_gate && toml_paths.is_empty() {
return Err(SpecError::DetectorCorpusRejected {
dir: dir.display().to_string(),
failed_count: 0,
total: 0,
detail:
" - no detector TOML files found; add at least one valid `*.toml` detector spec"
.to_string(),
});
}
Ok(toml_paths)
}
fn parse_detector_files(toml_paths: &[PathBuf]) -> Vec<ReadDetectorOutcome> {
toml_paths
.par_iter()
.map(|path| read_detector_file(path))
.collect()
}
fn assemble_detector_load(
dir: &Path,
enforce_gate: bool,
total: usize,
parsed: Vec<ReadDetectorOutcome>,
) -> Result<Vec<DetectorSpec>, SpecError> {
let mut load_state = DetectorLoadState::default();
let mut detectors = Vec::with_capacity(parsed.len());
for outcome in parsed {
match outcome {
ReadDetectorOutcome::Loaded { path, spec } => {
if should_reject_detector(
&spec,
&path,
enforce_gate,
&mut load_state.gate_rejected,
&mut load_state.gate_errors,
&mut load_state.total_warnings,
) {
continue;
}
detectors.push(*spec);
}
ReadDetectorOutcome::Skipped { message } => {
load_state.skipped += 1;
load_state.load_errors.push(message);
}
}
}
detectors.sort_by(|a, b| a.id.cmp(&b.id));
let mut duplicate_ids: Vec<&str> = detectors
.windows(2)
.filter(|w| w[0].id == w[1].id)
.map(|w| w[0].id.as_str())
.collect();
duplicate_ids.dedup();
if !duplicate_ids.is_empty() {
load_state.gate_rejected += duplicate_ids.len();
for id in duplicate_ids {
load_state.gate_errors.push(format!(
"duplicate detector id `{id}` (a later spec would shadow the earlier)"
));
}
}
log_load_summary(&load_state);
if enforce_gate && load_state.has_failures() {
return Err(load_state.into_rejected_error(dir, total));
}
Ok(detectors)
}
#[derive(Default)]
struct DetectorLoadState {
skipped: usize,
load_errors: Vec<String>,
gate_rejected: usize,
gate_errors: Vec<String>,
total_warnings: usize,
}
impl DetectorLoadState {
fn has_failures(&self) -> bool {
self.skipped > 0 || self.gate_rejected > 0
}
fn into_rejected_error(self, dir: &Path, total: usize) -> SpecError {
let mut details = self.load_errors;
details.extend(self.gate_errors);
let detail = details
.into_iter()
.map(|line| format!(" - {line}"))
.collect::<Vec<_>>()
.join("\n");
SpecError::DetectorCorpusRejected {
dir: dir.display().to_string(),
failed_count: self.skipped + self.gate_rejected,
total,
detail,
}
}
}
fn log_load_summary(state: &DetectorLoadState) {
if state.skipped > 0 {
let version_skew = state
.load_errors
.iter()
.filter(|error| error.contains("unknown field"))
.count();
let examples = state
.load_errors
.iter()
.take(3)
.map(String::as_str)
.collect::<Vec<_>>()
.join(" | ");
if version_skew > 0 {
tracing::warn!(
"skipped {} detector file(s); {} declare a field this keyhog version \
does not understand (the detector corpus is newer than the binary) - \
run `keyhog update`. Examples: {examples}",
state.skipped,
version_skew
);
} else {
tracing::warn!(
"skipped {} malformed/unreadable detector file(s) - run \
`keyhog detectors --detectors <DIR>` or -vv for the full list. \
Examples: {examples}",
state.skipped
);
}
}
if state.gate_rejected > 0 {
tracing::warn!(
"quality gate rejected {} detectors (see per-detector warnings above)",
state.gate_rejected
);
}
if state.total_warnings > 0 {
tracing::debug!("quality gate: {} advisory warnings", state.total_warnings);
}
}
enum ReadDetectorOutcome {
Loaded {
path: PathBuf,
spec: Box<DetectorSpec>,
},
Skipped {
message: String,
},
}
fn read_detector_file(path: &Path) -> ReadDetectorOutcome {
let contents = match read_detector_toml_file(path) {
Ok(contents) => contents,
Err(error) => {
let message = format!("failed to read {}: {}", path.display(), error);
tracing::debug!(
detector_path = %path.display(),
error = %error,
"skipping detector - unreadable file" );
return ReadDetectorOutcome::Skipped { message };
}
};
match toml::from_str::<DetectorFile>(&contents) {
Ok(file) => ReadDetectorOutcome::Loaded {
path: path.to_path_buf(),
spec: Box::new(file.detector),
},
Err(error) => {
let message = format!("failed to parse {}: {}", path.display(), error);
tracing::debug!(
detector_path = %path.display(),
error = %error,
"skipping detector - TOML parse failed" );
ReadDetectorOutcome::Skipped { message }
}
}
}
fn should_reject_detector(
spec: &DetectorSpec,
path: &Path,
enforce_gate: bool,
gate_rejected: &mut usize,
gate_errors: &mut Vec<String>,
total_warnings: &mut usize,
) -> bool {
let mut has_errors = false;
let mut detector_errors = Vec::new();
for issue in validate_detector(spec) {
match issue {
QualityIssue::Warning(warning) => {
tracing::debug!(detector_path = %path.display(), "quality: {} - {}", spec.id, warning);
*total_warnings += 1;
}
QualityIssue::Error(error) => {
tracing::warn!(
detector_path = %path.display(),
"detector quality error: {}: {}",
spec.id,
error
);
detector_errors.push(format!("{}: {}: {}", path.display(), spec.id, error));
has_errors = true;
}
}
}
if has_errors && enforce_gate {
*gate_rejected += 1;
gate_errors.extend(detector_errors);
return true;
}
false
}
pub(crate) fn load_detectors_from_str(toml_str: &str) -> Result<Vec<DetectorSpec>, SpecError> {
let file: DetectorFile = toml::from_str(toml_str).map_err(|e| SpecError::InvalidToml {
path: PathBuf::from("<string>"),
source: e,
})?;
Ok(vec![file.detector])
}