use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn scan_text_file(content: &str, extra_args: &[&str]) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, content).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.args(extra_args)
.arg("--format")
.arg("json")
.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 scan_finds_planted_aws_key_and_returns_exit_1() {
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let (stdout, _stderr, code) = scan_text_file(fixture, &[]);
assert_eq!(
code,
Some(1),
"expected exit 1 (unverified findings); got {code:?}"
);
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(!arr.is_empty(), "expected at least one finding");
let aws = arr.iter().find(|f| {
matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key" | "hot-aws_key")
)
});
assert!(aws.is_some(), "expected an AWS key finding; got: {arr:?}");
}
#[test]
fn scan_returns_exit_0_on_clean_file() {
let fixture = "fn main() { println!(\"hello\"); }\n";
let (stdout, _stderr, code) = scan_text_file(fixture, &[]);
assert_eq!(code, Some(0), "expected exit 0 on clean file; got {code:?}");
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(arr.is_empty(), "expected zero findings; got: {arr:?}");
}
#[test]
fn scan_json_schema_carries_required_fields() {
let fixture = "GH_TOKEN = \"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\"\n";
let (stdout, _stderr, _code) = scan_text_file(fixture, &[]);
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(!arr.is_empty(), "expected the GH token to fire");
for f in arr {
for required in [
"detector_id",
"detector_name",
"service",
"severity",
"credential_redacted",
"credential_hash",
"location",
"verification",
] {
assert!(
f.get(required).is_some(),
"finding is missing required field `{required}`: {f}",
);
}
let loc = f.get("location").unwrap();
for required in ["source", "file_path", "line", "offset"] {
assert!(
loc.get(required).is_some(),
"location is missing required field `{required}`: {loc}",
);
}
}
}
#[test]
fn readme_banner_counts_match_loaded_corpus() {
const README_DETECTOR_COUNT: usize = 900;
const README_PATTERN_COUNT: usize = 1668;
let output = Command::new(binary())
.arg("detectors")
.arg("--json")
.output()
.expect("spawn keyhog detectors --json");
assert_eq!(output.status.code(), Some(0));
let arr: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).expect("detectors JSON parse");
let actual_patterns: usize = arr
.iter()
.map(|d| {
d.get("patterns")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0)
})
.sum();
assert_eq!(
arr.len(),
README_DETECTOR_COUNT,
"README banner says {README_DETECTOR_COUNT} detectors; actual={}. \
Update README and the constant in this test together.",
arr.len(),
);
assert_eq!(
actual_patterns, README_PATTERN_COUNT,
"README banner says {README_PATTERN_COUNT} patterns; actual={actual_patterns}. \
Update README and the constant in this test together.",
);
}
#[test]
fn detectors_subcommand_emits_json_array() {
let output = Command::new(binary())
.arg("detectors")
.arg("--json")
.output()
.expect("spawn keyhog detectors --json");
assert_eq!(
output.status.code(),
Some(0),
"detectors --json should exit 0; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("detectors --json stdout is valid JSON");
let arr = parsed.as_array().expect("--json output is a JSON array");
assert!(
arr.len() > 100,
"expected hundreds of detectors; got {}",
arr.len()
);
let aws = arr
.iter()
.find(|d| d.get("id").and_then(|v| v.as_str()) == Some("aws-access-key"));
assert!(
aws.is_some(),
"aws-access-key should appear in --json output"
);
let aws = aws.unwrap();
assert_eq!(
aws.get("service").and_then(|v| v.as_str()),
Some("aws"),
"aws-access-key should have service=aws",
);
}
#[test]
fn no_suppress_test_fixtures_surfaces_stripe_demo_key() {
let stripe_key = concat!("sk_", "live_", "4eC39HqLyjWDarjtT1zdp7dc");
let fixture = format!("STRIPE_KEY = \"{stripe_key}\"\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, &fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (default)");
let default_json = String::from_utf8_lossy(&default_out.stdout);
let default_findings: serde_json::Value =
serde_json::from_str(&default_json).expect("default-mode stdout is JSON");
let default_arr = default_findings.as_array().expect("array");
let has_stripe = default_arr
.iter()
.any(|f| f.get("service").and_then(|v| v.as_str()) == Some("stripe"));
assert!(
!has_stripe,
"default mode MUST suppress the Stripe demo key; got findings: {default_arr:?}"
);
let optout_out = Command::new(binary())
.arg("scan")
.arg("--no-suppress-test-fixtures")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (opt-out)");
let optout_json = String::from_utf8_lossy(&optout_out.stdout);
let optout_findings: serde_json::Value =
serde_json::from_str(&optout_json).expect("opt-out stdout is JSON");
let optout_arr = optout_findings.as_array().expect("array");
let has_stripe_now = optout_arr
.iter()
.any(|f| f.get("service").and_then(|v| v.as_str()) == Some("stripe"));
assert!(
has_stripe_now,
"--no-suppress-test-fixtures MUST surface the Stripe demo key; \
got findings: {optout_arr:?}"
);
}
#[test]
fn no_suppress_test_fixtures_surfaces_test_path_findings() {
let fixture = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.example.com:5432/prod\n";
let dir = TempDir::new().expect("tempdir");
let fixture_dir = dir.path().join("tests").join("fixtures");
std::fs::create_dir_all(&fixture_dir).expect("create fixture dir");
let path = fixture_dir.join("planted.env");
std::fs::write(&path, fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg("json")
.arg("--min-confidence")
.arg("0.0")
.arg(&path)
.output()
.expect("spawn keyhog scan (default)");
let default_json = String::from_utf8_lossy(&default_out.stdout);
let default_findings: serde_json::Value =
serde_json::from_str(&default_json).expect("default-mode stdout is JSON");
assert_eq!(
default_findings.as_array().map(Vec::len),
Some(0),
"default mode should suppress low-confidence test-path findings; got {default_json}"
);
let optout_out = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--no-suppress-test-fixtures")
.arg("--format")
.arg("json")
.arg("--min-confidence")
.arg("0.0")
.arg(&path)
.output()
.expect("spawn keyhog scan (opt-out)");
let optout_json = String::from_utf8_lossy(&optout_out.stdout);
let optout_findings: serde_json::Value =
serde_json::from_str(&optout_json).expect("opt-out stdout is JSON");
let optout_arr = optout_findings.as_array().expect("array");
let surfaced = optout_arr.iter().find(|f| {
f.get("detector_id").and_then(|v| v.as_str()) == Some("generic-password")
&& f.pointer("/location/line").and_then(|v| v.as_u64()) == Some(1)
&& f.pointer("/location/file_path")
.and_then(|v| v.as_str())
.is_some_and(|p| p.contains("/tests/fixtures/"))
});
assert!(
surfaced.is_some(),
"--no-suppress-test-fixtures must surface test-path findings; got {optout_json}"
);
let confidence = surfaced
.and_then(|f| f.get("confidence"))
.and_then(|v| v.as_f64())
.unwrap_or_default();
assert!(
confidence >= 0.69,
"fixture opt-out must bypass pre-ML test-path down-weighting; got {confidence}"
);
}
#[test]
fn demo_secret_aws_example_summary_distinguishes_suppression_from_clean() {
let fixture = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n";
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("demo-secret.env");
std::fs::write(&path, fixture).expect("write fixture");
let out = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg("text")
.arg(&path)
.output()
.expect("spawn keyhog scan demo-secret.env");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("example/test key") && stdout.contains("suppressed"),
"demo-secret.env summary must distinguish suppressed-example from a \
clean repo. Got stdout: {stdout}"
);
assert!(
!stdout.contains("Your code is clean."),
"the clean-repo summary must NOT fire when an example credential was \
suppressed. Got stdout: {stdout}"
);
}
#[test]
fn explicit_format_text_does_not_emit_json() {
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, fixture).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("text")
.arg(&path)
.output()
.expect("spawn keyhog scan --format text");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(
!stdout.trim_start().starts_with('['),
"text format must not start with JSON `[`; got: {stdout}",
);
assert!(
combined.to_lowercase().contains("aws") || combined.contains("AKIA"),
"text format should mention the finding somewhere; \
stdout={stdout:?}, stderr={stderr:?}, exit={:?}",
output.status.code(),
);
}
#[test]
fn scan_comments_flag_surfaces_credentials_in_comments() {
let aws_key = concat!("AKIA", "ROTATIONNEEDED77");
let fixture = format!("// TODO: rotate this - {aws_key}\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("comment_planted.go");
std::fs::write(&path, &fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (default)");
let default_json = String::from_utf8_lossy(&default_out.stdout);
let default_findings: serde_json::Value =
serde_json::from_str(&default_json).expect("default-mode stdout is JSON");
let default_count = default_findings.as_array().map(|a| a.len()).unwrap_or(0);
let opt_in_out = Command::new(binary())
.arg("scan")
.arg("--scan-comments")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan --scan-comments");
let opt_in_json = String::from_utf8_lossy(&opt_in_out.stdout);
let opt_in_findings: serde_json::Value =
serde_json::from_str(&opt_in_json).expect("opt-in stdout is JSON");
let opt_in_count = opt_in_findings.as_array().map(|a| a.len()).unwrap_or(0);
assert!(
opt_in_count >= default_count,
"--scan-comments must not LOSE findings vs default; \
default={default_count}, --scan-comments={opt_in_count}, \
default_json={default_json}, opt_in_json={opt_in_json}"
);
assert!(
opt_in_count >= 1,
"--scan-comments MUST surface the AKIA-prefixed key in the \
comment; got {opt_in_count} findings: {opt_in_json}"
);
}
fn workspace_detectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../detectors")
.canonicalize()
.expect("workspace detectors dir")
}
#[cfg(feature = "git")]
fn init_git_repo(repo_path: &std::path::Path) {
use std::process::Command;
for args in [
["init", "-b", "main"],
["config", "user.email", "test@example.com"],
["config", "user.name", "Test User"],
] {
let output = Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.expect("git setup");
assert!(output.status.success(), "git setup failed: {output:?}");
}
}
#[cfg(feature = "git")]
#[test]
fn git_staged_scan_finds_only_staged_secret() {
use std::process::Command;
let repo = TempDir::new().expect("tempdir");
let repo_path = repo.path();
init_git_repo(repo_path);
std::fs::write(
repo_path.join("staged.env"),
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
std::fs::write(
repo_path.join("unstaged.env"),
"AWS_ACCESS_KEY_ID = \"AKIAQYLPMN5HUNSTAGEDKEY000000000000\"\n",
)
.unwrap();
Command::new("git")
.args(["add", "staged.env"])
.current_dir(repo_path)
.output()
.unwrap();
let output = Command::new(binary())
.current_dir(repo_path)
.args([
"scan",
"--git-staged",
"--no-daemon",
"--format",
"json",
"--path",
".",
])
.output()
.expect("git-staged scan");
assert_eq!(
output.status.code(),
Some(1),
"staged secret must exit 1; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.iter().any(|f| {
f.get("location")
.and_then(|l| l.get("file_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| p.ends_with("staged.env"))
}),
"must find staged file secret; got {arr:?}"
);
assert!(
!arr.iter().any(|f| {
f.get("location")
.and_then(|l| l.get("file_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| p.contains("unstaged.env"))
}),
"unstaged file must not be scanned; got {arr:?}"
);
}
#[test]
fn baseline_suppresses_acknowledged_findings_on_rescan() {
let dir = TempDir::new().expect("tempdir");
let fixture = dir.path().join("planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let baseline_path = dir.path().join("baseline.json");
let create = Command::new(binary())
.args([
"scan",
"--no-daemon",
"--create-baseline",
baseline_path.to_str().unwrap(),
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("create baseline");
assert_eq!(
create.status.code(),
Some(0),
"create-baseline must exit 0; stderr={}",
String::from_utf8_lossy(&create.stderr)
);
assert!(baseline_path.exists(), "baseline file must be written");
let filtered = Command::new(binary())
.args([
"scan",
"--no-daemon",
"--baseline",
baseline_path.to_str().unwrap(),
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("baseline-filter scan");
assert_eq!(
filtered.status.code(),
Some(0),
"baseline-filtered rescan must exit 0; stderr={}",
String::from_utf8_lossy(&filtered.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&filtered.stdout).expect("filtered stdout is JSON");
assert!(
findings.as_array().is_some_and(|a| a.is_empty()),
"baseline must suppress known findings; got {findings:?}"
);
}
#[test]
fn lockdown_bails_on_verify_flag() {
let dir = TempDir::new().expect("tempdir");
let fixture = dir.path().join("planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let mut cmd = Command::new("prlimit");
cmd.args(["--core=0"])
.arg(binary())
.args([
"scan",
"--no-daemon",
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture);
let output = match cmd.output() {
Ok(out) => out,
Err(_) => Command::new(binary())
.args([
"scan",
"--no-daemon",
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("lockdown+verify scan"),
};
assert_eq!(
output.status.code(),
Some(2),
"lockdown+verify must exit 2 (runtime error); got {:?}",
output.status.code()
);
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("lockdown mode forbids --verify")
|| combined.contains("protections failed to apply"),
"must refuse outbound verification in lockdown (or fail closed on \
hardening); got: {combined}"
);
if !combined.contains("protections failed to apply") {
assert!(
combined.contains("lockdown mode forbids --verify"),
"when lockdown protections apply, --verify must be refused; got: {combined}"
);
}
}
#[cfg(unix)]
fn start_daemon() -> (TempDir, std::process::Child) {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let runtime = TempDir::new().expect("runtime dir");
let detectors = workspace_detectors();
let daemon = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args([
"daemon",
"start",
"--detectors",
detectors.to_str().unwrap(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn daemon");
let socket = runtime.path().join("keyhog.sock");
let deadline = Instant::now() + Duration::from_secs(30);
while !socket.exists() {
assert!(
Instant::now() < deadline,
"daemon socket did not appear in time"
);
std::thread::sleep(Duration::from_millis(50));
}
(runtime, daemon)
}
#[cfg(unix)]
fn stop_daemon(runtime: &TempDir, daemon: &mut std::process::Child) {
use std::process::Command;
let _ = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["daemon", "stop"])
.output();
let _ = daemon.kill();
let _ = daemon.wait();
}
#[cfg(unix)]
#[test]
fn daemon_wire_scan_path_finds_planted_secret() {
use std::process::Command;
let dir = TempDir::new().expect("fixture dir");
let fixture = dir.path().join("daemon_planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let (runtime, mut daemon) = start_daemon();
let scan = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--format", "json"])
.arg(&fixture)
.output()
.expect("daemon scan");
stop_daemon(&runtime, &mut daemon);
assert_eq!(
scan.status.code(),
Some(1),
"daemon scan must find secret (exit 1); stderr={}",
String::from_utf8_lossy(&scan.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&scan.stdout).expect("daemon stdout is JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.iter().any(|f| matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key" | "hot-aws_key")
)),
"daemon wire path must return an AWS finding; got {arr:?}"
);
}
#[cfg(unix)]
#[test]
fn daemon_wire_scan_stdin_finds_planted_secret() {
use std::io::Write;
use std::process::{Command, Stdio};
let (runtime, mut daemon) = start_daemon();
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let mut child = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--stdin", "--format", "json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn daemon stdin scan");
child
.stdin
.take()
.expect("child stdin")
.write_all(fixture.as_bytes())
.expect("pipe fixture to stdin");
let scan = child.wait_with_output().expect("daemon stdin scan output");
stop_daemon(&runtime, &mut daemon);
assert_eq!(
scan.status.code(),
Some(1),
"daemon --stdin scan must find secret (exit 1); stderr={}",
String::from_utf8_lossy(&scan.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&scan.stdout).expect("daemon stdin stdout is JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.iter().any(|f| matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key" | "hot-aws_key")
)),
"daemon ScanText/stdin wire path must return an AWS finding; got {arr:?}"
);
}
#[cfg(unix)]
#[test]
fn daemon_wire_stdin_example_suppression_summary_propagates() {
use std::io::Write;
use std::process::{Command, Stdio};
let (runtime, mut daemon) = start_daemon();
let fixture = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n";
let mut child = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--stdin", "--format", "text"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn daemon stdin example scan");
child
.stdin
.take()
.expect("child stdin")
.write_all(fixture.as_bytes())
.expect("pipe example fixture to stdin");
let scan = child
.wait_with_output()
.expect("daemon stdin example scan output");
stop_daemon(&runtime, &mut daemon);
let stdout = String::from_utf8_lossy(&scan.stdout);
assert!(
stdout.contains("example/test key") && stdout.contains("suppressed"),
"wire-v2 engine_example_suppressions must propagate over the real \
socket so the daemon client distinguishes suppressed-example from a \
clean repo. Got stdout: {stdout}"
);
assert!(
!stdout.contains("Your code is clean."),
"the clean-repo summary must NOT fire when the daemon suppressed an \
example credential and reported a non-zero wire-v2 count. \
Got stdout: {stdout}"
);
}
#[cfg(unix)]
#[test]
fn daemon_status_reports_payload_after_live_scan() {
use std::process::Command;
let dir = TempDir::new().expect("fixture dir");
let fixture = dir.path().join("daemon_status_planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let (runtime, mut daemon) = start_daemon();
let scan = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--format", "json"])
.arg(&fixture)
.output()
.expect("daemon scan before status");
assert_eq!(
scan.status.code(),
Some(1),
"pre-status daemon scan must find the planted key; stderr={}",
String::from_utf8_lossy(&scan.stderr)
);
let status = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["daemon", "status"])
.output()
.expect("daemon status");
stop_daemon(&runtime, &mut daemon);
assert_eq!(
status.status.code(),
Some(0),
"`daemon status` against a live daemon must exit 0; stderr={}",
String::from_utf8_lossy(&status.stderr)
);
let out = String::from_utf8_lossy(&status.stdout);
assert!(
out.contains("scans served"),
"status payload must report scans-served; got: {out}"
);
assert!(
out.contains("detectors"),
"status payload must report the detector count; got: {out}"
);
assert!(
!out.contains("0 scans served"),
"status must report the scan we issued (non-zero scans-served); got: {out}"
);
}
#[test]
fn doctor_reports_corpus_and_passes_scan_self_test() {
let output = Command::new(binary())
.arg("doctor")
.env("KEYHOG_NO_GPU", "1")
.output()
.expect("run keyhog doctor");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
output.status.code(),
Some(0),
"doctor must exit 0 on a healthy host (PATH warning is non-fatal); stdout:\n{stdout}"
);
assert!(
stdout.contains("self-test"),
"doctor must run a self-test section; got:\n{stdout}"
);
assert!(
stdout.contains("PASS"),
"the scan-engine self-test must PASS; got:\n{stdout}"
);
let corpus = keyhog_core::embedded_detector_count();
assert!(corpus > 0, "binary must embed a detector corpus");
assert!(
stdout.contains(&corpus.to_string()),
"doctor must display the real embedded corpus count ({corpus}); got:\n{stdout}"
);
}
#[test]
fn update_subcommand_is_wired_with_its_flags() {
let output = Command::new(binary())
.arg("update")
.arg("--help")
.output()
.expect("run keyhog update --help");
assert!(
output.status.success(),
"`keyhog update --help` must succeed; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let help = String::from_utf8_lossy(&output.stdout);
for flag in ["--check", "--version", "--variant"] {
assert!(
help.contains(flag),
"`keyhog update --help` must document {flag}; got:\n{help}"
);
}
}
#[test]
fn repair_subcommand_is_wired_with_its_flags() {
let output = Command::new(binary())
.arg("repair")
.arg("--help")
.output()
.expect("run keyhog repair --help");
assert!(
output.status.success(),
"`keyhog repair --help` must succeed; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let help = String::from_utf8_lossy(&output.stdout);
for flag in ["--force", "--version", "--variant"] {
assert!(
help.contains(flag),
"`keyhog repair --help` must document {flag}; got:\n{help}"
);
}
}
#[test]
fn uninstall_dry_run_does_not_remove_the_binary() {
let bin = binary();
let output = Command::new(&bin)
.arg("uninstall")
.output()
.expect("run keyhog uninstall");
assert!(
output.status.success(),
"dry-run uninstall must exit 0; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let out = String::from_utf8_lossy(&output.stdout).to_lowercase();
assert!(
out.contains("dry run"),
"uninstall without --yes must announce it's a dry run; got:\n{out}"
);
assert!(
bin.exists(),
"dry-run uninstall MUST NOT delete the binary at {}",
bin.display()
);
}
fn scan_dir_with_config(
content: &str,
config: &str,
extra: &[&str],
) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("planted.txt"), content).expect("write fixture");
std::fs::write(dir.path().join(".keyhog.toml"), config).expect("write config");
let output = Command::new(binary())
.args(["scan", "--no-daemon", "--format", "json"])
.args(extra)
.arg(dir.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 config_detector_disable_drops_findings() {
let aws = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let (_o, _e, before) = scan_dir_with_config(aws, "", &[]);
assert_eq!(before, Some(1), "baseline: the AWS key must be found");
let (out, _e, code) = scan_dir_with_config(
aws,
"[detector.hot-aws_key]\nenabled = false\n[detector.aws-access-key]\nenabled = false\n",
&[],
);
assert_eq!(
code,
Some(0),
"disabling the AWS detectors via .keyhog.toml must yield zero findings; stdout={out}"
);
}
#[test]
fn config_detector_min_confidence_floor_drops_findings() {
let aws = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let (_o, _e, before) = scan_dir_with_config(aws, "", &[]);
assert_eq!(before, Some(1), "baseline: the AWS key must be found");
let (out_hi, _e, code_hi) = scan_dir_with_config(
aws,
"[detector.hot-aws_key]\nmin_confidence = 1.0\n\
[detector.aws-access-key]\nmin_confidence = 1.0\n",
&[],
);
assert_eq!(
code_hi,
Some(0),
"a per-detector min_confidence floor of 1.0 must suppress the finding; stdout={out_hi}"
);
let (_o, _e, code_lo) = scan_dir_with_config(
aws,
"[detector.hot-aws_key]\nmin_confidence = 0.0\n\
[detector.aws-access-key]\nmin_confidence = 0.0\n",
&[],
);
assert_eq!(
code_lo,
Some(1),
"a per-detector min_confidence floor of 0.0 must keep the finding"
);
}
#[test]
fn config_lockdown_require_refuses_without_flag() {
let (_o, err, code) =
scan_dir_with_config("ordinary content\n", "[lockdown]\nrequire = true\n", &[]);
assert_ne!(
code,
Some(0),
"a repo whose .keyhog.toml requires lockdown must NOT run without --lockdown"
);
assert!(
err.to_lowercase().contains("lockdown"),
"the refusal must name lockdown so the operator knows why; stderr={err}"
);
}
#[test]
fn precision_mode_keeps_strong_drops_weak() {
let fixture = concat!(
"aws_access_key_id = \"AKIA",
"QYLPMN5HGT3KZ7WB\"\n",
"aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n",
);
let parse = |s: &str| -> Vec<String> {
serde_json::from_str::<serde_json::Value>(s)
.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 (def_out, _e, _c) = scan_text_file(fixture, &[]);
let (prec_out, _e2, _c2) = scan_text_file(fixture, &["--precision"]);
let def = parse(&def_out);
let prec = parse(&prec_out);
assert!(
def.len() >= 2,
"default mode should surface both the access-key id and the secret key; got {def:?}"
);
assert!(
def.iter().any(|d| d == "aws-secret-access-key"),
"default must find the secret key; got {def:?}"
);
assert!(
prec.iter().any(|d| d == "aws-secret-access-key"),
"precision must KEEP the high-confidence secret key; got {prec:?}"
);
assert!(
prec.len() < def.len(),
"precision must be strictly tighter than default; default={def:?} precision={prec:?}"
);
assert!(
!prec
.iter()
.any(|d| d == "aws-access-key" || d == "hot-aws_key"),
"precision must drop the weaker access-key-id finding (below the 0.85 bar); got {prec:?}"
);
}
#[test]
fn precision_mode_conflicts_with_fast() {
let (_o, err, code) = scan_text_file("ordinary content\n", &["--precision", "--fast"]);
assert_eq!(
code,
Some(2),
"clap usage error (exit 2) expected for conflicting --precision --fast; got {code:?}"
);
assert!(
err.contains("cannot be used with") || err.to_lowercase().contains("precision"),
"the usage error must name the conflict; stderr={err}"
);
}