Skip to main content

rch_common/
ssh_utils.rs

1//! Platform-independent SSH utilities.
2//!
3//! These utilities work on all platforms and don't depend on openssh.
4
5use serde::{Deserialize, Serialize};
6use tracing::info;
7
8/// `-o IdentitiesOnly=yes` for a system-ssh spawn that passes `-i
9/// <identity_file>`, but only when that file exists (bd-ebszo).
10///
11/// Without it, ssh offers every ssh-agent key BEFORE the `-i` key. A
12/// dispatcher whose daemon inherited a desktop agent holding 7 keys spent 6
13/// wrong attempts per connection, tripped workers' MaxAuthTries, and OpenSSH
14/// PerSourcePenalties then locked the whole NAT address out of those workers
15/// (~600 failed auths/h, 2026-10-05/06). A missing identity file keeps the
16/// old agent-fallback behavior so agent-only setups still authenticate.
17#[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/// The `-i <key>` (plus [`identities_only_args`]) part of an `ssh` command
26/// line, shell-escaped, for `rsync -e "ssh ..."` transports (bd-ebszo).
27#[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
40// ============================================================================
41// Retry Classification
42// ============================================================================
43
44/// True if an SSH/transport error looks retryable (transient network / transport).
45///
46/// This is intentionally conservative: false negatives are acceptable (fail-open
47/// to local execution), false positives can cause needless retries.
48pub 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
56/// Message-only variant of [`is_retryable_transport_error`] (useful for tests).
57pub fn is_retryable_transport_error_text(message: &str) -> bool {
58    let message = message.to_lowercase();
59
60    // Fail-fast: non-retryable authentication / host trust issues. NOTE: a bare
61    // "no such file or directory" is deliberately NOT here — openssh emits it for
62    // a transient stale ControlMaster socket (retryable), and the genuinely-fatal
63    // missing-key case is already covered by "identity file"/"keyfile". Keeping
64    // it as an unconditional fatal mis-classified a recoverable mux-socket drop.
65    // A dual-role worker's receiver refuses a sync into its live checkout
66    // before rsync starts, so rsync reports "connection unexpectedly closed".
67    // The refusal is deterministic (bd-fbtws); retrying only repeats it.
68    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    // OpenSSH prefixes BOTH EAI_AGAIN and permanent resolver failures with
82    // "could not resolve hostname". An unconditional refusal here made the
83    // transient DNS rule below unreachable for the real SSH diagnostic.
84    // Require the temporary reason on that same diagnostic, not elsewhere in
85    // a combined rsync log. A permanent/unknown lookup failure still vetoes
86    // retry even when a later line reports a broken pipe or a timeout.
87    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    // Common transient transport failures.
100    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        // rsync's canonical transient-drop message ("rsync: connection
109        // unexpectedly closed") does NOT contain the substring "connection
110        // closed" (the word "unexpectedly" sits between), so it must be matched
111        // explicitly or the most common rsync transport drop is treated fatal.
112        || 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        // Stale ControlMaster / multiplexing socket — a reconnect re-establishes
118        // the master, so these are transient, not fatal.
119        || message.contains("control socket")
120        || message.contains("controlsocket")
121        || message.contains("mux_client")
122        || message.contains("multiplexing")
123}
124
125/// Result of a remote command execution.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct CommandResult {
128    /// Exit code of the command.
129    pub exit_code: i32,
130    /// Standard output.
131    pub stdout: String,
132    /// Standard error.
133    pub stderr: String,
134    /// Execution duration in milliseconds.
135    pub duration_ms: u64,
136}
137
138impl CommandResult {
139    /// Check if the command succeeded (exit code 0).
140    pub fn success(&self) -> bool {
141        self.exit_code == 0
142    }
143}
144
145/// Environment variable prefix for remote command execution.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct EnvPrefix {
148    /// Shell-safe prefix (includes trailing space when non-empty).
149    pub prefix: String,
150    /// Keys applied to the command.
151    pub applied: Vec<String>,
152    /// Keys rejected due to invalid name or unsafe value.
153    pub rejected: Vec<String>,
154}
155
156/// Build a shell-safe environment variable prefix from an allowlist.
157///
158/// - Missing variables are ignored silently.
159/// - Unsafe values (newline, carriage return, NUL) are rejected.
160/// - Invalid keys are rejected.
161pub 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            // Variable not set - this is normal, don't log
184            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
211/// Check if a string is a valid environment variable key.
212pub 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
223/// Escape a string for use in a shell command.
224///
225/// Wraps the string in single quotes and escapes internal single quotes.
226/// Returns None if the string contains unsafe control characters (newline, carriage return, NUL).
227pub fn shell_escape_value(value: &str) -> Option<String> {
228    // Reject values with control characters that could break shell parsing
229    // Note: These are logged at the call site with the variable name for debugging
230    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
258/// Escape a path for use in a shell command, allowing `~` to expand to `$HOME`.
259///
260/// For paths that start with `~/` or are exactly `~`, this returns a double-quoted
261/// string that expands `$HOME` while escaping special characters inside the suffix.
262/// For all other paths, this defers to `shell_escape_value`.
263pub 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    /// bd-ebszo: rsync's `-e` transport gets the same key pinning as direct ssh.
298    #[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        // rsync's canonical transient drop — must be retryable (it does not
329        // contain the substring "connection closed").
330        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        // A temporary result for a second lookup must not hide a permanent
388        // result in the same aggregated error (including an anyhow chain).
389        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        // A stale/missing ControlMaster socket is transient — a reconnect fixes
416        // it — even though the message contains "No such file or directory",
417        // which used to force it non-retryable.
418        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        // But a genuine missing identity file stays fatal (covered by the
425        // "identity file" marker, not the bare path error).
426        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        // Simple value
455        assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
456
457        // Empty string
458        assert_eq!(shell_escape_value(""), Some("''".to_string()));
459
460        // With spaces
461        assert_eq!(
462            shell_escape_value("with spaces"),
463            Some("'with spaces'".to_string())
464        );
465
466        // With single quote
467        assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
468
469        // Unsafe values
470        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}