use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const PLANTED: &str = concat!("ghp_", "1234567890123456789012345678902PDSiF");
const DETECTOR_ID: &str = "github-classic-pat";
const SEVERITY: &str = "critical";
const REDACTED: &str = "ghp_...DSiF";
const PLANTED_HASH: &str = "7b85310a29300230c865bc48ca1836f15b81bd50ac85e8c0785e8145e98ff175";
fn leak_line() -> String {
format!("GITHUB_TOKEN={PLANTED}\n")
}
fn plant_depth_tree() -> TempDir {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let lvl1 = root.join("lvl1");
let lvl2 = lvl1.join("lvl2");
std::fs::create_dir_all(&lvl2).expect("mkdir lvl1/lvl2");
std::fs::write(root.join("top.env"), leak_line()).expect("write top.env");
std::fs::write(lvl1.join("mid.env"), leak_line()).expect("write mid.env");
std::fs::write(lvl2.join("deep.env"), leak_line()).expect("write deep.env");
dir
}
fn scan(path: &Path, extra: &[&str]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(binary());
cmd.args(["scan", "--daemon=off"]);
if !extra.contains(&"--backend") {
cmd.args(["--backend", "cpu"]);
}
cmd.args(extra);
cmd.arg(path);
cmd.env("NO_COLOR", "1");
cmd.env_remove("KEYHOG_BACKEND");
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 json_array(stdout: &str) -> Vec<serde_json::Value> {
let v: serde_json::Value =
serde_json::from_str(stdout).unwrap_or_else(|e| panic!("stdout not JSON ({e}):\n{stdout}"));
v.as_array()
.unwrap_or_else(|| panic!("json report is not an array:\n{stdout}"))
.clone()
}
fn basename(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_string()
}
fn covered_basenames(stdout: &str) -> BTreeSet<String> {
let mut set = BTreeSet::new();
for f in json_array(stdout) {
if let Some(p) = f["location"]["file_path"].as_str() {
set.insert(basename(p));
}
if let Some(extra) = f["additional_locations"].as_array() {
for loc in extra {
if let Some(p) = loc["file_path"].as_str() {
set.insert(basename(p));
}
}
}
}
set
}
fn set_of(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn default_recursive_scan_reaches_all_three_depths() {
let tree = plant_depth_tree();
let (code, stdout, stderr) = scan(tree.path(), &["--format", "json"]);
assert_eq!(code, Some(1), "planted leaks → exit 1; stderr={stderr}");
assert_eq!(
covered_basenames(&stdout),
set_of(&["top.env", "mid.env", "deep.env"]),
"an unbounded walk must reach depth 1, 2 AND 3; stdout={stdout}"
);
}
#[test]
fn dedup_none_yields_exactly_three_findings_one_per_depth() {
let tree = plant_depth_tree();
let (code, stdout, stderr) = scan(tree.path(), &["--format", "json", "--dedup", "none"]);
assert_eq!(code, Some(1), "planted leaks → exit 1; stderr={stderr}");
let findings = json_array(&stdout);
assert_eq!(
findings.len(),
3,
"--dedup none must keep one finding per depth file; stdout={stdout}"
);
assert_eq!(
covered_basenames(&stdout),
set_of(&["top.env", "mid.env", "deep.env"]),
"each depth's file must be represented; stdout={stdout}"
);
for f in &findings {
assert_eq!(
f["detector_id"].as_str(),
Some(DETECTOR_ID),
"every planted PAT fires {DETECTOR_ID}; got {f}"
);
}
}
#[test]
fn all_depths_share_the_exact_planted_credential_hash() {
let tree = plant_depth_tree();
let (_code, stdout, _stderr) = scan(tree.path(), &["--format", "json", "--dedup", "none"]);
let hashes: BTreeSet<String> = json_array(&stdout)
.iter()
.filter_map(|f| f["credential_hash"].as_str().map(str::to_string))
.collect();
assert_eq!(
hashes,
set_of(&[PLANTED_HASH]),
"one token across all depths → one exact hash; stdout={stdout}"
);
}
#[test]
fn every_depth_finding_redacts_and_stdout_omits_plaintext() {
let tree = plant_depth_tree();
let (_code, stdout, _stderr) = scan(tree.path(), &["--format", "json", "--dedup", "none"]);
let findings = json_array(&stdout);
assert_eq!(findings.len(), 3, "expected 3 findings; stdout={stdout}");
for f in &findings {
assert_eq!(
f["credential_redacted"].as_str(),
Some(REDACTED),
"each finding must render the first4…last4 redaction; got {f}"
);
}
assert!(
!stdout.contains(PLANTED),
"the raw PAT must NEVER appear in report output; stdout={stdout}"
);
}
#[test]
fn every_depth_finding_reports_line_one() {
let tree = plant_depth_tree();
let (_code, stdout, _stderr) = scan(tree.path(), &["--format", "json", "--dedup", "none"]);
let findings = json_array(&stdout);
assert_eq!(findings.len(), 3, "expected 3 findings; stdout={stdout}");
for f in &findings {
assert_eq!(
f["location"]["line"].as_u64(),
Some(1),
"token planted on line 1 must report line 1; got {f}"
);
}
}
#[test]
fn every_depth_finding_is_critical_github_pat() {
let tree = plant_depth_tree();
let (_code, stdout, _stderr) = scan(tree.path(), &["--format", "json", "--dedup", "none"]);
let findings = json_array(&stdout);
assert_eq!(findings.len(), 3, "expected 3 findings; stdout={stdout}");
for f in &findings {
assert_eq!(
f["detector_id"].as_str(),
Some(DETECTOR_ID),
"detector; {f}"
);
assert_eq!(f["severity"].as_str(), Some(SEVERITY), "severity; {f}");
}
}
#[test]
fn scanning_deepest_subdir_finds_only_the_deep_file() {
let tree = plant_depth_tree();
let deepest = tree.path().join("lvl1").join("lvl2");
let (code, stdout, stderr) = scan(&deepest, &["--format", "json", "--dedup", "none"]);
assert_eq!(code, Some(1), "the deep leak → exit 1; stderr={stderr}");
assert_eq!(
json_array(&stdout).len(),
1,
"only one file lives under the deepest root; stdout={stdout}"
);
assert_eq!(
covered_basenames(&stdout),
set_of(&["deep.env"]),
"shallower files are outside the chosen root; stdout={stdout}"
);
}
#[test]
fn scanning_mid_subtree_excludes_the_top_level_file() {
let tree = plant_depth_tree();
let mid = tree.path().join("lvl1");
let (code, stdout, stderr) = scan(&mid, &["--format", "json", "--dedup", "none"]);
assert_eq!(code, Some(1), "leaks under lvl1 → exit 1; stderr={stderr}");
let covered = covered_basenames(&stdout);
assert_eq!(
covered,
set_of(&["mid.env", "deep.env"]),
"lvl1 root covers depth-2 and depth-3 only; stdout={stdout}"
);
assert!(
!covered.contains("top.env"),
"the depth-1 file above the root must NOT be scanned; got {covered:?}"
);
}
#[test]
fn scanning_a_single_file_root_finds_exactly_that_file() {
let tree = plant_depth_tree();
let top = tree.path().join("top.env");
let (code, stdout, stderr) = scan(&top, &["--format", "json", "--dedup", "none"]);
assert_eq!(code, Some(1), "single-file leak → exit 1; stderr={stderr}");
let findings = json_array(&stdout);
assert_eq!(findings.len(), 1, "one file, one finding; stdout={stdout}");
assert_eq!(
covered_basenames(&stdout),
set_of(&["top.env"]),
"the named file is the whole scope; stdout={stdout}"
);
assert_eq!(
findings[0]["detector_id"].as_str(),
Some(DETECTOR_ID),
"detector id; {}",
findings[0]
);
}
#[test]
fn sibling_dirs_at_the_same_depth_are_both_scanned() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let branch_a = root.join("branch_a");
let branch_b = root.join("branch_b");
std::fs::create_dir_all(&branch_a).expect("mkdir branch_a");
std::fs::create_dir_all(&branch_b).expect("mkdir branch_b");
std::fs::write(root.join("top.env"), leak_line()).expect("write top.env");
std::fs::write(branch_a.join("a.env"), leak_line()).expect("write a.env");
std::fs::write(branch_b.join("b.env"), leak_line()).expect("write b.env");
let (code, stdout, stderr) = scan(root, &["--format", "json", "--dedup", "none"]);
assert_eq!(
code,
Some(1),
"leaks in both branches → exit 1; stderr={stderr}"
);
assert_eq!(
json_array(&stdout).len(),
3,
"one finding per file across both siblings; stdout={stdout}"
);
assert_eq!(
covered_basenames(&stdout),
set_of(&["top.env", "a.env", "b.env"]),
"both same-depth siblings plus the root file are covered; stdout={stdout}"
);
}
#[test]
fn clean_nested_tree_exits_zero_with_empty_array() {
let dir = TempDir::new().expect("tempdir");
let deep = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&deep).expect("mkdir a/b/c");
std::fs::write(dir.path().join("r.rs"), "fn main() {}\n").expect("write r.rs");
std::fs::write(deep.join("d.rs"), "pub fn ok() -> u8 { 0 }\n").expect("write d.rs");
let (code, stdout, stderr) = scan(dir.path(), &["--format", "json"]);
assert_eq!(code, Some(0), "clean nested tree → exit 0; stderr={stderr}");
assert_eq!(
json_array(&stdout).len(),
0,
"no secrets anywhere in the tree; stdout={stdout}"
);
}
#[test]
fn max_depth_flag_is_rejected_as_unknown_argument() {
let tree = plant_depth_tree();
let (code, stdout, stderr) = scan(tree.path(), &["--format", "json", "--max-depth", "1"]);
assert_eq!(
code,
Some(2),
"an unknown --max-depth flag is a user error → exit 2; stderr={stderr}"
);
assert!(
stderr.contains("max-depth"),
"the error must name the unrecognized flag; stderr={stderr}"
);
assert!(
stdout.trim().is_empty(),
"a rejected parse must print no findings to stdout; stdout={stdout}"
);
}
#[test]
fn max_depth_zero_is_also_rejected_exit_two() {
let tree = plant_depth_tree();
let (code, _stdout, stderr) = scan(tree.path(), &["--format", "json", "--max-depth", "0"]);
assert_eq!(
code,
Some(2),
"no --max-depth value is accepted → exit 2; stderr={stderr}"
);
}
#[test]
fn simd_and_cpu_backends_yield_identical_depth_coverage() {
let tree = plant_depth_tree();
let (cpu_code, cpu_out, cpu_err) = scan(
tree.path(),
&["--format", "json", "--dedup", "none", "--backend", "cpu"],
);
let (simd_code, simd_out, simd_err) = scan(
tree.path(),
&["--format", "json", "--dedup", "none", "--backend", "simd"],
);
assert_eq!(cpu_code, Some(1), "cpu → exit 1; stderr={cpu_err}");
let cpu_cov = covered_basenames(&cpu_out);
assert_eq!(
cpu_cov,
set_of(&["top.env", "mid.env", "deep.env"]),
"cpu must reach all three depths; stdout={cpu_out}"
);
match simd_code {
Some(1) => {
let simd_cov = covered_basenames(&simd_out);
assert_eq!(
simd_cov, cpu_cov,
"simd depth coverage must equal cpu's, no silent per-backend degrade; \
stdout={simd_out}"
);
}
Some(3) => {
assert!(
simd_err.contains("silent cpu-fallback execution is forbidden"),
"simd without the prefilter must fail closed with the forbidden-\
silent-fallback message, got stderr={simd_err}"
);
}
other => panic!(
"simd backend must either match cpu (exit 1) or fail closed (exit 3), \
got exit {other:?}; stderr={simd_err}"
),
}
}