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 const ML_THRESHOLD_DEFAULT: f64 = 0.5;
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>> {
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 {
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>> {
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 fn load_detectors_or_embedded(path: &Path) -> Result<Vec<DetectorSpec>> {
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)
}
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 use the embedded corpus.",
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 \
use the embedded corpus.",
path.display()
);
}
Ok(())
}
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.precision {
ScannerConfig::high_precision()
} else if args.fast {
ScannerConfig::fast()
} else if args.deep {
ScannerConfig::thorough()
} else {
ScannerConfig::default()
};
if let Some(depth) = args.decode_depth {
config.max_decode_depth = depth;
}
if args.no_decode {
config.max_decode_depth = 0;
}
if let Some(size) = args.decode_size_limit {
config.max_decode_bytes = size;
}
if let Some(conf) = args.min_confidence {
config.min_confidence = if args.precision {
conf.max(ScannerConfig::HIGH_PRECISION_MIN_CONFIDENCE)
} else {
conf
};
}
if args.ml_threshold != ML_THRESHOLD_DEFAULT {
config.min_confidence = config.min_confidence.max(args.ml_threshold);
}
config.penalize_test_paths = !args.no_suppress_test_fixtures;
if !(args.fast || args.deep || args.precision) {
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.entropy_ml_authoritative = !args.no_entropy_ml_scoring;
config.generic_keyword_low_entropy =
config.generic_keyword_low_entropy && !args.no_keyword_low_entropy;
config.scan_comments = args.scan_comments;
config.ml_enabled = !args.fast && !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.sanitise();
config
}
#[derive(Debug, Clone)]
pub struct ResolvedScanConfig {
pub scanner: ScannerConfig,
pub min_confidence: f64,
pub ml_enabled: bool,
pub detector_min_confidence: std::collections::HashMap<String, f64>,
pub disabled_detectors: std::collections::HashSet<String>,
pub require_lockdown: bool,
}
pub fn resolve_scan_config(args: &mut ScanArgs) -> ResolvedScanConfig {
let outcome = crate::config::apply_config_file(args);
let scanner = build_scanner_config(args);
let min_confidence = scanner.min_confidence;
let ml_enabled = scanner.ml_enabled;
ResolvedScanConfig {
scanner,
min_confidence,
ml_enabled,
detector_min_confidence: outcome.detector_min_confidence,
disabled_detectors: outcome.disabled_detectors.into_iter().collect(),
require_lockdown: outcome.require_lockdown,
}
}
pub fn print_effective_config_if_requested(resolved: &ResolvedScanConfig) -> bool {
let requested = std::env::var("KEYHOG_PRINT_EFFECTIVE_CONFIG")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !requested {
return false;
}
print!("{}", render_effective_config(resolved));
true
}
pub fn render_effective_config(resolved: &ResolvedScanConfig) -> String {
use std::fmt::Write as _;
let s = &resolved.scanner;
let mut out = String::new();
let _ = writeln!(out, "[effective-config]");
let _ = writeln!(out, "min_confidence = {}", resolved.min_confidence);
let _ = writeln!(out, "ml_enabled = {}", resolved.ml_enabled);
let _ = writeln!(out, "ml_weight = {}", s.ml_weight);
let _ = writeln!(out, "entropy_enabled = {}", s.entropy_enabled);
let _ = writeln!(
out,
"entropy_ml_authoritative = {}",
s.entropy_ml_authoritative
);
let _ = writeln!(
out,
"generic_keyword_low_entropy = {}",
s.generic_keyword_low_entropy
);
let _ = writeln!(out, "entropy_threshold = {}", s.entropy_threshold);
let _ = writeln!(
out,
"entropy_in_source_files = {}",
s.entropy_in_source_files
);
let _ = writeln!(out, "max_decode_depth = {}", s.max_decode_depth);
let _ = writeln!(out, "max_decode_bytes = {}", s.max_decode_bytes);
let _ = writeln!(out, "scan_comments = {}", s.scan_comments);
let _ = writeln!(out, "unicode_normalization = {}", s.unicode_normalization);
let _ = writeln!(
out,
"disabled_detectors = {}",
resolved.disabled_detectors.len()
);
let _ = writeln!(out, "known_prefixes = {}", s.known_prefixes.len());
let _ = writeln!(out, "secret_keywords = {}", s.secret_keywords.len());
let _ = writeln!(out, "test_keywords = {}", s.test_keywords.len());
let _ = writeln!(
out,
"placeholder_keywords = {}",
s.placeholder_keywords.len()
);
let mut floors: Vec<(&String, &f64)> = resolved.detector_min_confidence.iter().collect();
floors.sort_by(|a, b| a.0.cmp(b.0));
for (id, floor) in floors {
let _ = writeln!(out, "detector_min_confidence.{id} = {floor}");
}
out
}