use crate::args::ScanArgs;
use anyhow::{Context, Result};
use keyhog_core::{load_detectors, DetectorSpec};
use keyhog_scanner::ScannerConfig;
use std::path::{Path, PathBuf};
pub const MAX_THREADS_CAP: usize = 256;
pub(crate) fn configure_threads(threads: Option<usize>, physical_cores: usize) {
let (n, source) = if let Some(t) = threads {
(
sanitise_thread_count(t, physical_cores, "cli-arg"),
"cli-arg",
)
} else if let Ok(env) = std::env::var("KEYHOG_THREADS") {
match env.parse::<usize>() {
Ok(t) => (
sanitise_thread_count(t, physical_cores, "env:KEYHOG_THREADS"),
"env:KEYHOG_THREADS",
),
Err(_) => {
tracing::warn!(value = %env, "ignoring invalid KEYHOG_THREADS value");
(physical_cores.max(1), "physical-cores")
}
}
} else {
(physical_cores.max(1), "physical-cores")
};
let builder = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.stack_size(8 * 1024 * 1024)
.thread_name(|i| format!("keyhog-worker-{i}"));
if let Err(error) = builder.build_global() {
tracing::warn!(
requested_threads = n,
source,
"failed to configure rayon thread pool: {error}"
);
} else {
tracing::info!(
threads = n,
source,
physical_cores,
"rayon thread pool configured"
);
}
}
fn sanitise_thread_count(requested: usize, physical_cores: usize, source: &'static str) -> usize {
let safe_default = physical_cores.max(1);
if requested == 0 {
tracing::warn!(
source,
requested = 0,
using = safe_default,
"thread count of 0 is not meaningful; falling back to physical-cores"
);
return safe_default;
}
if requested > MAX_THREADS_CAP {
tracing::warn!(
source,
requested,
cap = MAX_THREADS_CAP,
"requested thread count exceeds cap; clamping"
);
return MAX_THREADS_CAP;
}
requested
}
#[doc(hidden)]
#[allow(dead_code)]
pub fn sanitise_thread_count_for_test(
requested: usize,
physical_cores: usize,
source: &'static str,
) -> usize {
sanitise_thread_count(requested, physical_cores, source)
}
pub(crate) fn auto_discover_detectors(path: &Path) -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("KEYHOG_DETECTORS") {
let p = PathBuf::from(&env_path);
if p.exists() && p.is_dir() {
return Ok(p);
}
}
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 load_detectors_with_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
if path.exists() && path.is_dir() {
let cache_path = detector_cache_path(path);
if let Some(cache_path) = &cache_path {
if let Some(cached) = keyhog_core::load_detector_cache(cache_path, path) {
require_non_empty_detectors(&cached, path)?;
return Ok(cached);
}
}
let loaded = load_detectors(path)?;
require_non_empty_detectors(&loaded, path)?;
if let Some(cache_path) = &cache_path {
if let Err(error) = keyhog_core::save_detector_cache(&loaded, cache_path) {
tracing::debug!(
cache_path = %cache_path.display(),
%error,
"detector parse cache not written (cache dir unwritable); \
re-parsing TOML each run"
);
}
}
return Ok(loaded);
}
load_detectors_embedded_or_fail(path)
}
fn detector_cache_path(source_dir: &Path) -> Option<std::path::PathBuf> {
use std::hash::{Hash, Hasher};
let canonical = std::fs::canonicalize(source_dir).unwrap_or_else(|_| source_dir.to_path_buf());
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
let key = hasher.finish();
Some(
dirs::cache_dir()?
.join("keyhog")
.join(format!("detectors-{key:016x}.json")),
)
}
pub(crate) fn load_detectors_no_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
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 list --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 fn load_detectors_or_embedded(path: &Path) -> Result<Vec<DetectorSpec>> {
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)
}
fn load_detectors_embedded_or_fail(path: &Path) -> Result<Vec<DetectorSpec>> {
let embedded = keyhog_core::embedded_detector_tomls();
if !embedded.is_empty() {
tracing::info!(
embedded_count = embedded.len(),
"using embedded detectors (no external detectors directory found)"
);
let mut detectors = Vec::new();
for (name, toml_content) in embedded {
match toml::from_str::<keyhog_core::DetectorFile>(toml_content) {
Ok(file) => detectors.push(file.detector),
Err(error) => {
tracing::debug!("failed to parse embedded detector {}: {}", name, error)
}
}
}
if detectors.is_empty() {
anyhow::bail!(
"no detectors loaded from embedded data - every embedded TOML \
failed to parse. Fix: pass `--detectors <DIR>` to load from a \
directory of TOMLs, or rebuild keyhog from source so the \
build.rs detector-embedding step re-runs."
);
}
return Ok(detectors);
}
anyhow::bail!(
"detectors directory '{}' not found and no embedded detectors available. \
Fix: specify --detectors <path> or set KEYHOG_DETECTORS env var",
path.display()
)
}
pub fn build_scanner_config(args: &ScanArgs) -> ScannerConfig {
let mut config = if args.fast {
ScannerConfig::fast()
} else if args.deep {
ScannerConfig::thorough()
} else {
ScannerConfig::default()
};
if args.fast || args.deep {
return config;
}
if let Some(depth) = args.decode_depth {
config.max_decode_depth = depth;
}
if let Some(size) = args.decode_size_limit {
config.max_decode_bytes = size;
}
if let Some(conf) = args.min_confidence {
config.min_confidence = conf;
}
config.entropy_enabled = !args.no_entropy;
if let Some(threshold) = args.entropy_threshold {
config.entropy_threshold = threshold;
}
config.entropy_in_source_files = args.entropy_source_files;
config.scan_comments = args.scan_comments;
config.ml_enabled = !args.no_ml;
if let Some(weight) = args.ml_weight {
config.ml_weight = weight;
}
config.unicode_normalization = !args.no_unicode_norm;
if !args.known_prefixes.is_empty() {
config.known_prefixes = args.known_prefixes.clone();
}
if !args.secret_keywords.is_empty() {
config.secret_keywords = args.secret_keywords.clone();
}
if !args.test_keywords.is_empty() {
config.test_keywords = args.test_keywords.clone();
}
if !args.placeholder_keywords.is_empty() {
config.placeholder_keywords = args.placeholder_keywords.clone();
}
config
}