use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const AWS_KEY: &str = "AKIAQYLPMN5HFIQR7XYA";
const REDACTED: &str = "AK...YA";
fn drain_pipe<R: Read + Send + 'static>(pipe: R) -> Arc<Mutex<String>> {
let captured = Arc::new(Mutex::new(String::new()));
let writer = Arc::clone(&captured);
thread::spawn(move || {
let mut reader = BufReader::new(pipe);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => writer.lock().expect("buffer lock").push_str(&line),
}
}
});
captured
}
fn wait_until_contains(buffer: &Arc<Mutex<String>>, needles: &[&str], deadline: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < deadline {
{
let seen = buffer.lock().expect("buffer lock");
if needles.iter().all(|needle| seen.contains(needle)) {
return true;
}
}
thread::sleep(Duration::from_millis(50));
}
false
}
fn snapshot(buffer: &Arc<Mutex<String>>) -> String {
buffer.lock().expect("buffer lock").clone()
}
fn line_containing(buffer: &Arc<Mutex<String>>, needle: &str) -> Option<String> {
let seen = buffer.lock().expect("buffer lock");
seen.lines()
.find(|l| l.contains(needle))
.map(|l| l.to_string())
}
fn kill(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn spawn_watch(
root: &std::path::Path,
quiet: bool,
) -> (Child, Arc<Mutex<String>>, Arc<Mutex<String>>) {
let mut cmd = Command::new(binary());
cmd.arg("watch")
.arg(root)
.args(["--backend", "cpu"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if quiet {
cmd.arg("--quiet");
}
let mut child = cmd.spawn().expect("spawn keyhog watch");
let out = drain_pipe(child.stdout.take().expect("stdout piped"));
let err = drain_pipe(child.stderr.take().expect("stderr piped"));
(child, out, err)
}
fn plant_until_found(
path: &std::path::Path,
key_line: usize,
stdout: &Arc<Mutex<String>>,
deadline: Duration,
) -> bool {
let leading = "# pad\n".repeat(key_line.saturating_sub(1));
let start = Instant::now();
let mut nonce = 0u64;
while start.elapsed() < deadline {
nonce += 1;
let body = format!("{leading}AWS_ACCESS_KEY_ID = \"{AWS_KEY}\"\n# nonce {nonce}\n");
std::fs::write(path, body).expect("write planted secret");
if wait_until_contains(stdout, &["aws-access-key"], Duration::from_millis(400)) {
return true;
}
}
false
}
#[test]
fn watch_help_exits_zero_and_documents_quiet_flag() {
let output = Command::new(binary())
.args(["watch", "--help"])
.output()
.expect("spawn keyhog watch --help");
assert_eq!(output.status.code(), Some(0), "watch --help must exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--quiet"),
"help must list the --quiet flag; got:\n{stdout}"
);
assert!(
stdout.contains("Quiet mode: only print findings"),
"help must carry the exact --quiet description; got:\n{stdout}"
);
}
#[test]
fn noisy_watch_prints_compiled_banner_on_stderr() {
let dir = TempDir::new().expect("tempdir");
let (mut child, _out, err) = spawn_watch(dir.path(), false);
let ready = wait_until_contains(&err, &["detectors compiled)"], Duration::from_secs(20));
kill(&mut child);
assert!(
ready,
"noisy banner must appear on stderr; stderr=\n{}",
snapshot(&err)
);
let stderr = snapshot(&err);
assert!(
stderr.contains("keyhog watch ("),
"banner must name the daemon; stderr=\n{stderr}"
);
assert!(
stderr.contains("Ctrl-C to exit"),
"banner must carry the Ctrl-C hint; stderr=\n{stderr}"
);
}
#[test]
fn noisy_watch_banner_names_canonical_root_on_stderr() {
let dir = TempDir::new().expect("tempdir");
let canon = dir.path().canonicalize().expect("canonicalize tempdir");
let (mut child, _out, err) = spawn_watch(dir.path(), false);
let ready = wait_until_contains(
&err,
&["watching:", &canon.display().to_string()],
Duration::from_secs(20),
);
kill(&mut child);
assert!(
ready,
"banner must announce the canonical watched root; stderr=\n{}",
snapshot(&err)
);
}
#[test]
fn noisy_watch_keeps_startup_chatter_off_stdout() {
let dir = TempDir::new().expect("tempdir");
let (mut child, out, err) = spawn_watch(dir.path(), false);
let ready = wait_until_contains(&err, &["detectors compiled)"], Duration::from_secs(20));
kill(&mut child);
assert!(
ready,
"banner must reach stderr; stderr=\n{}",
snapshot(&err)
);
assert_eq!(
snapshot(&out).trim(),
"",
"no change means no finding: STDOUT must stay empty while the banner is on STDERR"
);
}
#[test]
fn quiet_watch_emits_full_finding_on_stdout() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("a.env");
let (mut child, out, _err) = spawn_watch(dir.path(), true);
let found = plant_until_found(&file, 1, &out, Duration::from_secs(20));
kill(&mut child);
assert!(
found,
"quiet watch must still print the finding; stdout=\n{}",
snapshot(&out)
);
let stdout = snapshot(&out);
assert!(
stdout.contains("aws-access-key"),
"detector id missing; stdout=\n{stdout}"
);
assert!(
stdout.contains("CRITICAL (1.00)"),
"severity/confidence wrong; stdout=\n{stdout}"
);
assert!(
stdout.contains(REDACTED),
"redacted value missing; stdout=\n{stdout}"
);
assert!(
stdout.contains("a.env:1"),
"path:line marker wrong; stdout=\n{stdout}"
);
}
#[test]
fn quiet_watch_stderr_is_empty_even_with_findings() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("a.env");
let (mut child, out, err) = spawn_watch(dir.path(), true);
let found = plant_until_found(&file, 1, &out, Duration::from_secs(20));
kill(&mut child);
assert!(
found,
"quiet watch must emit a finding to gate readiness; stdout=\n{}",
snapshot(&out)
);
let stderr = snapshot(&err);
assert_eq!(
stderr, "",
"quiet mode must produce an EMPTY stderr; got:\n{stderr}"
);
assert!(
!stderr.contains("watching:"),
"quiet stderr must omit the watching banner"
);
assert!(
!stderr.contains("detectors compiled"),
"quiet stderr must omit the compiled header"
);
assert!(
!stderr.contains("Ctrl-C to exit"),
"quiet stderr must omit the Ctrl-C hint"
);
}
#[test]
fn quiet_watch_redacts_credential_never_leaks_full_key() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("leak.env");
let (mut child, out, _err) = spawn_watch(dir.path(), true);
let found = plant_until_found(&file, 1, &out, Duration::from_secs(20));
kill(&mut child);
assert!(
found,
"watch must fire on the planted key; stdout=\n{}",
snapshot(&out)
);
let stdout = snapshot(&out);
assert!(
stdout.contains(REDACTED),
"must show redacted form; stdout=\n{stdout}"
);
assert!(
!stdout.contains(AWS_KEY),
"the full credential must NEVER reach stdout; stdout=\n{stdout}"
);
}
#[test]
fn quiet_watch_finding_is_on_stdout_not_stderr() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("sep.env");
let (mut child, out, err) = spawn_watch(dir.path(), true);
let found = plant_until_found(&file, 1, &out, Duration::from_secs(20));
kill(&mut child);
assert!(found, "watch must fire; stdout=\n{}", snapshot(&out));
assert!(
snapshot(&out).contains("aws-access-key"),
"finding belongs on STDOUT"
);
assert!(
!snapshot(&err).contains("aws-access-key"),
"finding must NOT appear on STDERR; stderr=\n{}",
snapshot(&err)
);
}
#[test]
fn quiet_and_noisy_emit_identical_finding_line() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("same.env");
let (mut noisy, noisy_out, _noisy_err) = spawn_watch(dir.path(), false);
let n_found = plant_until_found(&file, 1, &noisy_out, Duration::from_secs(20));
kill(&mut noisy);
let noisy_line = line_containing(&noisy_out, "aws-access-key");
let (mut quiet, quiet_out, _quiet_err) = spawn_watch(dir.path(), true);
let q_found = plant_until_found(&file, 1, &quiet_out, Duration::from_secs(20));
kill(&mut quiet);
let quiet_line = line_containing(&quiet_out, "aws-access-key");
assert!(
n_found && q_found,
"both modes must fire; noisy={n_found} quiet={q_found}"
);
assert_eq!(
noisy_line, quiet_line,
"the aws-access-key finding line must be byte-identical across quiet/noisy"
);
let canon_file = file.canonicalize().expect("canonicalize planted file");
let expected = format!(
"\u{1F50D} aws-access-key {}:1 CRITICAL (1.00) {REDACTED}",
canon_file.display()
);
assert_eq!(
quiet_line.as_deref(),
Some(expected.as_str()),
"finding line must match the exact expected rendering"
);
}
#[test]
fn quiet_stderr_is_strictly_shorter_than_noisy_stderr() {
let noisy_dir = TempDir::new().expect("tempdir");
let (mut noisy, _noisy_out, noisy_err) = spawn_watch(noisy_dir.path(), false);
let noisy_ready = wait_until_contains(&noisy_err, &["Ctrl-C to exit"], Duration::from_secs(20));
kill(&mut noisy);
let noisy_len = snapshot(&noisy_err).len();
let quiet_dir = TempDir::new().expect("tempdir");
let (mut quiet, quiet_out, quiet_err) = spawn_watch(quiet_dir.path(), true);
let qfile = quiet_dir.path().join("ready.env");
let q_ready = plant_until_found(&qfile, 1, &quiet_out, Duration::from_secs(20));
kill(&mut quiet);
let quiet_len = snapshot(&quiet_err).len();
assert!(
noisy_ready,
"noisy banner must appear; stderr=\n{}",
snapshot(&noisy_err)
);
assert!(
q_ready,
"quiet daemon must reach readiness; stdout=\n{}",
snapshot(&quiet_out)
);
assert_eq!(
quiet_len, 0,
"quiet stderr must be exactly 0 bytes; got {quiet_len}"
);
assert!(
noisy_len > 0,
"noisy stderr must be non-empty; got {noisy_len}"
);
assert!(
quiet_len < noisy_len,
"quiet must show strictly less on stderr: quiet={quiet_len} noisy={noisy_len}"
);
}
#[test]
fn invalid_detectors_exits_two_regardless_of_quiet() {
let dir = TempDir::new().expect("tempdir");
let missing = dir.path().join("no-such-detectors");
let noisy = Command::new(binary())
.arg("watch")
.arg(dir.path())
.arg("--detectors")
.arg(&missing)
.output()
.expect("spawn noisy");
let quiet = Command::new(binary())
.arg("watch")
.arg(dir.path())
.arg("--detectors")
.arg(&missing)
.arg("--quiet")
.output()
.expect("spawn quiet");
assert_eq!(
noisy.status.code(),
Some(2),
"noisy invalid-detectors must exit 2"
);
assert_eq!(
quiet.status.code(),
Some(2),
"quiet invalid-detectors must exit 2"
);
assert_eq!(
noisy.status.code(),
quiet.status.code(),
"--quiet must not change the exit code"
);
let noisy_err = String::from_utf8_lossy(&noisy.stderr);
let quiet_err = String::from_utf8_lossy(&quiet.stderr);
assert!(
noisy_err.contains("detectors directory") && noisy_err.contains("does not exist"),
"noisy error must name the missing detectors dir; stderr=\n{noisy_err}"
);
assert!(
quiet_err.contains("detectors directory") && quiet_err.contains("does not exist"),
"even quiet mode must surface the fatal detector-load error; stderr=\n{quiet_err}"
);
}
#[test]
fn quiet_watch_reports_real_line_number() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("deep.env");
let (mut child, out, _err) = spawn_watch(dir.path(), true);
let found = plant_until_found(&file, 3, &out, Duration::from_secs(20));
kill(&mut child);
assert!(
found,
"watch must fire on the line-3 key; stdout=\n{}",
snapshot(&out)
);
let stdout = snapshot(&out);
assert!(
stdout.contains("deep.env:3"),
"finding must report the real line (3), not a constant; stdout=\n{stdout}"
);
assert!(
!stdout.contains("deep.env:1"),
"line number must not collapse to 1; stdout=\n{stdout}"
);
}
#[test]
fn watch_uses_toml_worker_count() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(".keyhog.toml"), "[scan]\nthreads = 2\n").expect("write config");
let (mut child, _out, err) = spawn_watch(dir.path(), false);
let ready = wait_until_contains(&err, &["workers: 2"], Duration::from_secs(60));
kill(&mut child);
assert!(
ready,
"watch must configure and surface the TOML worker count; stderr=\n{}",
snapshot(&err)
);
}
#[test]
fn watch_auto_discovers_installed_detector_corpus() {
let home = TempDir::new().expect("home tempdir");
let root = TempDir::new().expect("watch tempdir");
let detectors = home.path().join(".keyhog/detectors");
std::fs::create_dir_all(&detectors).expect("create installed detector directory");
std::fs::write(
detectors.join("installed-only.toml"),
r#"[detector]
id = "installed-only"
name = "Installed Only"
service = "test"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
keywords = ["INSTALLED_ONLY"]
[[detector.patterns]]
regex = "INSTALLED_ONLY=(?P<secret>[A-Za-z0-9]{20})"
description = "installed detector"
group = 1
"#,
)
.expect("write installed detector");
let mut child = Command::new(binary())
.arg("watch")
.arg(root.path())
.args(["--backend", "cpu"])
.current_dir(root.path())
.env("HOME", home.path())
.env("XDG_DATA_HOME", home.path().join("xdg-data"))
.env("XDG_DATA_DIRS", home.path().join("xdg-data-dirs"))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog watch");
let _out = drain_pipe(child.stdout.take().expect("stdout piped"));
let err = drain_pipe(child.stderr.take().expect("stderr piped"));
let ready = wait_until_contains(&err, &["1 detectors compiled"], Duration::from_secs(60));
kill(&mut child);
assert!(
ready,
"watch must compile the auto-discovered installed corpus, not embedded rules; stderr=\n{}",
snapshot(&err)
);
}
fn write_watch_detector(directory: &std::path::Path, id: &str) {
std::fs::create_dir_all(directory).expect("create detector directory");
let keyword = id.replace('-', "_").to_ascii_uppercase();
std::fs::write(
directory.join(format!("{id}.toml")),
format!(
r#"[detector]
id = "{id}"
name = "{id}"
service = "test"
severity = "high"
ml = {{ match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }}
keywords = ["{keyword}"]
[[detector.patterns]]
regex = "{keyword}_(?P<secret>[A-Z0-9]{{20}})"
description = "watch corpus precedence"
group = 1
"#
),
)
.expect("write detector");
}
#[test]
fn watch_resolves_configured_detector_corpus_and_cli_precedence() {
let root = TempDir::new().expect("watch tempdir");
let configured = root.path().join("configured-detectors");
let explicit = root.path().join("detectors");
write_watch_detector(&configured, "configured-only");
write_watch_detector(&explicit, "explicit-one");
write_watch_detector(&explicit, "explicit-two");
std::fs::write(
root.path().join(".keyhog.toml"),
format!("detectors = {:?}\n", configured.display().to_string()),
)
.expect("write config");
let spawn = |explicit_cli: bool| {
let mut command = Command::new(binary());
command
.arg("watch")
.arg(root.path())
.args(["--backend", "cpu"])
.current_dir(root.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if explicit_cli {
command.args(["--detectors", "detectors"]);
}
let mut child = command.spawn().expect("spawn keyhog watch");
let _out = drain_pipe(child.stdout.take().expect("stdout piped"));
let err = drain_pipe(child.stderr.take().expect("stderr piped"));
(child, err)
};
let (mut configured_child, configured_err) = spawn(false);
let configured_ready = wait_until_contains(
&configured_err,
&["1 detectors compiled"],
Duration::from_secs(60),
);
kill(&mut configured_child);
assert!(
configured_ready,
"watch must load the config-selected detector corpus; stderr=\n{}",
snapshot(&configured_err)
);
let (mut explicit_child, explicit_err) = spawn(true);
let explicit_ready = wait_until_contains(
&explicit_err,
&["2 detectors compiled"],
Duration::from_secs(60),
);
kill(&mut explicit_child);
assert!(
explicit_ready,
"an explicit --detectors value must win over config; stderr=\n{}",
snapshot(&explicit_err)
);
}