use anyhow::{Context, Result};
use keyhog_core::{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 = 3;
const DETECTOR_CACHE_FILE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Serialize, Deserialize)]
struct DetectorCacheFile {
version: u32,
source_fingerprint: String,
detectors: Vec<DetectorSpec>,
}
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 load_detectors_with_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
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, path)?;
return Ok(loaded);
}
let loaded = load_detectors(path)?;
require_non_empty_detectors(&loaded, path)?;
return Ok(loaded);
}
load_detectors_embedded_or_fail(path)
}
fn load_detectors_from_dir_with_cache(
source_dir: &Path,
cache_path: &Path,
) -> Result<Vec<DetectorSpec>> {
if let Some(cached) = load_detector_cache(cache_path, source_dir) {
return Ok(cached);
}
let loaded = load_detectors(source_dir).map_err(anyhow::Error::from)?;
if loaded.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(
detectors: &[DetectorSpec],
cache_path: &Path,
source_fingerprint: String,
) -> std::io::Result<()> {
for detector in detectors {
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,
detectors: detectors.to_vec(),
})?;
crate::atomic_file::write_bytes(cache_path, &json)
}
fn load_detector_cache(cache_path: &Path, source_dir: &Path) -> Option<Vec<DetectorSpec>> {
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;
}
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(validated)
}
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")),
)
}
pub(crate) fn load_detectors_no_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
validate_detector_path_for_scan(path)?;
if path.exists() && path.is_dir() {
let loaded = load_detectors(path).map_err(anyhow::Error::from)?;
require_non_empty_detectors(&loaded, path)?;
return Ok(loaded);
}
load_detectors_embedded_or_fail(path)
}
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)
}
}