//! Remote test execution over SSH. Validates the test filter, runs the target's //! configured command, and parses the output into a `TestRun`. use tokio::process::Command; use tracing::instrument; use crate::checks::parse; use crate::config::TestsConfig; use crate::types::{TestRun, TestSummary}; /// Returns `true` if every character in `filter` is in `[a-zA-Z0-9_:-]`. /// An empty string is considered valid (no characters to reject). pub fn validate_test_filter(filter: &str) -> bool { filter .chars() .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '-') } #[instrument(skip_all)] pub async fn run_tests(target_name: &str, config: &TestsConfig, filter: Option<&str>) -> TestRun { let started_at = chrono::Utc::now().to_rfc3339(); let start = std::time::Instant::now(); // Validate filter characters before appending to SSH command. // Only allow alphanumeric, underscore, colon, dash, covers all valid Rust test filter patterns. if let Some(f) = filter && !validate_test_filter(f) { let finished_at = chrono::Utc::now().to_rfc3339(); let duration_secs = start.elapsed().as_secs() as i64; return TestRun { id: None, target: target_name.to_string(), started_at, finished_at: Some(finished_at), duration_secs: Some(duration_secs), exit_code: None, passed: false, summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![], }, raw_output: format!( "Invalid filter: contains characters outside [a-zA-Z0-9_:-]. Got: {f}" ), filter: Some(f.to_string()), }; } let mut cmd_str = config.command.clone(); if let Some(f) = filter { cmd_str.push(' '); cmd_str.push_str(f); } // `timeout_secs` is documented as "max seconds before killing the test // command," but was only wired to SSH ConnectTimeout, which bounds the // handshake, not a connected-then-hung remote command (fuzz-2026-07-06). Wrap // the whole run in a total timeout and `kill_on_drop` so the ssh child is // reaped when it elapses, honoring the field's contract. let mut command = Command::new("ssh"); command .arg("-o") .arg("BatchMode=yes") .arg("-o") .arg(format!("ConnectTimeout={}", config.timeout_secs)) .arg(&config.ssh) .arg("--") .arg(&cmd_str) .kill_on_drop(true); let total_timeout = std::time::Duration::from_secs(config.timeout_secs.max(1)); let result = tokio::time::timeout(total_timeout, command.output()).await; let finished_at = chrono::Utc::now().to_rfc3339(); let duration_secs = start.elapsed().as_secs() as i64; match result { Err(_elapsed) => TestRun { id: None, target: target_name.to_string(), started_at, finished_at: Some(finished_at), duration_secs: Some(duration_secs), exit_code: None, passed: false, summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![], }, raw_output: format!( "SSH test command timed out after {}s (killed)", config.timeout_secs ), filter: filter.map(String::from), }, Ok(Ok(output)) => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let raw_output = format!("{stdout}{stderr}"); let exit_code = output.status.code(); let passed = output.status.success(); let summary = parse::parse_ci_output(&raw_output); TestRun { id: None, target: target_name.to_string(), started_at, finished_at: Some(finished_at), duration_secs: Some(duration_secs), exit_code, passed, summary, raw_output, filter: filter.map(String::from), } } Ok(Err(e)) => TestRun { id: None, target: target_name.to_string(), started_at, finished_at: Some(finished_at), duration_secs: Some(duration_secs), exit_code: None, passed: false, summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![], }, raw_output: format!("SSH connection failed: {e}"), filter: filter.map(String::from), }, } } #[cfg(test)] mod tests { use super::*; #[test] fn validate_test_filter_valid_simple() { assert!(validate_test_filter("foo")); } #[test] fn validate_test_filter_valid_module_path() { assert!(validate_test_filter("foo::bar")); } #[test] fn validate_test_filter_valid_underscore() { assert!(validate_test_filter("foo_bar")); } #[test] fn validate_test_filter_valid_dash() { assert!(validate_test_filter("foo-bar")); } #[test] fn validate_test_filter_valid_alphanumeric() { assert!(validate_test_filter("a123")); } #[test] fn validate_test_filter_empty_is_valid() { assert!(validate_test_filter("")); } #[test] fn validate_test_filter_rejects_semicolon() { assert!(!validate_test_filter("foo;rm")); } #[test] fn validate_test_filter_rejects_ampersand() { assert!(!validate_test_filter("foo && bar")); } #[test] fn validate_test_filter_rejects_pipe() { assert!(!validate_test_filter("foo|bar")); } #[test] fn validate_test_filter_rejects_subshell() { assert!(!validate_test_filter("$(cmd)")); } #[test] fn validate_test_filter_rejects_space() { assert!(!validate_test_filter("foo bar")); } }