Skip to main content

max / makenotwork

6.1 KB · 202 lines History Blame Raw
1 //! Remote test execution over SSH. Validates the test filter, runs the target's
2 //! configured command, and parses the output into a `TestRun`.
3
4 use tokio::process::Command;
5 use tracing::instrument;
6
7 use crate::checks::parse;
8 use crate::config::TestsConfig;
9 use crate::types::{TestRun, TestSummary};
10
11 /// Returns `true` if every character in `filter` is in `[a-zA-Z0-9_:-]`.
12 /// An empty string is considered valid (no characters to reject).
13 pub fn validate_test_filter(filter: &str) -> bool {
14 filter
15 .chars()
16 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '-')
17 }
18
19 #[instrument(skip_all)]
20 pub async fn run_tests(target_name: &str, config: &TestsConfig, filter: Option<&str>) -> TestRun {
21 let started_at = chrono::Utc::now().to_rfc3339();
22 let start = std::time::Instant::now();
23
24 // Validate filter characters before appending to SSH command.
25 // Only allow alphanumeric, underscore, colon, dash, covers all valid Rust test filter patterns.
26 if let Some(f) = filter
27 && !validate_test_filter(f)
28 {
29 let finished_at = chrono::Utc::now().to_rfc3339();
30 let duration_secs = start.elapsed().as_secs() as i64;
31 return TestRun {
32 id: None,
33 target: target_name.to_string(),
34 started_at,
35 finished_at: Some(finished_at),
36 duration_secs: Some(duration_secs),
37 exit_code: None,
38 passed: false,
39 summary: TestSummary {
40 steps: vec![],
41 total_passed: None,
42 total_failed: None,
43 details: vec![],
44 },
45 raw_output: format!(
46 "Invalid filter: contains characters outside [a-zA-Z0-9_:-]. Got: {f}"
47 ),
48 filter: Some(f.to_string()),
49 };
50 }
51
52 let mut cmd_str = config.command.clone();
53 if let Some(f) = filter {
54 cmd_str.push(' ');
55 cmd_str.push_str(f);
56 }
57
58 // `timeout_secs` is documented as "max seconds before killing the test
59 // command," but was only wired to SSH ConnectTimeout, which bounds the
60 // handshake, not a connected-then-hung remote command (fuzz-2026-07-06). Wrap
61 // the whole run in a total timeout and `kill_on_drop` so the ssh child is
62 // reaped when it elapses, honoring the field's contract.
63 let mut command = Command::new("ssh");
64 command
65 .arg("-o")
66 .arg("BatchMode=yes")
67 .arg("-o")
68 .arg(format!("ConnectTimeout={}", config.timeout_secs))
69 .arg(&config.ssh)
70 .arg("--")
71 .arg(&cmd_str)
72 .kill_on_drop(true);
73
74 let total_timeout = std::time::Duration::from_secs(config.timeout_secs.max(1));
75 let result = tokio::time::timeout(total_timeout, command.output()).await;
76
77 let finished_at = chrono::Utc::now().to_rfc3339();
78 let duration_secs = start.elapsed().as_secs() as i64;
79
80 match result {
81 Err(_elapsed) => TestRun {
82 id: None,
83 target: target_name.to_string(),
84 started_at,
85 finished_at: Some(finished_at),
86 duration_secs: Some(duration_secs),
87 exit_code: None,
88 passed: false,
89 summary: TestSummary {
90 steps: vec![],
91 total_passed: None,
92 total_failed: None,
93 details: vec![],
94 },
95 raw_output: format!(
96 "SSH test command timed out after {}s (killed)",
97 config.timeout_secs
98 ),
99 filter: filter.map(String::from),
100 },
101 Ok(Ok(output)) => {
102 let stdout = String::from_utf8_lossy(&output.stdout);
103 let stderr = String::from_utf8_lossy(&output.stderr);
104 let raw_output = format!("{stdout}{stderr}");
105
106 let exit_code = output.status.code();
107 let passed = output.status.success();
108 let summary = parse::parse_ci_output(&raw_output);
109
110 TestRun {
111 id: None,
112 target: target_name.to_string(),
113 started_at,
114 finished_at: Some(finished_at),
115 duration_secs: Some(duration_secs),
116 exit_code,
117 passed,
118 summary,
119 raw_output,
120 filter: filter.map(String::from),
121 }
122 }
123 Ok(Err(e)) => TestRun {
124 id: None,
125 target: target_name.to_string(),
126 started_at,
127 finished_at: Some(finished_at),
128 duration_secs: Some(duration_secs),
129 exit_code: None,
130 passed: false,
131 summary: TestSummary {
132 steps: vec![],
133 total_passed: None,
134 total_failed: None,
135 details: vec![],
136 },
137 raw_output: format!("SSH connection failed: {e}"),
138 filter: filter.map(String::from),
139 },
140 }
141 }
142
143 #[cfg(test)]
144 mod tests {
145 use super::*;
146
147 #[test]
148 fn validate_test_filter_valid_simple() {
149 assert!(validate_test_filter("foo"));
150 }
151
152 #[test]
153 fn validate_test_filter_valid_module_path() {
154 assert!(validate_test_filter("foo::bar"));
155 }
156
157 #[test]
158 fn validate_test_filter_valid_underscore() {
159 assert!(validate_test_filter("foo_bar"));
160 }
161
162 #[test]
163 fn validate_test_filter_valid_dash() {
164 assert!(validate_test_filter("foo-bar"));
165 }
166
167 #[test]
168 fn validate_test_filter_valid_alphanumeric() {
169 assert!(validate_test_filter("a123"));
170 }
171
172 #[test]
173 fn validate_test_filter_empty_is_valid() {
174 assert!(validate_test_filter(""));
175 }
176
177 #[test]
178 fn validate_test_filter_rejects_semicolon() {
179 assert!(!validate_test_filter("foo;rm"));
180 }
181
182 #[test]
183 fn validate_test_filter_rejects_ampersand() {
184 assert!(!validate_test_filter("foo && bar"));
185 }
186
187 #[test]
188 fn validate_test_filter_rejects_pipe() {
189 assert!(!validate_test_filter("foo|bar"));
190 }
191
192 #[test]
193 fn validate_test_filter_rejects_subshell() {
194 assert!(!validate_test_filter("$(cmd)"));
195 }
196
197 #[test]
198 fn validate_test_filter_rejects_space() {
199 assert!(!validate_test_filter("foo bar"));
200 }
201 }
202