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