Skip to main content

max / makenotwork

9.1 KB · 274 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 /// Build the process that runs the suite: an `ssh` invocation when the target
20 /// names a runner host, a local shell otherwise.
21 ///
22 /// Local execution exists because the runner is often the machine PoM already
23 /// runs on. Routing that through `ssh` to its own address needs a regular sshd
24 /// listening, and a Tailscale-SSH host has none: tailscaled does not intercept
25 /// a node connecting to itself, so the hop fails with `Connection refused`
26 /// while every other SSH path into the box keeps working.
27 fn build_command(config: &TestsConfig, cmd_str: &str) -> Command {
28 match config.ssh.as_deref() {
29 Some(host) => {
30 let mut command = Command::new("ssh");
31 command
32 .arg("-o")
33 .arg("BatchMode=yes")
34 .arg("-o")
35 .arg(format!("ConnectTimeout={}", config.timeout_secs))
36 .arg(host)
37 .arg("--")
38 .arg(cmd_str);
39 command
40 }
41 None => {
42 let mut command = Command::new("sh");
43 command.arg("-c").arg(cmd_str);
44 command
45 }
46 }
47 }
48
49 #[instrument(skip_all)]
50 pub async fn run_tests(target_name: &str, config: &TestsConfig, filter: Option<&str>) -> TestRun {
51 let started_at = chrono::Utc::now().to_rfc3339();
52 let start = std::time::Instant::now();
53
54 // Validate filter characters before appending to SSH command.
55 // Only allow alphanumeric, underscore, colon, dash, covers all valid Rust test filter patterns.
56 if let Some(f) = filter
57 && !validate_test_filter(f)
58 {
59 let finished_at = chrono::Utc::now().to_rfc3339();
60 let duration_secs = start.elapsed().as_secs() as i64;
61 return TestRun {
62 id: None,
63 target: target_name.to_string(),
64 started_at,
65 finished_at: Some(finished_at),
66 duration_secs: Some(duration_secs),
67 exit_code: None,
68 passed: false,
69 summary: TestSummary {
70 steps: vec![],
71 total_passed: None,
72 total_failed: None,
73 details: vec![],
74 },
75 raw_output: format!(
76 "Invalid filter: contains characters outside [a-zA-Z0-9_:-]. Got: {f}"
77 ),
78 filter: Some(f.to_string()),
79 };
80 }
81
82 let mut cmd_str = config.command.clone();
83 if let Some(f) = filter {
84 cmd_str.push(' ');
85 cmd_str.push_str(f);
86 }
87
88 // `timeout_secs` is documented as "max seconds before killing the test
89 // command," but was only wired to SSH ConnectTimeout, which bounds the
90 // handshake, not a connected-then-hung remote command (fuzz-2026-07-06). Wrap
91 // the whole run in a total timeout and `kill_on_drop` so the ssh child is
92 // reaped when it elapses, honoring the field's contract.
93 let mut command = build_command(config, &cmd_str);
94 command.kill_on_drop(true);
95
96 let total_timeout = std::time::Duration::from_secs(config.timeout_secs.max(1));
97 let result = tokio::time::timeout(total_timeout, command.output()).await;
98
99 let finished_at = chrono::Utc::now().to_rfc3339();
100 let duration_secs = start.elapsed().as_secs() as i64;
101
102 match result {
103 Err(_elapsed) => TestRun {
104 id: None,
105 target: target_name.to_string(),
106 started_at,
107 finished_at: Some(finished_at),
108 duration_secs: Some(duration_secs),
109 exit_code: None,
110 passed: false,
111 summary: TestSummary {
112 steps: vec![],
113 total_passed: None,
114 total_failed: None,
115 details: vec![],
116 },
117 raw_output: format!(
118 "test command timed out after {}s (killed)",
119 config.timeout_secs
120 ),
121 filter: filter.map(String::from),
122 },
123 Ok(Ok(output)) => {
124 let stdout = String::from_utf8_lossy(&output.stdout);
125 let stderr = String::from_utf8_lossy(&output.stderr);
126 let raw_output = format!("{stdout}{stderr}");
127
128 let exit_code = output.status.code();
129 let passed = output.status.success();
130 let summary = parse::parse_ci_output(&raw_output);
131
132 TestRun {
133 id: None,
134 target: target_name.to_string(),
135 started_at,
136 finished_at: Some(finished_at),
137 duration_secs: Some(duration_secs),
138 exit_code,
139 passed,
140 summary,
141 raw_output,
142 filter: filter.map(String::from),
143 }
144 }
145 Ok(Err(e)) => TestRun {
146 id: None,
147 target: target_name.to_string(),
148 started_at,
149 finished_at: Some(finished_at),
150 duration_secs: Some(duration_secs),
151 exit_code: None,
152 passed: false,
153 summary: TestSummary {
154 steps: vec![],
155 total_passed: None,
156 total_failed: None,
157 details: vec![],
158 },
159 raw_output: match config.ssh.as_deref() {
160 Some(host) => format!("SSH connection to {host} failed: {e}"),
161 None => format!("local test command failed to spawn: {e}"),
162 },
163 filter: filter.map(String::from),
164 },
165 }
166 }
167
168 #[cfg(test)]
169 mod tests {
170 use super::*;
171
172 fn config_with_ssh(ssh: Option<&str>) -> TestsConfig {
173 TestsConfig {
174 ssh: ssh.map(String::from),
175 command: "cargo test".to_string(),
176 timeout_secs: 42,
177 staleness_days: 7,
178 }
179 }
180
181 /// The program plus its args, which is what distinguishes the two paths.
182 fn command_line(command: &Command) -> Vec<String> {
183 let std = command.as_std();
184 std::iter::once(std.get_program())
185 .chain(std.get_args())
186 .map(|s| s.to_string_lossy().into_owned())
187 .collect()
188 }
189
190 #[test]
191 fn build_command_uses_ssh_when_a_host_is_named() {
192 let config = config_with_ssh(Some("astra"));
193 let line = command_line(&build_command(&config, "cargo test"));
194 assert_eq!(line[0], "ssh");
195 assert!(line.contains(&"astra".to_string()));
196 assert!(line.contains(&"BatchMode=yes".to_string()));
197 assert!(line.contains(&"ConnectTimeout=42".to_string()));
198 assert_eq!(line.last().unwrap(), "cargo test");
199 }
200
201 #[test]
202 fn build_command_runs_locally_when_no_host_is_named() {
203 // Omitting `ssh` must not degrade into an SSH call to localhost: a
204 // Tailscale-SSH host has no sshd, so that hop is refused outright.
205 let config = config_with_ssh(None);
206 let line = command_line(&build_command(&config, "cargo test"));
207 assert_eq!(line, vec!["sh", "-c", "cargo test"]);
208 }
209
210 #[test]
211 fn build_command_passes_the_whole_command_as_one_argument() {
212 // The command is a shell string, `cd x && cargo test` among them. Split
213 // on whitespace it would run `cd` with the rest as arguments.
214 let config = config_with_ssh(None);
215 let line = command_line(&build_command(&config, "cd /srv/app && cargo test"));
216 assert_eq!(line.last().unwrap(), "cd /srv/app && cargo test");
217 }
218
219 #[test]
220 fn validate_test_filter_valid_simple() {
221 assert!(validate_test_filter("foo"));
222 }
223
224 #[test]
225 fn validate_test_filter_valid_module_path() {
226 assert!(validate_test_filter("foo::bar"));
227 }
228
229 #[test]
230 fn validate_test_filter_valid_underscore() {
231 assert!(validate_test_filter("foo_bar"));
232 }
233
234 #[test]
235 fn validate_test_filter_valid_dash() {
236 assert!(validate_test_filter("foo-bar"));
237 }
238
239 #[test]
240 fn validate_test_filter_valid_alphanumeric() {
241 assert!(validate_test_filter("a123"));
242 }
243
244 #[test]
245 fn validate_test_filter_empty_is_valid() {
246 assert!(validate_test_filter(""));
247 }
248
249 #[test]
250 fn validate_test_filter_rejects_semicolon() {
251 assert!(!validate_test_filter("foo;rm"));
252 }
253
254 #[test]
255 fn validate_test_filter_rejects_ampersand() {
256 assert!(!validate_test_filter("foo && bar"));
257 }
258
259 #[test]
260 fn validate_test_filter_rejects_pipe() {
261 assert!(!validate_test_filter("foo|bar"));
262 }
263
264 #[test]
265 fn validate_test_filter_rejects_subshell() {
266 assert!(!validate_test_filter("$(cmd)"));
267 }
268
269 #[test]
270 fn validate_test_filter_rejects_space() {
271 assert!(!validate_test_filter("foo bar"));
272 }
273 }
274