use rch_common::testing::{TestLogger, TestPhase};
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const ANSI_ESC: &str = "\x1b[";
const MAX_HOOK_TIME_MS: u64 = 25;
fn rch_binary() -> String {
std::env::var("RCH_BINARY").unwrap_or_else(|_| {
if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
return format!("{target_dir}/release/rch");
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
format!(
"{}/target/release/rch",
manifest_dir.trim_end_matches("/rch")
)
})
}
fn run_hook(input: &str) -> (i32, String, String, Duration) {
let rch = rch_binary();
let start = Instant::now();
let mut child = Command::new(&rch)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("Failed to spawn {}: {}", rch, e));
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes()).ok();
}
let output = child.wait_with_output().expect("Failed to wait for rch");
let duration = start.elapsed();
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
duration,
)
}
fn check_binary() -> bool {
let rch = rch_binary();
std::path::Path::new(&rch).exists()
}
macro_rules! require_binary {
() => {
if !check_binary() {
eprintln!("Skipping test: rch binary not found at {}", rch_binary());
eprintln!("Build with: cargo build -p rch --release");
return;
}
};
}
#[test]
fn test_hook_stdout_is_valid_json_or_empty() {
let logger = TestLogger::for_test("test_hook_stdout_is_valid_json_or_empty");
require_binary!();
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
logger.log(TestPhase::Execute, "Running hook with passthrough command");
let (exit, stdout, _stderr, _dur) = run_hook(input);
logger.log(TestPhase::Verify, "Checking stdout JSON validity");
if !stdout.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"stdout must be valid JSON or empty, got: {}",
stdout
);
}
assert_eq!(exit, 0, "Passthrough command should exit 0");
logger.pass();
}
#[test]
fn test_hook_stdout_no_ansi_codes() {
let logger = TestLogger::for_test("test_hook_stdout_no_ansi_codes");
require_binary!();
let test_cases = [
r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#,
r#"{"tool_name":"Bash","tool_input":{"command":"ls -la"}}"#,
r#"{"tool_name":"Bash","tool_input":{"command":"pwd"}}"#,
r#"{"tool_name":"Read","tool_input":{"command":"/some/file"}}"#,
];
logger.log(
TestPhase::Execute,
format!(
"Testing {} command variants for ANSI codes",
test_cases.len()
),
);
for input in test_cases {
let (_exit, stdout, _stderr, _dur) = run_hook(input);
assert!(
!stdout.contains(ANSI_ESC),
"stdout contains ANSI escape codes for input: {}\nstdout: {}",
input,
stdout
);
}
logger.log(TestPhase::Verify, "All commands produced clean stdout");
logger.pass();
}
#[test]
fn test_hook_exit_code_allow() {
require_binary!();
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
let (exit, _stdout, _stderr, _dur) = run_hook(input);
assert_eq!(exit, 0, "Allow response should exit 0");
}
#[test]
fn test_hook_exit_code_non_bash_tool() {
require_binary!();
let input = r#"{"tool_name":"Read","tool_input":{"command":"/some/file"}}"#;
let (exit, _stdout, _stderr, _dur) = run_hook(input);
assert_eq!(exit, 0, "Non-Bash tool should exit 0 (passthrough)");
}
#[test]
fn test_hook_empty_input_failopen() {
require_binary!();
let (exit, _stdout, _stderr, _dur) = run_hook("");
assert_eq!(exit, 0, "Empty input should exit 0 (fail-open)");
}
#[test]
fn test_hook_invalid_json_failopen() {
require_binary!();
let (exit, _stdout, _stderr, _dur) = run_hook("not valid json at all");
assert_eq!(exit, 0, "Invalid JSON should exit 0 (fail-open)");
}
#[test]
fn test_hook_malformed_json_failopen() {
require_binary!();
let input = r#"{"tool_name":"Bash"}"#;
let (exit, _stdout, _stderr, _dur) = run_hook(input);
assert_eq!(exit, 0, "Malformed JSON should exit 0 (fail-open)");
}
#[test]
fn test_hook_classification_timing() {
let logger = TestLogger::for_test("test_hook_classification_timing");
require_binary!();
let iterations = 20;
let mut total_ms = 0u128;
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo test"}}"#;
logger.log(
TestPhase::Execute,
format!("Running {} hook iterations for timing", iterations),
);
for _ in 0..iterations {
let (_exit, _stdout, _stderr, dur) = run_hook(input);
total_ms += dur.as_millis();
}
let avg_ms = total_ms / iterations as u128;
logger.log_with_data(
TestPhase::Verify,
"Checking timing threshold",
serde_json::json!({
"avg_ms": avg_ms,
"threshold_ms": MAX_HOOK_TIME_MS,
"iterations": iterations
}),
);
assert!(
avg_ms <= MAX_HOOK_TIME_MS as u128,
"Average hook time {}ms exceeds threshold {}ms",
avg_ms,
MAX_HOOK_TIME_MS
);
logger.pass();
}
#[test]
fn test_hook_stderr_stdout_separation() {
require_binary!();
let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo build --release"}}"#;
let (_exit, stdout, stderr, _dur) = run_hook(input);
if !stdout.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"stdout must be valid JSON when non-empty, got: {}",
stdout
);
}
eprintln!("stderr length: {} bytes", stderr.len());
}
#[test]
fn test_hook_deny_json_structure() {
require_binary!();
let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo build --release"}}"#;
let (_exit, stdout, _stderr, _dur) = run_hook(input);
if !stdout.is_empty() {
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
if let Some(output) = parsed.get("hookSpecificOutput") {
assert!(
output.get("hookEventName").is_some(),
"Response missing hookEventName"
);
assert!(
output.get("permissionDecision").is_some(),
"Response missing permissionDecision"
);
if output.get("permissionDecision").and_then(|v| v.as_str()) == Some("deny") {
assert!(
output.get("permissionDecisionReason").is_some(),
"Deny response missing permissionDecisionReason"
);
}
}
}
}
#[test]
fn test_hook_no_state_leakage() {
require_binary!();
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
let mut results: Vec<(i32, String)> = Vec::new();
for _ in 0..5 {
let (exit, stdout, _stderr, _dur) = run_hook(input);
results.push((exit, stdout));
}
let first = &results[0];
for (i, result) in results.iter().enumerate().skip(1) {
assert_eq!(
result.0, first.0,
"Exit code mismatch at iteration {}: {} vs {}",
i, result.0, first.0
);
assert_eq!(
result.1, first.1,
"stdout mismatch at iteration {}: '{}' vs '{}'",
i, result.1, first.1
);
}
}
#[test]
fn test_hook_output_deterministic() {
require_binary!();
let inputs = [
r#"{"tool_name":"Bash","tool_input":{"command":"echo test"}}"#,
r#"{"tool_name":"Bash","tool_input":{"command":"cargo check"}}"#,
r#"{"tool_name":"Read","tool_input":{"command":"/tmp/file"}}"#,
];
for input in inputs {
let (exit1, stdout1, _, _) = run_hook(input);
let (exit2, stdout2, _, _) = run_hook(input);
assert_eq!(exit1, exit2, "Exit code not deterministic for: {}", input);
assert_eq!(stdout1, stdout2, "stdout not deterministic for: {}", input);
}
}
#[test]
fn test_hook_non_interference_summary() {
let logger = TestLogger::for_test("test_hook_non_interference_summary");
require_binary!();
logger.log(TestPhase::Setup, "Starting hook non-interference summary");
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
logger.log(TestPhase::Execute, "Running passthrough command");
let (exit, stdout, _stderr, dur) = run_hook(input);
logger.log_with_data(
TestPhase::Verify,
"Passthrough command result",
serde_json::json!({
"exit_code": exit,
"stdout_len": stdout.len(),
"duration_ms": dur.as_millis() as u64
}),
);
let has_ansi = stdout.contains(ANSI_ESC);
assert!(!has_ansi, "stdout must not contain ANSI codes");
logger.log(TestPhase::Verify, "No ANSI codes in stdout");
if !stdout.is_empty() {
let valid_json = serde_json::from_str::<serde_json::Value>(&stdout).is_ok();
assert!(valid_json, "stdout must be valid JSON");
logger.log(TestPhase::Verify, "stdout is valid JSON");
} else {
logger.log(TestPhase::Verify, "stdout is empty (allow response)");
}
let mut total_time = Duration::ZERO;
let iterations = 10;
for _ in 0..iterations {
let (_, _, _, d) = run_hook(input);
total_time += d;
}
let avg_time = total_time / iterations;
logger.log_with_data(
TestPhase::Verify,
"Timing validation",
serde_json::json!({
"avg_ms": avg_time.as_millis() as u64,
"threshold_ms": MAX_HOOK_TIME_MS,
"iterations": iterations
}),
);
assert!(
avg_time.as_millis() <= MAX_HOOK_TIME_MS as u128,
"Hook timing exceeds threshold"
);
logger.pass();
}