use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const TOKEN_A: &str = "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";
const HASH_A: &str = "7b85310a29300230c865bc48ca1836f15b81bd50ac85e8c0785e8145e98ff175";
const REDACTED_A: &str = "ghp_...DSiF";
const TOKEN_B: &str = "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";
const HASH_B: &str = "b1b3c6272a683aa8a4ca50250745b4c8b9d9c88570e8acb73eae2f9de9ec65e3";
const REDACTED_B: &str = "ghp_...8GjS";
const TOKEN_C: &str = "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";
const HASH_C: &str = "97dc6af90caace47c39142ab4d92f1e58eaebd858842ea9a2f0ee6e7542bce7f";
const DETECTOR_ID: &str = "github-classic-pat";
const SEVERITY: &str = "critical";
const EXIT_SUCCESS: i32 = 0;
const EXIT_FINDINGS: i32 = 1;
const EXIT_USER_ERROR: i32 = 2;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn plant_skip_tree() -> TempDir {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("a.env"), format!("{TOKEN_A}\n")).expect("write a.env");
let skip = dir.path().join("skip");
std::fs::create_dir(&skip).expect("mkdir skip");
std::fs::write(skip.join("b.env"), format!("{TOKEN_B}\n")).expect("write skip/b.env");
dir
}
fn plant_deep_skip_tree() -> TempDir {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("a.env"), format!("{TOKEN_A}\n")).expect("write a.env");
let deep = dir.path().join("skip").join("deep");
std::fs::create_dir_all(&deep).expect("mkdir skip/deep");
std::fs::write(deep.join("c.env"), format!("{TOKEN_C}\n")).expect("write skip/deep/c.env");
dir
}
struct Finding {
basename: String,
hash: String,
detector_id: String,
severity: String,
line: i64,
offset: i64,
additional_locations: usize,
}
fn run_json(root: &Path, extra: &[&str]) -> (i32, Vec<Finding>, Vec<u8>, String) {
let root_str = root.to_str().expect("utf8 tempdir path").to_owned();
let mut args: Vec<String> = vec![
"scan".into(),
"--daemon=off".into(),
"--backend".into(),
"cpu".into(),
"--no-suppress-test-fixtures".into(),
"--format".into(),
"json".into(),
"--path".into(),
root_str,
];
for a in extra {
args.push((*a).to_owned());
}
let output = Command::new(binary())
.args(&args)
.output()
.expect("spawn keyhog");
let code = output.status.code().expect("exit code");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let findings = if code == EXIT_SUCCESS || code == EXIT_FINDINGS {
let value: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is a JSON array");
let array = value.as_array().expect("top-level JSON array").clone();
array
.into_iter()
.map(|f| {
let file_path = f["location"]["file_path"]
.as_str()
.expect("location.file_path string");
let basename = file_path
.rsplit('/')
.next()
.expect("non-empty path")
.to_owned();
Finding {
basename,
hash: f["credential_hash"]
.as_str()
.expect("credential_hash string")
.to_owned(),
detector_id: f["detector_id"]
.as_str()
.expect("detector_id string")
.to_owned(),
severity: f["severity"].as_str().expect("severity string").to_owned(),
line: f["location"]["line"].as_i64().expect("line int"),
offset: f["location"]["offset"].as_i64().expect("offset int"),
additional_locations: f["additional_locations"]
.as_array()
.expect("additional_locations array")
.len(),
}
})
.collect()
} else {
Vec::new()
};
(code, findings, output.stdout, stderr)
}
fn basenames(findings: &[Finding]) -> BTreeSet<String> {
findings.iter().map(|f| f.basename.clone()).collect()
}
fn hashes(findings: &[Finding]) -> BTreeSet<String> {
findings.iter().map(|f| f.hash.clone()).collect()
}
#[test]
fn baseline_finds_root_and_nested_env() {
let dir = plant_skip_tree();
let (code, findings, _stdout, stderr) = run_json(dir.path(), &[]);
assert_eq!(
code, EXIT_FINDINGS,
"findings present -> exit 1; stderr:\n{stderr}"
);
assert_eq!(findings.len(), 2, "one finding per planted .env file");
assert_eq!(
basenames(&findings),
BTreeSet::from(["a.env".to_owned(), "b.env".to_owned()]),
"root a.env and nested skip/b.env both scanned"
);
assert_eq!(
hashes(&findings),
BTreeSet::from([HASH_A.to_owned(), HASH_B.to_owned()]),
"distinct tokens produce distinct credential hashes"
);
}
#[test]
fn exclude_skip_globstar_drops_nested_env() {
let dir = plant_skip_tree();
let (code, findings, _stdout, stderr) =
run_json(dir.path(), &["--exclude-paths", "**/skip/**"]);
assert_eq!(
code, EXIT_FINDINGS,
"root a.env still leaks -> exit 1; stderr:\n{stderr}"
);
assert_eq!(
findings.len(),
1,
"only a.env survives the **/skip/** exclude"
);
let only = &findings[0];
assert_eq!(only.basename, "a.env");
assert_eq!(
only.hash, HASH_A,
"surviving finding is TOKEN_A, not TOKEN_B"
);
assert_eq!(only.detector_id, DETECTOR_ID);
assert!(
!findings.iter().any(|f| f.hash == HASH_B),
"the excluded skip/b.env (TOKEN_B) must not survive"
);
}
#[test]
fn exclude_bare_dirname_prunes_skip_subtree() {
let dir = plant_skip_tree();
let (code, findings, _stdout, stderr) = run_json(dir.path(), &["--exclude-paths", "skip"]);
assert_eq!(
code, EXIT_FINDINGS,
"root a.env still leaks; stderr:\n{stderr}"
);
assert_eq!(findings.len(), 1, "skip/ pruned, a.env kept");
assert_eq!(basenames(&findings), BTreeSet::from(["a.env".to_owned()]));
assert_eq!(findings[0].hash, HASH_A);
}
#[test]
fn exclude_skip_globstar_prunes_deep_subtree() {
let dir = plant_deep_skip_tree();
let (code, findings, _stdout, stderr) =
run_json(dir.path(), &["--exclude-paths", "**/skip/**"]);
assert_eq!(
code, EXIT_FINDINGS,
"root a.env still leaks; stderr:\n{stderr}"
);
assert_eq!(findings.len(), 1, "the whole skip/deep subtree is pruned");
assert_eq!(findings[0].basename, "a.env");
assert_eq!(findings[0].hash, HASH_A);
assert!(
!findings.iter().any(|f| f.hash == HASH_C),
"deeply-nested TOKEN_C under skip/deep must not survive"
);
}
#[test]
fn exclude_nonmatching_glob_keeps_both() {
let dir = plant_skip_tree();
let (code, findings, _stdout, _stderr) =
run_json(dir.path(), &["--exclude-paths", "**/other/**"]);
assert_eq!(code, EXIT_FINDINGS);
assert_eq!(findings.len(), 2, "**/other/** matches no planted file");
assert_eq!(
basenames(&findings),
BTreeSet::from(["a.env".to_owned(), "b.env".to_owned()])
);
assert_eq!(
hashes(&findings),
BTreeSet::from([HASH_A.to_owned(), HASH_B.to_owned()])
);
}
#[test]
fn exclude_star_env_matches_any_depth_empties_result() {
let dir = plant_skip_tree();
let (code, findings, stdout, stderr) = run_json(dir.path(), &["--exclude-paths", "*.env"]);
assert_eq!(
code, EXIT_SUCCESS,
"every .env excluded -> exit 0; stderr:\n{stderr}"
);
assert_eq!(findings.len(), 0, "*.env prunes a.env AND skip/b.env");
assert_eq!(
stdout, b"[]",
"empty findings render as the exact bytes `[]`"
);
}
#[test]
fn exclude_recursive_env_glob_empties_result() {
let dir = plant_skip_tree();
let (code, findings, stdout, _stderr) = run_json(dir.path(), &["--exclude-paths", "**/*.env"]);
assert_eq!(code, EXIT_SUCCESS, "no .env survives -> exit 0");
assert_eq!(findings.len(), 0);
assert_eq!(stdout, b"[]");
}
#[test]
fn exclude_anchored_relative_path_drops_one() {
let dir = plant_skip_tree();
let (code, findings, _stdout, stderr) =
run_json(dir.path(), &["--exclude-paths", "skip/b.env"]);
assert_eq!(code, EXIT_FINDINGS, "a.env still leaks; stderr:\n{stderr}");
assert_eq!(findings.len(), 1, "only skip/b.env pruned");
assert_eq!(findings[0].basename, "a.env");
assert_eq!(findings[0].hash, HASH_A);
}
#[test]
fn multiple_excludes_compose_to_empty() {
let dir = plant_skip_tree();
let (code, findings, stdout, stderr) =
run_json(dir.path(), &["--exclude-paths", "a.env", "**/skip/**"]);
assert_eq!(
code, EXIT_SUCCESS,
"both operands prune everything -> exit 0; stderr:\n{stderr}"
);
assert_eq!(findings.len(), 0, "a.env and skip/** both excluded");
assert_eq!(stdout, b"[]");
}
#[test]
fn multiple_excludes_partial_match_keeps_root() {
let dir = plant_skip_tree();
let (code, findings, _stdout, _stderr) =
run_json(dir.path(), &["--exclude-paths", "**/nope/**", "**/skip/**"]);
assert_eq!(code, EXIT_FINDINGS);
assert_eq!(findings.len(), 1, "only skip/** matched; a.env survives");
assert_eq!(findings[0].basename, "a.env");
assert_eq!(findings[0].hash, HASH_A);
}
#[test]
fn bare_exclude_flag_is_usage_error() {
let dir = plant_skip_tree();
let root = dir.path().to_str().expect("utf8").to_owned();
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--path",
&root,
"--exclude",
"**/skip/**",
])
.output()
.expect("spawn keyhog");
let code = output.status.code().expect("exit code");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
code, EXIT_USER_ERROR,
"bare --exclude is unknown -> exit 2; stderr:\n{stderr}"
);
assert!(
stderr.contains("--exclude"),
"clap must name the rejected --exclude token; got:\n{stderr}"
);
}
#[test]
fn surviving_env_finding_fields_are_pinned() {
let dir = plant_skip_tree();
let (_code, findings, _stdout, _stderr) =
run_json(dir.path(), &["--exclude-paths", "**/skip/**"]);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.basename, "a.env");
assert_eq!(f.hash, HASH_A);
assert_eq!(f.detector_id, DETECTOR_ID);
assert_eq!(f.severity, SEVERITY);
assert_eq!(f.line, 1, "planted on the first line");
assert_eq!(f.offset, 0, "token starts at byte 0 of the line");
assert_eq!(
f.additional_locations, 0,
"single location for a unique value"
);
}
#[test]
fn text_summary_reflects_skip_exclude() {
let dir = plant_skip_tree();
let root = dir.path().to_str().expect("utf8").to_owned();
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--no-suppress-test-fixtures",
"--format",
"text",
"--path",
&root,
"--exclude-paths",
"**/skip/**",
])
.output()
.expect("spawn keyhog");
assert_eq!(output.status.code(), Some(EXIT_FINDINGS));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("1 secret found"),
"filtered scan reports exactly one secret; got:\n{stdout}"
);
assert!(
stdout.contains(REDACTED_A),
"the surviving a.env secret ({REDACTED_A}) is shown; got:\n{stdout}"
);
assert!(
!stdout.contains(REDACTED_B),
"the excluded skip/b.env secret ({REDACTED_B}) must NOT appear; got:\n{stdout}"
);
}