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// ============================================================================
9// Retry Classification
10// ============================================================================
11
12/// True if an SSH/transport error looks retryable (transient network / transport).
13///
14/// This is intentionally conservative: false negatives are acceptable (fail-open
15/// to local execution), false positives can cause needless retries.
16pub fn is_retryable_transport_error(err: &anyhow::Error) -> bool {
17    let mut parts = Vec::new();
18    for cause in err.chain() {
19        parts.push(cause.to_string());
20    }
21    is_retryable_transport_error_text(&parts.join(": "))
22}
23
24/// Message-only variant of [`is_retryable_transport_error`] (useful for tests).
25pub fn is_retryable_transport_error_text(message: &str) -> bool {
26    let message = message.to_lowercase();
27
28    // Fail-fast: non-retryable authentication / host trust issues. NOTE: a bare
29    // "no such file or directory" is deliberately NOT here — openssh emits it for
30    // a transient stale ControlMaster socket (retryable), and the genuinely-fatal
31    // missing-key case is already covered by "identity file"/"keyfile". Keeping
32    // it as an unconditional fatal mis-classified a recoverable mux-socket drop.
33    if message.contains("permission denied")
34        || message.contains("host key verification failed")
35        || message.contains("could not resolve hostname")
36        || message.contains("identity file")
37        || message.contains("keyfile")
38        || message.contains("invalid format")
39        || message.contains("unknown option")
40    {
41        return false;
42    }
43
44    // Common transient transport failures.
45    message.contains("connection timed out")
46        || message.contains("timed out")
47        || message.contains("connection reset")
48        || message.contains("broken pipe")
49        || message.contains("connection refused")
50        || message.contains("network is unreachable")
51        || message.contains("no route to host")
52        || message.contains("connection closed")
53        // rsync's canonical transient-drop message ("rsync: connection
54        // unexpectedly closed") does NOT contain the substring "connection
55        // closed" (the word "unexpectedly" sits between), so it must be matched
56        // explicitly or the most common rsync transport drop is treated fatal.
57        || message.contains("connection unexpectedly closed")
58        || message.contains("connection lost")
59        || message.contains("ssh_exchange_identification")
60        || message.contains("kex_exchange_identification")
61        || message.contains("temporary failure in name resolution")
62        // Stale ControlMaster / multiplexing socket — a reconnect re-establishes
63        // the master, so these are transient, not fatal.
64        || message.contains("control socket")
65        || message.contains("controlsocket")
66        || message.contains("mux_client")
67        || message.contains("multiplexing")
68}
69
70/// Result of a remote command execution.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CommandResult {
73    /// Exit code of the command.
74    pub exit_code: i32,
75    /// Standard output.
76    pub stdout: String,
77    /// Standard error.
78    pub stderr: String,
79    /// Execution duration in milliseconds.
80    pub duration_ms: u64,
81}
82
83impl CommandResult {
84    /// Check if the command succeeded (exit code 0).
85    pub fn success(&self) -> bool {
86        self.exit_code == 0
87    }
88}
89
90/// Environment variable prefix for remote command execution.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EnvPrefix {
93    /// Shell-safe prefix (includes trailing space when non-empty).
94    pub prefix: String,
95    /// Keys applied to the command.
96    pub applied: Vec<String>,
97    /// Keys rejected due to invalid name or unsafe value.
98    pub rejected: Vec<String>,
99}
100
101/// Build a shell-safe environment variable prefix from an allowlist.
102///
103/// - Missing variables are ignored silently.
104/// - Unsafe values (newline, carriage return, NUL) are rejected.
105/// - Invalid keys are rejected.
106pub fn build_env_prefix<F>(allowlist: &[String], mut get_env: F) -> EnvPrefix
107where
108    F: FnMut(&str) -> Option<String>,
109{
110    let mut parts = Vec::new();
111    let mut applied = Vec::new();
112    let mut rejected = Vec::new();
113
114    for raw_key in allowlist {
115        let key = raw_key.trim();
116        if key.is_empty() {
117            continue;
118        }
119        if !is_valid_env_key(key) {
120            info!(
121                "Rejecting env var '{}': invalid key name (must start with letter/underscore, contain only alphanumeric/underscore)",
122                key
123            );
124            rejected.push(key.to_string());
125            continue;
126        }
127        let Some(value) = get_env(key) else {
128            // Variable not set - this is normal, don't log
129            continue;
130        };
131        let Some(escaped) = shell_escape_value(&value) else {
132            info!(
133                "Rejecting env var '{}': value contains unsafe characters (newline, carriage return, or NUL)",
134                key
135            );
136            rejected.push(key.to_string());
137            continue;
138        };
139        parts.push(format!("{}={}", key, escaped));
140        applied.push(key.to_string());
141    }
142
143    let prefix = if parts.is_empty() {
144        String::new()
145    } else {
146        format!("{} ", parts.join(" "))
147    };
148
149    EnvPrefix {
150        prefix,
151        applied,
152        rejected,
153    }
154}
155
156/// Check if a string is a valid environment variable key.
157pub fn is_valid_env_key(key: &str) -> bool {
158    let mut chars = key.chars();
159    let Some(first) = chars.next() else {
160        return false;
161    };
162    if !(first == '_' || first.is_ascii_alphabetic()) {
163        return false;
164    }
165    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
166}
167
168/// Escape a string for use in a shell command.
169///
170/// Wraps the string in single quotes and escapes internal single quotes.
171/// Returns None if the string contains unsafe control characters (newline, carriage return, NUL).
172pub fn shell_escape_value(value: &str) -> Option<String> {
173    // Reject values with control characters that could break shell parsing
174    // Note: These are logged at the call site with the variable name for debugging
175    if value.contains('\n') || value.contains('\r') || value.contains('\0') {
176        return None;
177    }
178
179    if value.is_empty() {
180        return Some("''".to_string());
181    }
182
183    let needs_quotes = value
184        .chars()
185        .any(|c| !c.is_ascii_alphanumeric() && c != '_');
186    if !needs_quotes {
187        return Some(value.to_string());
188    }
189
190    let mut escaped = String::with_capacity(value.len() + 2);
191    escaped.push('\'');
192    for ch in value.chars() {
193        if ch == '\'' {
194            escaped.push_str("'\\''");
195        } else {
196            escaped.push(ch);
197        }
198    }
199    escaped.push('\'');
200    Some(escaped)
201}
202
203/// Escape a path for use in a shell command, allowing `~` to expand to `$HOME`.
204///
205/// For paths that start with `~/` or are exactly `~`, this returns a double-quoted
206/// string that expands `$HOME` while escaping special characters inside the suffix.
207/// For all other paths, this defers to `shell_escape_value`.
208pub fn shell_escape_path_with_home(path: &str) -> Option<String> {
209    if path.contains('\n') || path.contains('\r') || path.contains('\0') {
210        return None;
211    }
212
213    if path == "~" {
214        return Some("\"$HOME\"".to_string());
215    }
216
217    if let Some(suffix) = path.strip_prefix("~/") {
218        let escaped_suffix = escape_for_double_quotes(suffix);
219        return Some(format!("\"$HOME/{}\"", escaped_suffix));
220    }
221
222    shell_escape_value(path)
223}
224
225fn escape_for_double_quotes(value: &str) -> String {
226    let mut escaped = String::with_capacity(value.len());
227    for ch in value.chars() {
228        match ch {
229            '\\' => escaped.push_str("\\\\"),
230            '"' => escaped.push_str("\\\""),
231            '$' => escaped.push_str("\\$"),
232            '`' => escaped.push_str("\\`"),
233            _ => escaped.push(ch),
234        }
235    }
236    escaped
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::test_guard;
243
244    #[test]
245    fn test_retryable_transport_error_text() {
246        let _guard = test_guard!();
247        assert!(is_retryable_transport_error_text(
248            "ssh: connect to host 1.2.3.4 port 22: Connection timed out"
249        ));
250        assert!(is_retryable_transport_error_text(
251            "kex_exchange_identification: Connection reset by peer"
252        ));
253        assert!(is_retryable_transport_error_text("Broken pipe"));
254        assert!(is_retryable_transport_error_text("Network is unreachable"));
255        // rsync's canonical transient drop — must be retryable (it does not
256        // contain the substring "connection closed").
257        assert!(is_retryable_transport_error_text(
258            "rsync: connection unexpectedly closed (0 bytes received so far) [sender]"
259        ));
260        assert!(!"rsync: connection unexpectedly closed".contains("connection closed"));
261    }
262
263    #[test]
264    fn test_non_retryable_transport_error_text() {
265        let _guard = test_guard!();
266        assert!(!is_retryable_transport_error_text(
267            "Permission denied (publickey)."
268        ));
269        assert!(!is_retryable_transport_error_text(
270            "Host key verification failed."
271        ));
272        assert!(!is_retryable_transport_error_text(
273            "Could not resolve hostname worker.example.com: Name or service not known"
274        ));
275        assert!(!is_retryable_transport_error_text(
276            "Identity file /nope/id_rsa not accessible: No such file or directory"
277        ));
278    }
279
280    #[test]
281    fn test_stale_control_socket_is_retryable_not_fatal() {
282        let _guard = test_guard!();
283        // A stale/missing ControlMaster socket is transient — a reconnect fixes
284        // it — even though the message contains "No such file or directory",
285        // which used to force it non-retryable.
286        assert!(is_retryable_transport_error_text(
287            "Control socket connect(/tmp/rch/cm-worker): No such file or directory"
288        ));
289        assert!(is_retryable_transport_error_text(
290            "mux_client_request_session: read from master failed: Broken pipe"
291        ));
292        // But a genuine missing identity file stays fatal (covered by the
293        // "identity file" marker, not the bare path error).
294        assert!(!is_retryable_transport_error_text(
295            "Warning: Identity file ~/.ssh/id_ed25519 not accessible: No such file or directory"
296        ));
297    }
298
299    #[test]
300    fn test_command_result_success() {
301        let _guard = test_guard!();
302        let result = CommandResult {
303            exit_code: 0,
304            stdout: "output".to_string(),
305            stderr: String::new(),
306            duration_ms: 100,
307        };
308        assert!(result.success());
309
310        let failed = CommandResult {
311            exit_code: 1,
312            stdout: String::new(),
313            stderr: "error".to_string(),
314            duration_ms: 50,
315        };
316        assert!(!failed.success());
317    }
318
319    #[test]
320    fn test_shell_escape_value() {
321        let _guard = test_guard!();
322        // Simple value
323        assert_eq!(shell_escape_value("simple"), Some("simple".to_string()));
324
325        // Empty string
326        assert_eq!(shell_escape_value(""), Some("''".to_string()));
327
328        // With spaces
329        assert_eq!(
330            shell_escape_value("with spaces"),
331            Some("'with spaces'".to_string())
332        );
333
334        // With single quote
335        assert_eq!(shell_escape_value("it's"), Some("'it'\\''s'".to_string()));
336
337        // Unsafe values
338        assert!(shell_escape_value("line1\nline2").is_none());
339        assert!(shell_escape_value("line1\rline2").is_none());
340        assert!(shell_escape_value("line1\0line2").is_none());
341    }
342
343    #[test]
344    fn test_shell_escape_path_with_home() {
345        let _guard = test_guard!();
346        assert_eq!(
347            shell_escape_path_with_home("~/.local/bin"),
348            Some("\"$HOME/.local/bin\"".to_string())
349        );
350        assert_eq!(
351            shell_escape_path_with_home("~"),
352            Some("\"$HOME\"".to_string())
353        );
354        assert_eq!(
355            shell_escape_path_with_home("/usr/local/bin"),
356            Some("'/usr/local/bin'".to_string())
357        );
358    }
359
360    #[test]
361    fn test_is_valid_env_key() {
362        let _guard = test_guard!();
363        assert!(is_valid_env_key("PATH"));
364        assert!(is_valid_env_key("_PRIVATE"));
365        assert!(is_valid_env_key("MY_VAR_123"));
366        assert!(!is_valid_env_key("123VAR"));
367        assert!(!is_valid_env_key("MY-VAR"));
368        assert!(!is_valid_env_key(""));
369    }
370
371    #[test]
372    fn test_build_env_prefix() {
373        let _guard = test_guard!();
374        use std::collections::HashMap;
375
376        let mut env = HashMap::new();
377        env.insert("RUSTFLAGS".to_string(), "-C target-cpu=native".to_string());
378        env.insert("QUOTED".to_string(), "a'b".to_string());
379        env.insert("BADVAL".to_string(), "line1\nline2".to_string());
380
381        let allowlist = vec![
382            "RUSTFLAGS".to_string(),
383            "QUOTED".to_string(),
384            "MISSING".to_string(),
385            "BADVAL".to_string(),
386            "BAD=KEY".to_string(),
387        ];
388
389        let prefix = build_env_prefix(&allowlist, |key| env.get(key).cloned());
390
391        assert!(prefix.prefix.contains("RUSTFLAGS='-C target-cpu=native'"));
392        assert!(prefix.prefix.contains("QUOTED='a'\\''b'"));
393        assert!(!prefix.prefix.contains("MISSING="));
394        assert!(!prefix.prefix.contains("BADVAL="));
395        assert!(prefix.rejected.contains(&"BADVAL".to_string()));
396        assert!(prefix.rejected.contains(&"BAD=KEY".to_string()));
397    }
398}