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/// Parse host key verification error from SSH stderr output.
482/// Returns (hostname, known_hosts_path) if the error is a changed host key.
483///
484/// Uses two detection strategies:
485/// 1. English string matching for hostname and known_hosts path extraction.
486/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
487///    regardless of locale, combined with a known_hosts path from "Offending" line.
488///    When the English hostname line is missing, falls back to extracting the
489///    hostname from the known_hosts file path.
490pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
491    // Primary: English locale detection
492    let has_english_error = stderr.contains("Host key verification failed.");
493    // Fallback: the @@@ banner is locale-independent and always present for host key errors
494    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
495
496    if !has_english_error && !has_banner {
497        return None;
498    }
499
500    // Parse hostname from "Host key for <hostname> has changed"
501    let hostname = stderr
502        .lines()
503        .find(|l| l.contains("Host key for") && l.contains("has changed"))
504        .and_then(|l| {
505            let start = l.find("Host key for ")? + "Host key for ".len();
506            let rest = &l[start..];
507            let end = rest.find(" has changed")?;
508            Some(rest[..end].to_string())
509        });
510
511    // Parse known_hosts path from "Offending ... key in <path>:<line>"
512    let known_hosts_path = stderr
513        .lines()
514        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
515        .and_then(|l| {
516            let start = l.find(" key in ")? + " key in ".len();
517            let rest = &l[start..];
518            let end = rest.rfind(':')?;
519            Some(rest[..end].to_string())
520        });
521
522    // We need at least the known_hosts path to be useful
523    let known_hosts_path = known_hosts_path?;
524
525    // If we couldn't parse the hostname (non-English locale), derive it from
526    // the known_hosts path by running ssh-keygen -F would be complex.
527    // Instead, use a reasonable default: the user will see the confirmation dialog
528    // with the known_hosts path, which is the critical piece for the reset.
529    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
530
531    Some((hostname, known_hosts_path))
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn transport_failure_is_only_code_255() {
540        assert!(is_ssh_transport_failure(255));
541        // Remote-command passthrough statuses are not ssh transport failures.
542        assert!(!is_ssh_transport_failure(0));
543        assert!(!is_ssh_transport_failure(1));
544        assert!(!is_ssh_transport_failure(2));
545        assert!(!is_ssh_transport_failure(254));
546        assert!(!is_ssh_transport_failure(-1));
547    }
548
549    #[test]
550    fn classify_ssh_exit_routes_only_255_to_error() {
551        // Pins the error-vs-debug routing shared by spawn_ssh_and_wait and the
552        // MCP run_command tool. A revert to "any non-zero is an error" fails here.
553        assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
554        assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
555        // Routine remote-command statuses stay out of the error arm.
556        assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
557        assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
558        assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
559        assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
560        assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
561        // Signal-killed ssh (status.code() == None -> -1) is routine teardown.
562        assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
563    }
564
565    #[test]
566    fn remote_exit_log_level_reserves_error_for_transport_failure() {
567        // Pins the level every remote-command call site (container list/actions,
568        // ...) uses for a failed exit. Only ssh's 255 transport failure is an
569        // error; routine remote statuses are debug. A revert to "non-zero is an
570        // error" fails here.
571        assert_eq!(remote_exit_log_level(255), log::Level::Error);
572        for code in [0, 1, 2, 126, 127, 130, 254, -1] {
573            assert_eq!(
574                remote_exit_log_level(code),
575                log::Level::Debug,
576                "exit {code} must not log at error level"
577            );
578        }
579    }
580
581    #[test]
582    fn connect_fails_with_nonexistent_config() {
583        // connect() should return an error when the config file doesn't exist and
584        let result = connect(
585            "nonexistent-host",
586            Path::new("/tmp/__purple_test_nonexistent_config__"),
587            None,
588            None,
589            false,
590            &SshLauncher::default(),
591        );
592        // SSH should exit with a non-zero status (config file not found)
593        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
594        let r = result.unwrap();
595        assert!(!r.status.success());
596    }
597
598    #[test]
599    fn connect_with_tunnel_flag_does_not_panic() {
600        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
601        let result = connect(
602            "nonexistent-host",
603            Path::new("/tmp/__purple_test_nonexistent_config__"),
604            None,
605            None,
606            true,
607            &SshLauncher::default(),
608        );
609        assert!(result.is_ok());
610        assert!(!result.unwrap().status.success());
611    }
612
613    #[test]
614    fn connect_runs_the_configured_launcher() {
615        // A launcher that echoes its argv proves purple's own arguments are
616        // appended after the configured words and the exit code flows back.
617        let launcher =
618            SshLauncher::parse("sh -c 'printf \"%s\\n\" \"$@\" >&2; exit 7' sh").unwrap();
619        let result = connect(
620            "launcher-host",
621            Path::new("/tmp/__purple_test_cfg__"),
622            None,
623            None,
624            true,
625            &launcher,
626        )
627        .unwrap();
628        assert_eq!(result.status.code(), Some(7));
629        let lines: Vec<&str> = result.stderr_output.lines().collect();
630        assert_eq!(
631            lines,
632            [
633                "-F",
634                "/tmp/__purple_test_cfg__",
635                "-o",
636                "ClearAllForwardings=yes",
637                "--",
638                "launcher-host"
639            ]
640        );
641    }
642
643    #[test]
644    fn connect_reports_missing_launcher_program() {
645        let launcher = SshLauncher::parse("/nonexistent/purple-test-launcher ssh").unwrap();
646        let err = connect(
647            "launcher-host",
648            Path::new("/tmp/__purple_test_cfg__"),
649            None,
650            None,
651            false,
652            &launcher,
653        )
654        .unwrap_err();
655        assert!(
656            err.to_string()
657                .contains("Failed to launch /nonexistent/purple-test-launcher connection"),
658            "unexpected error: {err}"
659        );
660    }
661
662    #[test]
663    fn tmux_window_args_wrap_launcher_words() {
664        let launcher = SshLauncher::parse("kitten ssh").unwrap();
665        let args = tmux_window_args(
666            "web1",
667            &launcher,
668            "/home/u/.ssh/config",
669            false,
670            "web1",
671            None,
672        );
673        assert_eq!(
674            args,
675            [
676                "new-window",
677                "-n",
678                "web1",
679                "--",
680                "kitten",
681                "ssh",
682                "-F",
683                "/home/u/.ssh/config",
684                "--",
685                "web1"
686            ]
687        );
688        let args = tmux_window_args(
689            "web1/app",
690            &SshLauncher::default(),
691            "/home/u/.ssh/config",
692            true,
693            "web1",
694            Some("docker exec -it abc sh"),
695        );
696        assert_eq!(
697            args,
698            [
699                "new-window",
700                "-n",
701                "web1/app",
702                "--",
703                "ssh",
704                "-F",
705                "/home/u/.ssh/config",
706                "-t",
707                "-o",
708                "ClearAllForwardings=yes",
709                "--",
710                "web1",
711                "docker exec -it abc sh"
712            ]
713        );
714    }
715
716    #[test]
717    fn connect_captures_stderr() {
718        // SSH should produce some stderr output when failing.
719        let result = connect(
720            "nonexistent-host",
721            Path::new("/tmp/__purple_test_nonexistent_config__"),
722            None,
723            None,
724            false,
725            &SshLauncher::default(),
726        );
727        assert!(result.is_ok());
728        // SSH writes errors to stderr; we should have captured something
729        // (either "Can't open user config file" or a connection error)
730        let r = result.unwrap();
731        assert!(
732            !r.stderr_output.is_empty() || !r.status.success(),
733            "SSH should produce stderr or fail"
734        );
735    }
736
737    // --- parse_host_key_error tests ---
738
739    #[test]
740    fn parse_host_key_error_detects_changed_key() {
741        let stderr = "\
742@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
743@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
744@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
745IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
746Someone could be eavesdropping on you right now (man-in-the-middle attack)!
747It is also possible that a host key has just been changed.
748The fingerprint for the ED25519 key sent by the remote host is
749SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
750Please contact your system administrator.
751Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
752Offending ECDSA key in /Users/user/.ssh/known_hosts:55
753Host key for example.com has changed and you have requested strict checking.
754Host key verification failed.
755";
756        let result = parse_host_key_error(stderr);
757        assert!(result.is_some());
758        let (hostname, path) = result.unwrap();
759        assert_eq!(hostname, "example.com");
760        assert_eq!(path, "/Users/user/.ssh/known_hosts");
761    }
762
763    #[test]
764    fn parse_host_key_error_returns_none_for_other_errors() {
765        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
766        assert!(parse_host_key_error(stderr).is_none());
767    }
768
769    #[test]
770    fn parse_host_key_error_returns_none_for_empty() {
771        assert!(parse_host_key_error("").is_none());
772    }
773
774    #[test]
775    fn parse_host_key_error_handles_ip_address() {
776        let stderr = "\
777Offending ECDSA key in /home/user/.ssh/known_hosts:12
778Host key for 10.0.0.1 has changed and you have requested strict checking.
779Host key verification failed.
780";
781        let result = parse_host_key_error(stderr);
782        assert!(result.is_some());
783        let (hostname, path) = result.unwrap();
784        assert_eq!(hostname, "10.0.0.1");
785        assert_eq!(path, "/home/user/.ssh/known_hosts");
786    }
787
788    #[test]
789    fn parse_host_key_error_handles_custom_known_hosts_path() {
790        let stderr = "\
791Offending RSA key in /etc/ssh/known_hosts:3
792Host key for server.local has changed and you have requested strict checking.
793Host key verification failed.
794";
795        let result = parse_host_key_error(stderr);
796        assert!(result.is_some());
797        let (hostname, path) = result.unwrap();
798        assert_eq!(hostname, "server.local");
799        assert_eq!(path, "/etc/ssh/known_hosts");
800    }
801
802    #[test]
803    fn parse_host_key_error_handles_ipv6() {
804        let stderr = "\
805Offending ED25519 key in /Users/user/.ssh/known_hosts:7
806Host key for ::1 has changed and you have requested strict checking.
807Host key verification failed.
808";
809        let result = parse_host_key_error(stderr);
810        assert!(result.is_some());
811        let (hostname, _) = result.unwrap();
812        assert_eq!(hostname, "::1");
813    }
814
815    #[test]
816    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
817        // When no tmux server is running (or tmux is absent), should return an error.
818        // Skip if we're actually inside a live tmux session (the command would succeed).
819        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
820        // the guard read and the call to connect_tmux_window.
821        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
822        if std::env::var("TMUX").is_ok() {
823            return;
824        }
825        let result = connect_tmux_window(
826            "test-host",
827            Path::new("/tmp/__purple_test_nonexistent_config__"),
828            false,
829            &SshLauncher::default(),
830        );
831        assert!(result.is_err());
832        let err = result.unwrap_err().to_string();
833        assert!(
834            err.contains("tmux") || err.contains("No such file"),
835            "unexpected error: {err}"
836        );
837    }
838
839    #[test]
840    fn connect_tmux_window_with_tunnel_does_not_panic() {
841        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
842        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
843        // tests from racing this guard read.
844        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
845        if std::env::var("TMUX").is_ok() {
846            return;
847        }
848        let result = connect_tmux_window(
849            "tunnel-host",
850            Path::new("/tmp/__purple_test_nonexistent_config__"),
851            true,
852            &SshLauncher::default(),
853        );
854        assert!(result.is_err());
855    }
856
857    /// Mutex to serialise tests that mutate the TMUX env var.
858    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
859
860    #[test]
861    fn is_in_tmux_returns_true_when_set() {
862        let env = crate::runtime::env::Env::for_test("/tmp/x")
863            .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
864        assert!(is_in_tmux(&env));
865    }
866
867    #[test]
868    fn is_in_tmux_returns_false_when_unset() {
869        let env = crate::runtime::env::Env::for_test("/tmp/x");
870        assert!(!is_in_tmux(&env));
871    }
872
873    // --- first_stderr_line tests ---
874
875    #[test]
876    fn stderr_summary_joins_all_lines() {
877        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
878                      stdio forwarding failed\n\
879                      Connection closed by UNKNOWN port 65535\n";
880        let result = stderr_summary(stderr);
881        assert_eq!(
882            result.as_deref(),
883            Some(
884                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
885            )
886        );
887    }
888
889    #[test]
890    fn stderr_summary_skips_banner_lines() {
891        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
892                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
893                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
894                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
895        let result = stderr_summary(stderr);
896        assert_eq!(
897            result.as_deref(),
898            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
899        );
900    }
901
902    #[test]
903    fn stderr_summary_returns_none_for_empty() {
904        assert!(stderr_summary("").is_none());
905        assert!(stderr_summary("   \n  \n").is_none());
906        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
907    }
908
909    #[test]
910    fn stderr_summary_truncates_long_output() {
911        let long = "x".repeat(250);
912        let result = stderr_summary(&long).unwrap();
913        assert_eq!(result.len(), 200);
914        assert!(result.ends_with("..."));
915    }
916
917    #[test]
918    fn stderr_summary_truncates_multibyte_safely() {
919        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
920        let long = "日".repeat(100);
921        let result = stderr_summary(&long).unwrap();
922        assert!(result.ends_with("..."));
923        // Must not panic and must be valid UTF-8
924        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
925    }
926
927    #[test]
928    fn stderr_summary_simple_errors() {
929        assert_eq!(
930            stderr_summary("Connection refused\n").as_deref(),
931            Some("Connection refused")
932        );
933        assert_eq!(
934            stderr_summary("Permission denied (publickey).\n").as_deref(),
935            Some("Permission denied (publickey).")
936        );
937    }
938}