1use serde::{Deserialize, Serialize};
6use tracing::info;
7
8#[must_use]
18pub fn identities_only_args(identity_file: &str) -> Option<[&'static str; 2]> {
19 let expanded = shellexpand::tilde(identity_file);
20 std::path::Path::new(expanded.as_ref())
21 .is_file()
22 .then_some(["-o", "IdentitiesOnly=yes"])
23}
24
25#[must_use]
28pub fn identity_shell_args(identity_file: &str) -> String {
29 let expanded = shellexpand::tilde(identity_file).into_owned();
30 let mut args = vec!["-i".to_owned(), expanded];
31 if let Some(opts) = identities_only_args(identity_file) {
32 args.extend(opts.iter().map(|opt| (*opt).to_owned()));
33 }
34 args.into_iter()
35 .map(|arg| shell_escape::escape(arg.into()).into_owned())
36 .collect::<Vec<_>>()
37 .join(" ")
38}
39
40pub fn is_retryable_transport_error(err: &anyhow::Error) -> bool {
49 let mut parts = Vec::new();
50 for cause in err.chain() {
51 parts.push(cause.to_string());
52 }
53 is_retryable_transport_error_text(&parts.join(": "))
54}
55
56pub fn is_retryable_transport_error_text(message: &str) -> bool {
58 let message = message.to_lowercase();
59
60 if message.contains("permission denied")
66 || message.contains("host key verification failed")
67 || message.contains("could not resolve hostname")
68 || message.contains("identity file")
69 || message.contains("keyfile")
70 || message.contains("invalid format")
71 || message.contains("unknown option")
72 {
73 return false;
74 }
75
76 message.contains("connection timed out")
78 || message.contains("timed out")
79 || message.contains("connection reset")
80 || message.contains("broken pipe")
81 || message.contains("connection refused")
82 || message.contains("network is unreachable")
83 || message.contains("no route to host")
84 || message.contains("connection closed")
85 || message.contains("connection unexpectedly closed")
90 || message.contains("connection lost")
91 || message.contains("ssh_exchange_identification")
92 || message.contains("kex_exchange_identification")
93 || message.contains("temporary failure in name resolution")
94 || message.contains("control socket")
97 || message.contains("controlsocket")
98 || message.contains("mux_client")
99 || message.contains("multiplexing")
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CommandResult {
105 pub exit_code: i32,
107 pub stdout: String,
109 pub stderr: String,
111 pub duration_ms: u64,
113}
114
115impl CommandResult {
116 pub fn success(&self) -> bool {
118 self.exit_code == 0
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct EnvPrefix {
125 pub prefix: String,
127 pub applied: Vec<String>,
129 pub rejected: Vec<String>,
131}
132
133pub fn build_env_prefix<F>(allowlist: &[String], mut get_env: F) -> EnvPrefix
139where
140 F: FnMut(&str) -> Option<String>,
141{
142 let mut parts = Vec::new();
143 let mut applied = Vec::new();
144 let mut rejected = Vec::new();
145
146 for raw_key in allowlist {
147 let key = raw_key.trim();
148 if key.is_empty() {
149 continue;
150 }
151 if !is_valid_env_key(key) {
152 info!(
153 "Rejecting env var '{}': invalid key name (must start with letter/underscore, contain only alphanumeric/underscore)",
154 key
155 );
156 rejected.push(key.to_string());
157 continue;
158 }
159 let Some(value) = get_env(key) else {
160 continue;
162 };
163 let Some(escaped) = shell_escape_value(&value) else {
164 info!(
165 "Rejecting env var '{}': value contains unsafe characters (newline, carriage return, or NUL)",
166 key
167 );
168 rejected.push(key.to_string());
169 continue;
170 };
171 parts.push(format!("{}={}", key, escaped));
172 applied.push(key.to_string());
173 }
174
175 let prefix = if parts.is_empty() {
176 String::new()
177 } else {
178 format!("{} ", parts.join(" "))
179 };
180
181 EnvPrefix {
182 prefix,
183 applied,
184 rejected,
185 }
186}
187
188pub fn is_valid_env_key(key: &str) -> bool {
190 let mut chars = key.chars();
191 let Some(first) = chars.next() else {
192 return false;
193 };
194 if !(first == '_' || first.is_ascii_alphabetic()) {
195 return false;
196 }
197 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
198}
199
200pub fn shell_escape_value(value: &str) -> Option<String> {
205 if value.contains('\n') || value.contains('\r') || value.contains('\0') {
208 return None;
209 }
210
211 if value.is_empty() {
212 return Some("''".to_string());
213 }
214
215 let needs_quotes = value
216 .chars()
217 .any(|c| !c.is_ascii_alphanumeric() && c != '_');
218 if !needs_quotes {
219 return Some(value.to_string());
220 }
221
222 let mut escaped = String::with_capacity(value.len() + 2);
223 escaped.push('\'');
224 for ch in value.chars() {
225 if ch == '\'' {
226 escaped.push_str("'\\''");
227 } else {
228 escaped.push(ch);
229 }
230 }
231 escaped.push('\'');
232 Some(escaped)
233}
234
235pub fn shell_escape_path_with_home(path: &str) -> Option<String> {
241 if path.contains('\n') || path.contains('\r') || path.contains('\0') {
242 return None;
243 }
244
245 if path == "~" {
246 return Some("\"$HOME\"".to_string());
247 }
248
249 if let Some(suffix) = path.strip_prefix("~/") {
250 let escaped_suffix = escape_for_double_quotes(suffix);
251 return Some(format!("\"$HOME/{}\"", escaped_suffix));
252 }
253
254 shell_escape_value(path)
255}
256
257fn escape_for_double_quotes(value: &str) -> String {
258 let mut escaped = String::with_capacity(value.len());
259 for ch in value.chars() {
260 match ch {
261 '\\' => escaped.push_str("\\\\"),
262 '"' => escaped.push_str("\\\""),
263 '$' => escaped.push_str("\\$"),
264 '`' => escaped.push_str("\\`"),
265 _ => escaped.push(ch),
266 }
267 }
268 escaped
269}
270
271#[cfg(test)]
272mod tests {
273
274 #[test]
276 fn identity_shell_args_pin_an_existing_key_for_rsync() {
277 let dir = tempfile::tempdir().unwrap();
278 let key = dir.path().join("id_ed25519");
279 std::fs::write(&key, "k").unwrap();
280 let key = key.to_str().unwrap();
281 let escaped = shell_escape::escape(key.into()).into_owned();
282 assert_eq!(
283 super::identity_shell_args(key),
284 format!("-i {escaped} -o IdentitiesOnly=yes")
285 );
286 let missing = dir.path().join("absent");
287 let missing = missing.to_str().unwrap();
288 let escaped = shell_escape::escape(missing.into()).into_owned();
289 assert_eq!(super::identity_shell_args(missing), format!("-i {escaped}"));
290 }
291 use super::*;
292 use crate::test_guard;
293
294 #[test]
295 fn test_retryable_transport_error_text() {
296 let _guard = test_guard!();
297 assert!(is_retryable_transport_error_text(
298 "ssh: connect to host 1.2.3.4 port 22: Connection timed out"
299 ));
300 assert!(is_retryable_transport_error_text(
301 "kex_exchange_identification: Connection reset by peer"
302 ));
303 assert!(is_retryable_transport_error_text("Broken pipe"));
304 assert!(is_retryable_transport_error_text("Network is unreachable"));
305 assert!(is_retryable_transport_error_text(
308 "rsync: connection unexpectedly closed (0 bytes received so far) [sender]"
309 ));
310 assert!(!"rsync: connection unexpectedly closed".contains("connection closed"));
311 }
312
313 #[test]
314 fn test_non_retryable_transport_error_text() {
315 let _guard = test_guard!();
316 assert!(!is_retryable_transport_error_text(
317 "Permission denied (publickey)."
318 ));
319 assert!(!is_retryable_transport_error_text(
320 "Host key verification failed."
321 ));
322 assert!(!is_retryable_transport_error_text(
323 "Could not resolve hostname worker.example.com: Name or service not known"
324 ));
325 assert!(!is_retryable_transport_error_text(
326 "Identity file /nope/id_rsa not accessible: No such file or directory"
327 ));
328 }
329
330 #[test]
331 fn test_stale_control_socket_is_retryable_not_fatal() {
332 let _guard = test_guard!();
333 assert!(is_retryable_transport_error_text(
337 "Control socket connect(/tmp/rch/cm-worker): No such file or directory"
338 ));
339 assert!(is_retryable_transport_error_text(
340 "mux_client_request_session: read from master failed: Broken pipe"
341 ));
342 assert!(!is_retryable_transport_error_text(
345 "Warning: Identity file ~/.ssh/id_ed25519 not accessible: No such file or directory"
346 ));
347 }
348
349 #[test]
350 fn test_command_result_success() {
351 let _guard = test_guard!();
352 let result = CommandResult {
353 exit_code: 0,
354 stdout: "output".to_string(),
355 stderr: String::new(),
356 duration_ms: 100,
357 };
358 assert!(result.success());
359
360 let failed = CommandResult {
361 exit_code: 1,
362 stdout: String::new(),
363 stderr: "error".to_string(),
364 duration_ms: 50,
365 };
366 assert!(!failed.success());
367 }
368
369 #[test]
370 fn test_shell_escape_value() {
371 let _guard = test_guard!();
372 assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
374
375 assert_eq!(shell_escape_value(""), Some("''".to_string()));
377
378 assert_eq!(
380 shell_escape_value("with spaces"),
381 Some("'with spaces'".to_string())
382 );
383
384 assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
386
387 assert!(shell_escape_value("line1\nline2").is_none());
389 assert!(shell_escape_value("line1\rline2").is_none());
390 assert!(shell_escape_value("line1\0line2").is_none());
391 }
392
393 #[test]
394 fn test_shell_escape_path_with_home() {
395 let _guard = test_guard!();
396 assert_eq!(
397 shell_escape_path_with_home("~/.local/bin"),
398 Some("\"$HOME/.local/bin\"".to_string())
399 );
400 assert_eq!(
401 shell_escape_path_with_home("~"),
402 Some("\"$HOME\"".to_string())
403 );
404 assert_eq!(
405 shell_escape_path_with_home("/usr/local/bin"),
406 Some("'/usr/local/bin'".to_string())
407 );
408 }
409
410 #[test]
411 fn test_is_valid_env_key() {
412 let _guard = test_guard!();
413 assert!(is_valid_env_key("PATH"));
414 assert!(is_valid_env_key("_PRIVATE"));
415 assert!(is_valid_env_key("MY_VAR_123"));
416 assert!(!is_valid_env_key("123VAR"));
417 assert!(!is_valid_env_key("MY-VAR"));
418 assert!(!is_valid_env_key(""));
419 }
420
421 #[test]
422 fn test_build_env_prefix() {
423 let _guard = test_guard!();
424 use std::collections::HashMap;
425
426 let mut env = HashMap::new();
427 env.insert("RUSTFLAGS".to_string(), "-C target-cpu=native".to_string());
428 env.insert("QUOTED".to_string(), "a'b".to_string());
429 env.insert("BADVAL".to_string(), "line1\nline2".to_string());
430
431 let allowlist = vec![
432 "RUSTFLAGS".to_string(),
433 "QUOTED".to_string(),
434 "MISSING".to_string(),
435 "BADVAL".to_string(),
436 "BAD=KEY".to_string(),
437 ];
438
439 let prefix = build_env_prefix(&allowlist, |key| env.get(key).cloned());
440
441 assert!(prefix.prefix.contains("RUSTFLAGS='-C target-cpu=native'"));
442 assert!(prefix.prefix.contains("QUOTED='a'\\''b'"));
443 assert!(!prefix.prefix.contains("MISSING="));
444 assert!(!prefix.prefix.contains("BADVAL="));
445 assert!(prefix.rejected.contains(&"BADVAL".to_string()));
446 assert!(prefix.rejected.contains(&"BAD=KEY".to_string()));
447 }
448}