use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const KEY_HIDDEN: &str = concat!("AKIA", "QYLPMN5HFIQR7XYA");
const KEY_VISIBLE: &str = concat!("AKIA", "KPQXRMSNTBVWYZBN");
const KEY_KEEP: &str = concat!("AKIA", "3M7XZ9QWPLND6KRT");
const KEY_DROP: &str = concat!("AKIA", "Z3KLMN7PQRS5TUVW");
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn init_git_repo(root: &Path) {
let git = root.join(".git");
std::fs::create_dir(&git).expect("mkdir .git");
std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").expect("write .git/HEAD");
}
fn write(path: PathBuf, body: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir parent");
}
std::fs::write(path, body).expect("write fixture");
}
fn scan(root: &Path, extra: &[&str]) -> (Vec<serde_json::Value>, Option<i32>) {
let mut cmd = Command::new(binary());
cmd.arg("scan")
.arg("--daemon=off")
.args(["--backend", "cpu"])
.arg("--no-suppress-test-fixtures")
.args(extra)
.args(["--format", "json"])
.arg(root)
.env_remove("KEYHOG_BACKEND");
let output = cmd.output().expect("spawn keyhog scan");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|error| {
panic!("keyhog scan stdout was not JSON: {error}\nstdout={stdout}\nstderr={stderr}")
});
let arr = value
.as_array()
.unwrap_or_else(|| panic!("findings JSON is not an array: {stdout}"))
.clone();
(arr, output.status.code())
}
fn aws_findings(findings: &[serde_json::Value]) -> Vec<&serde_json::Value> {
findings
.iter()
.filter(|f| {
matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key")
)
})
.collect()
}
fn provenance_basenames(findings: &[&serde_json::Value]) -> BTreeSet<String> {
findings
.iter()
.map(|f| {
let path = f
.pointer("/location/file_path")
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("finding missing location.file_path: {f}"));
Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
})
.collect()
}
fn help(subcommand: &str) -> (String, Option<i32>) {
let output = Command::new(binary())
.arg(subcommand)
.arg("--help")
.output()
.expect("spawn keyhog --help");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
output.status.code(),
)
}
#[test]
fn default_scan_skips_gitignored_secret_in_git_repo() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "hidden.env\n");
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(0),
"gitignored secret must be skipped => clean exit 0; findings={findings:?}"
);
assert_eq!(
aws_findings(&findings).len(),
0,
"no AWS finding may surface from the gitignored hidden.env; got {findings:?}"
);
}
#[test]
fn default_scan_finds_secret_in_tracked_file() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "hidden.env\n");
write(
root.join("tracked.txt"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(1),
"an un-ignored planted secret must surface => exit 1; findings={findings:?}"
);
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"exactly one AWS finding expected; got {aws:?}"
);
assert_eq!(
provenance_basenames(&aws),
BTreeSet::from(["tracked.txt".to_string()]),
"the surfaced AWS secret must come from tracked.txt"
);
}
#[test]
fn gitignored_and_tracked_twins_only_tracked_surfaces() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "hidden.env\n");
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
write(
root.join("tracked.txt"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(code, Some(1), "the tracked twin must surface => exit 1");
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"only the tracked twin may surface (the gitignored twin is dropped); got {aws:?}"
);
let names = provenance_basenames(&aws);
assert_eq!(
names,
BTreeSet::from(["tracked.txt".to_string()]),
"provenance must be exactly tracked.txt"
);
assert!(
!names.contains("hidden.env"),
"no finding may be attributed to the gitignored hidden.env"
);
}
#[test]
fn gitignore_inert_without_git_repo_secret_surfaces() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(root.join(".gitignore"), "hidden.env\n");
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(1),
"without a .git/ the gitignore is inert and the secret surfaces => exit 1"
);
let aws = aws_findings(&findings);
assert_eq!(aws.len(), 1, "one AWS finding expected; got {aws:?}");
assert_eq!(
provenance_basenames(&aws),
BTreeSet::from(["hidden.env".to_string()]),
"the non-repo scan must surface the secret from hidden.env"
);
}
#[test]
fn no_default_excludes_does_not_reinclude_gitignored_secret() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "hidden.env\n");
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
let (findings, code) = scan(root, &["--no-default-excludes"]);
assert_eq!(
code,
Some(0),
"--no-default-excludes must NOT re-include a gitignored file => still clean exit 0; \
findings={findings:?}"
);
assert_eq!(
aws_findings(&findings).len(),
0,
"the gitignored secret stays skipped even with default excludes off"
);
}
#[test]
fn keyhogignore_skips_secret_without_git_repo() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(root.join(".keyhogignore"), "hidden.env\n");
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
write(
root.join("visible.txt"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(1),
"visible.txt surfaces while .keyhogignore drops hidden.env => exit 1"
);
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"exactly one AWS finding expected; got {aws:?}"
);
assert_eq!(
provenance_basenames(&aws),
BTreeSet::from(["visible.txt".to_string()]),
".keyhogignore must drop hidden.env and keep visible.txt"
);
}
#[test]
fn exclude_paths_cli_suppresses_matching_file() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(
root.join("hidden.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
write(
root.join("visible.txt"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
let (findings, code) = scan(root, &["--exclude-paths", "hidden.env"]);
assert_eq!(
code,
Some(1),
"visible.txt still surfaces while --exclude-paths drops hidden.env => exit 1"
);
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"exactly one AWS finding expected; got {aws:?}"
);
assert_eq!(
provenance_basenames(&aws),
BTreeSet::from(["visible.txt".to_string()]),
"--exclude-paths hidden.env must suppress hidden.env only"
);
}
#[test]
fn gitignore_same_file_negation_reincludes_at_cli() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "*.env\n!keep.env\n");
write(
root.join("keep.env"),
&format!("aws_key = \"{KEY_KEEP}\"\n"),
);
write(
root.join("drop.env"),
&format!("aws_key = \"{KEY_DROP}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(1),
"keep.env is re-included and surfaces => exit 1"
);
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"only the re-included keep.env surfaces; drop.env stays ignored; got {aws:?}"
);
let names = provenance_basenames(&aws);
assert_eq!(
names,
BTreeSet::from(["keep.env".to_string()]),
"the later '!keep.env' negation wins over '*.env'"
);
assert!(
!names.contains("drop.env"),
"drop.env matches only '*.env' and must stay ignored"
);
}
#[test]
fn gitignore_directory_rule_skips_nested_secret() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "secretstash/\n");
write(
root.join("secretstash").join("lib.txt"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
write(
root.join("main.txt"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(1),
"main.txt surfaces; secretstash/ is skipped => exit 1"
);
let aws = aws_findings(&findings);
assert_eq!(aws.len(), 1, "only main.txt surfaces; got {aws:?}");
let names = provenance_basenames(&aws);
assert_eq!(
names,
BTreeSet::from(["main.txt".to_string()]),
"the 'secretstash/' directory rule must drop the nested lib.txt"
);
assert!(
!names.contains("lib.txt"),
"no finding may come from the gitignored secretstash/ subtree"
);
}
#[test]
fn nested_child_gitignore_scoped_to_subtree_at_cli() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "\n");
write(root.join("sub").join(".gitignore"), "*.env\n");
write(
root.join("a.env"),
&format!("aws_key = \"{KEY_VISIBLE}\"\n"),
);
write(
root.join("sub").join("b.env"),
&format!("aws_key = \"{KEY_HIDDEN}\"\n"),
);
let (findings, code) = scan(root, &[]);
assert_eq!(code, Some(1), "root a.env surfaces => exit 1");
let aws = aws_findings(&findings);
assert_eq!(
aws.len(),
1,
"the child *.env rule drops sub/b.env only; root a.env stays; got {aws:?}"
);
let names = provenance_basenames(&aws);
assert_eq!(
names,
BTreeSet::from(["a.env".to_string()]),
"the child .gitignore *.env must not reach up to the root a.env"
);
assert!(
!names.contains("b.env"),
"sub/b.env is dropped by the child's own *.env rule"
);
}
#[test]
fn wildcard_gitignore_drops_all_matching_secrets() {
let dir = TempDir::new().unwrap();
let root = dir.path();
init_git_repo(root);
write(root.join(".gitignore"), "*.env\n");
write(root.join("a.env"), &format!("aws_key = \"{KEY_HIDDEN}\"\n"));
write(root.join("b.env"), &format!("aws_key = \"{KEY_DROP}\"\n"));
let (findings, code) = scan(root, &[]);
assert_eq!(
code,
Some(0),
"every *.env secret is gitignored => clean exit 0; findings={findings:?}"
);
assert_eq!(
aws_findings(&findings).len(),
0,
"no AWS finding may surface when all secret-bearing files match '*.env'"
);
}
#[test]
fn scan_system_help_advertises_respect_gitignore_flag() {
let (stdout, code) = help("scan-system");
assert_eq!(code, Some(0), "`scan-system --help` must exit 0");
assert!(
stdout.contains("--respect-gitignore"),
"`scan-system --help` must advertise the --respect-gitignore toggle; got:\n{stdout}"
);
}
#[test]
fn scan_help_omits_gitignore_override_flag() {
let (stdout, code) = help("scan");
assert_eq!(code, Some(0), "`scan --help` must exit 0");
assert!(
stdout.contains("--exclude-paths"),
"`scan --help` should list --exclude-paths; got:\n{stdout}"
);
assert!(
!stdout.contains("respect-gitignore"),
"`keyhog scan` must NOT expose a --respect-gitignore override (only scan-system does)"
);
assert!(
!stdout.contains("no-respect-gitignore") && !stdout.contains("no-gitignore"),
"`keyhog scan` exposes no gitignore-disable flag; got:\n{stdout}"
);
}