use super::limits::apply_limits_section;
use super::schema::{ConfigFile, ScanSection};
use super::sections::config_relative_path;
use crate::args::{DetectorMode, ScanArgs};
use crate::value_parsers::{
parse_byte_size, parse_dedup_scope, parse_entropy_bpe_max_bytes_per_token,
parse_entropy_threshold, parse_min_confidence, parse_ml_threshold, parse_ml_weight,
parse_output_format, parse_severity_filter, value_enum_expected,
};
use std::path::{Path, PathBuf};
const DETECTOR_MODE_ACCEPTED: &str = "expected one of replace, overlay";
fn parse_detector_mode(value: &str) -> Option<DetectorMode> {
match value {
"replace" => Some(DetectorMode::Replace),
"overlay" => Some(DetectorMode::Overlay),
_ => None,
}
}
fn keyword_list_is_nonempty(errors: &mut Vec<String>, field: &str, entries: &[String]) -> bool {
if entries.iter().any(|entry| entry.is_empty()) {
errors.push(format!(
"- {field}: entries must not be empty; remove the empty \"\" item"
));
return false;
}
true
}
pub(super) fn parse_config_byte_size(
errors: &mut Vec<String>,
field: &str,
value: &str,
) -> Option<usize> {
match parse_byte_size(value) {
Ok(size) => Some(size),
Err(error) => {
errors.push(super::invalid_config_value(field, value, &error));
None
}
}
}
fn parse_config_decode_depth(errors: &mut Vec<String>, field: &str, depth: usize) -> Option<usize> {
let limit = keyhog_core::max_decode_depth_limit();
if (1..=limit).contains(&depth) {
return Some(depth);
}
errors.push(format!(
"- {field} = {depth}: decode depth must be between 1 and {limit}"
));
None
}
fn parse_config_f64(
errors: &mut Vec<String>,
field: &str,
value: f64,
parse: impl Fn(&str) -> Result<f64, String>,
) -> Option<f64> {
let rendered = value.to_string();
match parse(&rendered) {
Ok(value) => Some(value),
Err(error) => {
errors.push(super::invalid_config_value(field, &rendered, &error));
None
}
}
}
fn parse_config_ml_threshold(errors: &mut Vec<String>, field: &str, threshold: f64) -> Option<f64> {
parse_config_f64(errors, field, threshold, parse_ml_threshold)
}
pub(super) fn parse_config_min_confidence(
errors: &mut Vec<String>,
field: &str,
confidence: f64,
) -> Option<f64> {
parse_config_f64(errors, field, confidence, parse_min_confidence)
}
fn parse_config_ml_weight(errors: &mut Vec<String>, field: &str, weight: f64) -> Option<f64> {
parse_config_f64(errors, field, weight, parse_ml_weight)
}
fn parse_config_entropy_bpe_bound(
errors: &mut Vec<String>,
field: &str,
bound: f64,
) -> Option<f64> {
parse_config_f64(errors, field, bound, parse_entropy_bpe_max_bytes_per_token)
}
fn parse_config_entropy_threshold(
errors: &mut Vec<String>,
field: &str,
threshold: f64,
) -> Option<f64> {
parse_config_f64(errors, field, threshold, parse_entropy_threshold)
}
pub(super) fn validate_scan_preset_conflicts(
args: &ScanArgs,
config_errors: &mut Vec<String>,
config: &ConfigFile,
) {
if args.fast || args.deep || args.precision {
return;
}
let toml_fast = config.fast == Some(true);
let toml_deep = config.deep == Some(true);
let toml_precision = config.precision == Some(true);
if !(toml_fast || toml_deep || toml_precision) {
return;
}
let presets = [
("fast", toml_fast),
("deep", toml_deep),
("precision", toml_precision),
];
let selected: Vec<_> = presets
.into_iter()
.filter_map(|(name, enabled)| enabled.then_some(name))
.collect();
if selected.len() > 1 {
config_errors.push(format!(
"- {}: choose only one scan preset in .keyhog.toml",
selected.join("/")
));
}
if !(toml_fast || toml_precision) {
return;
}
let preset = if toml_fast {
"fast = true"
} else {
"precision = true"
};
let mode = if toml_fast {
"fast mode"
} else {
"precision mode"
};
for field in config_fast_noop_fields(config) {
config_errors.push(format!(
"- {field}: cannot be combined with {preset} because {mode} disables entropy/decode for that knob"
));
}
}
fn config_fast_noop_fields(config: &ConfigFile) -> Vec<&'static str> {
let mut fields = Vec::new();
if config.no_decode == Some(true) {
fields.push("no_decode");
}
if config.no_entropy == Some(true) {
fields.push("no_entropy");
}
if config.entropy_source_files == Some(true) {
fields.push("entropy_source_files");
}
if config.generic_keyword_low_entropy == Some(false) {
fields.push("generic_keyword_low_entropy = false");
}
if config
.scan
.as_ref()
.is_some_and(|scan| scan.entropy_threshold.is_some())
{
fields.push("[scan].entropy_threshold");
}
if config
.scan
.as_ref()
.is_some_and(|scan| scan.min_secret_len.is_some())
{
fields.push("[scan].min_secret_len");
}
if config
.scan
.as_ref()
.is_some_and(|scan| scan.entropy_bpe_max_bytes_per_token.is_some())
{
fields.push("[scan].entropy_bpe_max_bytes_per_token");
}
fields
}
fn apply_positive_int_field<T: Copy + PartialEq + From<u8>>(
config_errors: &mut Vec<String>,
target: &mut Option<T>,
label: &str,
value: T,
) {
if value == T::from(0u8) {
config_errors.push(format!("- {label} = 0: use a positive integer"));
} else if target.is_none() {
*target = Some(value);
}
}
pub(super) fn apply_scan_section(
args: &mut ScanArgs,
config_errors: &mut Vec<String>,
scan: Option<ScanSection>,
) {
let _scan_section_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
if let Some(scan) = scan {
if let Some(ref s) = scan.severity {
match parse_severity_filter(s) {
Some(severity) => {
if args.severity.is_none() {
args.severity = Some(severity);
}
}
None => config_errors.push(super::invalid_config_value(
"[scan].severity",
s,
&value_enum_expected::<crate::args::SeverityFilter>(),
)),
}
}
if let Some(confidence) = scan.min_confidence {
let parsed_confidence =
parse_config_min_confidence(config_errors, "[scan].min_confidence", confidence);
if args.min_confidence.is_none() {
args.min_confidence = parsed_confidence;
}
}
if let Some(threshold) = scan.ml_threshold {
let parsed_threshold =
parse_config_ml_threshold(config_errors, "[scan].ml_threshold", threshold);
if args.ml_threshold.is_none() {
args.ml_threshold = parsed_threshold;
}
}
if let Some(threshold) = scan.entropy_threshold {
let parsed_threshold = parse_config_entropy_threshold(
config_errors,
"[scan].entropy_threshold",
threshold,
);
if args.entropy_threshold.is_none() {
args.entropy_threshold = parsed_threshold;
}
}
if let Some(bound) = scan.entropy_bpe_max_bytes_per_token {
let parsed_bound = parse_config_entropy_bpe_bound(
config_errors,
"[scan].entropy_bpe_max_bytes_per_token",
bound,
);
if args.entropy_bpe_max_bytes_per_token.is_none() {
args.entropy_bpe_max_bytes_per_token = parsed_bound;
}
}
if let Some(depth) = scan.decode_depth {
let parsed_depth =
parse_config_decode_depth(config_errors, "[scan].decode_depth", depth);
if args.decode_depth.is_none() {
args.decode_depth = parsed_depth;
}
}
if let Some(min_secret_len) = scan.min_secret_len {
apply_positive_int_field(
config_errors,
&mut args.min_secret_len,
"[scan].min_secret_len",
min_secret_len,
);
}
if let Some(ref f) = scan.format {
match parse_output_format(f) {
Some(fmt) => {
if !args.format_cli_explicit
&& matches!(args.format, crate::args::OutputFormat::Text)
{
args.format = fmt;
}
}
None => config_errors.push(super::invalid_config_value(
"[scan].format",
f,
&value_enum_expected::<crate::args::OutputFormat>(),
)),
}
}
if args.exclude_paths.is_none() {
args.exclude_paths = scan.exclude;
}
if let Some(threads) = scan.threads {
apply_positive_int_field(config_errors, &mut args.threads, "[scan].threads", threads);
}
if let Some(threads) = scan.reader_threads {
apply_positive_int_field(
config_errors,
&mut args.reader_threads,
"[scan].reader_threads",
threads,
);
}
if let Some(batch) = scan.fused_batch {
apply_positive_int_field(
config_errors,
&mut args.fused_batch,
"[scan].fused_batch",
batch,
);
}
if let Some(depth) = scan.fused_depth {
apply_positive_int_field(
config_errors,
&mut args.fused_depth,
"[scan].fused_depth",
depth,
);
}
if let Some(timeout_ms) = scan.per_chunk_timeout_ms {
apply_positive_int_field(
config_errors,
&mut args.per_chunk_timeout_ms,
"[scan].per_chunk_timeout_ms",
timeout_ms,
);
}
if let Some(ref d) = scan.dedup {
match parse_dedup_scope(d) {
Some(scope) => {
if !args.dedup_cli_explicit
&& matches!(args.dedup, crate::args::CliDedupScope::Credential)
{
args.dedup = scope;
}
}
None => config_errors.push(super::invalid_config_value(
"[scan].dedup",
d,
&value_enum_expected::<crate::args::CliDedupScope>(),
)),
}
}
if let Some(incremental) = scan.incremental {
if !args.incremental {
args.incremental = incremental;
}
}
if args.incremental_cache.is_none() {
args.incremental_cache = scan.incremental_cache;
}
if let Some(ref limit) = scan.gpu_batch_input_limit {
let parsed =
parse_config_byte_size(config_errors, "[scan].gpu_batch_input_limit", limit);
if args.gpu_batch_input_limit.is_none() {
args.gpu_batch_input_limit = parsed;
}
}
}
}
pub(super) fn apply_top_level_scan_fields(
args: &mut ScanArgs,
config_errors: &mut Vec<String>,
config_path: &Path,
config: &mut ConfigFile,
) {
let cli_preset_selected = args.fast || args.deep || args.precision;
if let Some(detectors_str) = &config.detectors {
if !args.detectors_cli_explicit && args.detectors == PathBuf::from("detectors") {
args.detectors = config_relative_path(config_path, detectors_str);
args.detectors_cli_explicit = true;
}
}
if let Some(mode) = &config.detectors_mode {
match parse_detector_mode(mode) {
Some(mode) if args.detectors_mode.is_none() => args.detectors_mode = Some(mode),
Some(_) => {}
None => config_errors.push(super::invalid_config_value(
"detectors_mode",
mode,
DETECTOR_MODE_ACCEPTED,
)),
}
}
if let Some(no_decode) = config.no_decode {
if !args.no_decode && !cli_preset_selected {
args.no_decode = no_decode;
}
}
if let Some(no_entropy) = config.no_entropy {
if !args.no_entropy && !cli_preset_selected {
args.no_entropy = no_entropy;
}
}
if let Some(fast) = config.fast {
if !args.fast && !args.deep && !args.precision {
args.fast = fast;
}
}
if let Some(deep) = config.deep {
if !args.fast && !args.deep && !args.precision {
args.deep = deep;
}
}
if let Some(precision) = config.precision {
if !args.fast && !args.deep && !args.precision {
args.precision = precision;
}
}
#[cfg(feature = "verify")]
if let Some(verify) = config.verify {
if !args.verify && !args.no_verify {
args.verify = verify;
}
}
#[cfg(not(feature = "verify"))]
if config.verify.is_some() && !args.no_verify {
config_errors.push(
"- verify: this key requires the `verify` feature in this keyhog build".to_string(),
);
}
#[cfg(feature = "verify")]
if let Some(timeout) = config.timeout {
if args.timeout.is_none() {
args.timeout = Some(timeout);
}
}
#[cfg(not(feature = "verify"))]
if config.timeout.is_some() {
config_errors.push(
"- timeout: this key requires the `verify` feature in this keyhog build".to_string(),
);
}
#[cfg(feature = "verify")]
if let Some(concurrency) = config.verify_concurrency {
if concurrency == 0 {
config_errors.push("- verify_concurrency = 0: expected an integer >= 1 (maximum in-flight verification requests per service)".to_string());
} else if args.verify_concurrency.is_none() {
args.verify_concurrency = Some(concurrency);
}
}
#[cfg(not(feature = "verify"))]
if config.verify_concurrency.is_some() {
config_errors.push(
"- verify_concurrency: this key requires the `verify` feature in this keyhog build"
.to_string(),
);
}
#[cfg(feature = "git")]
if let Some(max_commits) = config.max_commits {
if args.max_commits.is_none() {
args.max_commits = Some(max_commits);
}
}
#[cfg(not(feature = "git"))]
if config.max_commits.is_some() {
config_errors.push(
"- max_commits: this key requires the `git` feature in this keyhog build".to_string(),
);
}
if let Some(show_secrets) = config.show_secrets {
if !args.show_secrets {
args.show_secrets = show_secrets;
}
}
if let Some(ref limit_str) = config.decode_size_limit {
let parsed_size = parse_config_byte_size(config_errors, "decode_size_limit", limit_str);
if args.decode_size_limit.is_none() {
if let Some(size) = parsed_size {
args.decode_size_limit = Some(size);
}
}
}
if let Some(entropy_source) = config.entropy_source_files {
if !args.entropy_source_files {
args.entropy_source_files = entropy_source;
}
}
if config.generic_keyword_low_entropy == Some(false) && !args.no_keyword_low_entropy {
args.no_keyword_low_entropy = true;
}
if let Some(no_unicode_norm) = config.no_unicode_norm {
if !args.no_unicode_norm {
args.no_unicode_norm = no_unicode_norm;
}
}
if let Some(no_ml) = config.no_ml {
if !args.no_ml {
args.no_ml = no_ml;
}
}
if let Some(ml_weight) = config.ml_weight {
let parsed = parse_config_ml_weight(config_errors, "ml_weight", ml_weight);
if args.ml_weight.is_none() {
args.ml_weight = parsed;
}
}
if let Some(ref limit_str) = config.max_file_size {
let parsed_size = parse_config_byte_size(config_errors, "max_file_size", limit_str);
if args.max_file_size.is_none() {
if let Some(size) = parsed_size {
args.max_file_size = Some(size);
}
}
}
if let Some(ref limit_str) = config.regex_dfa_limit {
let parsed_size = parse_config_byte_size(config_errors, "regex_dfa_limit", limit_str);
if args.regex_dfa_limit.is_none() {
if let Some(size) = parsed_size {
args.regex_dfa_limit = Some(size);
}
}
}
if let Some(limits) = config.limits.take() {
apply_limits_section(args, config_errors, limits);
}
if let Some(prefixes) = config.known_prefixes.take() {
if keyword_list_is_nonempty(config_errors, "known_prefixes", &prefixes) {
args.known_prefixes = prefixes;
}
}
if let Some(keywords) = config.secret_keywords.take() {
if keyword_list_is_nonempty(config_errors, "secret_keywords", &keywords) {
args.secret_keywords = keywords;
}
}
if let Some(keywords) = config.test_keywords.take() {
if keyword_list_is_nonempty(config_errors, "test_keywords", &keywords) {
args.test_keywords = keywords;
}
}
if let Some(keywords) = config.placeholder_keywords.take() {
if keyword_list_is_nonempty(config_errors, "placeholder_keywords", &keywords) {
args.placeholder_keywords = keywords;
}
}
}