use std::path::PathBuf;
use std::process::{Command, Output};
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn run(args: &[&str]) -> (String, String, Option<i32>) {
let out: Output = Command::new(binary())
.args(args)
.output()
.expect("spawn keyhog");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
fn effective_config(args: &[&str]) -> (String, String, Option<i32>) {
let out: Output = Command::new(binary())
.env("KEYHOG_PRINT_EFFECTIVE_CONFIG", "1")
.args(args)
.output()
.expect("spawn keyhog");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
fn scan_file(name: &str, content: &str, extra: &[&str]) -> (TempDir, String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, content).expect("write fixture");
let mut args: Vec<String> = vec![
"scan".into(),
"--no-daemon".into(),
"--format".into(),
"json".into(),
];
for a in extra {
args.push((*a).into());
}
args.push(path.to_string_lossy().into_owned());
let out = Command::new(binary())
.args(&args)
.output()
.expect("spawn keyhog scan");
(
dir,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
fn parse_findings(stdout: &str) -> Vec<serde_json::Value> {
let v: serde_json::Value =
serde_json::from_str(stdout).unwrap_or_else(|_| serde_json::json!([]));
v.as_array().cloned().unwrap_or_default()
}
const AWS_KEY: &str = "AWS_ACCESS_KEY_ID = \"AKIAQYLPMN5HFIQR7XYA\"\n";
const SENTRY_DSN: &str =
"SENTRY_DSN = \"https://0123456789abcdef0123456789abcdef@o123456.ingest.sentry.io/4501\"\n";
#[test]
fn severity_critical_keeps_critical_aws_finding() {
let (_d, out, err, code) = scan_file("config.txt", AWS_KEY, &["--severity", "critical"]);
assert_eq!(
code,
Some(1),
"critical AWS key must survive --severity critical; stderr={err}"
);
let findings = parse_findings(&out);
assert!(
findings
.iter()
.any(|f| f["detector_id"] == "aws-access-key" && f["severity"] == "critical"),
"expected a critical aws-access-key finding; got {out}"
);
}
#[test]
fn severity_high_keeps_critical_finding_boundary() {
let (_d, out, err, code) = scan_file("config.txt", AWS_KEY, &["--severity", "high"]);
assert_eq!(
code,
Some(1),
"Critical >= High must pass --severity high; stderr={err}"
);
let findings = parse_findings(&out);
assert!(
findings
.iter()
.any(|f| f["detector_id"] == "aws-access-key"),
"Critical finding must clear the High floor; got {out}"
);
}
#[test]
fn severity_info_floor_keeps_client_safe_finding() {
let (_d0, base_out, _e0, base_code) = scan_file("app.txt", SENTRY_DSN, &[]);
if base_code != Some(1) {
return;
}
let base = parse_findings(&base_out);
assert!(
base.iter().any(|f| f["severity"] == "client-safe"),
"baseline Sentry DSN must be tiered client-safe; got {base_out}"
);
let (_d, out, err, code) = scan_file("app.txt", SENTRY_DSN, &["--severity", "info"]);
assert_eq!(
code,
Some(1),
"Info floor keeps ClientSafe (Info < ClientSafe); stderr={err}"
);
let findings = parse_findings(&out);
assert!(
findings.iter().any(|f| f["severity"] == "client-safe"),
"--severity info must keep client-safe findings; got {out}"
);
}
#[test]
fn severity_low_drops_client_safe_finding() {
let (_d0, base_out, _e0, base_code) = scan_file("app.txt", SENTRY_DSN, &[]);
if base_code != Some(1)
|| !parse_findings(&base_out)
.iter()
.any(|f| f["severity"] == "client-safe")
{
return; }
let (_d, out, err, code) = scan_file("app.txt", SENTRY_DSN, &["--severity", "low"]);
assert_eq!(
code,
Some(0),
"ClientSafe < Low so --severity low drops every finding; stderr={err}"
);
assert!(
parse_findings(&out).is_empty(),
"--severity low must drop the client-safe Sentry DSN; got {out}"
);
}
#[test]
fn severity_accepts_five_levels_and_rejects_client_safe() {
for level in ["info", "low", "medium", "high", "critical"] {
let (_d, _o, err, code) = scan_file("config.txt", "plain text\n", &["--severity", level]);
assert_eq!(
code,
Some(0),
"--severity {level} on clean input must parse and exit 0; stderr={err}"
);
}
let (_d, _o, err, code) =
scan_file("config.txt", "plain text\n", &["--severity", "client-safe"]);
assert_eq!(
code,
Some(2),
"`--severity client-safe` must be a clap error; stderr={err}"
);
assert!(
err.to_lowercase().contains("invalid value")
|| err.to_lowercase().contains("possible values"),
"clap must name the invalid --severity value; stderr={err}"
);
}
#[test]
fn severity_rejects_unknown_value() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--severity",
"ultra",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"unknown --severity value must be a clap error; stderr={err}"
);
}
#[test]
fn min_confidence_default_is_point_four() {
let (out, err, code) = effective_config(&["scan", "--no-daemon"]);
assert_eq!(code, Some(0), "oracle must exit 0; stderr={err}");
assert!(
out.contains("min_confidence = 0.4"),
"default floor must be 0.4; got {out}"
);
}
#[test]
fn min_confidence_sets_floor_outright_in_default_mode() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--min-confidence", "0.9"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.9"),
"--min-confidence 0.9 must set the floor to 0.9; got {out}"
);
}
#[test]
fn min_confidence_can_lower_floor_below_default() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--min-confidence", "0.3"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.3"),
"plain-mode --min-confidence 0.3 must drop the floor to 0.3; got {out}"
);
}
#[test]
fn min_confidence_accepts_inclusive_bounds() {
let (out0, e0, c0) = effective_config(&["scan", "--no-daemon", "--min-confidence", "0.0"]);
assert_eq!(c0, Some(0), "0.0 is in range; stderr={e0}");
assert!(
out0.contains("min_confidence = 0"),
"0.0 floor must render; got {out0}"
);
let (out1, e1, c1) = effective_config(&["scan", "--no-daemon", "--min-confidence", "1.0"]);
assert_eq!(c1, Some(0), "1.0 is in range; stderr={e1}");
assert!(
out1.contains("min_confidence = 1"),
"1.0 floor must render; got {out1}"
);
}
#[test]
fn min_confidence_rejects_above_one() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--min-confidence",
"1.5",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"1.5 is out of range -> clap error; stderr={err}"
);
assert!(
err.contains("min_confidence must be between 0.0 and 1.0"),
"parser error text must surface; stderr={err}"
);
}
#[test]
fn min_confidence_rejects_negative() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--min-confidence=-0.5",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"negative confidence -> clap error; stderr={err}"
);
assert!(
err.contains("min_confidence must be between 0.0 and 1.0"),
"parser error text must surface; stderr={err}"
);
}
#[test]
fn min_confidence_rejects_nan() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--min-confidence",
"nan",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"NaN confidence must be rejected; stderr={err}"
);
assert!(
err.contains("min_confidence must be between 0.0 and 1.0"),
"NaN must hit the range gate (not silently pass); stderr={err}"
);
}
#[test]
fn min_confidence_floor_survives_no_ml() {
let (out, err, code) =
effective_config(&["scan", "--no-daemon", "--min-confidence", "0.77", "--no-ml"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.77"),
"--no-ml must not erase the explicit --min-confidence floor; got {out}"
);
assert!(
out.contains("ml_enabled = false"),
"--no-ml must disable ML in the resolved config; got {out}"
);
}
#[test]
fn precision_preset_floor_entropy_decode() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--precision"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.85"),
"precision floor must be 0.85; got {out}"
);
assert!(
out.contains("entropy_enabled = false"),
"precision disables entropy; got {out}"
);
assert!(
out.contains("max_decode_depth = 1"),
"precision pins decode depth 1; got {out}"
);
}
#[test]
fn precision_min_confidence_above_floor_tightens() {
let (out, err, code) = effective_config(&[
"scan",
"--no-daemon",
"--precision",
"--min-confidence",
"0.9",
]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.9"),
"0.9 > 0.85 so precision must tighten to 0.9; got {out}"
);
}
#[test]
fn precision_min_confidence_below_floor_clamped_to_point_eight_five() {
let (out, err, code) = effective_config(&[
"scan",
"--no-daemon",
"--precision",
"--min-confidence",
"0.3",
]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.85"),
"0.3 < 0.85 must NOT lower the precision floor; got {out}"
);
assert!(
!out.contains("min_confidence = 0.3"),
"the precision floor must never drop to 0.3; got {out}"
);
}
#[test]
fn precision_min_confidence_equal_to_floor() {
let (out, err, code) = effective_config(&[
"scan",
"--no-daemon",
"--precision",
"--min-confidence",
"0.85",
]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.85"),
"equal floor must stay 0.85; got {out}"
);
}
#[test]
fn precision_conflicts_with_fast() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--precision",
"--fast",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--precision + --fast must conflict; stderr={err}"
);
assert!(
err.to_lowercase().contains("cannot be used with")
|| err.to_lowercase().contains("conflict"),
"clap must name the preset conflict; stderr={err}"
);
}
#[test]
fn precision_conflicts_with_deep() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--precision",
"--deep",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--precision + --deep must conflict; stderr={err}"
);
}
#[test]
fn fast_conflicts_with_deep() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--fast",
"--deep",
"/nonexistent-path-xyz",
]);
assert_eq!(code, Some(2), "--fast + --deep must conflict; stderr={err}");
}
#[test]
fn no_decode_sets_depth_zero() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--no-decode"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("max_decode_depth = 0"),
"--no-decode -> depth 0; got {out}"
);
}
#[test]
fn no_entropy_disables_entropy() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--no-entropy"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("entropy_enabled = false"),
"--no-entropy -> off; got {out}"
);
}
#[test]
fn default_mode_entropy_on_decode_ten() {
let (out, err, code) = effective_config(&["scan", "--no-daemon"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("entropy_enabled = true"),
"default entropy on; got {out}"
);
assert!(
out.contains("max_decode_depth = 10"),
"default decode 10; got {out}"
);
assert!(
out.contains("max_decode_bytes = 524288"),
"default decode bytes; got {out}"
);
}
#[test]
fn fast_conflicts_with_no_decode() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--fast",
"--no-decode",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--fast + --no-decode must conflict; stderr={err}"
);
}
#[test]
fn fast_conflicts_with_no_entropy() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--fast",
"--no-entropy",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--fast + --no-entropy must conflict; stderr={err}"
);
}
#[test]
fn deep_conflicts_with_no_decode() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--deep",
"--no-decode",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--deep + --no-decode must conflict; stderr={err}"
);
}
#[test]
fn deep_conflicts_with_no_entropy() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--deep",
"--no-entropy",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--deep + --no-entropy must conflict; stderr={err}"
);
}
#[test]
fn precision_conflicts_with_no_decode() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--precision",
"--no-decode",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--precision + --no-decode must conflict; stderr={err}"
);
}
#[test]
fn precision_conflicts_with_no_entropy() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--precision",
"--no-entropy",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"--precision + --no-entropy must conflict; stderr={err}"
);
}
#[test]
fn no_decode_and_no_entropy_compose_without_preset() {
let (out, err, code) =
effective_config(&["scan", "--no-daemon", "--no-decode", "--no-entropy"]);
assert_eq!(
code,
Some(0),
"two negatives without a preset are allowed; stderr={err}"
);
assert!(out.contains("max_decode_depth = 0"), "depth 0; got {out}");
assert!(
out.contains("entropy_enabled = false"),
"entropy off; got {out}"
);
}
#[test]
fn fast_preset_disables_ml_entropy_decode() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--fast"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("ml_enabled = false"),
"fast disables ml; got {out}"
);
assert!(
out.contains("entropy_enabled = false"),
"fast disables entropy; got {out}"
);
assert!(
out.contains("max_decode_depth = 0"),
"fast pins decode 0; got {out}"
);
}
#[test]
fn deep_preset_enables_ml_entropy_keeps_default_floor() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--deep"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("ml_enabled = true"),
"deep enables ml; got {out}"
);
assert!(
out.contains("entropy_enabled = true"),
"deep enables entropy; got {out}"
);
assert!(
out.contains("max_decode_depth = 10"),
"deep decode 10; got {out}"
);
assert!(
out.contains("min_confidence = 0.4"),
"deep keeps canonical 0.40 floor; got {out}"
);
}
#[test]
fn ml_threshold_default_is_noop_on_floor() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--ml-threshold", "0.5"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.4"),
"--ml-threshold 0.5 (the default) must not move the 0.40 floor; got {out}"
);
}
#[test]
fn ml_threshold_above_floor_raises_it() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--ml-threshold", "0.9"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.9"),
"--ml-threshold 0.9 must raise the floor to 0.9; got {out}"
);
}
#[test]
fn ml_threshold_below_floor_does_not_lower_it() {
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--ml-threshold", "0.1"]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.4"),
"--ml-threshold below the floor must not lower it; got {out}"
);
}
#[test]
fn ml_threshold_composes_with_min_confidence_via_max() {
let (out, err, code) = effective_config(&[
"scan",
"--no-daemon",
"--min-confidence",
"0.6",
"--ml-threshold",
"0.8",
]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.8"),
"max(0.6 floor, 0.8 ml-threshold) must be 0.8; got {out}"
);
}
#[test]
fn ml_threshold_rejects_nan() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--ml-threshold",
"nan",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"NaN ml-threshold must be rejected; stderr={err}"
);
assert!(
err.contains("--ml-threshold must be a finite number"),
"the finite-number guard must fire on NaN; stderr={err}"
);
}
#[test]
fn ml_threshold_rejects_above_one() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--ml-threshold",
"2.0",
"/nonexistent-path-xyz",
]);
assert_eq!(code, Some(2), "2.0 ml-threshold out of range; stderr={err}");
assert!(
err.contains("--ml-threshold must be between 0.0 and 1.0"),
"range guard text must surface; stderr={err}"
);
}
#[test]
fn dedup_accepts_all_three_scopes() {
for scope in ["credential", "file", "none"] {
let (_d, _o, err, code) = scan_file("config.txt", "nothing here\n", &["--dedup", scope]);
assert_eq!(code, Some(0), "--dedup {scope} must parse; stderr={err}");
}
}
#[test]
fn dedup_rejects_unknown_scope() {
let (_o, err, code) = run(&[
"scan",
"--no-daemon",
"--dedup",
"everything",
"/nonexistent-path-xyz",
]);
assert_eq!(
code,
Some(2),
"unknown --dedup value must be a clap error; stderr={err}"
);
assert!(
err.to_lowercase().contains("invalid value")
|| err.to_lowercase().contains("possible values"),
"clap must name the invalid dedup value; stderr={err}"
);
}
#[test]
fn dedup_credential_collapses_duplicate_credential() {
let body = "first = \"AKIAQYLPMN5HFIQR7XYA\"\nsecond = \"AKIAQYLPMN5HFIQR7XYA\"\n";
let (_d, out, err, code) = scan_file("config.txt", body, &["--dedup", "credential"]);
assert_eq!(
code,
Some(1),
"duplicate AWS key must still produce a finding; stderr={err}"
);
let findings = parse_findings(&out);
let aws: Vec<_> = findings
.iter()
.filter(|f| f["detector_id"] == "aws-access-key")
.collect();
assert_eq!(
aws.len(),
1,
"credential-scope dedup must collapse the repeated AWS key to one finding; got {out}"
);
}
#[test]
fn dedup_none_keeps_both_occurrences() {
let body = "first = \"AKIAQYLPMN5HFIQR7XYA\"\nsecond = \"AKIAQYLPMN5HFIQR7XYA\"\n";
let (_d, out, err, code) = scan_file("config.txt", body, &["--dedup", "none"]);
assert_eq!(code, Some(1), "stderr={err}");
let findings = parse_findings(&out);
let aws = findings
.iter()
.filter(|f| f["detector_id"] == "aws-access-key")
.count();
assert_eq!(
aws, 1,
"--dedup none disables scope dedup, but the always-on cross-detector \
pass (keyed on credential_hash + file) folds the identical-credential \
same-file pair into one finding; got {aws} in {out}"
);
}
#[test]
fn client_safe_finding_present_by_default() {
let (_d, out, err, code) = scan_file("app.txt", SENTRY_DSN, &[]);
if code != Some(1) {
return; }
let findings = parse_findings(&out);
assert!(
findings.iter().any(|f| f["severity"] == "client-safe"),
"default mode must surface the Sentry DSN at client-safe tier; got {out} (stderr={err})"
);
}
#[test]
fn hide_client_safe_drops_client_safe_findings() {
let (_d0, base_out, _e0, base_code) = scan_file("app.txt", SENTRY_DSN, &[]);
if base_code != Some(1)
|| !parse_findings(&base_out)
.iter()
.any(|f| f["severity"] == "client-safe")
{
return; }
let (_d, out, err, code) = scan_file("app.txt", SENTRY_DSN, &["--hide-client-safe"]);
assert_eq!(
code,
Some(0),
"--hide-client-safe must drop the only (client-safe) finding -> exit 0; stderr={err}"
);
let findings = parse_findings(&out);
assert!(
!findings.iter().any(|f| f["severity"] == "client-safe"),
"--hide-client-safe must remove client-safe findings; got {out}"
);
}
#[test]
fn hide_client_safe_keeps_non_client_safe_findings() {
let (_d, out, err, code) = scan_file("config.txt", AWS_KEY, &["--hide-client-safe"]);
assert_eq!(
code,
Some(1),
"critical AWS key must survive --hide-client-safe; stderr={err}"
);
let findings = parse_findings(&out);
assert!(
findings
.iter()
.any(|f| f["detector_id"] == "aws-access-key"),
"--hide-client-safe must not touch critical findings; got {out}"
);
}
#[test]
fn show_secrets_off_redacts_credential() {
let (_d, out, err, code) = scan_file("config.txt", AWS_KEY, &[]);
assert_eq!(code, Some(1), "stderr={err}");
let findings = parse_findings(&out);
let aws = findings
.iter()
.find(|f| f["detector_id"] == "aws-access-key")
.unwrap_or_else(|| panic!("aws finding expected; got {out}"));
let red = aws["credential_redacted"].as_str().unwrap_or("");
assert_eq!(
red, "AKIA...7XYA",
"redacted form must be first4...last4 of the key; got {red:?} in {out}"
);
assert_ne!(
red, "AKIAQYLPMN5HFIQR7XYA",
"default mode must NOT print the plaintext credential; got {out}"
);
}
#[test]
fn show_secrets_on_prints_plaintext() {
let (_d, out, err, code) = scan_file("config.txt", AWS_KEY, &["--show-secrets"]);
assert_eq!(code, Some(1), "stderr={err}");
let findings = parse_findings(&out);
let aws = findings
.iter()
.find(|f| f["detector_id"] == "aws-access-key")
.unwrap_or_else(|| panic!("aws finding expected; got {out}"));
let red = aws["credential_redacted"].as_str().unwrap_or("");
assert_eq!(
red, "AKIAQYLPMN5HFIQR7XYA",
"--show-secrets must emit the full plaintext credential; got {red:?} in {out}"
);
}
#[test]
fn lockdown_forbids_show_secrets() {
let (_d, _out, err, code) = scan_file("config.txt", AWS_KEY, &["--lockdown", "--show-secrets"]);
assert_eq!(
code,
Some(2),
"lockdown + show-secrets must be refused (user error 2); stderr={err}"
);
assert!(
err.contains("lockdown mode forbids --show-secrets"),
"the error must name the lockdown/show-secrets conflict; stderr={err}"
);
}
#[test]
fn daemon_conflicts_with_no_daemon() {
let (_o, err, code) = run(&["scan", "--daemon", "--no-daemon", "/nonexistent-path-xyz"]);
assert_eq!(
code,
Some(2),
"--daemon + --no-daemon must conflict; stderr={err}"
);
}
#[test]
fn severity_forces_in_process_path_and_still_finds() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("config.txt");
std::fs::write(&path, AWS_KEY).expect("write");
let out = Command::new(binary())
.args(["scan", "--format", "json", "--severity", "critical"])
.arg(&path)
.output()
.expect("spawn");
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
code,
Some(1),
"--severity must force in-process and still find the key; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
parse_findings(&stdout)
.iter()
.any(|f| f["detector_id"] == "aws-access-key"),
"in-process path under --severity must surface the critical finding; got {stdout}"
);
}
#[test]
fn property_min_confidence_is_identity_in_plain_mode() {
for &v in &[0.0_f64, 0.1, 0.25, 0.4, 0.5, 0.75, 0.85, 0.95, 1.0] {
let s = format!("{v}");
let (out, err, code) = effective_config(&["scan", "--no-daemon", "--min-confidence", &s]);
assert_eq!(code, Some(0), "min-confidence {v} must parse; stderr={err}");
let expected = format!("min_confidence = {v}");
assert!(
out.contains(&expected),
"plain-mode floor must equal the requested {v}; expected `{expected}`; got {out}"
);
}
}
#[test]
fn property_precision_floor_is_max_with_point_eight_five() {
for &v in &[0.0_f64, 0.2, 0.5, 0.84, 0.85, 0.86, 0.9, 1.0] {
let s = format!("{v}");
let (out, err, code) =
effective_config(&["scan", "--no-daemon", "--precision", "--min-confidence", &s]);
assert_eq!(
code,
Some(0),
"precision min-confidence {v} must parse; stderr={err}"
);
let resolved = v.max(0.85_f64);
let expected = format!("min_confidence = {resolved}");
assert!(
out.contains(&expected),
"precision floor must be max({v}, 0.85) = {resolved}; expected `{expected}`; got {out}"
);
}
}
#[test]
fn composition_min_conf_ml_threshold_no_ml_independent() {
let (out, err, code) = effective_config(&[
"scan",
"--no-daemon",
"--min-confidence",
"0.7",
"--ml-threshold",
"0.6",
"--no-ml",
]);
assert_eq!(code, Some(0), "stderr={err}");
assert!(
out.contains("min_confidence = 0.7"),
"floor = max(0.7,0.6) = 0.7; got {out}"
);
assert!(
out.contains("ml_enabled = false"),
"--no-ml must disable ml; got {out}"
);
}