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("identity file")
68 || message.contains("keyfile")
69 || message.contains("invalid format")
70 || message.contains("unknown option")
71 {
72 return false;
73 }
74
75 if message.contains("name or service not known")
82 || message.contains("nodename nor servname provided")
83 || message.lines().any(|line| {
84 line.contains("could not resolve hostname")
85 && !line
86 .trim_end()
87 .ends_with(": temporary failure in name resolution")
88 })
89 {
90 return false;
91 }
92
93 message.contains("connection timed out")
95 || message.contains("timed out")
96 || message.contains("connection reset")
97 || message.contains("broken pipe")
98 || message.contains("connection refused")
99 || message.contains("network is unreachable")
100 || message.contains("no route to host")
101 || message.contains("connection closed")
102 || message.contains("connection unexpectedly closed")
107 || message.contains("connection lost")
108 || message.contains("ssh_exchange_identification")
109 || message.contains("kex_exchange_identification")
110 || message.contains("temporary failure in name resolution")
111 || message.contains("control socket")
114 || message.contains("controlsocket")
115 || message.contains("mux_client")
116 || message.contains("multiplexing")
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CommandResult {
122 pub exit_code: i32,
124 pub stdout: String,
126 pub stderr: String,
128 pub duration_ms: u64,
130}
131
132impl CommandResult {
133 pub fn success(&self) -> bool {
135 self.exit_code == 0
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct EnvPrefix {
142 pub prefix: String,
144 pub applied: Vec<String>,
146 pub rejected: Vec<String>,
148}
149
150pub fn build_env_prefix<F>(allowlist: &[String], mut get_env: F) -> EnvPrefix
156where
157 F: FnMut(&str) -> Option<String>,
158{
159 let mut parts = Vec::new();
160 let mut applied = Vec::new();
161 let mut rejected = Vec::new();
162
163 for raw_key in allowlist {
164 let key = raw_key.trim();
165 if key.is_empty() {
166 continue;
167 }
168 if !is_valid_env_key(key) {
169 info!(
170 "Rejecting env var '{}': invalid key name (must start with letter/underscore, contain only alphanumeric/underscore)",
171 key
172 );
173 rejected.push(key.to_string());
174 continue;
175 }
176 let Some(value) = get_env(key) else {
177 continue;
179 };
180 let Some(escaped) = shell_escape_value(&value) else {
181 info!(
182 "Rejecting env var '{}': value contains unsafe characters (newline, carriage return, or NUL)",
183 key
184 );
185 rejected.push(key.to_string());
186 continue;
187 };
188 parts.push(format!("{}={}", key, escaped));
189 applied.push(key.to_string());
190 }
191
192 let prefix = if parts.is_empty() {
193 String::new()
194 } else {
195 format!("{} ", parts.join(" "))
196 };
197
198 EnvPrefix {
199 prefix,
200 applied,
201 rejected,
202 }
203}
204
205pub fn is_valid_env_key(key: &str) -> bool {
207 let mut chars = key.chars();
208 let Some(first) = chars.next() else {
209 return false;
210 };
211 if !(first == '_' || first.is_ascii_alphabetic()) {
212 return false;
213 }
214 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
215}
216
217pub fn shell_escape_value(value: &str) -> Option<String> {
222 if value.contains('\n') || value.contains('\r') || value.contains('\0') {
225 return None;
226 }
227
228 if value.is_empty() {
229 return Some("''".to_string());
230 }
231
232 let needs_quotes = value
233 .chars()
234 .any(|c| !c.is_ascii_alphanumeric() && c != '_');
235 if !needs_quotes {
236 return Some(value.to_string());
237 }
238
239 let mut escaped = String::with_capacity(value.len() + 2);
240 escaped.push('\'');
241 for ch in value.chars() {
242 if ch == '\'' {
243 escaped.push_str("'\\''");
244 } else {
245 escaped.push(ch);
246 }
247 }
248 escaped.push('\'');
249 Some(escaped)
250}
251
252pub fn shell_escape_path_with_home(path: &str) -> Option<String> {
258 if path.contains('\n') || path.contains('\r') || path.contains('\0') {
259 return None;
260 }
261
262 if path == "~" {
263 return Some("\"$HOME\"".to_string());
264 }
265
266 if let Some(suffix) = path.strip_prefix("~/") {
267 let escaped_suffix = escape_for_double_quotes(suffix);
268 return Some(format!("\"$HOME/{}\"", escaped_suffix));
269 }
270
271 shell_escape_value(path)
272}
273
274fn escape_for_double_quotes(value: &str) -> String {
275 let mut escaped = String::with_capacity(value.len());
276 for ch in value.chars() {
277 match ch {
278 '\\' => escaped.push_str("\\\\"),
279 '"' => escaped.push_str("\\\""),
280 '$' => escaped.push_str("\\$"),
281 '`' => escaped.push_str("\\`"),
282 _ => escaped.push(ch),
283 }
284 }
285 escaped
286}
287
288#[cfg(test)]
289mod tests {
290
291 #[test]
293 fn identity_shell_args_pin_an_existing_key_for_rsync() {
294 let dir = tempfile::tempdir().unwrap();
295 let key = dir.path().join("id_ed25519");
296 std::fs::write(&key, "k").unwrap();
297 let key = key.to_str().unwrap();
298 let escaped = shell_escape::escape(key.into()).into_owned();
299 assert_eq!(
300 super::identity_shell_args(key),
301 format!("-i {escaped} -o IdentitiesOnly=yes")
302 );
303 let missing = dir.path().join("absent");
304 let missing = missing.to_str().unwrap();
305 let escaped = shell_escape::escape(missing.into()).into_owned();
306 assert_eq!(super::identity_shell_args(missing), format!("-i {escaped}"));
307 }
308 use super::*;
309 use crate::test_guard;
310
311 #[test]
312 fn test_retryable_transport_error_text() {
313 let _guard = test_guard!();
314 assert!(is_retryable_transport_error_text(
315 "ssh: connect to host 1.2.3.4 port 22: Connection timed out"
316 ));
317 assert!(is_retryable_transport_error_text(
318 "kex_exchange_identification: Connection reset by peer"
319 ));
320 assert!(is_retryable_transport_error_text("Broken pipe"));
321 assert!(is_retryable_transport_error_text("Network is unreachable"));
322 assert!(is_retryable_transport_error_text(
325 "rsync: connection unexpectedly closed (0 bytes received so far) [sender]"
326 ));
327 assert!(!"rsync: connection unexpectedly closed".contains("connection closed"));
328 }
329
330 #[test]
331 fn test_non_retryable_transport_error_text() {
332 let _guard = test_guard!();
333 assert!(!is_retryable_transport_error_text(
334 "Permission denied (publickey)."
335 ));
336 assert!(!is_retryable_transport_error_text(
337 "Host key verification failed."
338 ));
339 assert!(!is_retryable_transport_error_text(
340 "Could not resolve hostname worker.example.com: Name or service not known"
341 ));
342 assert!(!is_retryable_transport_error_text(
343 "Identity file /nope/id_rsa not accessible: No such file or directory"
344 ));
345 }
346
347 #[test]
348 fn temporary_dns_failures_retry_through_real_ssh_and_rsync_diagnostics() {
349 for message in [
350 "ssh: Could not resolve hostname worker: Temporary failure in name resolution",
351 "SSH: COULD NOT RESOLVE HOSTNAME WORKER: TEMPORARY FAILURE IN NAME RESOLUTION\r\n",
352 "failed to sync project: ssh: Could not resolve hostname worker: Temporary failure in name resolution",
353 "ssh: Could not resolve hostname worker: Temporary failure in name resolution\nrsync: connection unexpectedly closed (0 bytes received so far) [sender]",
354 "Temporary failure in name resolution",
355 ] {
356 assert!(is_retryable_transport_error_text(message), "{message}");
357 }
358 let error = anyhow::anyhow!(
359 "ssh: Could not resolve hostname worker: Temporary failure in name resolution"
360 )
361 .context("source sync failed")
362 .context("selected worker could not be reached");
363 assert!(is_retryable_transport_error(&error));
364 }
365
366 #[test]
367 fn permanent_or_ambiguous_dns_failures_never_inherit_a_retryable_suffix() {
368 for reason in [
369 "Name or service not known",
370 "nodename nor servname provided, or not known",
371 "unknown resolver failure",
372 "",
373 ] {
374 let message = format!(
375 "ssh: Could not resolve hostname worker: {reason}\n\
376 rsync: connection unexpectedly closed\n\
377 previous attempt: Temporary failure in name resolution"
378 );
379 assert!(!is_retryable_transport_error_text(&message), "{message}");
380 }
381 assert!(!is_retryable_transport_error_text(
384 "Could not resolve hostname first: Name or service not known: \
385 Could not resolve hostname second: Temporary failure in name resolution"
386 ));
387 }
388
389 #[test]
390 fn temporary_dns_never_overrides_authentication_or_configuration_refusals() {
391 for fatal in [
392 "Permission denied (publickey)",
393 "Host key verification failed",
394 "Identity file /missing not accessible",
395 "Cannot open keyfile",
396 "Invalid format",
397 "Unknown option",
398 ] {
399 let error = anyhow::anyhow!(fatal).context(
400 "ssh: Could not resolve hostname worker: Temporary failure in name resolution",
401 );
402 assert!(!is_retryable_transport_error(&error), "{fatal}");
403 }
404 }
405
406 #[test]
407 fn test_stale_control_socket_is_retryable_not_fatal() {
408 let _guard = test_guard!();
409 assert!(is_retryable_transport_error_text(
413 "Control socket connect(/tmp/rch/cm-worker): No such file or directory"
414 ));
415 assert!(is_retryable_transport_error_text(
416 "mux_client_request_session: read from master failed: Broken pipe"
417 ));
418 assert!(!is_retryable_transport_error_text(
421 "Warning: Identity file ~/.ssh/id_ed25519 not accessible: No such file or directory"
422 ));
423 }
424
425 #[test]
426 fn test_command_result_success() {
427 let _guard = test_guard!();
428 let result = CommandResult {
429 exit_code: 0,
430 stdout: "output".to_string(),
431 stderr: String::new(),
432 duration_ms: 100,
433 };
434 assert!(result.success());
435
436 let failed = CommandResult {
437 exit_code: 1,
438 stdout: String::new(),
439 stderr: "error".to_string(),
440 duration_ms: 50,
441 };
442 assert!(!failed.success());
443 }
444
445 #[test]
446 fn test_shell_escape_value() {
447 let _guard = test_guard!();
448 assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
450
451 assert_eq!(shell_escape_value(""), Some("''".to_string()));
453
454 assert_eq!(
456 shell_escape_value("with spaces"),
457 Some("'with spaces'".to_string())
458 );
459
460 assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
462
463 assert!(shell_escape_value("line1\nline2").is_none());
465 assert!(shell_escape_value("line1\rline2").is_none());
466 assert!(shell_escape_value("line1\0line2").is_none());
467 }
468
469 #[test]
470 fn test_shell_escape_path_with_home() {
471 let _guard = test_guard!();
472 assert_eq!(
473 shell_escape_path_with_home("~/.local/bin"),
474 Some("\"$HOME/.local/bin\"".to_string())
475 );
476 assert_eq!(
477 shell_escape_path_with_home("~"),
478 Some("\"$HOME\"".to_string())
479 );
480 assert_eq!(
481 shell_escape_path_with_home("/usr/local/bin"),
482 Some("'/usr/local/bin'".to_string())
483 );
484 }
485
486 #[test]
487 fn test_is_valid_env_key() {
488 let _guard = test_guard!();
489 assert!(is_valid_env_key("PATH"));
490 assert!(is_valid_env_key("_PRIVATE"));
491 assert!(is_valid_env_key("MY_VAR_123"));
492 assert!(!is_valid_env_key("123VAR"));
493 assert!(!is_valid_env_key("MY-VAR"));
494 assert!(!is_valid_env_key(""));
495 }
496
497 #[test]
498 fn test_build_env_prefix() {
499 let _guard = test_guard!();
500 use std::collections::HashMap;
501
502 let mut env = HashMap::new();
503 env.insert("RUSTFLAGS".to_string(), "-C target-cpu=native".to_string());
504 env.insert("QUOTED".to_string(), "a'b".to_string());
505 env.insert("BADVAL".to_string(), "line1\nline2".to_string());
506
507 let allowlist = vec![
508 "RUSTFLAGS".to_string(),
509 "QUOTED".to_string(),
510 "MISSING".to_string(),
511 "BADVAL".to_string(),
512 "BAD=KEY".to_string(),
513 ];
514
515 let prefix = build_env_prefix(&allowlist, |key| env.get(key).cloned());
516
517 assert!(prefix.prefix.contains("RUSTFLAGS='-C target-cpu=native'"));
518 assert!(prefix.prefix.contains("QUOTED='a'\\''b'"));
519 assert!(!prefix.prefix.contains("MISSING="));
520 assert!(!prefix.prefix.contains("BADVAL="));
521 assert!(prefix.rejected.contains(&"BADVAL".to_string()));
522 assert!(prefix.rejected.contains(&"BAD=KEY".to_string()));
523 }
524}