use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const GITHUB_PAT_LINE: &str = concat!(
"github_pat = \"ghp_",
"0000000000000000000000000000002C8GjS",
"\"\n"
);
const SLACK_WEBHOOK_LINE: &str = concat!(
"url=https://hooks.slack.com/services/T0000ABCD1/B0000ABCD2/",
"abcdefghijklmnopqrstuvwx",
"\n"
);
const AWS_KEY_LINE: &str = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
fn scan_dir_with(file_name: &str, body: &str) -> TempDir {
let dir = TempDir::new().expect("scan tempdir");
std::fs::write(dir.path().join(file_name), body).expect("write fixture");
dir
}
fn config_file(body: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("config tempdir");
let path = dir.path().join("tool.toml");
std::fs::write(&path, body).expect("write tool.toml");
(dir, path)
}
fn scan(scan_dir: &std::path::Path, extra: &[&str]) -> (Option<i32>, String, String) {
let output = Command::new(binary())
.arg("scan")
.arg("--daemon=off")
.arg("--backend")
.arg("cpu")
.args(extra)
.arg(scan_dir)
.output()
.expect("spawn keyhog scan");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn explicit_config_min_confidence_suppresses_below_threshold() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (code, stdout, stderr) = scan(dir.path(), &["--format", "json"]);
assert_eq!(
code,
Some(1),
"no-config baseline: the github PAT must fire (exit 1) on the cpu path.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"github-classic-pat\""),
"baseline stdout must carry the github-classic-pat finding.\n--- stdout ---\n{stdout}"
);
assert!(
stdout.contains("\"confidence\":0.9"),
"baseline finding confidence must be exactly 0.9.\n--- stdout ---\n{stdout}"
);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 0.95\n");
let cfg = cfg_path.to_str().unwrap();
let (code, stdout, stderr) = scan(dir.path(), &["--format", "json", "--config", cfg]);
assert_eq!(
code,
Some(0),
"an explicit --config with min_confidence = 0.95 must drop the 0.9 finding \
→ exit 0.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert_eq!(
stdout.trim(),
"[]",
"the suppressed scan must emit exactly an empty JSON array.\n--- stdout ---\n{stdout}"
);
}
#[test]
fn explicit_config_min_confidence_boundary_is_inclusive() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_c1, at_path) = config_file("[scan]\nmin_confidence = 0.9\n");
let (code, stdout, stderr) = scan(
dir.path(),
&["--format", "json", "--config", at_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(1),
"min_confidence = 0.9 == finding confidence must KEEP it (inclusive floor).\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"github-classic-pat\""),
"boundary-equal scan must still report the github PAT.\n--- stdout ---\n{stdout}"
);
let (_c2, above_path) = config_file("[scan]\nmin_confidence = 0.900001\n");
let (code, stdout, _e) = scan(
dir.path(),
&["--format", "json", "--config", above_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(0),
"min_confidence just above 0.9 must drop the finding → exit 0.\n--- stdout ---\n{stdout}"
);
assert_eq!(stdout.trim(), "[]", "dropped scan must be empty JSON.");
}
#[test]
fn explicit_config_min_confidence_top_keeps_only_perfect_confidence() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("aws.txt"), AWS_KEY_LINE).expect("write aws");
std::fs::write(dir.path().join("gh.txt"), GITHUB_PAT_LINE).expect("write gh");
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 1.0\n");
let (code, stdout, stderr) = scan(
dir.path(),
&["--format", "json", "--config", cfg_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(1),
"min_confidence = 1.0 must keep the confidence-1.0 aws key.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"aws-access-key\""),
"the 1.0-confidence aws-access-key must survive the top floor.\n--- stdout ---\n{stdout}"
);
assert!(
!stdout.contains("\"detector_id\":\"github-classic-pat\""),
"the 0.9-confidence github PAT must be dropped by min_confidence = 1.0.\n\
--- stdout ---\n{stdout}"
);
}
#[test]
fn explicit_config_severity_filter_drops_lower_severity() {
let dir = scan_dir_with("hook.txt", SLACK_WEBHOOK_LINE);
let (code, stdout, stderr) = scan(dir.path(), &["--format", "json"]);
assert_eq!(
code,
Some(1),
"no-config baseline: the high-severity slack webhook must fire.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"slack-webhook-url\""),
"baseline must report slack-webhook-url.\n--- stdout ---\n{stdout}"
);
assert!(
stdout.contains("\"severity\":\"high\""),
"the slack webhook finding must be severity high.\n--- stdout ---\n{stdout}"
);
let (_cfg, cfg_path) = config_file("[scan]\nseverity = \"critical\"\n");
let (code, stdout, stderr) = scan(
dir.path(),
&["--format", "json", "--config", cfg_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(0),
"--config severity = \"critical\" must filter out the high finding → exit 0.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert_eq!(
stdout.trim(),
"[]",
"severity-filtered scan must emit exactly an empty JSON array.\n--- stdout ---\n{stdout}"
);
}
#[test]
fn explicit_config_severity_at_or_below_keeps_finding() {
let dir = scan_dir_with("hook.txt", SLACK_WEBHOOK_LINE);
let (_c1, high_path) = config_file("[scan]\nseverity = \"high\"\n");
let (code, stdout, stderr) = scan(
dir.path(),
&["--format", "json", "--config", high_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(1),
"severity = \"high\" (== finding severity) must keep the finding.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"slack-webhook-url\""),
"severity=high scan must report slack-webhook-url.\n--- stdout ---\n{stdout}"
);
let (_c2, med_path) = config_file("[scan]\nseverity = \"medium\"\n");
let (code, stdout, _e) = scan(
dir.path(),
&["--format", "json", "--config", med_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(1),
"severity = \"medium\" (below high) must keep the high finding.\n--- stdout ---\n{stdout}"
);
assert!(
stdout.contains("\"detector_id\":\"slack-webhook-url\""),
"severity=medium scan must still report the finding.\n--- stdout ---\n{stdout}"
);
}
#[test]
fn explicit_config_overrides_discovered_keyhog_toml() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
std::fs::write(
dir.path().join(".keyhog.toml"),
"[scan]\nmin_confidence = 0.99\n",
)
.expect("write discovered config");
let (code, stdout, stderr) = scan(dir.path(), &["--format", "json"]);
assert_eq!(
code,
Some(0),
"discovered .keyhog.toml min_confidence = 0.99 alone must suppress the 0.9 PAT.\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert_eq!(
stdout.trim(),
"[]",
"discovered-only scan must be empty JSON."
);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 0.5\n");
let (code, stdout, stderr) = scan(
dir.path(),
&["--format", "json", "--config", cfg_path.to_str().unwrap()],
);
assert_eq!(
code,
Some(1),
"explicit --config (0.5) must override the discovered .keyhog.toml (0.99): \
the PAT must fire.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stdout.contains("\"detector_id\":\"github-classic-pat\""),
"override scan must report the github PAT the discovered config tried to hide.\n\
--- stdout ---\n{stdout}"
);
}
#[test]
fn cli_min_confidence_flag_overrides_config_value() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 0.5\n");
let (code, stdout, stderr) = scan(
dir.path(),
&[
"--format",
"json",
"--config",
cfg_path.to_str().unwrap(),
"--min-confidence",
"0.99",
],
);
assert_eq!(
code,
Some(0),
"CLI --min-confidence 0.99 must override config min_confidence = 0.5 and \
drop the 0.9 PAT → exit 0.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert_eq!(
stdout.trim(),
"[]",
"CLI-overridden scan must emit an empty JSON array.\n--- stdout ---\n{stdout}"
);
}
#[test]
fn explicit_config_missing_file_fails_closed_with_fix() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (code, _stdout, stderr) = scan(
dir.path(),
&[
"--config",
"/nonexistent/keyhog/tool.toml",
"--format",
"json",
],
);
assert_eq!(
code,
Some(2),
"--config to a missing file must fail closed with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("invalid .keyhog.toml configuration"),
"error must announce the invalid config.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("failed to read config file"),
"error must identify the read failure.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains(
"make the file readable, pass a valid --config path, or run with --no-config"
),
"error must name the fix for an unreadable config.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn explicit_config_pointing_at_directory_fails_closed() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let cfg_dir = TempDir::new().expect("dir-as-config");
let (code, _stdout, stderr) = scan(
dir.path(),
&[
"--config",
cfg_dir.path().to_str().unwrap(),
"--format",
"json",
],
);
assert_eq!(
code,
Some(2),
"--config pointing at a directory must fail closed with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("failed to read config file"),
"error must identify the read failure.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("Is a directory"),
"error must carry the OS read reason (Is a directory).\n--- stderr ---\n{stderr}"
);
}
#[test]
fn explicit_config_unknown_field_fails_closed() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_cfg, cfg_path) = config_file("bogus_key = 1\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"an unknown field in the --config file must fail closed with exit 2.\n\
--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("invalid .keyhog.toml configuration"),
"error must announce the invalid config.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("failed to parse TOML"),
"error must identify the TOML parse failure.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("unknown field `bogus_key`"),
"error must name the offending unknown field.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn explicit_config_invalid_severity_value_lists_valid_values() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_cfg, cfg_path) = config_file("[scan]\nseverity = \"nope\"\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"an invalid severity value in --config must fail closed with exit 2.\n\
--- stderr ---\n{stderr}"
);
assert!(
stderr.contains(
"- [scan].severity = \"nope\": expected one of info, client-safe, low, medium, high, critical"
),
"error must quote the bad value and list the valid severities.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn config_and_no_config_flags_are_mutually_exclusive() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 0.5\n");
let output = Command::new(binary())
.arg("scan")
.arg("--daemon=off")
.arg("--backend")
.arg("cpu")
.arg("--config")
.arg(&cfg_path)
.arg("--no-config")
.arg(dir.path())
.output()
.expect("spawn keyhog scan");
assert_eq!(
output.status.code(),
Some(2),
"--config together with --no-config must be a clap usage error (exit 2)."
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with '--no-config'"),
"clap error must name the --config / --no-config conflict.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn min_confidence_range_validation_matches_between_cli_and_config() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (code, _stdout, stderr) =
scan(dir.path(), &["--min-confidence", "5.0", "--format", "json"]);
assert_eq!(
code,
Some(2),
"CLI --min-confidence 5.0 must be rejected with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("min_confidence must be between 0.0 and 1.0"),
"CLI rejection must name the [0.0, 1.0] bound.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 5.0\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"config min_confidence = 5.0 must fail closed with exit 2, matching the \
CLI.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("invalid .keyhog.toml configuration"),
"error must announce the invalid config.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("min_confidence must be between 0.0 and 1.0"),
"config rejection must name the SAME [0.0, 1.0] bound as the CLI.\n\
--- stderr ---\n{stderr}"
);
}
#[test]
fn config_min_confidence_boundaries_validate() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = -0.5\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"[scan].min_confidence = -0.5 (below floor) must fail closed.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("min_confidence must be between 0.0 and 1.0"),
"negative scan value must be rejected with the bound.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 2.0\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"[scan].min_confidence = 2.0 must fail closed too.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("min_confidence must be between 0.0 and 1.0"),
"[scan] over-range value must be rejected with the bound.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("[scan]\nmin_confidence = 0.5\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_ne!(
code,
Some(2),
"an in-range min_confidence = 0.5 must NOT be rejected as invalid config.\n\
--- stderr ---\n{stderr}"
);
assert!(
!stderr.contains("invalid .keyhog.toml configuration"),
"in-range value must not raise a config error.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn ml_weight_range_validation_matches_between_cli_and_config() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (code, _stdout, stderr) = scan(dir.path(), &["--ml-weight", "5.0", "--format", "json"]);
assert_eq!(
code,
Some(2),
"CLI --ml-weight 5.0 must be rejected with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("ml_weight must be between 0.0 and 1.0"),
"CLI rejection must name the [0.0, 1.0] bound.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("ml_weight = 5.0\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"config ml_weight = 5.0 must fail closed with exit 2, matching the CLI.\n\
--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("invalid .keyhog.toml configuration"),
"error must announce the invalid config.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("ml_weight must be between 0.0 and 1.0"),
"config rejection must name the SAME bound as the CLI.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("ml_weight = 0.75\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_ne!(
code,
Some(2),
"an in-range ml_weight = 0.75 must NOT be rejected as invalid config.\n\
--- stderr ---\n{stderr}"
);
assert!(
!stderr.contains("invalid .keyhog.toml configuration"),
"in-range ml_weight must not raise a config error.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn config_threads_zero_is_rejected_matching_reader_threads_and_cli() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
let (code, _stdout, stderr) = scan(dir.path(), &["--threads", "0", "--format", "json"]);
assert_eq!(
code,
Some(2),
"CLI --threads 0 must be rejected with exit 2.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("[scan]\nthreads = 0\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"config threads = 0 must fail closed with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("invalid .keyhog.toml configuration")
&& stderr.contains("threads = 0: use a positive integer"),
"config threads = 0 must name the positive-integer fix.\n--- stderr ---\n{stderr}"
);
let (_cfg, cfg_path) = config_file("[scan]\nthreads = 2\n");
let (code, _stdout, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_ne!(
code,
Some(2),
"an in-range threads = 2 must NOT be rejected as invalid config.\n--- stderr ---\n{stderr}"
);
}
#[test]
fn every_positive_int_scan_config_knob_rejects_zero() {
let dir = scan_dir_with("gh.txt", GITHUB_PAT_LINE);
for field in [
"threads",
"reader_threads",
"fused_batch",
"fused_depth",
"per_chunk_timeout_ms",
"min_secret_len",
] {
let (_cfg, cfg_path) = config_file(&format!("[scan]\n{field} = 0\n"));
let (code, _out, stderr) = scan(
dir.path(),
&["--config", cfg_path.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(2),
"[scan].{field} = 0 must fail closed with exit 2.\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains(&format!("[scan].{field} = 0: use a positive integer")),
"[scan].{field} = 0 must name the positive-integer fix.\n--- stderr ---\n{stderr}"
);
}
}