Skip to main content

purple_ssh/
connection.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use log::{debug, error, info, warn};
6
7use crate::ssh_launcher::SshLauncher;
8
9/// Result of an SSH connection attempt.
10#[derive(Debug)]
11pub struct ConnectResult {
12    pub status: std::process::ExitStatus,
13    pub stderr_output: String,
14}
15
16/// Returns true if the process is running inside a tmux session, per the
17/// resolved environment (so tests inject `TMUX` via `Env` instead of mutating
18/// the process env).
19#[cfg(unix)]
20pub fn is_in_tmux(env: &crate::runtime::env::Env) -> bool {
21    env.in_tmux()
22}
23
24/// Returns true if the current process is running inside a tmux session.
25#[cfg(not(unix))]
26pub fn is_in_tmux(_env: &crate::runtime::env::Env) -> bool {
27    false
28}
29
30/// Open an SSH connection in a new tmux window.
31/// Returns immediately after the window is created. The SSH session runs
32/// asynchronously in the new window. Returns an error if tmux is not
33/// available or the window cannot be created.
34///
35/// This path deliberately does not wire up SSH_ASKPASS. The caller in `main.rs`
36/// guards this with `askpass.is_none()`, because an askpass-backed host needs an
37/// inherited stdin (so purple's askpass subprocess can print back to the ssh
38/// parent) and that inheritance does not survive the `tmux new-window` fork.
39/// Hosts with a password source therefore keep using the suspend-TUI `connect()`
40/// flow instead.
41pub fn connect_tmux_window(
42    alias: &str,
43    config_path: &Path,
44    has_active_tunnel: bool,
45    launcher: &SshLauncher,
46) -> Result<()> {
47    info!("[external] SSH connection via tmux: {alias}");
48
49    let config_str = config_path
50        .to_str()
51        .context("SSH config path is not valid UTF-8")?;
52
53    let args = tmux_window_args(alias, launcher, config_str, has_active_tunnel, alias, None);
54
55    debug!("[external] tmux args: {:?}", args);
56
57    let status = Command::new("tmux")
58        .args(&args)
59        .status()
60        .with_context(|| format!("Failed to launch tmux new-window for '{alias}'"))?;
61
62    if status.success() {
63        info!("[external] tmux window created: {alias}");
64        Ok(())
65    } else {
66        let code = status.code().unwrap_or(-1);
67        error!("[external] tmux new-window failed for {alias} (exit {code})");
68        anyhow::bail!("tmux new-window exited with code {code}")
69    }
70}
71
72/// Argument list for `tmux new-window` that runs the launcher inside a fresh
73/// window. `remote_command` adds `-t` plus the command, as the exec path needs.
74fn tmux_window_args(
75    window_label: &str,
76    launcher: &SshLauncher,
77    config_str: &str,
78    has_active_tunnel: bool,
79    alias: &str,
80    remote_command: Option<&str>,
81) -> Vec<String> {
82    let mut args: Vec<String> = ["new-window", "-n", window_label, "--"]
83        .into_iter()
84        .map(str::to_string)
85        .collect();
86    args.extend(launcher.words().iter().cloned());
87    args.push("-F".to_string());
88    args.push(config_str.to_string());
89    if remote_command.is_some() {
90        args.push("-t".to_string());
91    }
92    if has_active_tunnel {
93        args.push("-o".to_string());
94        args.push("ClearAllForwardings=yes".to_string());
95    }
96    args.push("--".to_string());
97    args.push(alias.to_string());
98    if let Some(cmd) = remote_command {
99        args.push(cmd.to_string());
100    }
101    args
102}
103
104/// RAII guard that restores the signal mask when dropped.
105/// Ensures SIGINT/SIGTSTP are unmasked even on early return or error.
106#[cfg(unix)]
107struct SignalMaskGuard {
108    old: libc::sigset_t,
109}
110
111#[cfg(unix)]
112impl SignalMaskGuard {
113    /// Block SIGINT and SIGTSTP, saving the previous mask for restore on drop.
114    fn block_interactive() -> Self {
115        // SAFETY: `old` and `mask` are stack-allocated `sigset_t`s zeroed before
116        // use. The libc sigset / sigprocmask calls only read/write these
117        // pointers, which are valid for the duration of this block. `old` is
118        // moved into `Self` so the mask can be restored on drop.
119        unsafe {
120            let mut old: libc::sigset_t = std::mem::zeroed();
121            let mut mask: libc::sigset_t = std::mem::zeroed();
122            libc::sigemptyset(&mut mask);
123            libc::sigaddset(&mut mask, libc::SIGINT);
124            libc::sigaddset(&mut mask, libc::SIGTSTP);
125            libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old);
126            Self { old }
127        }
128    }
129}
130
131#[cfg(unix)]
132impl Drop for SignalMaskGuard {
133    fn drop(&mut self) {
134        // SAFETY: `self.old` is a valid `sigset_t` captured by
135        // `block_interactive`. `pending` is zeroed before `sigpending` writes
136        // to it. `libc::signal` is called with valid signal numbers. The
137        // sigprocmask call restores the previously-saved mask, which is still
138        // live for the duration of this drop.
139        unsafe {
140            // Discard any pending SIGINT/SIGTSTP that arrived while masked.
141            // Without this, queued signals would fire immediately on unmask and
142            // kill/suspend purple before the TUI can be restored.
143            let mut pending: libc::sigset_t = std::mem::zeroed();
144            libc::sigpending(&mut pending);
145            let has_sigint = libc::sigismember(&pending, libc::SIGINT) == 1;
146            let has_sigtstp = libc::sigismember(&pending, libc::SIGTSTP) == 1;
147            // Temporarily ignore pending signals so they're consumed on unmask.
148            if has_sigint {
149                libc::signal(libc::SIGINT, libc::SIG_IGN);
150            }
151            if has_sigtstp {
152                libc::signal(libc::SIGTSTP, libc::SIG_IGN);
153            }
154            libc::sigprocmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut());
155            // Restore default handlers after pending signals are consumed.
156            if has_sigint {
157                libc::signal(libc::SIGINT, libc::SIG_DFL);
158            }
159            if has_sigtstp {
160                libc::signal(libc::SIGTSTP, libc::SIG_DFL);
161            }
162        }
163    }
164}
165
166/// Spawn `cmd`, mask interactive signals in the parent, tee SSH's
167/// stderr to the real stderr while capturing it for error detection,
168/// then wait for the child to exit. Both `connect` and
169/// `connect_with_remote_command` build their `Command` independently
170/// (different argv) and delegate the spawn/wait/tee plumbing here so
171/// the long stderr-buffer + signal-guard sequence lives in one place.
172///
173/// `log_label` is interpolated into the started/ended/failed log lines
174/// so a reader can tell host-login from container-exec at a glance.
175///
176/// Exit 255 is ssh's own failure (auth, network, host-key). Any other code is
177/// either a remote-command exit status or a signal-kill (`-1`), both routine.
178pub(crate) fn is_ssh_transport_failure(code: i32) -> bool {
179    code == 255
180}
181
182/// Intended log severity for an ssh exit code. The single source of truth for
183/// the started/ended/failed routing in `spawn_ssh_and_wait` and the MCP
184/// `run_command` tool, so the error-vs-debug decision is testable as data.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub(crate) enum SshExitClass {
187    /// Exit 0: the command ran and succeeded.
188    Success,
189    /// Exit 255: ssh's own transport failure (auth, network, host-key). Error.
190    TransportFailure,
191    /// Any other non-zero code: the remote command's own status. Routine.
192    RemoteStatus,
193}
194
195pub(crate) fn classify_ssh_exit(code: i32) -> SshExitClass {
196    if code == 0 {
197        SshExitClass::Success
198    } else if is_ssh_transport_failure(code) {
199        SshExitClass::TransportFailure
200    } else {
201        SshExitClass::RemoteStatus
202    }
203}
204
205/// Log level for a failed ssh-backed remote command. Only ssh's own transport
206/// failure (exit 255) is an actionable error; a routine non-zero remote status
207/// is debug noise. Call sites that run a remote command and branch on the exit
208/// code (container list/actions, ...) route their failure log through here so
209/// the error level stays reserved for genuine connection failures.
210pub(crate) fn remote_exit_log_level(code: i32) -> log::Level {
211    match classify_ssh_exit(code) {
212        SshExitClass::TransportFailure => log::Level::Error,
213        SshExitClass::Success | SshExitClass::RemoteStatus => log::Level::Debug,
214    }
215}
216
217fn spawn_ssh_and_wait(
218    mut cmd: Command,
219    alias: &str,
220    log_label: &str,
221    program: &str,
222) -> Result<ConnectResult> {
223    cmd.stdin(std::process::Stdio::inherit())
224        .stdout(std::process::Stdio::inherit())
225        .stderr(std::process::Stdio::piped());
226
227    // Reset signal mask in the child process so SSH receives Ctrl+C
228    // normally. We mask signals in the parent AFTER spawn so the
229    // child doesn't inherit the blocked mask.
230    #[cfg(unix)]
231    unsafe {
232        use std::os::unix::process::CommandExt;
233        cmd.pre_exec(|| {
234            let mut mask: libc::sigset_t = std::mem::zeroed();
235            libc::sigemptyset(&mut mask);
236            libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
237            Ok(())
238        });
239    }
240
241    let mut child = cmd
242        .spawn()
243        .with_context(|| format!("Failed to launch {program} {log_label} for '{alias}'"))?;
244
245    // Mask SIGINT/SIGTSTP in purple AFTER spawn so SSH doesn't inherit
246    // the blocked mask. The guard restores the mask on drop (even on
247    // early return).
248    #[cfg(unix)]
249    let _signal_guard = SignalMaskGuard::block_interactive();
250
251    let stderr_pipe = child.stderr.take().expect("stderr was piped");
252    let stderr_thread = std::thread::spawn(move || {
253        use std::io::{Read, Write};
254        let mut captured = Vec::new();
255        let mut buf = [0u8; 4096];
256        let mut reader = stderr_pipe;
257        let mut stderr_out = std::io::stderr();
258        loop {
259            match reader.read(&mut buf) {
260                Ok(0) => break,
261                Ok(n) => {
262                    let _ = stderr_out.write_all(&buf[..n]);
263                    let _ = stderr_out.flush();
264                    captured.extend_from_slice(&buf[..n]);
265                }
266                Err(_) => break,
267            }
268        }
269        String::from_utf8_lossy(&captured).to_string()
270    });
271
272    let status = child
273        .wait()
274        .with_context(|| format!("Failed to wait for {program} {log_label} for '{alias}'"))?;
275    let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
276        warn!("[purple] Stderr capture thread panicked for {alias}");
277        String::new()
278    });
279
280    let code = status.code().unwrap_or(-1);
281    match classify_ssh_exit(code) {
282        SshExitClass::Success => {
283            info!("[external] SSH {} ended: {alias} (exit 0)", log_label);
284        }
285        SshExitClass::TransportFailure => {
286            error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
287            if !stderr_output.is_empty() {
288                let stderr = stderr_output.trim();
289                let lower = stderr.to_lowercase();
290                if lower.contains("are too open") || lower.contains("bad permissions") {
291                    warn!("[config] SSH key permission issue: {stderr}");
292                } else {
293                    debug!("[external] SSH stderr: {stderr}");
294                }
295            }
296        }
297        SshExitClass::RemoteStatus => {
298            // Remote-command status or a signal-kill: routine flow, not a
299            // must-act error.
300            debug!("[external] SSH {} ended: {alias} (exit {code})", log_label);
301        }
302    }
303
304    Ok(ConnectResult {
305        status,
306        stderr_output,
307    })
308}
309
310/// Launch an SSH connection to the given host alias.
311/// Runs the `launcher` program (plain `ssh` unless the user configured a
312/// wrapper) with inherited stdin/stdout. Stderr is piped and forwarded to real
313/// stderr in real time so the output is captured for error detection.
314/// Passes `-F <config_path>` so the alias resolves against the correct config file.
315/// When `askpass` is Some, delegates to `askpass_env::configure_ssh_command` to wire up
316/// SSH_ASKPASS, SSH_ASKPASS_REQUIRE=force and the PURPLE_* env vars.
317pub fn connect(
318    alias: &str,
319    config_path: &Path,
320    askpass: Option<&str>,
321    bw_session: Option<&str>,
322    has_active_tunnel: bool,
323    launcher: &SshLauncher,
324) -> Result<ConnectResult> {
325    info!("[external] SSH connection started: {alias}");
326    debug!(
327        "[external] SSH command: {} -F {} -- {alias}",
328        launcher.display(),
329        config_path.display()
330    );
331
332    let mut cmd = launcher.command();
333    cmd.arg("-F").arg(config_path);
334
335    // When a tunnel is already running for this host, disable forwards in the
336    // interactive session to avoid "Address already in use" bind conflicts.
337    if has_active_tunnel {
338        cmd.arg("-o").arg("ClearAllForwardings=yes");
339    }
340
341    cmd.arg("--").arg(alias);
342
343    if askpass.is_some() {
344        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
345    }
346
347    if let Some(token) = bw_session {
348        cmd.env("BW_SESSION", token);
349    }
350
351    spawn_ssh_and_wait(cmd, alias, "connection", launcher.program())
352}
353
354/// Launch an SSH connection that runs a single remote command in
355/// interactive mode. Mirrors `connect()` exactly except for two
356/// additions: `-t` to allocate a TTY (required for the remote shell
357/// `docker exec` opens) and the trailing `remote_command` string passed
358/// to ssh as one argv slot. The remote shell receives the string as a
359/// single command line, so multi-token commands and shell operators
360/// like `||` work naturally.
361///
362/// Used by the containers overview Enter handler: the `remote_command`
363/// is built as `<runtime> exec -it <container_id> sh -c 'bash || sh'`
364/// where `container_id` has already been validated to alphanumeric +
365/// `-_.` so it cannot inject shell metacharacters.
366#[allow(clippy::too_many_arguments)]
367pub fn connect_with_remote_command(
368    alias: &str,
369    config_path: &Path,
370    env: &crate::runtime::env::Env,
371    askpass: Option<&str>,
372    bw_session: Option<&str>,
373    has_active_tunnel: bool,
374    remote_command: &str,
375    launcher: &SshLauncher,
376) -> Result<ConnectResult> {
377    info!("[external] SSH exec started: {alias}");
378    debug!(
379        "[external] SSH command: {} -F {} -t -- {alias} {}",
380        launcher.display(),
381        config_path.display(),
382        remote_command
383    );
384
385    // Renew the Vault SSH cert before exec'ing into a container so an
386    // expired cert is refreshed, mirroring the interactive connect path.
387    // No-op for non-vault hosts.
388    crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
389
390    let mut cmd = launcher.command();
391    cmd.arg("-F").arg(config_path).arg("-t");
392
393    if has_active_tunnel {
394        cmd.arg("-o").arg("ClearAllForwardings=yes");
395    }
396
397    cmd.arg("--").arg(alias).arg(remote_command);
398
399    if askpass.is_some() {
400        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
401    }
402
403    if let Some(token) = bw_session {
404        cmd.env("BW_SESSION", token);
405    }
406
407    spawn_ssh_and_wait(cmd, alias, "exec", launcher.program())
408}
409
410/// tmux variant of `connect_with_remote_command`. Opens a new tmux
411/// window running `ssh -t <alias> <remote_command>` so the TUI stays
412/// alive in the original window. Same askpass-incompatible caveat as
413/// `connect_tmux_window`.
414pub fn connect_tmux_window_with_remote_command(
415    alias: &str,
416    config_path: &Path,
417    env: &crate::runtime::env::Env,
418    has_active_tunnel: bool,
419    remote_command: &str,
420    window_label: &str,
421    launcher: &SshLauncher,
422) -> Result<()> {
423    info!("[external] SSH exec via tmux: {alias}");
424
425    // Renew the Vault SSH cert before exec'ing into a container so an
426    // expired cert is refreshed, mirroring the interactive connect path.
427    // No-op for non-vault hosts.
428    crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
429
430    let config_str = config_path
431        .to_str()
432        .context("SSH config path is not valid UTF-8")?;
433
434    let args = tmux_window_args(
435        window_label,
436        launcher,
437        config_str,
438        has_active_tunnel,
439        alias,
440        Some(remote_command),
441    );
442
443    debug!("[external] tmux exec args: {:?}", args);
444
445    let status = Command::new("tmux")
446        .args(&args)
447        .status()
448        .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
449
450    if status.success() {
451        info!("[external] tmux exec window created: {alias}");
452        Ok(())
453    } else {
454        let code = status.code().unwrap_or(-1);
455        error!("[external] tmux exec window failed for {alias} (exit {code})");
456        anyhow::bail!("tmux new-window exited with code {code}")
457    }
458}
459
460/// Extract a concise reason from SSH stderr for display in the toast.
461/// Joins all non-empty, non-banner lines with ` | ` so the full context
462/// is visible. Truncates to 200 chars (char-safe) if needed.
463pub fn stderr_summary(stderr: &str) -> Option<String> {
464    let summary: String = stderr
465        .lines()
466        .map(str::trim)
467        .filter(|l| !l.is_empty() && !l.starts_with('@'))
468        .collect::<Vec<_>>()
469        .join(" | ");
470    if summary.is_empty() {
471        return None;
472    }
473    if summary.len() > 200 {
474        let truncated: String = summary.chars().take(197).collect();
475        Some(format!("{truncated}..."))
476    } else {
477        Some(summary)
478    }
479}
480
481/// True when ssh gave up on authentication and a typed password could still
482/// help: the method list behind `Permission denied (` names `password` or
483/// `keyboard-interactive`. A bare `(publickey)` is false, because the server
484/// takes no password at all.
485pub fn needs_password(stderr: &str) -> bool {
486    stderr.split("Permission denied (").skip(1).any(|rest| {
487        let methods = rest.split(')').next().unwrap_or("");
488        methods
489            .split(',')
490            .map(str::trim)
491            .any(|m| m == "password" || m == "keyboard-interactive")
492    })
493}
494
495/// True when strict host key checking refused a host that is not in
496/// `known_hosts` yet. A changed key prints the `@@@` banner plus an
497/// `Offending` line instead and stays with `parse_host_key_error`.
498pub fn is_unknown_host_key(stderr: &str) -> bool {
499    stderr.contains("host key is known for") && stderr.contains("requested strict checking")
500}
501
502/// The host ssh named on its `Permission denied` line, without the user
503/// part. OpenSSH writes `<user>@<host>: Permission denied (<methods>).`,
504/// and on a ProxyJump chain that host is whichever hop refused. `None`
505/// when no line carries the prefix.
506pub fn denied_host(stderr: &str) -> Option<&str> {
507    stderr
508        .lines()
509        .map(str::trim)
510        .filter(|l| l.contains(": Permission denied ("))
511        .filter_map(|l| {
512            let prefix = l.split(": Permission denied (").next()?;
513            prefix.rsplit('@').next().filter(|h| !h.is_empty())
514        })
515        .next_back()
516}
517
518/// The host strict checking refused, as ssh spells it on the
519/// `No ... host key is known for <host> and you have requested strict
520/// checking.` line. Carries the bracketed form for a non-default port,
521/// so `[10.0.0.1]:2222` comes back whole.
522pub fn unknown_host_key_host(stderr: &str) -> Option<&str> {
523    stderr
524        .lines()
525        .map(str::trim)
526        .find(|l| l.contains("host key is known for") && l.contains("requested strict checking"))
527        .and_then(|l| {
528            let start = l.find("host key is known for ")? + "host key is known for ".len();
529            let rest = &l[start..];
530            let end = rest.find(" and you have requested")?;
531            Some(rest[..end].trim())
532        })
533        .filter(|h| !h.is_empty())
534}
535
536/// Strip ssh's `[host]:port` form down to the bare host, so a name parsed
537/// out of stderr compares against a `HostName` directive.
538pub fn bare_host(host: &str) -> &str {
539    host.strip_prefix('[')
540        .and_then(|rest| rest.split(']').next())
541        .unwrap_or(host)
542}
543
544/// Parse host key verification error from SSH stderr output.
545/// Returns (hostname, known_hosts_path) if the error is a changed host key.
546///
547/// Uses two detection strategies:
548/// 1. English string matching for hostname and known_hosts path extraction.
549/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
550///    regardless of locale, combined with a known_hosts path from "Offending" line.
551///    When the English hostname line is missing, falls back to extracting the
552///    hostname from the known_hosts file path.
553pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
554    // Primary: English locale detection
555    let has_english_error = stderr.contains("Host key verification failed.");
556    // Fallback: the @@@ banner is locale-independent and always present for host key errors
557    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
558
559    if !has_english_error && !has_banner {
560        return None;
561    }
562
563    // Parse hostname from "Host key for <hostname> has changed"
564    let hostname = stderr
565        .lines()
566        .find(|l| l.contains("Host key for") && l.contains("has changed"))
567        .and_then(|l| {
568            let start = l.find("Host key for ")? + "Host key for ".len();
569            let rest = &l[start..];
570            let end = rest.find(" has changed")?;
571            Some(rest[..end].to_string())
572        });
573
574    // Parse known_hosts path from "Offending ... key in <path>:<line>"
575    let known_hosts_path = stderr
576        .lines()
577        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
578        .and_then(|l| {
579            let start = l.find(" key in ")? + " key in ".len();
580            let rest = &l[start..];
581            let end = rest.rfind(':')?;
582            Some(rest[..end].to_string())
583        });
584
585    // We need at least the known_hosts path to be useful
586    let known_hosts_path = known_hosts_path?;
587
588    // If we couldn't parse the hostname (non-English locale), derive it from
589    // the known_hosts path by running ssh-keygen -F would be complex.
590    // Instead, use a reasonable default: the user will see the confirmation dialog
591    // with the known_hosts path, which is the critical piece for the reset.
592    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
593
594    Some((hostname, known_hosts_path))
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn transport_failure_is_only_code_255() {
603        assert!(is_ssh_transport_failure(255));
604        // Remote-command passthrough statuses are not ssh transport failures.
605        assert!(!is_ssh_transport_failure(0));
606        assert!(!is_ssh_transport_failure(1));
607        assert!(!is_ssh_transport_failure(2));
608        assert!(!is_ssh_transport_failure(254));
609        assert!(!is_ssh_transport_failure(-1));
610    }
611
612    #[test]
613    fn classify_ssh_exit_routes_only_255_to_error() {
614        // Pins the error-vs-debug routing shared by spawn_ssh_and_wait and the
615        // MCP run_command tool. A revert to "any non-zero is an error" fails here.
616        assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
617        assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
618        // Routine remote-command statuses stay out of the error arm.
619        assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
620        assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
621        assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
622        assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
623        assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
624        // Signal-killed ssh (status.code() == None -> -1) is routine teardown.
625        assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
626    }
627
628    #[test]
629    fn remote_exit_log_level_reserves_error_for_transport_failure() {
630        // Pins the level every remote-command call site (container list/actions,
631        // ...) uses for a failed exit. Only ssh's 255 transport failure is an
632        // error; routine remote statuses are debug. A revert to "non-zero is an
633        // error" fails here.
634        assert_eq!(remote_exit_log_level(255), log::Level::Error);
635        for code in [0, 1, 2, 126, 127, 130, 254, -1] {
636            assert_eq!(
637                remote_exit_log_level(code),
638                log::Level::Debug,
639                "exit {code} must not log at error level"
640            );
641        }
642    }
643
644    #[test]
645    fn connect_fails_with_nonexistent_config() {
646        // connect() should return an error when the config file doesn't exist and
647        let result = connect(
648            "nonexistent-host",
649            Path::new("/tmp/__purple_test_nonexistent_config__"),
650            None,
651            None,
652            false,
653            &SshLauncher::default(),
654        );
655        // SSH should exit with a non-zero status (config file not found)
656        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
657        let r = result.unwrap();
658        assert!(!r.status.success());
659    }
660
661    #[test]
662    fn connect_with_tunnel_flag_does_not_panic() {
663        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
664        let result = connect(
665            "nonexistent-host",
666            Path::new("/tmp/__purple_test_nonexistent_config__"),
667            None,
668            None,
669            true,
670            &SshLauncher::default(),
671        );
672        assert!(result.is_ok());
673        assert!(!result.unwrap().status.success());
674    }
675
676    #[test]
677    fn connect_runs_the_configured_launcher() {
678        // A launcher that echoes its argv proves purple's own arguments are
679        // appended after the configured words and the exit code flows back.
680        let launcher =
681            SshLauncher::parse("sh -c 'printf \"%s\\n\" \"$@\" >&2; exit 7' sh").unwrap();
682        let result = connect(
683            "launcher-host",
684            Path::new("/tmp/__purple_test_cfg__"),
685            None,
686            None,
687            true,
688            &launcher,
689        )
690        .unwrap();
691        assert_eq!(result.status.code(), Some(7));
692        let lines: Vec<&str> = result.stderr_output.lines().collect();
693        assert_eq!(
694            lines,
695            [
696                "-F",
697                "/tmp/__purple_test_cfg__",
698                "-o",
699                "ClearAllForwardings=yes",
700                "--",
701                "launcher-host"
702            ]
703        );
704    }
705
706    #[test]
707    fn connect_reports_missing_launcher_program() {
708        let launcher = SshLauncher::parse("/nonexistent/purple-test-launcher ssh").unwrap();
709        let err = connect(
710            "launcher-host",
711            Path::new("/tmp/__purple_test_cfg__"),
712            None,
713            None,
714            false,
715            &launcher,
716        )
717        .unwrap_err();
718        assert!(
719            err.to_string()
720                .contains("Failed to launch /nonexistent/purple-test-launcher connection"),
721            "unexpected error: {err}"
722        );
723    }
724
725    #[test]
726    fn tmux_window_args_wrap_launcher_words() {
727        let launcher = SshLauncher::parse("kitten ssh").unwrap();
728        let args = tmux_window_args(
729            "web1",
730            &launcher,
731            "/home/u/.ssh/config",
732            false,
733            "web1",
734            None,
735        );
736        assert_eq!(
737            args,
738            [
739                "new-window",
740                "-n",
741                "web1",
742                "--",
743                "kitten",
744                "ssh",
745                "-F",
746                "/home/u/.ssh/config",
747                "--",
748                "web1"
749            ]
750        );
751        let args = tmux_window_args(
752            "web1/app",
753            &SshLauncher::default(),
754            "/home/u/.ssh/config",
755            true,
756            "web1",
757            Some("docker exec -it abc sh"),
758        );
759        assert_eq!(
760            args,
761            [
762                "new-window",
763                "-n",
764                "web1/app",
765                "--",
766                "ssh",
767                "-F",
768                "/home/u/.ssh/config",
769                "-t",
770                "-o",
771                "ClearAllForwardings=yes",
772                "--",
773                "web1",
774                "docker exec -it abc sh"
775            ]
776        );
777    }
778
779    #[test]
780    fn connect_captures_stderr() {
781        // SSH should produce some stderr output when failing.
782        let result = connect(
783            "nonexistent-host",
784            Path::new("/tmp/__purple_test_nonexistent_config__"),
785            None,
786            None,
787            false,
788            &SshLauncher::default(),
789        );
790        assert!(result.is_ok());
791        // SSH writes errors to stderr; we should have captured something
792        // (either "Can't open user config file" or a connection error)
793        let r = result.unwrap();
794        assert!(
795            !r.stderr_output.is_empty() || !r.status.success(),
796            "SSH should produce stderr or fail"
797        );
798    }
799
800    // --- needs_password / is_unknown_host_key tests ---
801    //
802    // The strings are what OpenSSH 10.3 prints for a password-only sshd
803    // under BatchMode=yes and StrictHostKeyChecking=yes.
804
805    #[test]
806    fn needs_password_true_for_password_method() {
807        assert!(needs_password(
808            "test@localhost: Permission denied (password).\n"
809        ));
810    }
811
812    #[test]
813    fn needs_password_true_when_password_is_one_of_several_methods() {
814        assert!(needs_password(
815            "test@localhost: Permission denied (publickey,password).\n"
816        ));
817        assert!(needs_password(
818            "test@localhost: Permission denied (publickey,keyboard-interactive).\n"
819        ));
820    }
821
822    #[test]
823    fn needs_password_false_for_publickey_only() {
824        // A typed password cannot help when the server takes none.
825        assert!(!needs_password(
826            "test@localhost: Permission denied (publickey).\n"
827        ));
828    }
829
830    #[test]
831    fn needs_password_false_for_unrelated_errors() {
832        assert!(!needs_password(""));
833        assert!(!needs_password(
834            "ssh: connect to host example.com port 22: Connection refused\n"
835        ));
836        assert!(!needs_password("Permission denied, please try again.\n"));
837    }
838
839    #[test]
840    fn needs_password_survives_the_post_quantum_warning_prefix() {
841        let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
842                      ** This session may be vulnerable to \"store now, decrypt later\" attacks.\n\
843                      test@10.0.0.5: Permission denied (publickey,password).\n";
844        assert!(needs_password(stderr));
845    }
846
847    #[test]
848    fn is_unknown_host_key_true_for_strict_checking_refusal() {
849        let stderr = "No ED25519 host key is known for [localhost]:12222 and you have requested strict checking.\n\
850                      Host key verification failed.\n";
851        assert!(is_unknown_host_key(stderr));
852    }
853
854    #[test]
855    fn is_unknown_host_key_false_for_changed_key_banner() {
856        // A changed key is a security event: it keeps the reset dialog and
857        // never gets an automatic retry.
858        let stderr = "\
859@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
860@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
861@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
862Offending ECDSA key in /Users/user/.ssh/known_hosts:55
863Host key for example.com has changed and you have requested strict checking.
864Host key verification failed.
865";
866        assert!(!is_unknown_host_key(stderr));
867        assert!(parse_host_key_error(stderr).is_some());
868    }
869
870    #[test]
871    fn is_unknown_host_key_false_for_plain_permission_denied() {
872        assert!(!is_unknown_host_key(
873            "test@localhost: Permission denied (password).\n"
874        ));
875        assert!(!is_unknown_host_key(""));
876    }
877
878    // --- denied_host / unknown_host_key_host / bare_host tests ---
879    //
880    // These three name the hop ssh refused, which is what decides whether a
881    // dialog opens for the target or the run is reported as a jump host's
882    // refusal. The fixtures are verbatim OpenSSH output.
883
884    #[test]
885    fn denied_host_takes_the_host_and_drops_the_user() {
886        assert_eq!(
887            denied_host(
888                "nobodyuser@127.0.0.1: Permission denied (publickey,password,keyboard-interactive).\n"
889            ),
890            Some("127.0.0.1")
891        );
892    }
893
894    #[test]
895    fn denied_host_reads_a_line_carrying_no_user() {
896        assert_eq!(
897            denied_host("bastion.example.com: Permission denied (password).\n"),
898            Some("bastion.example.com")
899        );
900    }
901
902    #[test]
903    fn denied_host_survives_a_warning_prefix_and_trailing_lines() {
904        let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
905                      ops@10.0.0.5: Permission denied (publickey,password).\n\
906                      Connection closed by 10.0.0.5 port 22\n";
907        assert_eq!(denied_host(stderr), Some("10.0.0.5"));
908    }
909
910    #[test]
911    fn denied_host_takes_the_last_line_when_several_carry_the_prefix() {
912        // On a chain the hop that refused prints its own line. The last one
913        // is the deepest ssh got.
914        let stderr = "ops@bastion.example.com: Permission denied (password).\n\
915                      ops@target.example.com: Permission denied (password).\n";
916        assert_eq!(denied_host(stderr), Some("target.example.com"));
917    }
918
919    #[test]
920    fn denied_host_is_none_without_the_line() {
921        assert_eq!(denied_host(""), None);
922        assert_eq!(denied_host("Permission denied, please try again.\n"), None);
923        assert_eq!(
924            denied_host("ssh: connect to host x port 22: Connection refused\n"),
925            None
926        );
927    }
928
929    #[test]
930    fn unknown_host_key_host_keeps_the_bracketed_port_form() {
931        let stderr = "No ED25519 host key is known for [127.0.0.1]:12222 and you have requested strict checking.\n\
932                      Host key verification failed.\n";
933        assert_eq!(unknown_host_key_host(stderr), Some("[127.0.0.1]:12222"));
934    }
935
936    #[test]
937    fn unknown_host_key_host_reads_a_default_port_host() {
938        let stderr =
939            "No RSA host key is known for db.example.com and you have requested strict checking.\n";
940        assert_eq!(unknown_host_key_host(stderr), Some("db.example.com"));
941    }
942
943    #[test]
944    fn unknown_host_key_host_is_none_for_other_failures() {
945        assert_eq!(unknown_host_key_host(""), None);
946        assert_eq!(
947            unknown_host_key_host("test@localhost: Permission denied (password).\n"),
948            None
949        );
950    }
951
952    #[test]
953    fn bare_host_strips_the_bracketed_port() {
954        assert_eq!(bare_host("[127.0.0.1]:12222"), "127.0.0.1");
955        assert_eq!(bare_host("[2001:db8::1]:2222"), "2001:db8::1");
956        assert_eq!(bare_host("db.example.com"), "db.example.com");
957        assert_eq!(bare_host(""), "");
958    }
959
960    // --- parse_host_key_error tests ---
961
962    #[test]
963    fn parse_host_key_error_detects_changed_key() {
964        let stderr = "\
965@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
966@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
967@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
968IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
969Someone could be eavesdropping on you right now (man-in-the-middle attack)!
970It is also possible that a host key has just been changed.
971The fingerprint for the ED25519 key sent by the remote host is
972SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
973Please contact your system administrator.
974Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
975Offending ECDSA key in /Users/user/.ssh/known_hosts:55
976Host key for example.com has changed and you have requested strict checking.
977Host key verification failed.
978";
979        let result = parse_host_key_error(stderr);
980        assert!(result.is_some());
981        let (hostname, path) = result.unwrap();
982        assert_eq!(hostname, "example.com");
983        assert_eq!(path, "/Users/user/.ssh/known_hosts");
984    }
985
986    #[test]
987    fn parse_host_key_error_returns_none_for_other_errors() {
988        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
989        assert!(parse_host_key_error(stderr).is_none());
990    }
991
992    #[test]
993    fn parse_host_key_error_returns_none_for_empty() {
994        assert!(parse_host_key_error("").is_none());
995    }
996
997    #[test]
998    fn parse_host_key_error_handles_ip_address() {
999        let stderr = "\
1000Offending ECDSA key in /home/user/.ssh/known_hosts:12
1001Host key for 10.0.0.1 has changed and you have requested strict checking.
1002Host key verification failed.
1003";
1004        let result = parse_host_key_error(stderr);
1005        assert!(result.is_some());
1006        let (hostname, path) = result.unwrap();
1007        assert_eq!(hostname, "10.0.0.1");
1008        assert_eq!(path, "/home/user/.ssh/known_hosts");
1009    }
1010
1011    #[test]
1012    fn parse_host_key_error_handles_custom_known_hosts_path() {
1013        let stderr = "\
1014Offending RSA key in /etc/ssh/known_hosts:3
1015Host key for server.local has changed and you have requested strict checking.
1016Host key verification failed.
1017";
1018        let result = parse_host_key_error(stderr);
1019        assert!(result.is_some());
1020        let (hostname, path) = result.unwrap();
1021        assert_eq!(hostname, "server.local");
1022        assert_eq!(path, "/etc/ssh/known_hosts");
1023    }
1024
1025    #[test]
1026    fn parse_host_key_error_handles_ipv6() {
1027        let stderr = "\
1028Offending ED25519 key in /Users/user/.ssh/known_hosts:7
1029Host key for ::1 has changed and you have requested strict checking.
1030Host key verification failed.
1031";
1032        let result = parse_host_key_error(stderr);
1033        assert!(result.is_some());
1034        let (hostname, _) = result.unwrap();
1035        assert_eq!(hostname, "::1");
1036    }
1037
1038    #[test]
1039    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
1040        // When no tmux server is running (or tmux is absent), should return an error.
1041        // Skip if we're actually inside a live tmux session (the command would succeed).
1042        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
1043        // the guard read and the call to connect_tmux_window.
1044        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1045        if std::env::var("TMUX").is_ok() {
1046            return;
1047        }
1048        let result = connect_tmux_window(
1049            "test-host",
1050            Path::new("/tmp/__purple_test_nonexistent_config__"),
1051            false,
1052            &SshLauncher::default(),
1053        );
1054        assert!(result.is_err());
1055        let err = result.unwrap_err().to_string();
1056        assert!(
1057            err.contains("tmux") || err.contains("No such file"),
1058            "unexpected error: {err}"
1059        );
1060    }
1061
1062    #[test]
1063    fn connect_tmux_window_with_tunnel_does_not_panic() {
1064        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
1065        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
1066        // tests from racing this guard read.
1067        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1068        if std::env::var("TMUX").is_ok() {
1069            return;
1070        }
1071        let result = connect_tmux_window(
1072            "tunnel-host",
1073            Path::new("/tmp/__purple_test_nonexistent_config__"),
1074            true,
1075            &SshLauncher::default(),
1076        );
1077        assert!(result.is_err());
1078    }
1079
1080    /// Mutex to serialise tests that mutate the TMUX env var.
1081    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1082
1083    #[test]
1084    fn is_in_tmux_returns_true_when_set() {
1085        let env = crate::runtime::env::Env::for_test("/tmp/x")
1086            .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
1087        assert!(is_in_tmux(&env));
1088    }
1089
1090    #[test]
1091    fn is_in_tmux_returns_false_when_unset() {
1092        let env = crate::runtime::env::Env::for_test("/tmp/x");
1093        assert!(!is_in_tmux(&env));
1094    }
1095
1096    // --- first_stderr_line tests ---
1097
1098    #[test]
1099    fn stderr_summary_joins_all_lines() {
1100        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
1101                      stdio forwarding failed\n\
1102                      Connection closed by UNKNOWN port 65535\n";
1103        let result = stderr_summary(stderr);
1104        assert_eq!(
1105            result.as_deref(),
1106            Some(
1107                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
1108            )
1109        );
1110    }
1111
1112    #[test]
1113    fn stderr_summary_skips_banner_lines() {
1114        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1115                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
1116                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1117                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
1118        let result = stderr_summary(stderr);
1119        assert_eq!(
1120            result.as_deref(),
1121            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
1122        );
1123    }
1124
1125    #[test]
1126    fn stderr_summary_returns_none_for_empty() {
1127        assert!(stderr_summary("").is_none());
1128        assert!(stderr_summary("   \n  \n").is_none());
1129        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
1130    }
1131
1132    #[test]
1133    fn stderr_summary_truncates_long_output() {
1134        let long = "x".repeat(250);
1135        let result = stderr_summary(&long).unwrap();
1136        assert_eq!(result.len(), 200);
1137        assert!(result.ends_with("..."));
1138    }
1139
1140    #[test]
1141    fn stderr_summary_truncates_multibyte_safely() {
1142        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
1143        let long = "日".repeat(100);
1144        let result = stderr_summary(&long).unwrap();
1145        assert!(result.ends_with("..."));
1146        // Must not panic and must be valid UTF-8
1147        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
1148    }
1149
1150    #[test]
1151    fn stderr_summary_simple_errors() {
1152        assert_eq!(
1153            stderr_summary("Connection refused\n").as_deref(),
1154            Some("Connection refused")
1155        );
1156        assert_eq!(
1157            stderr_summary("Permission denied (publickey).\n").as_deref(),
1158            Some("Permission denied (publickey).")
1159        );
1160    }
1161}