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); } let result = Command::new("ssh") .arg("-o") .arg("BatchMode=yes") .arg("-o") .arg(format!("ConnectTimeout={}", config.timeout_secs)) .arg(&config.ssh) .arg("--") .arg(&cmd_str) .output() .await; let finished_at = chrono::Utc::now().to_rfc3339(); let duration_secs = start.elapsed().as_secs() as i64; match result { 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), } } 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")); } }