use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const EXIT_SUCCESS: i32 = 0;
const EXIT_FINDINGS: i32 = 1;
const EXIT_USER_ERROR: i32 = 2;
const EXIT_LIVE: i32 = 10;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const PLANTED: &str = concat!("ghp_", "1234567890123456789012345678902PDSiF");
fn scan_with(backend: &str, path: &Path, extra: &[&str]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(binary());
cmd.args(["scan", "--daemon=off", "--backend", backend]);
cmd.args(extra);
cmd.arg(path);
cmd.env_remove("KEYHOG_BACKEND");
cmd.env_remove("KEYHOG_REQUIRE_GPU");
let out = cmd.output().expect("spawn keyhog scan");
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn scan(path: &Path, extra: &[&str]) -> (Option<i32>, String, String) {
scan_with("cpu", path, extra)
}
#[test]
fn clean_scan_cpu_backend_exits_zero() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.rs");
std::fs::write(&path, "fn main() { println!(\"no secrets here\"); }\n").expect("write clean");
let (code, _stdout, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_SUCCESS),
"a secret-free tree scanned on the host-independent cpu backend must exit 0; stderr={stderr}"
);
}
#[test]
fn clean_scan_cpu_fallback_alias_also_exits_zero() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "the quick brown fox jumps over the lazy dog\n").expect("write clean");
let (code, _stdout, stderr) = scan_with("cpu-fallback", &path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_SUCCESS),
"the cpu-fallback backend alias must behave identically to cpu (clean -> 0); stderr={stderr}"
);
}
#[test]
fn planted_secret_cpu_backend_exits_one() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("leak.env");
std::fs::write(&path, format!("GITHUB_TOKEN={PLANTED}\n")).expect("write planted");
let (code, _stdout, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_FINDINGS),
"a detected-but-unverified secret is findings -> exit 1 on the cpu backend; stderr={stderr}"
);
}
#[test]
fn planted_secret_without_verify_never_exits_ten() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("leak.env");
std::fs::write(&path, format!("token = \"{PLANTED}\"\n")).expect("write planted");
let (code, _stdout, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_FINDINGS),
"unverified finding must be 1, not 10; stderr={stderr}"
);
assert_ne!(
code,
Some(EXIT_LIVE),
"exit 10 requires --verify; an unverified finding must never claim a live credential"
);
}
#[test]
fn planted_secret_json_names_the_detector_and_exits_one() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("leak.conf");
std::fs::write(&path, format!("gh={PLANTED}\n")).expect("write planted");
let (code, stdout, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_FINDINGS),
"planted PAT -> exit 1; stderr={stderr}"
);
assert!(
stdout.contains("github-classic-pat"),
"cpu backend must attribute the leak to github-classic-pat; stdout=\n{stdout}"
);
}
#[test]
fn missing_path_cpu_backend_exits_two() {
let missing = PathBuf::from("/keyhog-exit-matrix-no-such-path-9f8e7d6c5b4a");
let (code, _stdout, stderr) = scan(&missing, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_USER_ERROR),
"a named path that does not exist is a user error -> exit 2; stderr={stderr}"
);
}
#[test]
fn unknown_backend_value_exits_two() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "nothing sensitive\n").expect("write clean");
let (code, _stdout, stderr) = scan_with("quantum-warp", &path, &["--format", "json"]);
assert_eq!(
code,
Some(EXIT_USER_ERROR),
"an unknown --backend value is a clap usage error -> exit 2; stderr={stderr}"
);
}
#[test]
fn invalid_format_value_exits_two() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "nothing sensitive\n").expect("write clean");
let (code, _stdout, stderr) = scan(&path, &["--format", "yaml-but-not-real"]);
assert_eq!(
code,
Some(EXIT_USER_ERROR),
"an unknown --format value is a clap usage error -> exit 2; stderr={stderr}"
);
}
#[test]
fn unknown_flag_exits_two() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "nothing sensitive\n").expect("write clean");
let mut cmd = Command::new(binary());
cmd.args(["scan", "--daemon=off", "--this-flag-does-not-exist"]);
cmd.arg(&path);
let out = cmd.output().expect("spawn keyhog scan");
assert_eq!(
out.status.code(),
Some(EXIT_USER_ERROR),
"an unrecognized flag is a clap usage error -> exit 2; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn unknown_subcommand_exits_two() {
let out = Command::new(binary())
.args(["definitely-not-a-subcommand"])
.output()
.expect("spawn keyhog");
assert_eq!(
out.status.code(),
Some(EXIT_USER_ERROR),
"an unknown subcommand is a clap usage error -> exit 2; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn top_level_help_exits_zero_and_renders_exit_codes_block() {
let out = Command::new(binary())
.arg("--help")
.output()
.expect("spawn keyhog --help");
assert_eq!(
out.status.code(),
Some(EXIT_SUCCESS),
"--help must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("EXIT CODES:"),
"top-level --help must render the EXIT CODES: block; stdout=\n{stdout}"
);
assert!(
stdout.contains("Secrets found"),
"the exit-1 (findings) row must be documented in --help; stdout=\n{stdout}"
);
}
#[test]
fn scan_subcommand_help_exits_zero() {
let out = Command::new(binary())
.args(["scan", "--help"])
.output()
.expect("spawn keyhog scan --help");
assert_eq!(
out.status.code(),
Some(EXIT_SUCCESS),
"scan --help must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("EXIT CODES:"),
"scan --help carries the exit-code contract (after_help); stdout=\n{stdout}"
);
}
#[test]
fn version_flag_exits_zero() {
let out = Command::new(binary())
.arg("--version")
.output()
.expect("spawn keyhog --version");
assert_eq!(
out.status.code(),
Some(EXIT_SUCCESS),
"--version must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("KeyHog v"),
"--version prints the version banner; stdout=\n{stdout}"
);
}
#[test]
fn exit_code_constants_match_documented_numbers() {
assert_eq!(keyhog::exit_codes::EXIT_SUCCESS, 0);
assert_eq!(keyhog::exit_codes::EXIT_FINDINGS, 1);
assert_eq!(keyhog::exit_codes::EXIT_USER_ERROR, 2);
assert_eq!(keyhog::exit_codes::EXIT_SYSTEM_ERROR, 3);
assert_eq!(keyhog::exit_codes::EXIT_HEALTH_FAILURE, 4);
assert_eq!(keyhog::exit_codes::EXIT_LIVE_CREDENTIALS, 10);
assert_eq!(keyhog::exit_codes::EXIT_INTERRUPTED, 130);
assert_eq!(
keyhog::exit_codes::EXIT_CREDENTIALS_FOUND,
keyhog::exit_codes::EXIT_FINDINGS
);
assert_eq!(EXIT_SUCCESS, i32::from(keyhog::exit_codes::EXIT_SUCCESS));
assert_eq!(EXIT_FINDINGS, i32::from(keyhog::exit_codes::EXIT_FINDINGS));
assert_eq!(
EXIT_USER_ERROR,
i32::from(keyhog::exit_codes::EXIT_USER_ERROR)
);
assert_eq!(
EXIT_LIVE,
i32::from(keyhog::exit_codes::EXIT_LIVE_CREDENTIALS)
);
}
#[test]
fn success_and_findings_codes_are_distinct() {
assert_ne!(
keyhog::exit_codes::EXIT_SUCCESS,
keyhog::exit_codes::EXIT_FINDINGS,
"clean and findings must be different exit codes"
);
let dir = TempDir::new().expect("tempdir");
let clean = dir.path().join("clean.txt");
std::fs::write(&clean, "just some prose, no secrets\n").expect("write clean");
let (clean_code, _o1, e1) = scan(&clean, &["--format", "json"]);
let leak = dir.path().join("leak.env");
std::fs::write(&leak, format!("API={PLANTED}\n")).expect("write leak");
let (leak_code, _o2, e2) = scan(&leak, &["--format", "json"]);
assert_eq!(clean_code, Some(EXIT_SUCCESS), "clean -> 0; stderr={e1}");
assert_eq!(leak_code, Some(EXIT_FINDINGS), "leak -> 1; stderr={e2}");
assert_ne!(
clean_code, leak_code,
"clean and leaking trees must not share an exit code"
);
}