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("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    // Common transient transport failures.
77    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        // rsync's canonical transient-drop message ("rsync: connection
86        // unexpectedly closed") does NOT contain the substring "connection
87        // closed" (the word "unexpectedly" sits between), so it must be matched
88        // explicitly or the most common rsync transport drop is treated fatal.
89        || 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        // Stale ControlMaster / multiplexing socket — a reconnect re-establishes
95        // the master, so these are transient, not fatal.
96        || message.contains("control socket")
97        || message.contains("controlsocket")
98        || message.contains("mux_client")
99        || message.contains("multiplexing")
100}
101
102/// Result of a remote command execution.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CommandResult {
105    /// Exit code of the command.
106    pub exit_code: i32,
107    /// Standard output.
108    pub stdout: String,
109    /// Standard error.
110    pub stderr: String,
111    /// Execution duration in milliseconds.
112    pub duration_ms: u64,
113}
114
115impl CommandResult {
116    /// Check if the command succeeded (exit code 0).
117    pub fn success(&self) -> bool {
118        self.exit_code == 0
119    }
120}
121
122/// Environment variable prefix for remote command execution.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct EnvPrefix {
125    /// Shell-safe prefix (includes trailing space when non-empty).
126    pub prefix: String,
127    /// Keys applied to the command.
128    pub applied: Vec<String>,
129    /// Keys rejected due to invalid name or unsafe value.
130    pub rejected: Vec<String>,
131}
132
133/// Build a shell-safe environment variable prefix from an allowlist.
134///
135/// - Missing variables are ignored silently.
136/// - Unsafe values (newline, carriage return, NUL) are rejected.
137/// - Invalid keys are rejected.
138pub 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            // Variable not set - this is normal, don't log
161            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
188/// Check if a string is a valid environment variable key.
189pub 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
200/// Escape a string for use in a shell command.
201///
202/// Wraps the string in single quotes and escapes internal single quotes.
203/// Returns None if the string contains unsafe control characters (newline, carriage return, NUL).
204pub fn shell_escape_value(value: &str) -> Option<String> {
205    // Reject values with control characters that could break shell parsing
206    // Note: These are logged at the call site with the variable name for debugging
207    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
235/// Escape a path for use in a shell command, allowing `~` to expand to `$HOME`.
236///
237/// For paths that start with `~/` or are exactly `~`, this returns a double-quoted
238/// string that expands `$HOME` while escaping special characters inside the suffix.
239/// For all other paths, this defers to `shell_escape_value`.
240pub 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    /// bd-ebszo: rsync's `-e` transport gets the same key pinning as direct ssh.
275    #[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        // rsync's canonical transient drop — must be retryable (it does not
306        // contain the substring "connection closed").
307        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        // A stale/missing ControlMaster socket is transient — a reconnect fixes
334        // it — even though the message contains "No such file or directory",
335        // which used to force it non-retryable.
336        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        // But a genuine missing identity file stays fatal (covered by the
343        // "identity file" marker, not the bare path error).
344        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        // Simple value
373        assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
374
375        // Empty string
376        assert_eq!(shell_escape_value(""), Some("''".to_string()));
377
378        // With spaces
379        assert_eq!(
380            shell_escape_value("with spaces"),
381            Some("'with spaces'".to_string())
382        );
383
384        // With single quote
385        assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
386
387        // Unsafe values
388        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}