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    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    // OpenSSH prefixes BOTH EAI_AGAIN and permanent resolver failures with
76    // "could not resolve hostname". An unconditional refusal here made the
77    // transient DNS rule below unreachable for the real SSH diagnostic.
78    // Require the temporary reason on that same diagnostic, not elsewhere in
79    // a combined rsync log. A permanent/unknown lookup failure still vetoes
80    // retry even when a later line reports a broken pipe or a timeout.
81    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    // Common transient transport failures.
94    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        // rsync's canonical transient-drop message ("rsync: connection
103        // unexpectedly closed") does NOT contain the substring "connection
104        // closed" (the word "unexpectedly" sits between), so it must be matched
105        // explicitly or the most common rsync transport drop is treated fatal.
106        || 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        // Stale ControlMaster / multiplexing socket — a reconnect re-establishes
112        // the master, so these are transient, not fatal.
113        || message.contains("control socket")
114        || message.contains("controlsocket")
115        || message.contains("mux_client")
116        || message.contains("multiplexing")
117}
118
119/// Result of a remote command execution.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CommandResult {
122    /// Exit code of the command.
123    pub exit_code: i32,
124    /// Standard output.
125    pub stdout: String,
126    /// Standard error.
127    pub stderr: String,
128    /// Execution duration in milliseconds.
129    pub duration_ms: u64,
130}
131
132impl CommandResult {
133    /// Check if the command succeeded (exit code 0).
134    pub fn success(&self) -> bool {
135        self.exit_code == 0
136    }
137}
138
139/// Environment variable prefix for remote command execution.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct EnvPrefix {
142    /// Shell-safe prefix (includes trailing space when non-empty).
143    pub prefix: String,
144    /// Keys applied to the command.
145    pub applied: Vec<String>,
146    /// Keys rejected due to invalid name or unsafe value.
147    pub rejected: Vec<String>,
148}
149
150/// Build a shell-safe environment variable prefix from an allowlist.
151///
152/// - Missing variables are ignored silently.
153/// - Unsafe values (newline, carriage return, NUL) are rejected.
154/// - Invalid keys are rejected.
155pub 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            // Variable not set - this is normal, don't log
178            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
205/// Check if a string is a valid environment variable key.
206pub 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
217/// Escape a string for use in a shell command.
218///
219/// Wraps the string in single quotes and escapes internal single quotes.
220/// Returns None if the string contains unsafe control characters (newline, carriage return, NUL).
221pub fn shell_escape_value(value: &str) -> Option<String> {
222    // Reject values with control characters that could break shell parsing
223    // Note: These are logged at the call site with the variable name for debugging
224    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
252/// Escape a path for use in a shell command, allowing `~` to expand to `$HOME`.
253///
254/// For paths that start with `~/` or are exactly `~`, this returns a double-quoted
255/// string that expands `$HOME` while escaping special characters inside the suffix.
256/// For all other paths, this defers to `shell_escape_value`.
257pub 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    /// bd-ebszo: rsync's `-e` transport gets the same key pinning as direct ssh.
292    #[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        // rsync's canonical transient drop — must be retryable (it does not
323        // contain the substring "connection closed").
324        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        // A temporary result for a second lookup must not hide a permanent
382        // result in the same aggregated error (including an anyhow chain).
383        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        // A stale/missing ControlMaster socket is transient — a reconnect fixes
410        // it — even though the message contains "No such file or directory",
411        // which used to force it non-retryable.
412        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        // But a genuine missing identity file stays fatal (covered by the
419        // "identity file" marker, not the bare path error).
420        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        // Simple value
449        assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
450
451        // Empty string
452        assert_eq!(shell_escape_value(""), Some("''".to_string()));
453
454        // With spaces
455        assert_eq!(
456            shell_escape_value("with spaces"),
457            Some("'with spaces'".to_string())
458        );
459
460        // With single quote
461        assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
462
463        // Unsafe values
464        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}