use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn keyhog() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const EXPECTED_HOOK_CONTENT: &str = concat!(
"#!/bin/sh\n",
"# KeyHog pre-commit hook, auto-generated by `keyhog hook install`\n",
"#\n",
"# If keyhog is not on PATH, block with a clear message. A missing scanner\n",
"# means this security control did not run; letting the commit continue would\n",
"# silently turn the installed hook into a stub.\n",
"if ! command -v keyhog >/dev/null 2>&1; then\n",
" echo \"keyhog: not found on PATH - blocking commit because the pre-commit secret scan did not run.\" >&2\n",
" echo \" Install keyhog (https://github.com/santhreal/keyhog), fix PATH,\" >&2\n",
" echo \" or run 'keyhog hook uninstall' if this repository should not be protected.\" >&2\n",
" exit 127\n",
"fi\n",
"exec keyhog scan --fast --git-staged --backend cpu\n",
);
fn init_git_repo(dir: &Path) {
let out = Command::new("git")
.arg("init")
.arg("-q")
.current_dir(dir)
.output()
.expect("spawn git init");
assert!(
out.status.success(),
"git init must succeed to set up the hook test repo; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
fn run_hook(repo: &Path, args: &[&str]) -> std::process::Output {
Command::new(keyhog())
.current_dir(repo)
.env("NO_COLOR", "1")
.arg("hook")
.args(args)
.output()
.expect("spawn keyhog hook")
}
fn hook_path(repo: &Path) -> PathBuf {
repo.join(".git").join("hooks").join("pre-commit")
}
#[test]
fn hook_install_writes_exact_bytes_and_exits_zero() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let out = run_hook(dir.path(), &["install"]);
assert_eq!(
out.status.code(),
Some(0),
"hook install must exit 0 on a fresh repo; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read installed hook");
assert_eq!(
content, EXPECTED_HOOK_CONTENT,
"installed pre-commit hook must equal the shipped template byte-for-byte"
);
}
#[test]
fn hook_install_emits_exact_shebang_exec_and_guard_lines() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
run_hook(dir.path(), &["install"]);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
let mut lines = content.lines();
assert_eq!(
lines.next(),
Some("#!/bin/sh"),
"first line must be the POSIX shebang"
);
assert!(
content.contains("\nexec keyhog scan --fast --git-staged --backend cpu\n"),
"hook must exec the canonical scan verbatim; got:\n{content}"
);
assert!(
content.contains("\n exit 127\n"),
"missing-keyhog PATH guard must block the commit with exit 127; got:\n{content}"
);
assert_eq!(
content.matches("exec keyhog ").count(),
1,
"hook must contain exactly one `exec keyhog` line"
);
}
#[cfg(unix)]
#[test]
fn hook_install_sets_all_executable_bits() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
run_hook(dir.path(), &["install"]);
let mode = std::fs::metadata(hook_path(dir.path()))
.expect("stat hook")
.permissions()
.mode();
assert_eq!(
mode & 0o111,
0o111,
"hook install must OR in the executable bits (u+g+o x); mode was {mode:#o}"
);
}
#[test]
fn hook_install_success_message_is_exact() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let out = run_hook(dir.path(), &["install"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("KeyHog pre-commit hook installed at"),
"install must announce success; stderr={stderr}"
);
assert!(
stderr.contains(".git/hooks/pre-commit"),
"install message must name the pre-commit path; stderr={stderr}"
);
assert!(
!stderr.contains("installed/updated"),
"a plain install must say `installed`, not `installed/updated`; stderr={stderr}"
);
}
#[test]
fn hook_install_twice_reports_already_installed_exit_zero() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
run_hook(dir.path(), &["install"]);
let second = run_hook(dir.path(), &["install"]);
assert_eq!(
second.status.code(),
Some(0),
"re-installing over KeyHog's own hook must exit 0 (idempotent)"
);
let stderr = String::from_utf8_lossy(&second.stderr);
assert!(
stderr.contains("KeyHog pre-commit hook is already installed at"),
"second install must report the already-installed state; stderr={stderr}"
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
assert_eq!(content, EXPECTED_HOOK_CONTENT);
}
#[test]
fn hook_install_rewrites_stale_keyhog_owned_hook() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let stale = concat!(
"#!/bin/sh\n",
"# KeyHog pre-commit hook, auto-generated by `keyhog hook install`\n",
"exec keyhog scan --git-staged --backend cpu\n",
);
std::fs::write(hook_path(dir.path()), stale).expect("write stale hook");
let out = run_hook(dir.path(), &["install"]);
assert_eq!(
out.status.code(),
Some(0),
"stale KeyHog-owned hook must be rewritten; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
assert_eq!(
content, EXPECTED_HOOK_CONTENT,
"rewritten hook must match the current template"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("KeyHog pre-commit hook installed"),
"update path must report installed/updated; stderr={stderr}"
);
}
#[test]
fn hook_install_refuses_foreign_hook_without_force() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let foreign = "#!/bin/sh\necho other-tool\n";
std::fs::write(hook_path(dir.path()), foreign).expect("write foreign hook");
let out = run_hook(dir.path(), &["install"]);
assert_eq!(
out.status.code(),
Some(2),
"clobbering a foreign hook without --force must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("a pre-commit hook already exists at") && stderr.contains("--force"),
"refusal must name the conflict and offer --force; stderr={stderr}"
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
assert_eq!(
content, foreign,
"a refused install must not touch the foreign hook's bytes"
);
}
#[test]
fn hook_install_force_replaces_foreign_hook() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
std::fs::write(hook_path(dir.path()), "#!/bin/sh\necho other-tool\n")
.expect("write foreign hook");
let out = run_hook(dir.path(), &["install", "--force"]);
assert_eq!(
out.status.code(),
Some(0),
"hook install --force must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("KeyHog pre-commit hook installed/updated at"),
"forced replace must report installed/updated; stderr={stderr}"
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
assert_eq!(
content, EXPECTED_HOOK_CONTENT,
"forced install must overwrite the foreign hook with the KeyHog template"
);
}
#[test]
fn hook_uninstall_removes_keyhog_hook() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
run_hook(dir.path(), &["install"]);
assert!(hook_path(dir.path()).exists());
let out = run_hook(dir.path(), &["uninstall"]);
assert_eq!(
out.status.code(),
Some(0),
"uninstall of a KeyHog hook must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("KeyHog pre-commit hook removed from"),
"uninstall must report the removal; stderr={stderr}"
);
assert!(
!hook_path(dir.path()).exists(),
"uninstall must delete the pre-commit file"
);
}
#[test]
fn hook_uninstall_no_hook_reports_none_found_exit_zero() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let out = run_hook(dir.path(), &["uninstall"]);
assert_eq!(
out.status.code(),
Some(0),
"uninstall with no hook present must exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("No pre-commit hook found at"),
"uninstall with nothing installed must say so; stderr={stderr}"
);
}
#[test]
fn hook_uninstall_refuses_foreign_hook() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let foreign = "#!/bin/sh\necho other-tool\n";
std::fs::write(hook_path(dir.path()), foreign).expect("write foreign hook");
let out = run_hook(dir.path(), &["uninstall"]);
assert_eq!(
out.status.code(),
Some(2),
"uninstall of a foreign hook must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("was not installed by KeyHog"),
"refusal must explain KeyHog did not install it; stderr={stderr}"
);
let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
assert_eq!(
content, foreign,
"a refused uninstall must not delete the foreign hook"
);
}
#[test]
fn hook_install_outside_git_repo_fails_user_error() {
let dir = TempDir::new().unwrap(); let out = run_hook(dir.path(), &["install"]);
assert_eq!(
out.status.code(),
Some(2),
"hook install outside a git repo must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not a git repository"),
"error must name the missing-repo cause; stderr={stderr}"
);
}
#[cfg(unix)]
fn daemon_slot() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(unix)]
fn start_daemon(dir: &Path) -> (std::process::Child, PathBuf) {
use std::io::Read;
use std::os::unix::net::UnixStream as StdUnixStream;
use std::time::{Duration, Instant};
let socket = dir.join("d.sock");
let mut child = Command::new(keyhog())
.args(["daemon", "start", "--socket"])
.arg(&socket)
.args(["--backend", "cpu"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn daemon start");
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().expect("poll daemon process") {
let mut stderr = String::new();
child
.stderr
.take()
.expect("daemon stderr pipe")
.read_to_string(&mut stderr)
.expect("read daemon stderr");
panic!("daemon exited before readiness with {status}: {stderr}");
}
if socket.exists() && StdUnixStream::connect(&socket).is_ok() {
return (child, socket);
}
std::thread::sleep(Duration::from_millis(100));
}
if child.try_wait().expect("poll timed-out daemon").is_none() {
child.kill().expect("kill timed-out daemon");
}
let status = child.wait().expect("reap timed-out daemon");
let mut stderr = String::new();
child
.stderr
.take()
.expect("daemon stderr pipe")
.read_to_string(&mut stderr)
.expect("read timed-out daemon stderr");
panic!("daemon did not become ready within 30s; final status {status}: {stderr}");
}
#[cfg(unix)]
#[test]
fn daemon_start_status_stop_reports_exact_lines_and_codes() {
use sha2::{Digest, Sha256};
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
let _daemon_slot = daemon_slot();
let dir = TempDir::new().unwrap();
let (mut child, socket) = start_daemon(dir.path());
assert_eq!(
std::fs::metadata(dir.path())
.expect("daemon socket parent metadata")
.permissions()
.mode()
& 0o777,
0o700,
"the daemon socket parent must remain private"
);
assert_eq!(
std::fs::metadata(&socket)
.expect("daemon socket metadata")
.permissions()
.mode()
& 0o777,
0o600,
"the credential-streaming daemon socket must remain user-only"
);
let status = Command::new(keyhog())
.args(["daemon", "status", "--socket"])
.arg(&socket)
.output()
.expect("spawn daemon status");
assert_eq!(
status.status.code(),
Some(0),
"status against a live daemon must exit 0; stderr={}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
status.stderr, b"",
"a current, ready daemon must not emit a stale/authentication warning"
);
let stdout = std::str::from_utf8(&status.stdout).expect("daemon status stdout is UTF-8");
let lines: Vec<_> = stdout.lines().collect();
let [warm_line, uptime_line, scope_line, policy_line, health_line] = lines.as_slice() else {
panic!(
"daemon status must emit exactly the five v8 operator lines in order; got:\n{stdout}"
);
};
let warm_fields: Vec<_> = warm_line.split(" · ").collect();
let [readiness, generation_field, engine_field, binary_field, detectors_field, config_field, gpu_field] =
warm_fields.as_slice()
else {
panic!("warm-backend line must contain every ordered identity field; got: {warm_line}");
};
assert_eq!(*readiness, "warm backend: ready");
let generation = generation_field
.strip_prefix("generation ")
.expect("ordered daemon generation field");
let generation_tail = generation
.strip_prefix(&format!("{}-after-", child.id()))
.expect("generation must identify this daemon process and a post-epoch start");
let (started_ns, sequence) = generation_tail
.split_once('-')
.expect("generation must carry clock and sequence identities");
let is_lower_hex = |value: &str, width: usize| {
value.len() == width
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
};
assert!(
is_lower_hex(started_ns, 32),
"generation clock identity must be 32 lowercase hex digits; got {started_ns}"
);
assert_eq!(
sequence, "0000000000000000",
"the first warm generation in this daemon process must use sequence zero"
);
let engine = engine_field
.strip_prefix("engine ")
.expect("ordered engine identity field");
let engine_parts: Vec<_> = engine.split(' ').collect();
let [cli_features, scanner_features, source_features, verifier_features] =
engine_parts.as_slice()
else {
panic!("engine identity must name cli/scanner/source/verifier feature sets; got {engine}");
};
for (part, label) in [
(*cli_features, "cli"),
(*scanner_features, "scanner"),
(*source_features, "sources"),
(*verifier_features, "verifier"),
] {
let values = part
.strip_prefix(&format!("{label}=["))
.and_then(|value| value.strip_suffix(']'))
.unwrap_or_else(|| panic!("engine identity field must be `{label}=[…]`; got {part}"));
let entries: Vec<_> = values.split(',').collect();
assert!(
!entries.is_empty()
&& entries.iter().all(|entry| !entry.is_empty())
&& entries.windows(2).all(|pair| pair[0] < pair[1]),
"{label} engine identities must be non-empty, unique, and sorted; got {values}"
);
}
let binary_identity = binary_field
.strip_prefix("binary ")
.expect("ordered binary identity field");
let executable = std::fs::read(keyhog()).expect("read tested keyhog binary");
assert_eq!(
binary_identity,
format!("{:x}", Sha256::digest(executable)),
"status must attest the exact binary that served this connection"
);
let detector_identity = detectors_field
.strip_prefix("detectors ")
.expect("ordered detector identity field");
assert_eq!(
detector_identity,
keyhog_core::detector_digest(),
"status must attest the exact embedded detector corpus loaded by the daemon"
);
let config_identity = config_field
.strip_prefix("config ")
.expect("ordered resolved-config identity field");
assert!(
is_lower_hex(config_identity, 16),
"resolved-config identity must be 16 lowercase hex digits; got {config_identity}"
);
assert_eq!(
*gpu_field, "GPU artifact none",
"a forced CPU daemon must not claim a GPU artifact identity"
);
let embedded_detector_count = keyhog_core::load_embedded_detectors_or_fail()
.expect("load embedded detector count")
.len();
let uptime_suffix =
format!("s · 0 scans served · 0 active · {embedded_detector_count} detectors");
let uptime = uptime_line
.strip_prefix("keyhog daemon: uptime ")
.and_then(|line| line.strip_suffix(&uptime_suffix))
.and_then(|seconds| seconds.parse::<u64>().ok())
.unwrap_or_else(|| panic!("uptime line must carry exact idle counters; got {uptime_line}"));
assert!(
uptime <= 30,
"a newly ready daemon must report a fresh uptime, got {uptime}s"
);
assert_eq!(
*scope_line,
"scan scope: warm stdin/single-file requests only; start with --mass for bounded source transactions. Warm daemon requests return before baseline, Merkle state, verification, lockdown, and per-request scanner policy; those post-steps run in-process."
);
assert_eq!(
*policy_line,
"backend policy: forced cpu-fallback (daemon startup diagnostic override)"
);
assert_eq!(
*health_line, "backend health: no recovered runtime faults",
"an idle forced-CPU daemon must report the exact clean recovery state"
);
assert!(
stdout.ends_with('\n'),
"daemon status must terminate its final operator line"
);
let stop = Command::new(keyhog())
.args(["daemon", "stop", "--socket"])
.arg(&socket)
.output()
.expect("spawn daemon stop");
assert_eq!(
stop.status.code(),
Some(0),
"daemon stop must exit 0; stderr={}",
String::from_utf8_lossy(&stop.stderr)
);
assert_eq!(
stop.stdout, b"",
"daemon stop must reserve stdout for machine-readable command output"
);
assert_eq!(
stop.stderr, b"keyhog daemon stopped\n",
"daemon stop must print exactly one confirmation line"
);
let child_status = child.wait().expect("reap stopped daemon");
assert_eq!(
child_status.code(),
Some(0),
"a confirmed graceful stop must make the daemon process exit 0"
);
let deadline = Instant::now() + Duration::from_secs(10);
while socket.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
assert!(
!socket.exists(),
"daemon stop must remove the socket so a later start does not refuse it"
);
}
#[cfg(unix)]
#[test]
fn daemon_second_start_refuses_already_bound_socket() {
let _daemon_slot = daemon_slot();
let dir = TempDir::new().unwrap();
let (mut child, socket) = start_daemon(dir.path());
let second = Command::new(keyhog())
.args(["daemon", "start", "--socket"])
.arg(&socket)
.args(["--backend", "cpu"])
.output()
.expect("spawn second daemon start");
assert_eq!(
second.status.code(),
Some(2),
"a second start on a live socket must exit 2 (user error), not clobber; stderr={}",
String::from_utf8_lossy(&second.stderr)
);
let stderr = String::from_utf8_lossy(&second.stderr);
assert!(
stderr.contains("is already bound by another keyhog daemon"),
"second start must report the already-bound refusal; stderr={stderr}"
);
assert!(
stderr.contains("keyhog daemon stop"),
"refusal must tell the operator to stop the existing daemon first; stderr={stderr}"
);
let status = Command::new(keyhog())
.args(["daemon", "status", "--socket"])
.arg(&socket)
.output()
.expect("status after refused second start");
assert_eq!(
status.status.code(),
Some(0),
"the first daemon must still be serving after the refused second start"
);
let _ = Command::new(keyhog())
.args(["daemon", "stop", "--socket"])
.arg(&socket)
.output();
let _ = child.wait();
}
#[cfg(unix)]
#[test]
fn daemon_stop_without_daemon_exits_user_error() {
let dir = TempDir::new().unwrap();
let socket = dir.path().join("absent.sock");
let out = Command::new(keyhog())
.args(["daemon", "stop", "--socket"])
.arg(&socket)
.output()
.expect("spawn daemon stop");
assert_eq!(
out.status.code(),
Some(2),
"stop with no daemon must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("daemon stop: no daemon at") && stderr.contains("already stopped"),
"stop must report no-daemon with the already-stopped hint; stderr={stderr}"
);
}
#[cfg(unix)]
#[test]
fn daemon_status_without_daemon_exits_user_error() {
let dir = TempDir::new().unwrap();
let socket = dir.path().join("absent.sock");
let out = Command::new(keyhog())
.args(["daemon", "status", "--socket"])
.arg(&socket)
.output()
.expect("spawn daemon status");
assert_eq!(
out.status.code(),
Some(2),
"status with no daemon must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("daemon status: no daemon at") && stderr.contains("keyhog daemon start"),
"status must report no-daemon and how to start one; stderr={stderr}"
);
}