use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use tempfile::TempDir;
const ESC: u8 = 0x1b;
const GHP: &str = "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";
const GHP_DETECTOR: &str = "github-classic-pat";
const AKIA: &str = "AKIAQYLPMN5HFIQR7XYA";
const AKIA_DETECTOR: &str = "aws-access-key";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn cache_home() -> TempDir {
TempDir::new().expect("tempdir")
}
fn fixture(name: &str, content: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, content).expect("write fixture");
(dir, path)
}
struct Out {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
#[derive(Clone, Copy)]
enum Color {
NoColorOne,
NoColorEmpty,
Removed,
}
fn scan(home: &Path, path: &Path, color: Color, format: Option<&str>) -> Out {
let mut cmd = Command::new(binary());
cmd.env("HOME", home).env("XDG_CACHE_HOME", home);
match color {
Color::NoColorOne => {
cmd.env("NO_COLOR", "1");
}
Color::NoColorEmpty => {
cmd.env("NO_COLOR", "");
}
Color::Removed => {
cmd.env_remove("NO_COLOR");
}
}
cmd.args(["scan", "--daemon=off", "--backend", "cpu"]);
if let Some(f) = format {
cmd.args(["--format", f]);
}
cmd.arg(path);
let output = cmd.output().expect("spawn keyhog scan");
Out {
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
}
}
fn text(v: &[u8]) -> String {
String::from_utf8_lossy(v).into_owned()
}
fn has_esc(v: &[u8]) -> bool {
v.contains(&ESC)
}
fn detector_ids(stdout: &[u8]) -> Vec<String> {
let v: Value = serde_json::from_slice(stdout)
.unwrap_or_else(|e| panic!("scan json stdout must parse ({e}); stdout={}", text(stdout)));
let mut ids: Vec<String> = v
.as_array()
.expect("JSON report is a top-level array")
.iter()
.map(|f| {
f.get("detector_id")
.and_then(Value::as_str)
.expect("every finding carries a detector_id string")
.to_string()
})
.collect();
ids.sort();
ids
}
#[test]
fn no_color_text_scan_stdout_has_no_escape_and_concrete_summary() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let out = scan(home.path(), &path, Color::NoColorOne, None);
assert_eq!(
out.code,
Some(1),
"planted secret, no --verify -> exit 1; stderr={}",
text(&out.stderr)
);
assert_eq!(
has_esc(&out.stdout),
false,
"NO_COLOR=1 text stdout must contain NO ANSI escape byte; stdout={}",
text(&out.stdout)
);
let stdout = text(&out.stdout);
assert!(
stdout.contains("1 secret found"),
"single finding must render `1 secret found`; stdout={stdout}"
);
assert!(
stdout.contains("1 unverified"),
"no --verify -> the finding is `1 unverified`; stdout={stdout}"
);
assert!(
!stdout.contains("No secrets detected"),
"a scan WITH a finding must NOT print the empty-summary line; stdout={stdout}"
);
}
#[test]
fn no_color_text_summary_is_exact_joined_token() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let out = scan(home.path(), &path, Color::NoColorOne, None);
assert_eq!(out.code, Some(1));
let stdout = text(&out.stdout);
assert!(
stdout.contains("1 secret found · 1 unverified"),
"reporter must join summary parts with ` · `; stdout={stdout}"
);
}
#[test]
fn default_env_piped_text_scan_is_also_plain() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let out = scan(home.path(), &path, Color::Removed, None);
assert_eq!(out.code, Some(1), "stderr={}", text(&out.stderr));
assert_eq!(
has_esc(&out.stdout),
false,
"piped stdout is plain even without NO_COLOR (TTY-gated); stdout={}",
text(&out.stdout)
);
assert!(text(&out.stdout).contains("1 secret found"));
}
#[test]
fn no_color_toggle_yields_byte_identical_text_stdout() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let with_nc = scan(home.path(), &path, Color::NoColorOne, None);
let without_nc = scan(home.path(), &path, Color::Removed, None);
assert_eq!(with_nc.code, Some(1));
assert_eq!(without_nc.code, Some(1));
assert_eq!(
has_esc(&with_nc.stdout),
false,
"NO_COLOR=1 leaked an escape"
);
assert_eq!(
has_esc(&without_nc.stdout),
false,
"default piped run leaked an escape"
);
assert_eq!(
with_nc.stdout, without_nc.stdout,
"NO_COLOR must not add/remove any byte from the captured text report"
);
}
#[test]
fn no_color_empty_value_piped_is_byte_identical_to_no_color_one() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let one = scan(home.path(), &path, Color::NoColorOne, None);
let empty = scan(home.path(), &path, Color::NoColorEmpty, None);
assert_eq!(one.code, Some(1));
assert_eq!(empty.code, Some(1));
assert_eq!(
has_esc(&empty.stdout),
false,
"NO_COLOR=\"\" leaked an escape"
);
assert_eq!(
one.stdout, empty.stdout,
"under a pipe both postures are plain (TTY-gated), so stdout is byte-identical"
);
}
#[test]
fn text_report_redacts_raw_token_but_keeps_secret_label() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let out = scan(home.path(), &path, Color::NoColorOne, None);
assert_eq!(out.code, Some(1));
let stdout = text(&out.stdout);
assert!(
!stdout.contains(GHP),
"the redacted text report must NOT contain the full raw token; stdout={stdout}"
);
assert!(
stdout.contains("Secret:"),
"the finding box must still carry the `Secret:` label; stdout={stdout}"
);
assert!(
stdout.contains("Action:"),
"the finding box must carry the remediation `Action:` label; stdout={stdout}"
);
assert_eq!(
has_esc(&out.stdout),
false,
"redacted report leaked an escape"
);
}
#[test]
fn text_report_next_steps_render_plain() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let out = scan(home.path(), &path, Color::NoColorOne, None);
assert_eq!(out.code, Some(1));
let stdout = text(&out.stdout);
assert!(
stdout.contains("Revoke active secrets in the provider's dashboard."),
"the numbered next-steps block must render; stdout={stdout}"
);
assert_eq!(has_esc(&out.stdout), false);
}
#[test]
fn clean_file_empty_summary_is_plain_and_toggle_identical() {
let home = cache_home();
let (_d, path) = fixture("clean.txt", "the quick brown fox jumps over the lazy dog\n");
let with_nc = scan(home.path(), &path, Color::NoColorOne, None);
let without_nc = scan(home.path(), &path, Color::Removed, None);
assert_eq!(with_nc.code, Some(0), "clean file -> exit 0");
assert_eq!(without_nc.code, Some(0));
assert!(
text(&with_nc.stdout).contains("No secrets detected in the scanned files."),
"clean scan must print the honest empty summary; stdout={}",
text(&with_nc.stdout)
);
assert_eq!(
has_esc(&with_nc.stdout),
false,
"clean NO_COLOR leaked escape"
);
assert_eq!(
with_nc.stdout, without_nc.stdout,
"clean-file report must be byte-identical across the NO_COLOR toggle"
);
}
#[test]
fn two_secrets_render_plural_summary_plain() {
let home = cache_home();
let content = format!("GITHUB_TOKEN={GHP}\nAWS_ACCESS_KEY_ID={AKIA}\n");
let (_d, path) = fixture("multi.env", &content);
let out = scan(home.path(), &path, Color::NoColorOne, None);
assert_eq!(out.code, Some(1), "stderr={}", text(&out.stderr));
let stdout = text(&out.stdout);
assert!(
stdout.contains("2 secrets found · 2 unverified"),
"two findings must render the plural joined summary; stdout={stdout}"
);
assert_eq!(
has_esc(&out.stdout),
false,
"multi-secret report leaked escape"
);
}
#[test]
fn json_scan_is_plain_toggle_stable_and_has_exact_detector() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let with_nc = scan(home.path(), &path, Color::NoColorOne, Some("json"));
let without_nc = scan(home.path(), &path, Color::Removed, Some("json"));
assert_eq!(with_nc.code, Some(1));
assert_eq!(without_nc.code, Some(1));
assert_eq!(
has_esc(&with_nc.stdout),
false,
"json NO_COLOR leaked escape"
);
assert_eq!(
has_esc(&without_nc.stdout),
false,
"json default piped leaked escape"
);
assert_eq!(
with_nc.stdout, without_nc.stdout,
"json report must be byte-identical across the NO_COLOR toggle"
);
assert_eq!(
detector_ids(&with_nc.stdout),
vec![GHP_DETECTOR.to_string()],
"json must carry exactly one github-classic-pat finding"
);
assert!(
!text(&with_nc.stdout).contains(GHP),
"json report carries the hash/redacted form, never the raw token"
);
}
#[test]
fn json_two_secret_detector_set_is_toggle_stable() {
let home = cache_home();
let content = format!("GITHUB_TOKEN={GHP}\nAWS_ACCESS_KEY_ID={AKIA}\n");
let (_d, path) = fixture("multi.env", &content);
let with_nc = scan(home.path(), &path, Color::NoColorOne, Some("json"));
let without_nc = scan(home.path(), &path, Color::Removed, Some("json"));
assert_eq!(with_nc.code, Some(1));
assert_eq!(
detector_ids(&with_nc.stdout),
vec![AKIA_DETECTOR.to_string(), GHP_DETECTOR.to_string()],
"json must surface BOTH planted detectors, sorted"
);
assert_eq!(
with_nc.stdout, without_nc.stdout,
"two-secret json must be byte-identical across NO_COLOR toggle"
);
assert_eq!(has_esc(&with_nc.stdout), false);
}
#[test]
fn scan_stderr_has_no_escape_across_toggle() {
let home = cache_home();
let content = format!("GITHUB_TOKEN={GHP}\nAWS_ACCESS_KEY_ID={AKIA}\n");
let (_d, path) = fixture("multi.env", &content);
let with_nc = scan(home.path(), &path, Color::NoColorOne, None);
let without_nc = scan(home.path(), &path, Color::Removed, None);
assert_eq!(
has_esc(&with_nc.stderr),
false,
"NO_COLOR=1 scan stderr had an escape byte; stderr={}",
text(&with_nc.stderr)
);
assert_eq!(
has_esc(&without_nc.stderr),
false,
"default piped scan stderr had an escape byte; stderr={}",
text(&without_nc.stderr)
);
}
#[test]
fn bad_backend_value_exits_two_with_plain_stderr_both_modes() {
let home = cache_home();
let (_d, path) = fixture("leak.env", &format!("GITHUB_TOKEN={GHP}\n"));
let run = |color: Color| -> Out {
let mut cmd = Command::new(binary());
cmd.env("HOME", home.path())
.env("XDG_CACHE_HOME", home.path());
match color {
Color::NoColorOne => {
cmd.env("NO_COLOR", "1");
}
Color::NoColorEmpty => {
cmd.env("NO_COLOR", "");
}
Color::Removed => {
cmd.env_remove("NO_COLOR");
}
}
let output = cmd
.args(["scan", "--daemon=off", "--backend", "turbo"])
.arg(&path)
.output()
.expect("spawn keyhog scan");
Out {
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
}
};
let a = run(Color::NoColorOne);
let b = run(Color::Removed);
assert_eq!(a.code, Some(2), "bad --backend must exit 2 (user error)");
assert_eq!(b.code, Some(2), "exit 2 regardless of NO_COLOR");
assert_eq!(
has_esc(&a.stderr),
false,
"usage-error stderr had an escape byte under NO_COLOR=1; stderr={}",
text(&a.stderr)
);
assert_eq!(
has_esc(&b.stderr),
false,
"usage-error stderr had an escape byte"
);
assert!(
text(&a.stderr).contains("turbo"),
"the parser error must name the rejected `turbo` value; stderr={}",
text(&a.stderr)
);
}