use anyhow::{Context, Result};
use keyhog_core::{
load_detector_corpus, load_detectors, validate_detector, DetectorSpec, QualityIssue,
};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::io::Read;
use std::path::{Path, PathBuf};
const DETECTOR_CACHE_VERSION: u32 = 4;
const DETECTOR_CACHE_FILE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Serialize, Deserialize)]
struct DetectorCacheFile {
version: u32,
source_fingerprint: String,
schema_version: u32,
detectors: Vec<DetectorSpec>,
}
#[derive(Debug, Clone)]
pub(crate) struct DetectorCorpusProvenance {
pub(crate) mode: &'static str,
pub(crate) source: String,
pub(crate) embedded_count: usize,
pub(crate) custom_count: usize,
}
#[derive(Debug)]
pub(crate) struct LoadedDetectorCorpus {
pub(crate) detectors: Vec<DetectorSpec>,
pub(crate) schema_version: u32,
pub(crate) provenance: DetectorCorpusProvenance,
}
pub(crate) fn auto_discover_detectors(path: &Path) -> Result<PathBuf> {
if path != Path::new("detectors") {
return Ok(path.to_path_buf());
}
if path == Path::new("detectors") && !path.exists() {
let mut default_dirs: Vec<Option<PathBuf>> = vec![
dirs::home_dir().map(|h| h.join(".keyhog/detectors")),
dirs::data_dir().map(|d| d.join("keyhog/detectors")),
dirs::data_local_dir().map(|d| d.join("keyhog/detectors")),
];
if cfg!(unix) {
default_dirs.push(Some(PathBuf::from("/usr/share/keyhog/detectors")));
default_dirs.push(Some(PathBuf::from("/usr/local/share/keyhog/detectors")));
}
default_dirs.push(
std::env::current_exe()
.ok() .and_then(|p| p.parent().map(|p| p.join("detectors"))),
);
for dir in default_dirs.into_iter().flatten() {
if dir.exists() && dir.is_dir() {
tracing::info!(detectors_dir = %dir.display(), "auto-detected detectors directory");
return Ok(dir);
}
}
}
Ok(path.to_path_buf())
}
pub(crate) fn validate_explicit_detector_path(path: &Path, explicit: bool) -> Result<()> {
if explicit && !path.exists() {
anyhow::bail!(
"explicit detectors directory '{}' does not exist. \
Fix: pass an existing detector directory, or omit --detectors to \
search installed detector locations and then use the embedded \
corpus when none is installed.",
path.display()
);
}
Ok(())
}
pub(crate) fn validate_detector_mode_selection(
custom_path_explicit: bool,
mode: Option<keyhog_core::DetectorCorpusMode>,
) -> Result<()> {
if mode.is_some() && !custom_path_explicit {
anyhow::bail!(
"--detectors-mode requires a custom corpus selected by --detectors \
or `detectors` in .keyhog.toml. Fix: select the reviewed directory, \
or omit --detectors-mode to retain default detector discovery."
);
}
Ok(())
}
fn load_detector_corpus_with_cache(path: &Path) -> Result<keyhog_core::LoadedDetectorCorpus> {
validate_detector_path_for_scan(path)?;
if path.exists() && path.is_dir() {
let cache_path = detector_cache_path(path);
if let Some(cache_path) = &cache_path {
let loaded = load_detectors_from_dir_with_cache(path, cache_path)
.context("loading detectors from directory with parse cache")?;
require_non_empty_detectors(&loaded.specs, path)?;
return Ok(loaded);
}
let loaded = load_detector_corpus(path)?;
require_non_empty_detectors(&loaded.specs, path)?;
return Ok(loaded);
}
let specs = load_detectors_embedded_or_fail(path)?;
Ok(keyhog_core::LoadedDetectorCorpus {
specs,
schema_version: keyhog_core::DETECTOR_CORPUS_SCHEMA_VERSION,
})
}
pub(crate) fn load_effective_detector_corpus(
path: &Path,
requested_mode: Option<keyhog_core::DetectorCorpusMode>,
use_cache: bool,
) -> Result<LoadedDetectorCorpus> {
validate_detector_path_for_scan(path)?;
if !path.exists() {
let detectors = load_detectors_embedded_or_fail(path)?;
let embedded_count = detectors.len();
return Ok(LoadedDetectorCorpus {
detectors,
schema_version: keyhog_core::DETECTOR_CORPUS_SCHEMA_VERSION,
provenance: DetectorCorpusProvenance {
mode: "embedded",
source: "embedded".to_string(),
embedded_count,
custom_count: 0,
},
});
}
let custom = if use_cache {
load_detector_corpus_with_cache(path)?
} else {
load_detector_corpus_no_cache(path)?
};
let custom_count = custom.specs.len();
let schema_version = custom.schema_version;
let custom_specs = custom.specs;
let mode = match requested_mode {
Some(mode) => mode,
None => keyhog_core::DetectorCorpusMode::Replace,
};
match mode {
keyhog_core::DetectorCorpusMode::Replace => Ok(LoadedDetectorCorpus {
detectors: custom_specs,
schema_version,
provenance: DetectorCorpusProvenance {
mode: "replace",
source: path.display().to_string(),
embedded_count: 0,
custom_count,
},
}),
keyhog_core::DetectorCorpusMode::Overlay => {
let embedded = keyhog_core::load_embedded_detectors_or_fail()
.context("loading embedded detectors for overlay")?;
let embedded_count = embedded.len();
let detectors = keyhog_core::compose_detector_corpus(embedded, custom_specs, mode)
.context("composing detector overlay")?;
Ok(LoadedDetectorCorpus {
detectors,
schema_version,
provenance: DetectorCorpusProvenance {
mode: "overlay",
source: format!("embedded+{}", path.display()),
embedded_count,
custom_count,
},
})
}
}
}
fn load_detectors_from_dir_with_cache(
source_dir: &Path,
cache_path: &Path,
) -> Result<keyhog_core::LoadedDetectorCorpus> {
if let Some(cached) = load_detector_cache(cache_path, source_dir) {
return Ok(cached);
}
let loaded = load_detector_corpus(source_dir).map_err(anyhow::Error::from)?;
if loaded.specs.is_empty() {
return Ok(loaded);
}
let source_fingerprint = match detector_source_fingerprint(source_dir) {
Ok(fingerprint) => fingerprint,
Err(error) => {
tracing::warn!(
source_dir = %source_dir.display(),
%error,
"detector source changed after load; parse cache not written"
);
return Ok(loaded);
}
};
if let Err(error) = save_detector_cache(&loaded, cache_path, source_fingerprint) {
tracing::debug!(
cache_path = %cache_path.display(),
%error,
"detector parse cache not written; re-parsing TOML on the next run"
);
}
Ok(loaded)
}
fn save_detector_cache(
corpus: &keyhog_core::LoadedDetectorCorpus,
cache_path: &Path,
source_fingerprint: String,
) -> std::io::Result<()> {
for detector in &corpus.specs {
let issues = validate_detector(detector);
if issues
.iter()
.any(|issue| matches!(issue, QualityIssue::Error(_)))
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"refusing to cache invalid detector '{}'. Fix: repair the detector before writing the cache",
detector.id
),
));
}
}
let json = serde_json::to_vec(&DetectorCacheFile {
version: DETECTOR_CACHE_VERSION,
source_fingerprint,
schema_version: corpus.schema_version,
detectors: corpus.specs.clone(),
})?;
crate::atomic_file::write_bytes(cache_path, &json)
}
fn load_detector_cache(
cache_path: &Path,
source_dir: &Path,
) -> Option<keyhog_core::LoadedDetectorCorpus> {
let source_fingerprint = match detector_source_fingerprint(source_dir) {
Ok(fingerprint) => fingerprint,
Err(error) => {
tracing::warn!(
source_dir = %source_dir.display(),
%error,
"cannot fingerprint detector source directory; ignoring detector cache and re-parsing TOML"
);
return None;
}
};
let data = match read_detector_cache_file(cache_path) {
Ok(data) => data,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => {
tracing::warn!(
"failed to read detector cache {}: {}",
cache_path.display(),
error
);
return None;
}
};
let cache: DetectorCacheFile = match serde_json::from_slice(&data) {
Ok(cache) => cache,
Err(error) => {
tracing::warn!(
"failed to parse detector cache {}: {}",
cache_path.display(),
error
);
return None;
}
};
if cache.version != DETECTOR_CACHE_VERSION {
return None;
}
if cache.source_fingerprint != source_fingerprint {
return None;
}
if !(keyhog_core::DETECTOR_CORPUS_MIN_SCHEMA_VERSION
..=keyhog_core::DETECTOR_CORPUS_SCHEMA_VERSION)
.contains(&cache.schema_version)
{
return None;
}
let schema_version = cache.schema_version;
let mut validated = Vec::with_capacity(cache.detectors.len());
for spec in cache.detectors {
let issues = validate_detector(&spec);
if issues
.iter()
.any(|issue| matches!(issue, QualityIssue::Error(_)))
{
tracing::warn!(
"cached detector '{}' failed quality gate; discarding the entire cache",
spec.id
);
return None;
}
validated.push(spec);
}
if validated.is_empty() {
tracing::warn!("detector cache is empty after validation, re-parsing detector TOML");
return None;
}
Some(keyhog_core::LoadedDetectorCorpus {
specs: validated,
schema_version,
})
}
fn read_detector_cache_file(cache_path: &Path) -> std::io::Result<Vec<u8>> {
let file = std::fs::File::open(cache_path)?;
let len = file.metadata()?.len();
if len > DETECTOR_CACHE_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"detector parse cache exceeds {} byte cap; delete the cache file to rebuild it",
DETECTOR_CACHE_FILE_BYTES
),
));
}
let mut data = Vec::with_capacity(len as usize);
file.take(DETECTOR_CACHE_FILE_BYTES.saturating_add(1))
.read_to_end(&mut data)?;
if data.len() as u64 > DETECTOR_CACHE_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"detector parse cache grew past {} byte cap while reading; retry after the file is stable",
DETECTOR_CACHE_FILE_BYTES
),
));
}
Ok(data)
}
fn detector_source_fingerprint(source_dir: &Path) -> std::io::Result<String> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(source_dir)? {
let entry = entry?;
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "toml") {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let contents = keyhog_core::read_detector_toml_file(&path)?;
entries.push((name, *blake3::hash(contents.as_bytes()).as_bytes()));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = blake3::Hasher::new();
for (name, hash) in entries {
hasher.update(name.as_bytes());
hasher.update(b"\0");
hasher.update(&hash);
hasher.update(b"\n");
}
Ok(keyhog_core::hex_encode(hasher.finalize().as_bytes()))
}
fn detector_cache_path(source_dir: &Path) -> Option<std::path::PathBuf> {
let canonical = std::fs::canonicalize(source_dir).unwrap_or_else(|_| source_dir.to_path_buf()); let mut hasher = crate::stable_hash::StableHasher::new("detector-parse-cache-path");
hasher.field_path("source_dir", &canonical);
hasher.field_str("binary_version", env!("CARGO_PKG_VERSION"));
let key = hasher.finish_u64();
Some(
dirs::cache_dir()?
.join("keyhog")
.join(format!("detectors-{key:016x}.json")),
)
}
fn load_detector_corpus_no_cache(path: &Path) -> Result<keyhog_core::LoadedDetectorCorpus> {
validate_detector_path_for_scan(path)?;
if path.exists() && path.is_dir() {
let loaded = load_detector_corpus(path).map_err(anyhow::Error::from)?;
require_non_empty_detectors(&loaded.specs, path)?;
return Ok(loaded);
}
let specs = load_detectors_embedded_or_fail(path)?;
Ok(keyhog_core::LoadedDetectorCorpus {
specs,
schema_version: keyhog_core::DETECTOR_CORPUS_SCHEMA_VERSION,
})
}
pub(crate) fn require_non_empty_detectors(
detectors: &[DetectorSpec],
detectors_path: &Path,
) -> Result<()> {
if detectors.is_empty() {
anyhow::bail!(
"loaded zero detectors from {}. \
Fix: verify the directory contains valid `*.toml` detector \
specs (run `keyhog detectors --detectors {}` to see \
which TOMLs were rejected, if any). Refusing to scan with \
no detectors loaded - that would silently report `no \
findings` regardless of what's in the source.",
detectors_path.display(),
detectors_path.display(),
);
}
Ok(())
}
pub(crate) fn load_detectors_or_embedded(
path: impl AsRef<std::path::Path>,
) -> Result<Vec<DetectorSpec>> {
let path = path.as_ref();
validate_detector_path_for_scan(path)?;
if path.exists() && path.is_dir() {
let loaded = load_detectors(path).context("loading detectors from directory")?;
require_non_empty_detectors(&loaded, path)?;
return Ok(loaded);
}
load_detectors_embedded_or_fail(path)
}
pub(crate) fn detector_compile_failed(
command: &str,
detectors_path: &Path,
error: impl fmt::Display,
) -> anyhow::Error {
anyhow::anyhow!(
"{command}: scanner compile failed while compiling detectors from '{}': {error}. \
Fix: run `keyhog detectors --audit --detectors {}` and repair detector errors, \
or omit --detectors to search installed detector locations and then use \
the embedded corpus when none is installed.",
detectors_path.display(),
detectors_path.display(),
)
}
fn validate_detector_path_for_scan(path: &Path) -> Result<()> {
if path.exists() && !path.is_dir() {
anyhow::bail!(
"detectors path '{}' is not a directory. \
Fix: pass a directory containing detector TOML files, or omit \
--detectors to search installed detector locations and then use \
the embedded corpus when none is installed.",
path.display()
);
}
if !path.exists() && path != Path::new("detectors") {
anyhow::bail!(
"detectors directory '{}' does not exist. \
Fix: pass an existing detector directory, or omit --detectors to \
search installed detector locations and then use the embedded \
corpus when none is installed.",
path.display()
);
}
Ok(())
}
pub(crate) fn load_detectors_embedded_or_fail(path: &Path) -> Result<Vec<DetectorSpec>> {
if keyhog_core::embedded_detector_count() == 0 {
anyhow::bail!(
"detectors directory '{}' not found and no embedded detectors available. \
Fix: specify --detectors <path> or install a binary with embedded detectors",
path.display()
);
}
tracing::info!(
embedded_count = keyhog_core::embedded_detector_count(),
"using embedded detectors (no external detectors directory found)"
);
Ok(keyhog_core::load_embedded_detectors_or_fail()?)
}
#[doc(hidden)]
pub(crate) mod testing {
use anyhow::Result;
use keyhog_core::DetectorSpec;
use std::path::Path;
pub(crate) fn load_detectors_from_dir_with_cache(
source_dir: &Path,
cache_path: &Path,
) -> Result<Vec<DetectorSpec>> {
super::load_detectors_from_dir_with_cache(source_dir, cache_path).map(|loaded| loaded.specs)
}
}