use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn scan_with_args(fixture: &str, args: &[&str]) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("config.txt");
std::fs::write(&path, fixture).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.args(args)
.arg("--format")
.arg("json")
.arg("--no-daemon")
.arg(&path)
.output()
.expect("spawn keyhog scan");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
#[test]
fn precision_mode_negative_twin_is_subset_of_default() {
let fixture = concat!(
"AWS_ACCESS_KEY_ID = \"AKIA",
"QYLPMN5HGT3KZ7WB\"\n",
"aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n",
);
let (def_out, _, _) = scan_with_args(fixture, &[]);
let (prec_out, _, _) = scan_with_args(fixture, &["--precision"]);
let def: Vec<String> = serde_json::from_str::<serde_json::Value>(&def_out)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| {
f.get("detector_id")
.and_then(|d| d.as_str())
.map(String::from)
})
.collect();
let prec: Vec<String> = serde_json::from_str::<serde_json::Value>(&prec_out)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| {
f.get("detector_id")
.and_then(|d| d.as_str())
.map(String::from)
})
.collect();
for det in &prec {
assert!(
def.contains(det),
"precision found detector {det:?} but default didn't; \
this violates the subset property. default={def:?}, precision={prec:?}"
);
}
assert!(
prec.len() < def.len(),
"precision must be strictly tighter than default; \
default={def:?}, precision={prec:?}"
);
}
#[test]
fn precision_mode_composes_with_no_suppress_test_fixtures() {
let stripe_key = concat!("sk_", "live_", "4eC39HqLyjWDarjtT1zdp7dc");
let fixture = format!("STRIPE_KEY = \"{stripe_key}\"\n");
let (def_suppressed, _, def_code) = scan_with_args(&fixture, &[]);
assert_eq!(
def_code,
Some(0),
"default mode suppresses the Stripe demo key"
);
let def: Vec<String> = serde_json::from_str::<serde_json::Value>(&def_suppressed)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| f.get("service").and_then(|s| s.as_str()).map(String::from))
.collect();
assert!(
!def.contains(&"stripe".to_string()),
"default suppresses Stripe"
);
let (prec_nosuppress, _, prec_code) =
scan_with_args(&fixture, &["--precision", "--no-suppress-test-fixtures"]);
assert_eq!(
prec_code,
Some(1),
"precision with --no-suppress-test-fixtures must find the Stripe key"
);
let prec: Vec<String> = serde_json::from_str::<serde_json::Value>(&prec_nosuppress)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| f.get("service").and_then(|s| s.as_str()).map(String::from))
.collect();
assert!(
prec.contains(&"stripe".to_string()),
"precision --no-suppress-test-fixtures must find Stripe; got {prec:?}"
);
}
#[test]
fn precision_mode_respects_min_confidence_when_higher() {
let fixture = "aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n";
let (out, _, code) = scan_with_args(fixture, &["--precision", "--min-confidence", "0.9"]);
assert_eq!(
code,
Some(1),
"--precision --min-confidence 0.9 must find the high-confidence AWS secret"
);
let findings: serde_json::Value = serde_json::from_str(&out).expect("JSON");
let arr = findings.as_array().expect("array");
assert!(
!arr.is_empty(),
"the AWS secret (conf > 0.9) must survive max(0.85, 0.9)"
);
}
#[test]
fn precision_mode_empty_file_exits_zero() {
let fixture = "";
let (out, _, code) = scan_with_args(fixture, &["--precision"]);
assert_eq!(code, Some(0), "empty file must exit 0");
let findings: serde_json::Value = serde_json::from_str(&out).expect("JSON");
let arr = findings.as_array().expect("array");
assert!(arr.is_empty(), "empty file must have no findings");
}
#[test]
fn precision_mode_whitespace_only_exits_zero() {
let fixture = " \n\t\n # just comments\n";
let (out, _, code) = scan_with_args(fixture, &["--precision"]);
assert_eq!(code, Some(0), "whitespace-only file must exit 0");
let findings: serde_json::Value = serde_json::from_str(&out).expect("JSON");
let arr = findings.as_array().expect("array");
assert!(arr.is_empty(), "whitespace-only file must have no findings");
}
#[test]
fn precision_mode_composes_with_verify_flag() {
let fixture = "aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n";
let (out, err, code) = scan_with_args(fixture, &["--precision", "--verify"]);
assert!(
code.is_some_and(|c| c == 0 || c == 1),
"precision --verify must succeed (exit 0 or 1 depending on findings/verify result); \
got {code:?}, stderr={err}"
);
}
#[test]
fn precision_mode_composes_with_scan_comments() {
let fixture =
"// TODO: rotate aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n";
let (out, err, code) = scan_with_args(fixture, &["--precision", "--scan-comments"]);
assert!(
code.is_some_and(|c| c == 1),
"precision --scan-comments must find the AWS secret in comment; \
got {code:?}, stderr={err}"
);
let findings: serde_json::Value = serde_json::from_str(&out).expect("JSON");
let arr = findings.as_array().expect("array");
assert!(
!arr.is_empty(),
"precision --scan-comments must find the AWS secret; got {out}"
);
}
#[test]
fn precision_mode_ignores_min_confidence_when_lower_than_0_85() {
let fixture = "PASSWORD = \"admin123\"\n";
let (out, _, code) = scan_with_args(fixture, &["--precision", "--min-confidence", "0.3"]);
assert_eq!(
code,
Some(0),
"precision must enforce 0.85 floor even with --min-confidence 0.3"
);
let findings: serde_json::Value = serde_json::from_str(&out).expect("JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.is_empty(),
"precision must drop the weak password (conf < 0.85); got {arr:?}"
);
}
#[test]
fn precision_mode_tightens_large_mixed_corpus() {
let fixture = concat!(
"# Real credentials\n",
"AWS_ACCESS_KEY_ID = \"AKIA",
"QYLPMN5HGT3KZ7WB\"\n",
"aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n",
"GH_TOKEN = \"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\"\n",
"# Weak patterns\n",
"PASSWORD = \"weak_pass\"\n",
"API_KEY = \"some_generic_key\"\n",
"SECRET = \"generic_secret\"\n",
);
let (def_out, _, _) = scan_with_args(fixture, &[]);
let (prec_out, _, _) = scan_with_args(fixture, &["--precision"]);
let def_count = serde_json::from_str::<serde_json::Value>(&def_out)
.ok()
.and_then(|v| v.as_array().map(|a| a.len()))
.unwrap_or(0);
let prec_count = serde_json::from_str::<serde_json::Value>(&prec_out)
.ok()
.and_then(|v| v.as_array().map(|a| a.len()))
.unwrap_or(0);
assert!(
def_count > prec_count,
"precision must reduce the finding count on a large mixed corpus; \
default={def_count}, precision={prec_count}"
);
assert!(
prec_count > 0,
"precision must still find the high-confidence credentials; got count={prec_count}"
);
}