use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const PAT: &str = concat!("ghp_", "1234567890123456789012345678902PDSiF");
const PAT_DETECTOR: &str = "github-classic-pat";
const AWS: &str = concat!("AKIA", "QYLPMN5HFIQR7XYA");
const AWS_DETECTOR: &str = "aws-access-key";
const FP_KEY: &str = "keyhog/credentialHash/v1";
fn scan(path: &Path, extra: &[&str]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(binary());
cmd.args(["scan", "--daemon=off", "--backend", "cpu"]);
cmd.args(extra);
cmd.arg(path);
cmd.env_remove("KEYHOG_BACKEND");
cmd.env("NO_COLOR", "1");
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 file_with(name: &str, body: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, body).expect("write fixture");
(dir, path)
}
fn json_of(s: &str) -> serde_json::Value {
serde_json::from_str(s).unwrap_or_else(|e| panic!("not JSON ({e}):\n{s}"))
}
fn read_baseline(path: &Path) -> serde_json::Value {
let raw = std::fs::read_to_string(path).expect("read baseline file");
json_of(&raw)
}
fn baseline_hashes(v: &serde_json::Value) -> Vec<String> {
v["entries"]
.as_array()
.expect("entries array")
.iter()
.map(|e| e["credential_hash"].as_str().expect("hash str").to_string())
.collect()
}
fn baseline_detectors(v: &serde_json::Value) -> Vec<String> {
v["entries"]
.as_array()
.expect("entries array")
.iter()
.map(|e| e["detector_id"].as_str().expect("id str").to_string())
.collect()
}
#[test]
fn create_baseline_writes_v1_one_entry_and_exits_zero() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let base = _d.path().join("baseline.json");
let (code, _out, err) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(
code,
Some(0),
"--create-baseline returns early with exit 0 even with findings; stderr={err}"
);
let v = read_baseline(&base);
assert_eq!(
v["version"].as_u64(),
Some(1),
"a created baseline must be version 1; got {v}"
);
let entries = v["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 1, "exactly one PAT → one entry; got {v}");
assert_eq!(
entries[0]["detector_id"].as_str(),
Some(PAT_DETECTOR),
"the entry must carry the github-classic-pat id; got {v}"
);
}
#[test]
fn create_baseline_hash_is_sha256_prefixed_64_hex() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let base = _d.path().join("baseline.json");
let (code, _o, _e) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(code, Some(0));
let v = read_baseline(&base);
let hashes = baseline_hashes(&v);
assert_eq!(hashes.len(), 1, "one hash; got {v}");
let h = &hashes[0];
let hex = h
.strip_prefix("sha256:")
.unwrap_or_else(|| panic!("hash must be sha256:-prefixed; got {h}"));
assert_eq!(
hex.len(),
64,
"sha256 hex body must be 64 chars; got {hex:?}"
);
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"hash body must be lowercase hex; got {hex:?}"
);
}
#[test]
fn create_baseline_records_file_path_and_line_two() {
let body = format!("# leading comment line\n{PAT}\n");
let (_d, f) = file_with("app.env", &body);
let base = _d.path().join("baseline.json");
let (code, _o, _e) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(code, Some(0));
let v = read_baseline(&base);
let e = &v["entries"][0];
assert_eq!(
e["line"].as_u64(),
Some(2),
"the token is on line 2 (1-indexed); got {v}"
);
let fp = e["file_path"].as_str().expect("file_path present");
assert!(
fp.ends_with("app.env"),
"file_path must name the fixture app.env; got {fp}"
);
}
#[test]
fn create_baseline_multi_entry_sorted_by_detector_id() {
let body = format!("{AWS}\n{PAT}\n");
let (_d, f) = file_with("multi.env", &body);
let base = _d.path().join("baseline.json");
let (code, _o, err) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(code, Some(0), "stderr={err}");
let v = read_baseline(&base);
let ids = baseline_detectors(&v);
assert_eq!(
ids,
vec![AWS_DETECTOR.to_string(), PAT_DETECTOR.to_string()],
"two entries sorted by detector id (aws before github); got {v}"
);
}
#[test]
fn baseline_suppresses_known_finding_exit_zero_empty_report() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let base = _d.path().join("baseline.json");
let (c0, _o, _e) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(c0, Some(0));
let (code, out, err) = scan(
&f,
&["--baseline", base.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(0),
"an all-baselined scan has no new findings → exit 0; stderr={err}"
);
assert_eq!(
out.trim(),
"[]",
"the suppressed report must be exactly the empty array; got {out}"
);
}
#[test]
fn empty_baseline_surfaces_finding_exit_one() {
let (clean_d, clean) = file_with("clean.txt", "nothing to see here\n");
let base = clean_d.path().join("empty-baseline.json");
let (c0, _o, _e) = scan(&clean, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(c0, Some(0));
let v0 = read_baseline(&base);
assert_eq!(
v0["entries"].as_array().expect("entries").len(),
0,
"a clean scan produces a zero-entry baseline; got {v0}"
);
let (_pd, pf) = file_with("secrets.env", &format!("{PAT}\n"));
let (code, out, err) = scan(
&pf,
&["--baseline", base.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(1),
"an unacknowledged finding surfaces → exit 1 (EXIT_FINDINGS); stderr={err}"
);
let report = json_of(&out);
let arr = report.as_array().expect("json report array");
assert_eq!(arr.len(), 1, "exactly the one un-baselined PAT; got {out}");
assert_eq!(
arr[0]["detector_id"].as_str(),
Some(PAT_DETECTOR),
"the surfaced finding is the PAT; got {out}"
);
}
#[test]
fn baseline_suppresses_pat_but_surfaces_new_aws_exit_one() {
let dir = TempDir::new().expect("tempdir");
let pat_file = dir.path().join("a.env");
std::fs::write(&pat_file, format!("{PAT}\n")).expect("write pat");
let base = dir.path().join("baseline.json");
let (c0, _o, _e) = scan(dir.path(), &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(c0, Some(0));
assert_eq!(
read_baseline(&base)["entries"]
.as_array()
.expect("entries")
.len(),
1,
"baseline snapshots only the PAT at create time"
);
let aws_file = dir.path().join("b.env");
std::fs::write(&aws_file, format!("{AWS}\n")).expect("write aws");
let (code, out, err) = scan(
dir.path(),
&["--baseline", base.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(1),
"the new AWS pair is unacknowledged → exit 1; stderr={err}"
);
let report = json_of(&out);
let ids: Vec<&str> = report
.as_array()
.expect("json array")
.iter()
.filter_map(|f| f["detector_id"].as_str())
.collect();
assert!(
ids.contains(&AWS_DETECTOR),
"the new AWS finding must surface; got {out}"
);
assert!(
!ids.contains(&PAT_DETECTOR),
"the baselined PAT must stay suppressed; got {out}"
);
}
#[test]
fn baseline_suppression_is_path_independent() {
let (_ad, a) = file_with("a.env", &format!("{PAT}\n"));
let base = _ad.path().join("baseline.json");
let (c0, _o, _e) = scan(&a, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(c0, Some(0));
let (_bd, b) = file_with("elsewhere.txt", &format!("l1\nl2\nl3\n{PAT}\n"));
let (code, out, err) = scan(
&b,
&["--baseline", base.to_str().unwrap(), "--format", "json"],
);
assert_eq!(
code,
Some(0),
"same (detector,hash) pair stays suppressed across path/line moves; stderr={err}"
);
assert_eq!(
out.trim(),
"[]",
"the moved-but-baselined secret must not surface; got {out}"
);
}
#[test]
fn update_baseline_creates_then_dedups_on_rerun() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let base = _d.path().join("evolving-baseline.json");
assert!(!base.exists(), "baseline must not pre-exist");
let (c1, _o1, e1) = scan(&f, &["--update-baseline", base.to_str().unwrap()]);
assert_eq!(
c1,
Some(1),
"first update records a NEW entry → exit 1; stderr={e1}"
);
let v1 = read_baseline(&base);
assert_eq!(
v1["entries"].as_array().expect("entries").len(),
1,
"first update writes exactly one entry; got {v1}"
);
let (c2, _o2, e2) = scan(&f, &["--update-baseline", base.to_str().unwrap()]);
assert_eq!(
c2,
Some(0),
"the PAT is now acknowledged → no new entries → exit 0; stderr={e2}"
);
let v2 = read_baseline(&base);
assert_eq!(
v2["entries"].as_array().expect("entries").len(),
1,
"re-running must not duplicate the entry (dedup by pair); got {v2}"
);
}
#[test]
fn update_baseline_grows_and_preserves_existing() {
let dir = TempDir::new().expect("tempdir");
let pat_file = dir.path().join("a.env");
std::fs::write(&pat_file, format!("{PAT}\n")).expect("write pat");
let base = dir.path().join("baseline.json");
let (c1, _o1, _e1) = scan(dir.path(), &["--update-baseline", base.to_str().unwrap()]);
assert_eq!(c1, Some(1), "first update: PAT is new → exit 1");
assert_eq!(
read_baseline(&base)["entries"]
.as_array()
.expect("entries")
.len(),
1
);
let aws_file = dir.path().join("b.env");
std::fs::write(&aws_file, format!("{AWS}\n")).expect("write aws");
let (c2, _o2, e2) = scan(dir.path(), &["--update-baseline", base.to_str().unwrap()]);
assert_eq!(
c2,
Some(1),
"second update: AWS is new → exit 1; stderr={e2}"
);
let v = read_baseline(&base);
let ids = baseline_detectors(&v);
assert_eq!(ids.len(), 2, "the file grew to two entries; got {v}");
assert!(
ids.contains(&AWS_DETECTOR.to_string()) && ids.contains(&PAT_DETECTOR.to_string()),
"both the preserved PAT and the new AWS entry must be present; got {v}"
);
}
#[test]
fn baseline_findings_report_fails_closed_exit_two() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let bad = _d.path().join("findings-report.json");
std::fs::write(
&bad,
r#"[{"detector_id":"github-classic-pat","credential_redacted":"ghp_...DSiF"}]"#,
)
.expect("write findings report");
let (code, _out, err) = scan(&f, &["--baseline", bad.to_str().unwrap()]);
assert_eq!(
code,
Some(2),
"a findings report is not a baseline → user error → exit 2; stderr={err}"
);
assert!(
err.contains("--create-baseline"),
"the error must point at --create-baseline to fix it; got {err}"
);
}
#[test]
fn baseline_unsupported_version_fails_closed_exit_two() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let bad = _d.path().join("v999-baseline.json");
std::fs::write(&bad, r#"{"version": 999, "created": "t", "entries": []}"#).expect("write v999");
let (code, _out, err) = scan(&f, &["--baseline", bad.to_str().unwrap()]);
assert_eq!(
code,
Some(2),
"an unsupported baseline version is a user error → exit 2; stderr={err}"
);
assert!(
err.contains("unsupported baseline version 999") && err.contains("expected 1"),
"the error must state seen version 999 and expected 1; got {err}"
);
}
#[test]
fn baseline_and_create_baseline_conflict_exit_two() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let (code, _out, err) = scan(&f, &["--baseline", "b.json", "--create-baseline", "c.json"]);
assert_eq!(
code,
Some(2),
"conflicting baseline flags are a clap usage error → exit 2; stderr={err}"
);
assert!(
err.contains("cannot be used with"),
"clap must report the flag conflict; got {err}"
);
}
#[test]
fn sarif_fingerprint_stable_across_two_runs() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let fp_of = |path: &Path| -> String {
let (_c, out, _e) = scan(path, &["--format", "sarif"]);
let v = json_of(&out);
v.pointer("/runs/0/results/0/partialFingerprints")
.and_then(|p| p.get(FP_KEY))
.and_then(|h| h.as_str())
.unwrap_or_else(|| panic!("missing partialFingerprints[{FP_KEY}] in:\n{out}"))
.to_string()
};
let first = fp_of(&f);
let second = fp_of(&f);
assert_eq!(
first.len(),
64,
"the credential fingerprint is a 64-hex sha256; got {first:?}"
);
assert_eq!(
first, second,
"the same secret must yield a byte-identical fingerprint across runs"
);
}
#[test]
fn sarif_fingerprint_equals_baseline_hash_body() {
let (_d, f) = file_with("secrets.env", &format!("{PAT}\n"));
let base = _d.path().join("baseline.json");
let (c0, _o, _e) = scan(&f, &["--create-baseline", base.to_str().unwrap()]);
assert_eq!(c0, Some(0));
let baseline_hash = baseline_hashes(&read_baseline(&base))
.into_iter()
.next()
.expect("one baseline hash");
let baseline_body = baseline_hash
.strip_prefix("sha256:")
.expect("sha256: prefix")
.to_string();
let (_c, out, _e2) = scan(&f, &["--format", "sarif"]);
let v = json_of(&out);
let sarif_fp = v
.pointer("/runs/0/results/0/partialFingerprints")
.and_then(|p| p.get(FP_KEY))
.and_then(|h| h.as_str())
.unwrap_or_else(|| panic!("missing partialFingerprints in:\n{out}"))
.to_string();
assert_eq!(
sarif_fp, baseline_body,
"SARIF fingerprint and baseline hash body must be the same 64-hex sha256"
);
}