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
7/// Result of an SSH connection attempt.
8pub struct ConnectResult {
9    pub status: std::process::ExitStatus,
10    pub stderr_output: String,
11}
12
13/// Returns true if the process is running inside a tmux session, per the
14/// resolved environment (so tests inject `TMUX` via `Env` instead of mutating
15/// the process env).
16#[cfg(unix)]
17pub fn is_in_tmux(env: &crate::runtime::env::Env) -> bool {
18    env.in_tmux()
19}
20
21/// Returns true if the current process is running inside a tmux session.
22#[cfg(not(unix))]
23pub fn is_in_tmux(_env: &crate::runtime::env::Env) -> bool {
24    false
25}
26
27/// Open an SSH connection in a new tmux window.
28/// Returns immediately after the window is created. The SSH session runs
29/// asynchronously in the new window. Returns an error if tmux is not
30/// available or the window cannot be created.
31///
32/// This path deliberately does not wire up SSH_ASKPASS. The caller in `main.rs`
33/// guards this with `askpass.is_none()`, because an askpass-backed host needs an
34/// inherited stdin (so purple's askpass subprocess can print back to the ssh
35/// parent) and that inheritance does not survive the `tmux new-window` fork.
36/// Hosts with a password source therefore keep using the suspend-TUI `connect()`
37/// flow instead.
38pub fn connect_tmux_window(alias: &str, config_path: &Path, has_active_tunnel: bool) -> Result<()> {
39    info!("[external] SSH connection via tmux: {alias}");
40
41    let config_str = config_path
42        .to_str()
43        .context("SSH config path is not valid UTF-8")?;
44
45    let mut args = vec!["new-window", "-n", alias, "--", "ssh", "-F", config_str];
46
47    if has_active_tunnel {
48        args.extend(["-o", "ClearAllForwardings=yes"]);
49    }
50
51    args.extend(["--", alias]);
52
53    debug!("[external] tmux args: {:?}", args);
54
55    let status = Command::new("tmux")
56        .args(&args)
57        .status()
58        .with_context(|| format!("Failed to launch tmux new-window for '{alias}'"))?;
59
60    if status.success() {
61        info!("[external] tmux window created: {alias}");
62        Ok(())
63    } else {
64        let code = status.code().unwrap_or(-1);
65        error!("[external] tmux new-window failed for {alias} (exit {code})");
66        anyhow::bail!("tmux new-window exited with code {code}")
67    }
68}
69
70/// RAII guard that restores the signal mask when dropped.
71/// Ensures SIGINT/SIGTSTP are unmasked even on early return or error.
72#[cfg(unix)]
73struct SignalMaskGuard {
74    old: libc::sigset_t,
75}
76
77#[cfg(unix)]
78impl SignalMaskGuard {
79    /// Block SIGINT and SIGTSTP, saving the previous mask for restore on drop.
80    fn block_interactive() -> Self {
81        // SAFETY: `old` and `mask` are stack-allocated `sigset_t`s zeroed before
82        // use. The libc sigset / sigprocmask calls only read/write these
83        // pointers, which are valid for the duration of this block. `old` is
84        // moved into `Self` so the mask can be restored on drop.
85        unsafe {
86            let mut old: libc::sigset_t = std::mem::zeroed();
87            let mut mask: libc::sigset_t = std::mem::zeroed();
88            libc::sigemptyset(&mut mask);
89            libc::sigaddset(&mut mask, libc::SIGINT);
90            libc::sigaddset(&mut mask, libc::SIGTSTP);
91            libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old);
92            Self { old }
93        }
94    }
95}
96
97#[cfg(unix)]
98impl Drop for SignalMaskGuard {
99    fn drop(&mut self) {
100        // SAFETY: `self.old` is a valid `sigset_t` captured by
101        // `block_interactive`. `pending` is zeroed before `sigpending` writes
102        // to it. `libc::signal` is called with valid signal numbers. The
103        // sigprocmask call restores the previously-saved mask, which is still
104        // live for the duration of this drop.
105        unsafe {
106            // Discard any pending SIGINT/SIGTSTP that arrived while masked.
107            // Without this, queued signals would fire immediately on unmask and
108            // kill/suspend purple before the TUI can be restored.
109            let mut pending: libc::sigset_t = std::mem::zeroed();
110            libc::sigpending(&mut pending);
111            let has_sigint = libc::sigismember(&pending, libc::SIGINT) == 1;
112            let has_sigtstp = libc::sigismember(&pending, libc::SIGTSTP) == 1;
113            // Temporarily ignore pending signals so they're consumed on unmask.
114            if has_sigint {
115                libc::signal(libc::SIGINT, libc::SIG_IGN);
116            }
117            if has_sigtstp {
118                libc::signal(libc::SIGTSTP, libc::SIG_IGN);
119            }
120            libc::sigprocmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut());
121            // Restore default handlers after pending signals are consumed.
122            if has_sigint {
123                libc::signal(libc::SIGINT, libc::SIG_DFL);
124            }
125            if has_sigtstp {
126                libc::signal(libc::SIGTSTP, libc::SIG_DFL);
127            }
128        }
129    }
130}
131
132/// Spawn `cmd`, mask interactive signals in the parent, tee SSH's
133/// stderr to the real stderr while capturing it for error detection,
134/// then wait for the child to exit. Both `connect` and
135/// `connect_with_remote_command` build their `Command` independently
136/// (different argv) and delegate the spawn/wait/tee plumbing here so
137/// the long stderr-buffer + signal-guard sequence lives in one place.
138///
139/// `log_label` is interpolated into the started/ended/failed log lines
140/// so a reader can tell host-login from container-exec at a glance.
141///
142/// Exit 255 is ssh's own failure (auth, network, host-key). Any other code is
143/// either a remote-command exit status or a signal-kill (`-1`), both routine.
144pub(crate) fn is_ssh_transport_failure(code: i32) -> bool {
145    code == 255
146}
147
148/// Intended log severity for an ssh exit code. The single source of truth for
149/// the started/ended/failed routing in `spawn_ssh_and_wait` and the MCP
150/// `run_command` tool, so the error-vs-debug decision is testable as data.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub(crate) enum SshExitClass {
153    /// Exit 0: the command ran and succeeded.
154    Success,
155    /// Exit 255: ssh's own transport failure (auth, network, host-key). Error.
156    TransportFailure,
157    /// Any other non-zero code: the remote command's own status. Routine.
158    RemoteStatus,
159}
160
161pub(crate) fn classify_ssh_exit(code: i32) -> SshExitClass {
162    if code == 0 {
163        SshExitClass::Success
164    } else if is_ssh_transport_failure(code) {
165        SshExitClass::TransportFailure
166    } else {
167        SshExitClass::RemoteStatus
168    }
169}
170
171/// Log level for a failed ssh-backed remote command. Only ssh's own transport
172/// failure (exit 255) is an actionable error; a routine non-zero remote status
173/// is debug noise. Call sites that run a remote command and branch on the exit
174/// code (container list/actions, ...) route their failure log through here so
175/// the error level stays reserved for genuine connection failures.
176pub(crate) fn remote_exit_log_level(code: i32) -> log::Level {
177    match classify_ssh_exit(code) {
178        SshExitClass::TransportFailure => log::Level::Error,
179        SshExitClass::Success | SshExitClass::RemoteStatus => log::Level::Debug,
180    }
181}
182
183fn spawn_ssh_and_wait(mut cmd: Command, alias: &str, log_label: &str) -> Result<ConnectResult> {
184    cmd.stdin(std::process::Stdio::inherit())
185        .stdout(std::process::Stdio::inherit())
186        .stderr(std::process::Stdio::piped());
187
188    // Reset signal mask in the child process so SSH receives Ctrl+C
189    // normally. We mask signals in the parent AFTER spawn so the
190    // child doesn't inherit the blocked mask.
191    #[cfg(unix)]
192    unsafe {
193        use std::os::unix::process::CommandExt;
194        cmd.pre_exec(|| {
195            let mut mask: libc::sigset_t = std::mem::zeroed();
196            libc::sigemptyset(&mut mask);
197            libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
198            Ok(())
199        });
200    }
201
202    let mut child = cmd
203        .spawn()
204        .with_context(|| format!("Failed to launch ssh {} for '{}'", log_label, alias))?;
205
206    // Mask SIGINT/SIGTSTP in purple AFTER spawn so SSH doesn't inherit
207    // the blocked mask. The guard restores the mask on drop (even on
208    // early return).
209    #[cfg(unix)]
210    let _signal_guard = SignalMaskGuard::block_interactive();
211
212    let stderr_pipe = child.stderr.take().expect("stderr was piped");
213    let stderr_thread = std::thread::spawn(move || {
214        use std::io::{Read, Write};
215        let mut captured = Vec::new();
216        let mut buf = [0u8; 4096];
217        let mut reader = stderr_pipe;
218        let mut stderr_out = std::io::stderr();
219        loop {
220            match reader.read(&mut buf) {
221                Ok(0) => break,
222                Ok(n) => {
223                    let _ = stderr_out.write_all(&buf[..n]);
224                    let _ = stderr_out.flush();
225                    captured.extend_from_slice(&buf[..n]);
226                }
227                Err(_) => break,
228            }
229        }
230        String::from_utf8_lossy(&captured).to_string()
231    });
232
233    let status = child
234        .wait()
235        .with_context(|| format!("Failed to wait for ssh {} for '{}'", log_label, alias))?;
236    let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
237        warn!("[purple] Stderr capture thread panicked for {alias}");
238        String::new()
239    });
240
241    let code = status.code().unwrap_or(-1);
242    match classify_ssh_exit(code) {
243        SshExitClass::Success => {
244            info!("[external] SSH {} ended: {alias} (exit 0)", log_label);
245        }
246        SshExitClass::TransportFailure => {
247            error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
248            if !stderr_output.is_empty() {
249                let stderr = stderr_output.trim();
250                let lower = stderr.to_lowercase();
251                if lower.contains("are too open") || lower.contains("bad permissions") {
252                    warn!("[config] SSH key permission issue: {stderr}");
253                } else {
254                    debug!("[external] SSH stderr: {stderr}");
255                }
256            }
257        }
258        SshExitClass::RemoteStatus => {
259            // Remote-command status or a signal-kill: routine flow, not a
260            // must-act error.
261            debug!("[external] SSH {} ended: {alias} (exit {code})", log_label);
262        }
263    }
264
265    Ok(ConnectResult {
266        status,
267        stderr_output,
268    })
269}
270
271/// Launch an SSH connection to the given host alias.
272/// Uses the system `ssh` binary with inherited stdin/stdout. Stderr is piped and
273/// forwarded to real stderr in real time so the output is captured for error detection.
274/// Passes `-F <config_path>` so the alias resolves against the correct config file.
275/// When `askpass` is Some, delegates to `askpass_env::configure_ssh_command` to wire up
276/// SSH_ASKPASS, SSH_ASKPASS_REQUIRE=force and the PURPLE_* env vars.
277pub fn connect(
278    alias: &str,
279    config_path: &Path,
280    askpass: Option<&str>,
281    bw_session: Option<&str>,
282    has_active_tunnel: bool,
283) -> Result<ConnectResult> {
284    info!("[external] SSH connection started: {alias}");
285    debug!(
286        "[external] SSH command: ssh -F {} -- {alias}",
287        config_path.display()
288    );
289
290    let mut cmd = Command::new("ssh");
291    cmd.arg("-F").arg(config_path);
292
293    // When a tunnel is already running for this host, disable forwards in the
294    // interactive session to avoid "Address already in use" bind conflicts.
295    if has_active_tunnel {
296        cmd.arg("-o").arg("ClearAllForwardings=yes");
297    }
298
299    cmd.arg("--").arg(alias);
300
301    if askpass.is_some() {
302        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
303    }
304
305    if let Some(token) = bw_session {
306        cmd.env("BW_SESSION", token);
307    }
308
309    spawn_ssh_and_wait(cmd, alias, "connection")
310}
311
312/// Launch an SSH connection that runs a single remote command in
313/// interactive mode. Mirrors `connect()` exactly except for two
314/// additions: `-t` to allocate a TTY (required for the remote shell
315/// `docker exec` opens) and the trailing `remote_command` string passed
316/// to ssh as one argv slot. The remote shell receives the string as a
317/// single command line, so multi-token commands and shell operators
318/// like `||` work naturally.
319///
320/// Used by the containers overview Enter handler: the `remote_command`
321/// is built as `<runtime> exec -it <container_id> sh -c 'bash || sh'`
322/// where `container_id` has already been validated to alphanumeric +
323/// `-_.` so it cannot inject shell metacharacters.
324pub fn connect_with_remote_command(
325    alias: &str,
326    config_path: &Path,
327    env: &crate::runtime::env::Env,
328    askpass: Option<&str>,
329    bw_session: Option<&str>,
330    has_active_tunnel: bool,
331    remote_command: &str,
332) -> Result<ConnectResult> {
333    info!("[external] SSH exec started: {alias}");
334    debug!(
335        "[external] SSH command: ssh -F {} -t -- {alias} {}",
336        config_path.display(),
337        remote_command
338    );
339
340    // Renew the Vault SSH cert before exec'ing into a container so an
341    // expired cert is refreshed, mirroring the interactive connect path.
342    // No-op for non-vault hosts.
343    crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
344
345    let mut cmd = Command::new("ssh");
346    cmd.arg("-F").arg(config_path).arg("-t");
347
348    if has_active_tunnel {
349        cmd.arg("-o").arg("ClearAllForwardings=yes");
350    }
351
352    cmd.arg("--").arg(alias).arg(remote_command);
353
354    if askpass.is_some() {
355        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
356    }
357
358    if let Some(token) = bw_session {
359        cmd.env("BW_SESSION", token);
360    }
361
362    spawn_ssh_and_wait(cmd, alias, "exec")
363}
364
365/// tmux variant of `connect_with_remote_command`. Opens a new tmux
366/// window running `ssh -t <alias> <remote_command>` so the TUI stays
367/// alive in the original window. Same askpass-incompatible caveat as
368/// `connect_tmux_window`.
369pub fn connect_tmux_window_with_remote_command(
370    alias: &str,
371    config_path: &Path,
372    env: &crate::runtime::env::Env,
373    has_active_tunnel: bool,
374    remote_command: &str,
375    window_label: &str,
376) -> Result<()> {
377    info!("[external] SSH exec via tmux: {alias}");
378
379    // Renew the Vault SSH cert before exec'ing into a container so an
380    // expired cert is refreshed, mirroring the interactive connect path.
381    // No-op for non-vault hosts.
382    crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
383
384    let config_str = config_path
385        .to_str()
386        .context("SSH config path is not valid UTF-8")?;
387
388    let mut args = vec![
389        "new-window",
390        "-n",
391        window_label,
392        "--",
393        "ssh",
394        "-F",
395        config_str,
396        "-t",
397    ];
398
399    if has_active_tunnel {
400        args.extend(["-o", "ClearAllForwardings=yes"]);
401    }
402
403    args.extend(["--", alias, remote_command]);
404
405    debug!("[external] tmux exec args: {:?}", args);
406
407    let status = Command::new("tmux")
408        .args(&args)
409        .status()
410        .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
411
412    if status.success() {
413        info!("[external] tmux exec window created: {alias}");
414        Ok(())
415    } else {
416        let code = status.code().unwrap_or(-1);
417        error!("[external] tmux exec window failed for {alias} (exit {code})");
418        anyhow::bail!("tmux new-window exited with code {code}")
419    }
420}
421
422/// Extract a concise reason from SSH stderr for display in the toast.
423/// Joins all non-empty, non-banner lines with ` | ` so the full context
424/// is visible. Truncates to 200 chars (char-safe) if needed.
425pub fn stderr_summary(stderr: &str) -> Option<String> {
426    let summary: String = stderr
427        .lines()
428        .map(str::trim)
429        .filter(|l| !l.is_empty() && !l.starts_with('@'))
430        .collect::<Vec<_>>()
431        .join(" | ");
432    if summary.is_empty() {
433        return None;
434    }
435    if summary.len() > 200 {
436        let truncated: String = summary.chars().take(197).collect();
437        Some(format!("{truncated}..."))
438    } else {
439        Some(summary)
440    }
441}
442
443/// Parse host key verification error from SSH stderr output.
444/// Returns (hostname, known_hosts_path) if the error is a changed host key.
445///
446/// Uses two detection strategies:
447/// 1. English string matching for hostname and known_hosts path extraction.
448/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
449///    regardless of locale, combined with a known_hosts path from "Offending" line.
450///    When the English hostname line is missing, falls back to extracting the
451///    hostname from the known_hosts file path.
452pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
453    // Primary: English locale detection
454    let has_english_error = stderr.contains("Host key verification failed.");
455    // Fallback: the @@@ banner is locale-independent and always present for host key errors
456    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
457
458    if !has_english_error && !has_banner {
459        return None;
460    }
461
462    // Parse hostname from "Host key for <hostname> has changed"
463    let hostname = stderr
464        .lines()
465        .find(|l| l.contains("Host key for") && l.contains("has changed"))
466        .and_then(|l| {
467            let start = l.find("Host key for ")? + "Host key for ".len();
468            let rest = &l[start..];
469            let end = rest.find(" has changed")?;
470            Some(rest[..end].to_string())
471        });
472
473    // Parse known_hosts path from "Offending ... key in <path>:<line>"
474    let known_hosts_path = stderr
475        .lines()
476        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
477        .and_then(|l| {
478            let start = l.find(" key in ")? + " key in ".len();
479            let rest = &l[start..];
480            let end = rest.rfind(':')?;
481            Some(rest[..end].to_string())
482        });
483
484    // We need at least the known_hosts path to be useful
485    let known_hosts_path = known_hosts_path?;
486
487    // If we couldn't parse the hostname (non-English locale), derive it from
488    // the known_hosts path by running ssh-keygen -F would be complex.
489    // Instead, use a reasonable default: the user will see the confirmation dialog
490    // with the known_hosts path, which is the critical piece for the reset.
491    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
492
493    Some((hostname, known_hosts_path))
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn transport_failure_is_only_code_255() {
502        assert!(is_ssh_transport_failure(255));
503        // Remote-command passthrough statuses are not ssh transport failures.
504        assert!(!is_ssh_transport_failure(0));
505        assert!(!is_ssh_transport_failure(1));
506        assert!(!is_ssh_transport_failure(2));
507        assert!(!is_ssh_transport_failure(254));
508        assert!(!is_ssh_transport_failure(-1));
509    }
510
511    #[test]
512    fn classify_ssh_exit_routes_only_255_to_error() {
513        // Pins the error-vs-debug routing shared by spawn_ssh_and_wait and the
514        // MCP run_command tool. A revert to "any non-zero is an error" fails here.
515        assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
516        assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
517        // Routine remote-command statuses stay out of the error arm.
518        assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
519        assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
520        assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
521        assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
522        assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
523        // Signal-killed ssh (status.code() == None -> -1) is routine teardown.
524        assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
525    }
526
527    #[test]
528    fn remote_exit_log_level_reserves_error_for_transport_failure() {
529        // Pins the level every remote-command call site (container list/actions,
530        // ...) uses for a failed exit. Only ssh's 255 transport failure is an
531        // error; routine remote statuses are debug. A revert to "non-zero is an
532        // error" fails here.
533        assert_eq!(remote_exit_log_level(255), log::Level::Error);
534        for code in [0, 1, 2, 126, 127, 130, 254, -1] {
535            assert_eq!(
536                remote_exit_log_level(code),
537                log::Level::Debug,
538                "exit {code} must not log at error level"
539            );
540        }
541    }
542
543    #[test]
544    fn connect_fails_with_nonexistent_config() {
545        // connect() should return an error when the config file doesn't exist and
546        let result = connect(
547            "nonexistent-host",
548            Path::new("/tmp/__purple_test_nonexistent_config__"),
549            None,
550            None,
551            false,
552        );
553        // SSH should exit with a non-zero status (config file not found)
554        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
555        let r = result.unwrap();
556        assert!(!r.status.success());
557    }
558
559    #[test]
560    fn connect_with_tunnel_flag_does_not_panic() {
561        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
562        let result = connect(
563            "nonexistent-host",
564            Path::new("/tmp/__purple_test_nonexistent_config__"),
565            None,
566            None,
567            true,
568        );
569        assert!(result.is_ok());
570        assert!(!result.unwrap().status.success());
571    }
572
573    #[test]
574    fn connect_captures_stderr() {
575        // SSH should produce some stderr output when failing.
576        let result = connect(
577            "nonexistent-host",
578            Path::new("/tmp/__purple_test_nonexistent_config__"),
579            None,
580            None,
581            false,
582        );
583        assert!(result.is_ok());
584        // SSH writes errors to stderr; we should have captured something
585        // (either "Can't open user config file" or a connection error)
586        let r = result.unwrap();
587        assert!(
588            !r.stderr_output.is_empty() || !r.status.success(),
589            "SSH should produce stderr or fail"
590        );
591    }
592
593    // --- parse_host_key_error tests ---
594
595    #[test]
596    fn parse_host_key_error_detects_changed_key() {
597        let stderr = "\
598@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
599@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
600@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
601IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
602Someone could be eavesdropping on you right now (man-in-the-middle attack)!
603It is also possible that a host key has just been changed.
604The fingerprint for the ED25519 key sent by the remote host is
605SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
606Please contact your system administrator.
607Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
608Offending ECDSA key in /Users/user/.ssh/known_hosts:55
609Host key for example.com has changed and you have requested strict checking.
610Host key verification failed.
611";
612        let result = parse_host_key_error(stderr);
613        assert!(result.is_some());
614        let (hostname, path) = result.unwrap();
615        assert_eq!(hostname, "example.com");
616        assert_eq!(path, "/Users/user/.ssh/known_hosts");
617    }
618
619    #[test]
620    fn parse_host_key_error_returns_none_for_other_errors() {
621        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
622        assert!(parse_host_key_error(stderr).is_none());
623    }
624
625    #[test]
626    fn parse_host_key_error_returns_none_for_empty() {
627        assert!(parse_host_key_error("").is_none());
628    }
629
630    #[test]
631    fn parse_host_key_error_handles_ip_address() {
632        let stderr = "\
633Offending ECDSA key in /home/user/.ssh/known_hosts:12
634Host key for 10.0.0.1 has changed and you have requested strict checking.
635Host key verification failed.
636";
637        let result = parse_host_key_error(stderr);
638        assert!(result.is_some());
639        let (hostname, path) = result.unwrap();
640        assert_eq!(hostname, "10.0.0.1");
641        assert_eq!(path, "/home/user/.ssh/known_hosts");
642    }
643
644    #[test]
645    fn parse_host_key_error_handles_custom_known_hosts_path() {
646        let stderr = "\
647Offending RSA key in /etc/ssh/known_hosts:3
648Host key for server.local has changed and you have requested strict checking.
649Host key verification failed.
650";
651        let result = parse_host_key_error(stderr);
652        assert!(result.is_some());
653        let (hostname, path) = result.unwrap();
654        assert_eq!(hostname, "server.local");
655        assert_eq!(path, "/etc/ssh/known_hosts");
656    }
657
658    #[test]
659    fn parse_host_key_error_handles_ipv6() {
660        let stderr = "\
661Offending ED25519 key in /Users/user/.ssh/known_hosts:7
662Host key for ::1 has changed and you have requested strict checking.
663Host key verification failed.
664";
665        let result = parse_host_key_error(stderr);
666        assert!(result.is_some());
667        let (hostname, _) = result.unwrap();
668        assert_eq!(hostname, "::1");
669    }
670
671    #[test]
672    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
673        // When no tmux server is running (or tmux is absent), should return an error.
674        // Skip if we're actually inside a live tmux session (the command would succeed).
675        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
676        // the guard read and the call to connect_tmux_window.
677        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
678        if std::env::var("TMUX").is_ok() {
679            return;
680        }
681        let result = connect_tmux_window(
682            "test-host",
683            Path::new("/tmp/__purple_test_nonexistent_config__"),
684            false,
685        );
686        assert!(result.is_err());
687        let err = result.unwrap_err().to_string();
688        assert!(
689            err.contains("tmux") || err.contains("No such file"),
690            "unexpected error: {err}"
691        );
692    }
693
694    #[test]
695    fn connect_tmux_window_with_tunnel_does_not_panic() {
696        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
697        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
698        // tests from racing this guard read.
699        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
700        if std::env::var("TMUX").is_ok() {
701            return;
702        }
703        let result = connect_tmux_window(
704            "tunnel-host",
705            Path::new("/tmp/__purple_test_nonexistent_config__"),
706            true,
707        );
708        assert!(result.is_err());
709    }
710
711    /// Mutex to serialise tests that mutate the TMUX env var.
712    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
713
714    #[test]
715    fn is_in_tmux_returns_true_when_set() {
716        let env = crate::runtime::env::Env::for_test("/tmp/x")
717            .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
718        assert!(is_in_tmux(&env));
719    }
720
721    #[test]
722    fn is_in_tmux_returns_false_when_unset() {
723        let env = crate::runtime::env::Env::for_test("/tmp/x");
724        assert!(!is_in_tmux(&env));
725    }
726
727    // --- first_stderr_line tests ---
728
729    #[test]
730    fn stderr_summary_joins_all_lines() {
731        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
732                      stdio forwarding failed\n\
733                      Connection closed by UNKNOWN port 65535\n";
734        let result = stderr_summary(stderr);
735        assert_eq!(
736            result.as_deref(),
737            Some(
738                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
739            )
740        );
741    }
742
743    #[test]
744    fn stderr_summary_skips_banner_lines() {
745        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
746                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
747                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
748                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
749        let result = stderr_summary(stderr);
750        assert_eq!(
751            result.as_deref(),
752            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
753        );
754    }
755
756    #[test]
757    fn stderr_summary_returns_none_for_empty() {
758        assert!(stderr_summary("").is_none());
759        assert!(stderr_summary("   \n  \n").is_none());
760        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
761    }
762
763    #[test]
764    fn stderr_summary_truncates_long_output() {
765        let long = "x".repeat(250);
766        let result = stderr_summary(&long).unwrap();
767        assert_eq!(result.len(), 200);
768        assert!(result.ends_with("..."));
769    }
770
771    #[test]
772    fn stderr_summary_truncates_multibyte_safely() {
773        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
774        let long = "日".repeat(100);
775        let result = stderr_summary(&long).unwrap();
776        assert!(result.ends_with("..."));
777        // Must not panic and must be valid UTF-8
778        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
779    }
780
781    #[test]
782    fn stderr_summary_simple_errors() {
783        assert_eq!(
784            stderr_summary("Connection refused\n").as_deref(),
785            Some("Connection refused")
786        );
787        assert_eq!(
788            stderr_summary("Permission denied (publickey).\n").as_deref(),
789            Some("Permission denied (publickey).")
790        );
791    }
792}