1use serde::{Deserialize, Serialize};
6use tracing::info;
7
8pub fn is_retryable_transport_error(err: &anyhow::Error) -> bool {
17 let mut parts = Vec::new();
18 for cause in err.chain() {
19 parts.push(cause.to_string());
20 }
21 is_retryable_transport_error_text(&parts.join(": "))
22}
23
24pub fn is_retryable_transport_error_text(message: &str) -> bool {
26 let message = message.to_lowercase();
27
28 if message.contains("permission denied")
34 || message.contains("host key verification failed")
35 || message.contains("could not resolve hostname")
36 || message.contains("identity file")
37 || message.contains("keyfile")
38 || message.contains("invalid format")
39 || message.contains("unknown option")
40 {
41 return false;
42 }
43
44 message.contains("connection timed out")
46 || message.contains("timed out")
47 || message.contains("connection reset")
48 || message.contains("broken pipe")
49 || message.contains("connection refused")
50 || message.contains("network is unreachable")
51 || message.contains("no route to host")
52 || message.contains("connection closed")
53 || message.contains("connection unexpectedly closed")
58 || message.contains("connection lost")
59 || message.contains("ssh_exchange_identification")
60 || message.contains("kex_exchange_identification")
61 || message.contains("temporary failure in name resolution")
62 || message.contains("control socket")
65 || message.contains("controlsocket")
66 || message.contains("mux_client")
67 || message.contains("multiplexing")
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CommandResult {
73 pub exit_code: i32,
75 pub stdout: String,
77 pub stderr: String,
79 pub duration_ms: u64,
81}
82
83impl CommandResult {
84 pub fn success(&self) -> bool {
86 self.exit_code == 0
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EnvPrefix {
93 pub prefix: String,
95 pub applied: Vec<String>,
97 pub rejected: Vec<String>,
99}
100
101pub fn build_env_prefix<F>(allowlist: &[String], mut get_env: F) -> EnvPrefix
107where
108 F: FnMut(&str) -> Option<String>,
109{
110 let mut parts = Vec::new();
111 let mut applied = Vec::new();
112 let mut rejected = Vec::new();
113
114 for raw_key in allowlist {
115 let key = raw_key.trim();
116 if key.is_empty() {
117 continue;
118 }
119 if !is_valid_env_key(key) {
120 info!(
121 "Rejecting env var '{}': invalid key name (must start with letter/underscore, contain only alphanumeric/underscore)",
122 key
123 );
124 rejected.push(key.to_string());
125 continue;
126 }
127 let Some(value) = get_env(key) else {
128 continue;
130 };
131 let Some(escaped) = shell_escape_value(&value) else {
132 info!(
133 "Rejecting env var '{}': value contains unsafe characters (newline, carriage return, or NUL)",
134 key
135 );
136 rejected.push(key.to_string());
137 continue;
138 };
139 parts.push(format!("{}={}", key, escaped));
140 applied.push(key.to_string());
141 }
142
143 let prefix = if parts.is_empty() {
144 String::new()
145 } else {
146 format!("{} ", parts.join(" "))
147 };
148
149 EnvPrefix {
150 prefix,
151 applied,
152 rejected,
153 }
154}
155
156pub fn is_valid_env_key(key: &str) -> bool {
158 let mut chars = key.chars();
159 let Some(first) = chars.next() else {
160 return false;
161 };
162 if !(first == '_' || first.is_ascii_alphabetic()) {
163 return false;
164 }
165 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
166}
167
168pub fn shell_escape_value(value: &str) -> Option<String> {
173 if value.contains('\n') || value.contains('\r') || value.contains('\0') {
176 return None;
177 }
178
179 if value.is_empty() {
180 return Some("''".to_string());
181 }
182
183 let needs_quotes = value
184 .chars()
185 .any(|c| !c.is_ascii_alphanumeric() && c != '_');
186 if !needs_quotes {
187 return Some(value.to_string());
188 }
189
190 let mut escaped = String::with_capacity(value.len() + 2);
191 escaped.push('\'');
192 for ch in value.chars() {
193 if ch == '\'' {
194 escaped.push_str("'\\''");
195 } else {
196 escaped.push(ch);
197 }
198 }
199 escaped.push('\'');
200 Some(escaped)
201}
202
203pub fn shell_escape_path_with_home(path: &str) -> Option<String> {
209 if path.contains('\n') || path.contains('\r') || path.contains('\0') {
210 return None;
211 }
212
213 if path == "~" {
214 return Some("\"$HOME\"".to_string());
215 }
216
217 if let Some(suffix) = path.strip_prefix("~/") {
218 let escaped_suffix = escape_for_double_quotes(suffix);
219 return Some(format!("\"$HOME/{}\"", escaped_suffix));
220 }
221
222 shell_escape_value(path)
223}
224
225fn escape_for_double_quotes(value: &str) -> String {
226 let mut escaped = String::with_capacity(value.len());
227 for ch in value.chars() {
228 match ch {
229 '\\' => escaped.push_str("\\\\"),
230 '"' => escaped.push_str("\\\""),
231 '$' => escaped.push_str("\\$"),
232 '`' => escaped.push_str("\\`"),
233 _ => escaped.push(ch),
234 }
235 }
236 escaped
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::test_guard;
243
244 #[test]
245 fn test_retryable_transport_error_text() {
246 let _guard = test_guard!();
247 assert!(is_retryable_transport_error_text(
248 "ssh: connect to host 1.2.3.4 port 22: Connection timed out"
249 ));
250 assert!(is_retryable_transport_error_text(
251 "kex_exchange_identification: Connection reset by peer"
252 ));
253 assert!(is_retryable_transport_error_text("Broken pipe"));
254 assert!(is_retryable_transport_error_text("Network is unreachable"));
255 assert!(is_retryable_transport_error_text(
258 "rsync: connection unexpectedly closed (0 bytes received so far) [sender]"
259 ));
260 assert!(!"rsync: connection unexpectedly closed".contains("connection closed"));
261 }
262
263 #[test]
264 fn test_non_retryable_transport_error_text() {
265 let _guard = test_guard!();
266 assert!(!is_retryable_transport_error_text(
267 "Permission denied (publickey)."
268 ));
269 assert!(!is_retryable_transport_error_text(
270 "Host key verification failed."
271 ));
272 assert!(!is_retryable_transport_error_text(
273 "Could not resolve hostname worker.example.com: Name or service not known"
274 ));
275 assert!(!is_retryable_transport_error_text(
276 "Identity file /nope/id_rsa not accessible: No such file or directory"
277 ));
278 }
279
280 #[test]
281 fn test_stale_control_socket_is_retryable_not_fatal() {
282 let _guard = test_guard!();
283 assert!(is_retryable_transport_error_text(
287 "Control socket connect(/tmp/rch/cm-worker): No such file or directory"
288 ));
289 assert!(is_retryable_transport_error_text(
290 "mux_client_request_session: read from master failed: Broken pipe"
291 ));
292 assert!(!is_retryable_transport_error_text(
295 "Warning: Identity file ~/.ssh/id_ed25519 not accessible: No such file or directory"
296 ));
297 }
298
299 #[test]
300 fn test_command_result_success() {
301 let _guard = test_guard!();
302 let result = CommandResult {
303 exit_code: 0,
304 stdout: "output".to_string(),
305 stderr: String::new(),
306 duration_ms: 100,
307 };
308 assert!(result.success());
309
310 let failed = CommandResult {
311 exit_code: 1,
312 stdout: String::new(),
313 stderr: "error".to_string(),
314 duration_ms: 50,
315 };
316 assert!(!failed.success());
317 }
318
319 #[test]
320 fn test_shell_escape_value() {
321 let _guard = test_guard!();
322 assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
324
325 assert_eq!(shell_escape_value(""), Some("''".to_string()));
327
328 assert_eq!(
330 shell_escape_value("with spaces"),
331 Some("'with spaces'".to_string())
332 );
333
334 assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
336
337 assert!(shell_escape_value("line1\nline2").is_none());
339 assert!(shell_escape_value("line1\rline2").is_none());
340 assert!(shell_escape_value("line1\0line2").is_none());
341 }
342
343 #[test]
344 fn test_shell_escape_path_with_home() {
345 let _guard = test_guard!();
346 assert_eq!(
347 shell_escape_path_with_home("~/.local/bin"),
348 Some("\"$HOME/.local/bin\"".to_string())
349 );
350 assert_eq!(
351 shell_escape_path_with_home("~"),
352 Some("\"$HOME\"".to_string())
353 );
354 assert_eq!(
355 shell_escape_path_with_home("/usr/local/bin"),
356 Some("'/usr/local/bin'".to_string())
357 );
358 }
359
360 #[test]
361 fn test_is_valid_env_key() {
362 let _guard = test_guard!();
363 assert!(is_valid_env_key("PATH"));
364 assert!(is_valid_env_key("_PRIVATE"));
365 assert!(is_valid_env_key("MY_VAR_123"));
366 assert!(!is_valid_env_key("123VAR"));
367 assert!(!is_valid_env_key("MY-VAR"));
368 assert!(!is_valid_env_key(""));
369 }
370
371 #[test]
372 fn test_build_env_prefix() {
373 let _guard = test_guard!();
374 use std::collections::HashMap;
375
376 let mut env = HashMap::new();
377 env.insert("RUSTFLAGS".to_string(), "-C target-cpu=native".to_string());
378 env.insert("QUOTED".to_string(), "a'b".to_string());
379 env.insert("BADVAL".to_string(), "line1\nline2".to_string());
380
381 let allowlist = vec![
382 "RUSTFLAGS".to_string(),
383 "QUOTED".to_string(),
384 "MISSING".to_string(),
385 "BADVAL".to_string(),
386 "BAD=KEY".to_string(),
387 ];
388
389 let prefix = build_env_prefix(&allowlist, |key| env.get(key).cloned());
390
391 assert!(prefix.prefix.contains("RUSTFLAGS='-C target-cpu=native'"));
392 assert!(prefix.prefix.contains("QUOTED='a'\\''b'"));
393 assert!(!prefix.prefix.contains("MISSING="));
394 assert!(!prefix.prefix.contains("BADVAL="));
395 assert!(prefix.rejected.contains(&"BADVAL".to_string()));
396 assert!(prefix.rejected.contains(&"BAD=KEY".to_string()));
397 }
398}