//! 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 == '-') } /// Build the process that runs the suite: an `ssh` invocation when the target /// names a runner host, a local shell otherwise. /// /// Local execution exists because the runner is often the machine PoM already /// runs on. Routing that through `ssh` to its own address needs a regular sshd /// listening, and a Tailscale-SSH host has none: tailscaled does not intercept /// a node connecting to itself, so the hop fails with `Connection refused` /// while every other SSH path into the box keeps working. fn build_command(config: &TestsConfig, cmd_str: &str) -> Command { match config.ssh.as_deref() { Some(host) => { let mut command = Command::new("ssh"); command .arg("-o") .arg("BatchMode=yes") .arg("-o") .arg(format!("ConnectTimeout={}", config.timeout_secs)) .arg(host) .arg("--") .arg(cmd_str); command } None => { let mut command = Command::new("sh"); command.arg("-c").arg(cmd_str); command } } } #[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 = build_command(config, &cmd_str); command.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!( "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: match config.ssh.as_deref() { Some(host) => format!("SSH connection to {host} failed: {e}"), None => format!("local test command failed to spawn: {e}"), }, filter: filter.map(String::from), }, } } #[cfg(test)] mod tests { use super::*; fn config_with_ssh(ssh: Option<&str>) -> TestsConfig { TestsConfig { ssh: ssh.map(String::from), command: "cargo test".to_string(), timeout_secs: 42, staleness_days: 7, } } /// The program plus its args, which is what distinguishes the two paths. fn command_line(command: &Command) -> Vec { let std = command.as_std(); std::iter::once(std.get_program()) .chain(std.get_args()) .map(|s| s.to_string_lossy().into_owned()) .collect() } #[test] fn build_command_uses_ssh_when_a_host_is_named() { let config = config_with_ssh(Some("astra")); let line = command_line(&build_command(&config, "cargo test")); assert_eq!(line[0], "ssh"); assert!(line.contains(&"astra".to_string())); assert!(line.contains(&"BatchMode=yes".to_string())); assert!(line.contains(&"ConnectTimeout=42".to_string())); assert_eq!(line.last().unwrap(), "cargo test"); } #[test] fn build_command_runs_locally_when_no_host_is_named() { // Omitting `ssh` must not degrade into an SSH call to localhost: a // Tailscale-SSH host has no sshd, so that hop is refused outright. let config = config_with_ssh(None); let line = command_line(&build_command(&config, "cargo test")); assert_eq!(line, vec!["sh", "-c", "cargo test"]); } #[test] fn build_command_passes_the_whole_command_as_one_argument() { // The command is a shell string, `cd x && cargo test` among them. Split // on whitespace it would run `cd` with the rest as arguments. let config = config_with_ssh(None); let line = command_line(&build_command(&config, "cd /srv/app && cargo test")); assert_eq!(line.last().unwrap(), "cd /srv/app && cargo test"); } #[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")); } }