use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{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"))
}
static WATCH_E2E_SLOT: Mutex<()> = Mutex::new(());
fn watch_e2e_slot() -> std::sync::MutexGuard<'static, ()> {
WATCH_E2E_SLOT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn spawn_watch_streaming(
extra: &[&str],
dir: &std::path::Path,
) -> (std::process::Child, Arc<Mutex<String>>) {
let mut cmd = Command::new(binary());
cmd.arg("watch").arg(dir);
for a in extra {
cmd.arg(a);
}
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog watch");
let buf = Arc::new(Mutex::new(String::new()));
let stderr = child.stderr.take().expect("piped stderr");
let sink = Arc::clone(&buf);
thread::spawn(move || {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => sink.lock().unwrap().push_str(&line),
}
}
});
(child, buf)
}
fn stderr_contains_within(buf: &Arc<Mutex<String>>, marker: &str, timeout: Duration) -> bool {
let start = Instant::now();
loop {
if buf.lock().unwrap().to_lowercase().contains(marker) {
return true;
}
if start.elapsed() >= timeout {
return false;
}
thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn watch_help_documents_arguments() {
let _slot = watch_e2e_slot();
let output = Command::new(binary())
.arg("watch")
.arg("--help")
.output()
.expect("spawn keyhog watch --help");
assert_eq!(output.status.code(), Some(0), "watch --help should exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("watch") || stdout.contains("PATH") || stdout.contains("--quiet"),
"help should document watch subcommand arguments; got: {stdout}"
);
assert!(
stdout.contains("--detectors") || stdout.contains("--quiet"),
"help should mention --detectors and --quiet flags; got: {stdout}"
);
}
#[test]
fn watch_path_starts_watching_directory() {
let _slot = watch_e2e_slot();
let dir = TempDir::new().expect("create tempdir");
let watch_path = dir.path();
let mut child = Command::new(binary())
.arg("watch")
.arg(watch_path)
.arg("--quiet")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog watch");
thread::sleep(Duration::from_millis(500));
match child.try_wait() {
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
}
Ok(Some(status)) => {
panic!(
"watch process exited prematurely with status: {status}. \
This may indicate watch failed to start."
);
}
Err(e) => {
panic!("failed to check watch status: {e}");
}
}
}
#[test]
fn watch_quiet_flag_suppresses_status_messages() {
let _slot = watch_e2e_slot();
let dir = TempDir::new().expect("create tempdir");
let (mut noisy, noisy_buf) = spawn_watch_streaming(&[], dir.path());
let noisy_start = Instant::now();
let noisy_saw_banner = stderr_contains_within(&noisy_buf, "watching", Duration::from_secs(60));
let noisy_banner_latency = noisy_start.elapsed();
let _ = noisy.kill();
let _ = noisy.wait();
assert!(
noisy_saw_banner,
"non-quiet watch must print the 'watching' status banner within 60s; got: {}",
noisy_buf.lock().unwrap()
);
let (mut quiet, quiet_buf) = spawn_watch_streaming(&["--quiet"], dir.path());
let quiet_window = noisy_banner_latency + Duration::from_secs(3);
let quiet_saw_banner = stderr_contains_within(&quiet_buf, "watching", quiet_window);
let _ = quiet.kill();
let _ = quiet.wait();
assert!(
!quiet_saw_banner,
"--quiet must suppress the 'watching' status banner entirely; got quiet output: {}",
quiet_buf.lock().unwrap()
);
}
#[test]
fn watch_detectors_flag_overrides_detector_directory() {
let _slot = watch_e2e_slot();
let dir = TempDir::new().expect("create tempdir");
let nonexistent = dir.path().join("nonexistent-detectors");
let output = Command::new(binary())
.arg("watch")
.arg(dir.path())
.arg("--detectors")
.arg(&nonexistent)
.output()
.expect("spawn keyhog watch --detectors <invalid>");
let code = output.status.code();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
code != Some(0),
"watch with invalid --detectors should fail; stderr: {stderr}"
);
assert!(
stderr.to_lowercase().contains("detector")
|| stderr.to_lowercase().contains("not found")
|| stderr.to_lowercase().contains("corpus"),
"error should identify the missing detector corpus; stderr: {stderr}"
);
}
fn drain_pipe<R: std::io::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 = std::time::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
}
#[test]
fn watch_detects_changes_under_every_root() {
let _slot = watch_e2e_slot();
let root_a = TempDir::new().expect("tempdir a");
let root_b = TempDir::new().expect("tempdir b");
let canon_a = root_a.path().canonicalize().expect("canonical a");
let canon_b = root_b.path().canonicalize().expect("canonical b");
let mut child = Command::new(binary())
.arg("watch")
.arg(root_a.path())
.arg(root_b.path())
.args(["--backend", "cpu"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog watch A B");
let stdout_buf = drain_pipe(child.stdout.take().expect("stdout piped"));
let stderr_buf = drain_pipe(child.stderr.take().expect("stderr piped"));
let registered = wait_until_contains(
&stderr_buf,
&[
&canon_a.display().to_string(),
&canon_b.display().to_string(),
],
Duration::from_secs(20),
);
assert!(
registered,
"watch must register BOTH roots in its banner; stderr={}",
stderr_buf.lock().expect("buffer lock")
);
const PLANTED: &str = "AWS_ACCESS_KEY_ID = \"AKIAQYLPMN5HFIQR7XYA\"\n";
std::fs::write(root_a.path().join("a.env"), PLANTED).expect("write under root A");
std::fs::write(root_b.path().join("b.env"), PLANTED).expect("write under root B");
let both_found = wait_until_contains(&stdout_buf, &["a.env", "b.env"], Duration::from_secs(15));
let credential_hash =
keyhog_core::hex_encode(keyhog_core::sha256_hash("AKIAQYLPMN5HFIQR7XYA").as_bytes());
let hash_found = wait_until_contains(
&stdout_buf,
&[&format!("sha256:{credential_hash}")],
Duration::from_secs(1),
);
let _ = child.kill();
let _ = child.wait();
assert!(
both_found,
"a change under EVERY watched root must be scanned (multi-root, not \
first-only); stdout={} stderr={}",
stdout_buf.lock().expect("buffer lock"),
stderr_buf.lock().expect("buffer lock"),
);
assert!(
hash_found,
"watch output must expose a stable redaction-safe credential identity; stdout={}",
stdout_buf.lock().expect("buffer lock"),
);
}
#[test]
fn watch_default_path_is_current_directory() {
let _slot = watch_e2e_slot();
let dir = TempDir::new().expect("create tempdir");
let mut child = Command::new(binary())
.arg("watch")
.arg("--quiet")
.current_dir(dir.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog watch (no path)");
thread::sleep(Duration::from_millis(300));
match child.try_wait() {
Ok(None) => {
let _ = child.kill();
let output = child.wait_with_output().expect("capture watch output");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("canonicalize"),
"watch default path should not fail path resolution; stderr: {stderr}"
);
}
Ok(Some(status)) => {
let output = child.wait_with_output().expect("capture watch output");
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("watch default path exited early with status {status}; stderr: {stderr}");
}
Err(e) => panic!("failed to check watch status: {e}"),
}
}