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 ssh gave up on authentication: some hop printed
496/// `<user>@<host>: Permission denied (<methods>).`. A remote command that
497/// fails with its own "permission denied" does not count.
498pub fn auth_refused(stderr: &str) -> bool {
499    stderr.lines().any(|l| l.contains(": Permission denied ("))
500}
501
502/// True when strict host key checking refused a host that is not in
503/// `known_hosts` yet. A changed key prints the `@@@` banner plus an
504/// `Offending` line instead and stays with `parse_host_key_error`.
505pub fn is_unknown_host_key(stderr: &str) -> bool {
506    stderr.contains("host key is known for") && stderr.contains("requested strict checking")
507}
508
509/// The host ssh named on its `Permission denied` line, without the user
510/// part. OpenSSH writes `<user>@<host>: Permission denied (<methods>).`,
511/// and on a ProxyJump chain that host is whichever hop refused. `None`
512/// when no line carries the prefix.
513pub fn denied_host(stderr: &str) -> Option<&str> {
514    stderr
515        .lines()
516        .map(str::trim)
517        .filter(|l| l.contains(": Permission denied ("))
518        .filter_map(|l| {
519            let prefix = l.split(": Permission denied (").next()?;
520            prefix.rsplit('@').next().filter(|h| !h.is_empty())
521        })
522        .next_back()
523}
524
525/// The host strict checking refused, as ssh spells it on the
526/// `No ... host key is known for <host> and you have requested strict
527/// checking.` line. Carries the bracketed form for a non-default port,
528/// so `[10.0.0.1]:2222` comes back whole.
529pub fn unknown_host_key_host(stderr: &str) -> Option<&str> {
530    stderr
531        .lines()
532        .map(str::trim)
533        .find(|l| l.contains("host key is known for") && l.contains("requested strict checking"))
534        .and_then(|l| {
535            let start = l.find("host key is known for ")? + "host key is known for ".len();
536            let rest = &l[start..];
537            let end = rest.find(" and you have requested")?;
538            Some(rest[..end].trim())
539        })
540        .filter(|h| !h.is_empty())
541}
542
543/// Strip ssh's `[host]:port` form down to the bare host, so a name parsed
544/// out of stderr compares against a `HostName` directive.
545pub fn bare_host(host: &str) -> &str {
546    host.strip_prefix('[')
547        .and_then(|rest| rest.split(']').next())
548        .unwrap_or(host)
549}
550
551/// Parse host key verification error from SSH stderr output.
552/// Returns (hostname, known_hosts_path) if the error is a changed host key.
553///
554/// Uses two detection strategies:
555/// 1. English string matching for hostname and known_hosts path extraction.
556/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
557///    regardless of locale, combined with a known_hosts path from "Offending" line.
558///    When the English hostname line is missing, falls back to extracting the
559///    hostname from the known_hosts file path.
560pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
561    // Primary: English locale detection
562    let has_english_error = stderr.contains("Host key verification failed.");
563    // Fallback: the @@@ banner is locale-independent and always present for host key errors
564    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
565
566    if !has_english_error && !has_banner {
567        return None;
568    }
569
570    // Parse hostname from "Host key for <hostname> has changed"
571    let hostname = stderr
572        .lines()
573        .find(|l| l.contains("Host key for") && l.contains("has changed"))
574        .and_then(|l| {
575            let start = l.find("Host key for ")? + "Host key for ".len();
576            let rest = &l[start..];
577            let end = rest.find(" has changed")?;
578            Some(rest[..end].to_string())
579        });
580
581    // Parse known_hosts path from "Offending ... key in <path>:<line>"
582    let known_hosts_path = stderr
583        .lines()
584        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
585        .and_then(|l| {
586            let start = l.find(" key in ")? + " key in ".len();
587            let rest = &l[start..];
588            let end = rest.rfind(':')?;
589            Some(rest[..end].to_string())
590        });
591
592    // We need at least the known_hosts path to be useful
593    let known_hosts_path = known_hosts_path?;
594
595    // If we couldn't parse the hostname (non-English locale), derive it from
596    // the known_hosts path by running ssh-keygen -F would be complex.
597    // Instead, use a reasonable default: the user will see the confirmation dialog
598    // with the known_hosts path, which is the critical piece for the reset.
599    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
600
601    Some((hostname, known_hosts_path))
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn transport_failure_is_only_code_255() {
610        assert!(is_ssh_transport_failure(255));
611        // Remote-command passthrough statuses are not ssh transport failures.
612        assert!(!is_ssh_transport_failure(0));
613        assert!(!is_ssh_transport_failure(1));
614        assert!(!is_ssh_transport_failure(2));
615        assert!(!is_ssh_transport_failure(254));
616        assert!(!is_ssh_transport_failure(-1));
617    }
618
619    #[test]
620    fn classify_ssh_exit_routes_only_255_to_error() {
621        // Pins the error-vs-debug routing shared by spawn_ssh_and_wait and the
622        // MCP run_command tool. A revert to "any non-zero is an error" fails here.
623        assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
624        assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
625        // Routine remote-command statuses stay out of the error arm.
626        assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
627        assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
628        assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
629        assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
630        assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
631        // Signal-killed ssh (status.code() == None -> -1) is routine teardown.
632        assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
633    }
634
635    #[test]
636    fn remote_exit_log_level_reserves_error_for_transport_failure() {
637        // Pins the level every remote-command call site (container list/actions,
638        // ...) uses for a failed exit. Only ssh's 255 transport failure is an
639        // error; routine remote statuses are debug. A revert to "non-zero is an
640        // error" fails here.
641        assert_eq!(remote_exit_log_level(255), log::Level::Error);
642        for code in [0, 1, 2, 126, 127, 130, 254, -1] {
643            assert_eq!(
644                remote_exit_log_level(code),
645                log::Level::Debug,
646                "exit {code} must not log at error level"
647            );
648        }
649    }
650
651    #[test]
652    fn connect_fails_with_nonexistent_config() {
653        // connect() should return an error when the config file doesn't exist and
654        let result = connect(
655            "nonexistent-host",
656            Path::new("/tmp/__purple_test_nonexistent_config__"),
657            None,
658            None,
659            false,
660            &SshLauncher::default(),
661        );
662        // SSH should exit with a non-zero status (config file not found)
663        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
664        let r = result.unwrap();
665        assert!(!r.status.success());
666    }
667
668    #[test]
669    fn connect_with_tunnel_flag_does_not_panic() {
670        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
671        let result = connect(
672            "nonexistent-host",
673            Path::new("/tmp/__purple_test_nonexistent_config__"),
674            None,
675            None,
676            true,
677            &SshLauncher::default(),
678        );
679        assert!(result.is_ok());
680        assert!(!result.unwrap().status.success());
681    }
682
683    #[test]
684    fn connect_runs_the_configured_launcher() {
685        // A launcher that echoes its argv proves purple's own arguments are
686        // appended after the configured words and the exit code flows back.
687        let launcher =
688            SshLauncher::parse("sh -c 'printf \"%s\\n\" \"$@\" >&2; exit 7' sh").unwrap();
689        let result = connect(
690            "launcher-host",
691            Path::new("/tmp/__purple_test_cfg__"),
692            None,
693            None,
694            true,
695            &launcher,
696        )
697        .unwrap();
698        assert_eq!(result.status.code(), Some(7));
699        let lines: Vec<&str> = result.stderr_output.lines().collect();
700        assert_eq!(
701            lines,
702            [
703                "-F",
704                "/tmp/__purple_test_cfg__",
705                "-o",
706                "ClearAllForwardings=yes",
707                "--",
708                "launcher-host"
709            ]
710        );
711    }
712
713    #[test]
714    fn connect_reports_missing_launcher_program() {
715        let launcher = SshLauncher::parse("/nonexistent/purple-test-launcher ssh").unwrap();
716        let err = connect(
717            "launcher-host",
718            Path::new("/tmp/__purple_test_cfg__"),
719            None,
720            None,
721            false,
722            &launcher,
723        )
724        .unwrap_err();
725        assert!(
726            err.to_string()
727                .contains("Failed to launch /nonexistent/purple-test-launcher connection"),
728            "unexpected error: {err}"
729        );
730    }
731
732    #[test]
733    fn tmux_window_args_wrap_launcher_words() {
734        let launcher = SshLauncher::parse("kitten ssh").unwrap();
735        let args = tmux_window_args(
736            "web1",
737            &launcher,
738            "/home/u/.ssh/config",
739            false,
740            "web1",
741            None,
742        );
743        assert_eq!(
744            args,
745            [
746                "new-window",
747                "-n",
748                "web1",
749                "--",
750                "kitten",
751                "ssh",
752                "-F",
753                "/home/u/.ssh/config",
754                "--",
755                "web1"
756            ]
757        );
758        let args = tmux_window_args(
759            "web1/app",
760            &SshLauncher::default(),
761            "/home/u/.ssh/config",
762            true,
763            "web1",
764            Some("docker exec -it abc sh"),
765        );
766        assert_eq!(
767            args,
768            [
769                "new-window",
770                "-n",
771                "web1/app",
772                "--",
773                "ssh",
774                "-F",
775                "/home/u/.ssh/config",
776                "-t",
777                "-o",
778                "ClearAllForwardings=yes",
779                "--",
780                "web1",
781                "docker exec -it abc sh"
782            ]
783        );
784    }
785
786    #[test]
787    fn connect_captures_stderr() {
788        // SSH should produce some stderr output when failing.
789        let result = connect(
790            "nonexistent-host",
791            Path::new("/tmp/__purple_test_nonexistent_config__"),
792            None,
793            None,
794            false,
795            &SshLauncher::default(),
796        );
797        assert!(result.is_ok());
798        // SSH writes errors to stderr; we should have captured something
799        // (either "Can't open user config file" or a connection error)
800        let r = result.unwrap();
801        assert!(
802            !r.stderr_output.is_empty() || !r.status.success(),
803            "SSH should produce stderr or fail"
804        );
805    }
806
807    // --- needs_password / is_unknown_host_key tests ---
808    //
809    // The strings are what OpenSSH 10.3 prints for a password-only sshd
810    // under BatchMode=yes and StrictHostKeyChecking=yes.
811
812    #[test]
813    fn needs_password_true_for_password_method() {
814        assert!(needs_password(
815            "test@localhost: Permission denied (password).\n"
816        ));
817    }
818
819    #[test]
820    fn needs_password_true_when_password_is_one_of_several_methods() {
821        assert!(needs_password(
822            "test@localhost: Permission denied (publickey,password).\n"
823        ));
824        assert!(needs_password(
825            "test@localhost: Permission denied (publickey,keyboard-interactive).\n"
826        ));
827    }
828
829    #[test]
830    fn auth_refused_reads_only_the_line_ssh_prints() {
831        assert!(auth_refused(
832            "sign_and_send_pubkey: signing failed\nops@10.0.0.5: Permission denied (publickey).\n"
833        ));
834        // A remote command's own refusal is not an authentication failure.
835        assert!(!auth_refused(
836            "Got permission denied while trying to connect to the Docker daemon socket"
837        ));
838        assert!(!auth_refused("ls: /root: Permission denied"));
839        assert!(!auth_refused(""));
840    }
841
842    #[test]
843    fn needs_password_false_for_publickey_only() {
844        // A typed password cannot help when the server takes none.
845        assert!(!needs_password(
846            "test@localhost: Permission denied (publickey).\n"
847        ));
848    }
849
850    #[test]
851    fn needs_password_false_for_unrelated_errors() {
852        assert!(!needs_password(""));
853        assert!(!needs_password(
854            "ssh: connect to host example.com port 22: Connection refused\n"
855        ));
856        assert!(!needs_password("Permission denied, please try again.\n"));
857    }
858
859    #[test]
860    fn needs_password_survives_the_post_quantum_warning_prefix() {
861        let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
862                      ** This session may be vulnerable to \"store now, decrypt later\" attacks.\n\
863                      test@10.0.0.5: Permission denied (publickey,password).\n";
864        assert!(needs_password(stderr));
865    }
866
867    #[test]
868    fn is_unknown_host_key_true_for_strict_checking_refusal() {
869        let stderr = "No ED25519 host key is known for [localhost]:12222 and you have requested strict checking.\n\
870                      Host key verification failed.\n";
871        assert!(is_unknown_host_key(stderr));
872    }
873
874    #[test]
875    fn is_unknown_host_key_false_for_changed_key_banner() {
876        // A changed key is a security event: it keeps the reset dialog and
877        // never gets an automatic retry.
878        let stderr = "\
879@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
880@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
881@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
882Offending ECDSA key in /Users/user/.ssh/known_hosts:55
883Host key for example.com has changed and you have requested strict checking.
884Host key verification failed.
885";
886        assert!(!is_unknown_host_key(stderr));
887        assert!(parse_host_key_error(stderr).is_some());
888    }
889
890    #[test]
891    fn is_unknown_host_key_false_for_plain_permission_denied() {
892        assert!(!is_unknown_host_key(
893            "test@localhost: Permission denied (password).\n"
894        ));
895        assert!(!is_unknown_host_key(""));
896    }
897
898    // --- denied_host / unknown_host_key_host / bare_host tests ---
899    //
900    // These three name the hop ssh refused, which is what decides whether a
901    // dialog opens for the target or the run is reported as a jump host's
902    // refusal. The fixtures are verbatim OpenSSH output.
903
904    #[test]
905    fn denied_host_takes_the_host_and_drops_the_user() {
906        assert_eq!(
907            denied_host(
908                "nobodyuser@127.0.0.1: Permission denied (publickey,password,keyboard-interactive).\n"
909            ),
910            Some("127.0.0.1")
911        );
912    }
913
914    #[test]
915    fn denied_host_reads_a_line_carrying_no_user() {
916        assert_eq!(
917            denied_host("bastion.example.com: Permission denied (password).\n"),
918            Some("bastion.example.com")
919        );
920    }
921
922    #[test]
923    fn denied_host_survives_a_warning_prefix_and_trailing_lines() {
924        let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
925                      ops@10.0.0.5: Permission denied (publickey,password).\n\
926                      Connection closed by 10.0.0.5 port 22\n";
927        assert_eq!(denied_host(stderr), Some("10.0.0.5"));
928    }
929
930    #[test]
931    fn denied_host_takes_the_last_line_when_several_carry_the_prefix() {
932        // On a chain the hop that refused prints its own line. The last one
933        // is the deepest ssh got.
934        let stderr = "ops@bastion.example.com: Permission denied (password).\n\
935                      ops@target.example.com: Permission denied (password).\n";
936        assert_eq!(denied_host(stderr), Some("target.example.com"));
937    }
938
939    #[test]
940    fn denied_host_is_none_without_the_line() {
941        assert_eq!(denied_host(""), None);
942        assert_eq!(denied_host("Permission denied, please try again.\n"), None);
943        assert_eq!(
944            denied_host("ssh: connect to host x port 22: Connection refused\n"),
945            None
946        );
947    }
948
949    #[test]
950    fn unknown_host_key_host_keeps_the_bracketed_port_form() {
951        let stderr = "No ED25519 host key is known for [127.0.0.1]:12222 and you have requested strict checking.\n\
952                      Host key verification failed.\n";
953        assert_eq!(unknown_host_key_host(stderr), Some("[127.0.0.1]:12222"));
954    }
955
956    #[test]
957    fn unknown_host_key_host_reads_a_default_port_host() {
958        let stderr =
959            "No RSA host key is known for db.example.com and you have requested strict checking.\n";
960        assert_eq!(unknown_host_key_host(stderr), Some("db.example.com"));
961    }
962
963    #[test]
964    fn unknown_host_key_host_is_none_for_other_failures() {
965        assert_eq!(unknown_host_key_host(""), None);
966        assert_eq!(
967            unknown_host_key_host("test@localhost: Permission denied (password).\n"),
968            None
969        );
970    }
971
972    #[test]
973    fn bare_host_strips_the_bracketed_port() {
974        assert_eq!(bare_host("[127.0.0.1]:12222"), "127.0.0.1");
975        assert_eq!(bare_host("[2001:db8::1]:2222"), "2001:db8::1");
976        assert_eq!(bare_host("db.example.com"), "db.example.com");
977        assert_eq!(bare_host(""), "");
978    }
979
980    // --- parse_host_key_error tests ---
981
982    #[test]
983    fn parse_host_key_error_detects_changed_key() {
984        let stderr = "\
985@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
986@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
987@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
988IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
989Someone could be eavesdropping on you right now (man-in-the-middle attack)!
990It is also possible that a host key has just been changed.
991The fingerprint for the ED25519 key sent by the remote host is
992SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
993Please contact your system administrator.
994Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
995Offending ECDSA key in /Users/user/.ssh/known_hosts:55
996Host key for example.com has changed and you have requested strict checking.
997Host key verification failed.
998";
999        let result = parse_host_key_error(stderr);
1000        assert!(result.is_some());
1001        let (hostname, path) = result.unwrap();
1002        assert_eq!(hostname, "example.com");
1003        assert_eq!(path, "/Users/user/.ssh/known_hosts");
1004    }
1005
1006    #[test]
1007    fn parse_host_key_error_returns_none_for_other_errors() {
1008        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
1009        assert!(parse_host_key_error(stderr).is_none());
1010    }
1011
1012    #[test]
1013    fn parse_host_key_error_returns_none_for_empty() {
1014        assert!(parse_host_key_error("").is_none());
1015    }
1016
1017    #[test]
1018    fn parse_host_key_error_handles_ip_address() {
1019        let stderr = "\
1020Offending ECDSA key in /home/user/.ssh/known_hosts:12
1021Host key for 10.0.0.1 has changed and you have requested strict checking.
1022Host key verification failed.
1023";
1024        let result = parse_host_key_error(stderr);
1025        assert!(result.is_some());
1026        let (hostname, path) = result.unwrap();
1027        assert_eq!(hostname, "10.0.0.1");
1028        assert_eq!(path, "/home/user/.ssh/known_hosts");
1029    }
1030
1031    #[test]
1032    fn parse_host_key_error_handles_custom_known_hosts_path() {
1033        let stderr = "\
1034Offending RSA key in /etc/ssh/known_hosts:3
1035Host key for server.local has changed and you have requested strict checking.
1036Host key verification failed.
1037";
1038        let result = parse_host_key_error(stderr);
1039        assert!(result.is_some());
1040        let (hostname, path) = result.unwrap();
1041        assert_eq!(hostname, "server.local");
1042        assert_eq!(path, "/etc/ssh/known_hosts");
1043    }
1044
1045    #[test]
1046    fn parse_host_key_error_handles_ipv6() {
1047        let stderr = "\
1048Offending ED25519 key in /Users/user/.ssh/known_hosts:7
1049Host key for ::1 has changed and you have requested strict checking.
1050Host key verification failed.
1051";
1052        let result = parse_host_key_error(stderr);
1053        assert!(result.is_some());
1054        let (hostname, _) = result.unwrap();
1055        assert_eq!(hostname, "::1");
1056    }
1057
1058    #[test]
1059    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
1060        // When no tmux server is running (or tmux is absent), should return an error.
1061        // Skip if we're actually inside a live tmux session (the command would succeed).
1062        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
1063        // the guard read and the call to connect_tmux_window.
1064        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1065        if std::env::var("TMUX").is_ok() {
1066            return;
1067        }
1068        let result = connect_tmux_window(
1069            "test-host",
1070            Path::new("/tmp/__purple_test_nonexistent_config__"),
1071            false,
1072            &SshLauncher::default(),
1073        );
1074        assert!(result.is_err());
1075        let err = result.unwrap_err().to_string();
1076        assert!(
1077            err.contains("tmux") || err.contains("No such file"),
1078            "unexpected error: {err}"
1079        );
1080    }
1081
1082    #[test]
1083    fn connect_tmux_window_with_tunnel_does_not_panic() {
1084        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
1085        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
1086        // tests from racing this guard read.
1087        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1088        if std::env::var("TMUX").is_ok() {
1089            return;
1090        }
1091        let result = connect_tmux_window(
1092            "tunnel-host",
1093            Path::new("/tmp/__purple_test_nonexistent_config__"),
1094            true,
1095            &SshLauncher::default(),
1096        );
1097        assert!(result.is_err());
1098    }
1099
1100    /// Mutex to serialise tests that mutate the TMUX env var.
1101    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1102
1103    #[test]
1104    fn is_in_tmux_returns_true_when_set() {
1105        let env = crate::runtime::env::Env::for_test("/tmp/x")
1106            .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
1107        assert!(is_in_tmux(&env));
1108    }
1109
1110    #[test]
1111    fn is_in_tmux_returns_false_when_unset() {
1112        let env = crate::runtime::env::Env::for_test("/tmp/x");
1113        assert!(!is_in_tmux(&env));
1114    }
1115
1116    // --- first_stderr_line tests ---
1117
1118    #[test]
1119    fn stderr_summary_joins_all_lines() {
1120        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
1121                      stdio forwarding failed\n\
1122                      Connection closed by UNKNOWN port 65535\n";
1123        let result = stderr_summary(stderr);
1124        assert_eq!(
1125            result.as_deref(),
1126            Some(
1127                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
1128            )
1129        );
1130    }
1131
1132    #[test]
1133    fn stderr_summary_skips_banner_lines() {
1134        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1135                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
1136                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1137                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
1138        let result = stderr_summary(stderr);
1139        assert_eq!(
1140            result.as_deref(),
1141            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
1142        );
1143    }
1144
1145    #[test]
1146    fn stderr_summary_returns_none_for_empty() {
1147        assert!(stderr_summary("").is_none());
1148        assert!(stderr_summary("   \n  \n").is_none());
1149        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
1150    }
1151
1152    #[test]
1153    fn stderr_summary_truncates_long_output() {
1154        let long = "x".repeat(250);
1155        let result = stderr_summary(&long).unwrap();
1156        assert_eq!(result.len(), 200);
1157        assert!(result.ends_with("..."));
1158    }
1159
1160    #[test]
1161    fn stderr_summary_truncates_multibyte_safely() {
1162        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
1163        let long = "日".repeat(100);
1164        let result = stderr_summary(&long).unwrap();
1165        assert!(result.ends_with("..."));
1166        // Must not panic and must be valid UTF-8
1167        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
1168    }
1169
1170    #[test]
1171    fn stderr_summary_simple_errors() {
1172        assert_eq!(
1173            stderr_summary("Connection refused\n").as_deref(),
1174            Some("Connection refused")
1175        );
1176        assert_eq!(
1177            stderr_summary("Permission denied (publickey).\n").as_deref(),
1178            Some("Permission denied (publickey).")
1179        );
1180    }
1181}