#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const PLANTED: &str = "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";
const PLANTED_2: &str = "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";
const DETECTOR_ID: &str = "github-classic-pat";
const DETECTOR_NAME: &str = "GitHub Classic PAT";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn leak_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("dump.txt");
std::fs::write(&path, format!("{PLANTED}\n")).expect("write leak fixture");
(dir, path)
}
fn two_leak_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("dump.txt");
std::fs::write(&path, format!("{PLANTED}\n{PLANTED_2}\n")).expect("write two-leak fixture");
(dir, path)
}
fn clean_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("notes.txt");
std::fs::write(
&path,
"just ordinary prose with plain everyday words here\n",
)
.expect("write clean fixture");
(dir, path)
}
fn base_cmd(target: &Path, format: &str, out: Option<&Path>) -> Command {
let mut cmd = Command::new(binary());
cmd.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--no-suppress-test-fixtures",
"--format",
format,
]);
if let Some(o) = out {
cmd.arg("--output").arg(o);
}
cmd.arg(target);
cmd
}
fn run(target: &Path, format: &str, out: Option<&Path>) -> (Option<i32>, String, String) {
let output = base_cmd(target, format, out)
.output()
.expect("spawn keyhog scan");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn relative_output_path_lands_in_cwd() {
let (_dir, target) = leak_fixture();
let cwd = TempDir::new().expect("cwd tempdir");
let output = base_cmd(&target, "json", Some(Path::new("report.json")))
.current_dir(cwd.path())
.output()
.expect("spawn keyhog scan");
assert_eq!(
output.status.code(),
Some(1),
"scan with a finding exits 1; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let landed = cwd.path().join("report.json");
let bytes =
std::fs::read_to_string(&landed).expect("relative --output must land in the process CWD");
let v: serde_json::Value = serde_json::from_str(&bytes).expect("cwd file must be json");
assert_eq!(
v.as_array()
.and_then(|a| a.first())
.and_then(|o| o.get("detector_id"))
.and_then(|x| x.as_str()),
Some(DETECTOR_ID),
"the CWD-relative file must carry the detector id"
);
}
#[test]
fn jsonl_output_file_two_objects_two_lines() {
let (_dir, target) = two_leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.jsonl");
let (code, _out, err) = run(&target, "jsonl", Some(&out_file));
assert_eq!(code, Some(1), "two findings still exits 1; stderr={err}");
let bytes = std::fs::read_to_string(&out_file).expect("jsonl file must exist");
assert!(
serde_json::from_str::<serde_json::Value>(bytes.trim()).is_err(),
"jsonl must NOT be a single parseable JSON document; got:\n{bytes}"
);
let lines: Vec<&str> = bytes.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 2, "two distinct secrets → two jsonl lines");
for line in &lines {
let v: serde_json::Value =
serde_json::from_str(line).expect("each jsonl line must be a json object");
assert!(v.is_object(), "each jsonl line is an object, not an array");
assert_eq!(
v.get("detector_id").and_then(|x| x.as_str()),
Some(DETECTOR_ID),
"each jsonl line carries the planted detector id"
);
}
}
#[test]
fn text_output_file_has_no_ansi_but_names_detector() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.txt");
let (code, _out, err) = run(&target, "text", Some(&out_file));
assert_eq!(
code,
Some(1),
"text scan with finding exits 1; stderr={err}"
);
let bytes = std::fs::read_to_string(&out_file).expect("text file must exist");
assert!(
!bytes.contains('\u{1b}'),
"a report written to a file must carry no ANSI colour escape; got:\n{bytes}"
);
assert!(
bytes.contains(DETECTOR_NAME),
"the text report file must name the detector (display name); got:\n{bytes}"
);
}
#[test]
fn github_annotations_output_file_error_command() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.gha");
let (code, _out, err) = run(&target, "github-annotations", Some(&out_file));
assert_eq!(code, Some(1), "annotation scan exits 1; stderr={err}");
let bytes = std::fs::read_to_string(&out_file).expect("annotation file must exist");
let first = bytes
.lines()
.find(|l| !l.trim().is_empty())
.expect("annotation file must have a command line");
assert!(
first.starts_with("::error "),
"critical finding → `::error ` workflow command; got: {first:?}"
);
assert!(
first.contains("title=keyhog critical github-classic-pat"),
"the annotation title must name severity+detector; got: {first:?}"
);
}
#[test]
fn junit_output_file_prologue_and_counts() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.xml");
let (code, _out, err) = run(&target, "junit", Some(&out_file));
assert_eq!(code, Some(1), "junit scan exits 1; stderr={err}");
let bytes = std::fs::read_to_string(&out_file).expect("junit file must exist");
assert!(
bytes.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
"junit file must open with the XML declaration; got:\n{bytes}"
);
assert!(
bytes.contains(
"<testsuite name=\"keyhog\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.0\">"
),
"junit file must carry the 1-test / 1-failure suite header; got:\n{bytes}"
);
}
#[test]
fn gitlab_sast_output_file_single_vulnerability() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("gl-sast.json");
let (code, _out, err) = run(&target, "gitlab-sast", Some(&out_file));
assert_eq!(code, Some(1), "gitlab-sast scan exits 1; stderr={err}");
let bytes = std::fs::read_to_string(&out_file).expect("gitlab-sast file must exist");
let v: serde_json::Value =
serde_json::from_str(&bytes).expect("gitlab-sast file must parse as json");
assert_eq!(
v.get("vulnerabilities")
.and_then(|x| x.as_array())
.map(Vec::len),
Some(1),
"one finding → one gitlab-sast vulnerability; doc was:\n{bytes}"
);
}
#[test]
fn text_output_leaves_stdout_empty() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.txt");
let (code, stdout, err) = run(&target, "text", Some(&out_file));
assert_eq!(code, Some(1), "text scan exits 1; stderr={err}");
assert!(
stdout.trim().is_empty(),
"with --output the text report must not also print to stdout; stdout was:\n{stdout}"
);
let bytes = std::fs::read_to_string(&out_file).expect("text file must exist");
assert!(
bytes.contains(DETECTOR_NAME),
"the report must be in the file; file was:\n{bytes}"
);
}
#[test]
fn clean_scan_replaces_larger_stale_file_with_empty_array() {
let (_dir, target) = clean_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.json");
let stale = "X".repeat(4096);
std::fs::write(&out_file, &stale).expect("seed large stale file");
let (code, _out, err) = run(&target, "json", Some(&out_file));
assert_eq!(code, Some(0), "clean scan exits 0; stderr={err}");
let bytes = std::fs::read_to_string(&out_file).expect("clean run must still write the file");
assert_eq!(
bytes.trim_end(),
"[]",
"a clean scan must replace the whole stale file with `[]`; got: {bytes:?}"
);
assert!(
!bytes.contains('X'),
"no byte of the larger stale content may survive the atomic replace"
);
}
#[test]
fn output_target_is_regular_file() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.json");
let (code, _out, err) = run(&target, "json", Some(&out_file));
assert_eq!(code, Some(1), "scan exits 1; stderr={err}");
let meta = std::fs::symlink_metadata(&out_file).expect("output file must exist");
assert!(
meta.file_type().is_file(),
"the --output target must be a plain regular file, got: {:?}",
meta.file_type()
);
}
#[test]
fn successful_write_leaves_no_temp_sibling() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.json");
let (code, _out, err) = run(&target, "json", Some(&out_file));
assert_eq!(code, Some(1), "scan exits 1; stderr={err}");
let entries: Vec<String> = std::fs::read_dir(out_dir.path())
.expect("read out dir")
.map(|e| {
e.expect("dir entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
entries,
vec!["report.json".to_string()],
"only the requested output file may remain; the atomic temp must be gone. Found: {entries:?}"
);
}
#[test]
fn invalid_format_value_writes_no_output_file() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("never.json");
let (code, _out, stderr) = run(&target, "bogus-format", Some(&out_file));
assert_eq!(
code,
Some(2),
"an unknown --format is a clap usage error → exit 2; stderr={stderr}"
);
assert!(
!out_file.exists(),
"a rejected --format must not create the output file"
);
}
#[test]
fn stream_preview_on_stderr_report_in_file_stdout_empty() {
let (_dir, target) = leak_fixture();
let out_dir = TempDir::new().expect("out tempdir");
let out_file = out_dir.path().join("report.json");
let mut cmd = base_cmd(&target, "json", Some(&out_file));
cmd.arg("--stream");
let output = cmd.output().expect("spawn keyhog scan --stream");
let code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(
code,
Some(1),
"stream scan with finding exits 1; stderr={stderr}"
);
assert!(
stdout.trim().is_empty(),
"with --output the JSON report must not print to stdout; stdout was:\n{stdout}"
);
assert!(
stderr.contains("[stream]"),
"the redacted preview must carry the `[stream]` tag on stderr; stderr was:\n{stderr}"
);
assert!(
stderr.contains(DETECTOR_ID),
"the `[stream]` preview must name the reported detector; stderr was:\n{stderr}"
);
let bytes = std::fs::read_to_string(&out_file).expect("report file must exist");
let v: serde_json::Value = serde_json::from_str(&bytes).expect("file must be json");
assert_eq!(
v.as_array().map(Vec::len),
Some(1),
"the file must hold the one-element JSON report; file was:\n{bytes}"
);
}