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!("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!("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!("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.
141fn spawn_ssh_and_wait(mut cmd: Command, alias: &str, log_label: &str) -> Result<ConnectResult> {
142    cmd.stdin(std::process::Stdio::inherit())
143        .stdout(std::process::Stdio::inherit())
144        .stderr(std::process::Stdio::piped());
145
146    // Reset signal mask in the child process so SSH receives Ctrl+C
147    // normally. We mask signals in the parent AFTER spawn so the
148    // child doesn't inherit the blocked mask.
149    #[cfg(unix)]
150    unsafe {
151        use std::os::unix::process::CommandExt;
152        cmd.pre_exec(|| {
153            let mut mask: libc::sigset_t = std::mem::zeroed();
154            libc::sigemptyset(&mut mask);
155            libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
156            Ok(())
157        });
158    }
159
160    let mut child = cmd
161        .spawn()
162        .with_context(|| format!("Failed to launch ssh {} for '{}'", log_label, alias))?;
163
164    // Mask SIGINT/SIGTSTP in purple AFTER spawn so SSH doesn't inherit
165    // the blocked mask. The guard restores the mask on drop (even on
166    // early return).
167    #[cfg(unix)]
168    let _signal_guard = SignalMaskGuard::block_interactive();
169
170    let stderr_pipe = child.stderr.take().expect("stderr was piped");
171    let stderr_thread = std::thread::spawn(move || {
172        use std::io::{Read, Write};
173        let mut captured = Vec::new();
174        let mut buf = [0u8; 4096];
175        let mut reader = stderr_pipe;
176        let mut stderr_out = std::io::stderr();
177        loop {
178            match reader.read(&mut buf) {
179                Ok(0) => break,
180                Ok(n) => {
181                    let _ = stderr_out.write_all(&buf[..n]);
182                    let _ = stderr_out.flush();
183                    captured.extend_from_slice(&buf[..n]);
184                }
185                Err(_) => break,
186            }
187        }
188        String::from_utf8_lossy(&captured).to_string()
189    });
190
191    let status = child
192        .wait()
193        .with_context(|| format!("Failed to wait for ssh {} for '{}'", log_label, alias))?;
194    let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
195        warn!("[purple] Stderr capture thread panicked for {alias}");
196        String::new()
197    });
198
199    let code = status.code().unwrap_or(-1);
200    if code == 0 {
201        info!("SSH {} ended: {alias} (exit 0)", log_label);
202    } else {
203        error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
204        if !stderr_output.is_empty() {
205            let stderr = stderr_output.trim();
206            let lower = stderr.to_lowercase();
207            if lower.contains("are too open") || lower.contains("bad permissions") {
208                warn!("[config] SSH key permission issue: {stderr}");
209            } else {
210                debug!("[external] SSH stderr: {stderr}");
211            }
212        }
213    }
214
215    Ok(ConnectResult {
216        status,
217        stderr_output,
218    })
219}
220
221/// Launch an SSH connection to the given host alias.
222/// Uses the system `ssh` binary with inherited stdin/stdout. Stderr is piped and
223/// forwarded to real stderr in real time so the output is captured for error detection.
224/// Passes `-F <config_path>` so the alias resolves against the correct config file.
225/// When `askpass` is Some, delegates to `askpass_env::configure_ssh_command` to wire up
226/// SSH_ASKPASS, SSH_ASKPASS_REQUIRE=force and the PURPLE_* env vars.
227pub fn connect(
228    alias: &str,
229    config_path: &Path,
230    askpass: Option<&str>,
231    bw_session: Option<&str>,
232    has_active_tunnel: bool,
233) -> Result<ConnectResult> {
234    info!("SSH connection started: {alias}");
235    debug!("SSH command: ssh -F {} -- {alias}", config_path.display());
236
237    let mut cmd = Command::new("ssh");
238    cmd.arg("-F").arg(config_path);
239
240    // When a tunnel is already running for this host, disable forwards in the
241    // interactive session to avoid "Address already in use" bind conflicts.
242    if has_active_tunnel {
243        cmd.arg("-o").arg("ClearAllForwardings=yes");
244    }
245
246    cmd.arg("--").arg(alias);
247
248    if askpass.is_some() {
249        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
250    }
251
252    if let Some(token) = bw_session {
253        cmd.env("BW_SESSION", token);
254    }
255
256    spawn_ssh_and_wait(cmd, alias, "connection")
257}
258
259/// Launch an SSH connection that runs a single remote command in
260/// interactive mode. Mirrors `connect()` exactly except for two
261/// additions: `-t` to allocate a TTY (required for the remote shell
262/// `docker exec` opens) and the trailing `remote_command` string passed
263/// to ssh as one argv slot. The remote shell receives the string as a
264/// single command line, so multi-token commands and shell operators
265/// like `||` work naturally.
266///
267/// Used by the containers overview Enter handler: the `remote_command`
268/// is built as `<runtime> exec -it <container_id> sh -c 'bash || sh'`
269/// where `container_id` has already been validated to alphanumeric +
270/// `-_.` so it cannot inject shell metacharacters.
271pub fn connect_with_remote_command(
272    alias: &str,
273    config_path: &Path,
274    askpass: Option<&str>,
275    bw_session: Option<&str>,
276    has_active_tunnel: bool,
277    remote_command: &str,
278) -> Result<ConnectResult> {
279    info!("SSH exec started: {alias}");
280    debug!(
281        "SSH command: ssh -F {} -t -- {alias} {}",
282        config_path.display(),
283        remote_command
284    );
285
286    // Renew the Vault SSH cert before exec'ing into a container so an
287    // expired cert is refreshed, mirroring the interactive connect path.
288    // No-op for non-vault hosts.
289    crate::runtime::helpers::ensure_vault_cert_for_alias(
290        &crate::runtime::env::Env::from_process(),
291        alias,
292        config_path,
293    );
294
295    let mut cmd = Command::new("ssh");
296    cmd.arg("-F").arg(config_path).arg("-t");
297
298    if has_active_tunnel {
299        cmd.arg("-o").arg("ClearAllForwardings=yes");
300    }
301
302    cmd.arg("--").arg(alias).arg(remote_command);
303
304    if askpass.is_some() {
305        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
306    }
307
308    if let Some(token) = bw_session {
309        cmd.env("BW_SESSION", token);
310    }
311
312    spawn_ssh_and_wait(cmd, alias, "exec")
313}
314
315/// tmux variant of `connect_with_remote_command`. Opens a new tmux
316/// window running `ssh -t <alias> <remote_command>` so the TUI stays
317/// alive in the original window. Same askpass-incompatible caveat as
318/// `connect_tmux_window`.
319pub fn connect_tmux_window_with_remote_command(
320    alias: &str,
321    config_path: &Path,
322    has_active_tunnel: bool,
323    remote_command: &str,
324    window_label: &str,
325) -> Result<()> {
326    info!("SSH exec via tmux: {alias}");
327
328    // Renew the Vault SSH cert before exec'ing into a container so an
329    // expired cert is refreshed, mirroring the interactive connect path.
330    // No-op for non-vault hosts.
331    crate::runtime::helpers::ensure_vault_cert_for_alias(
332        &crate::runtime::env::Env::from_process(),
333        alias,
334        config_path,
335    );
336
337    let config_str = config_path
338        .to_str()
339        .context("SSH config path is not valid UTF-8")?;
340
341    let mut args = vec![
342        "new-window",
343        "-n",
344        window_label,
345        "--",
346        "ssh",
347        "-F",
348        config_str,
349        "-t",
350    ];
351
352    if has_active_tunnel {
353        args.extend(["-o", "ClearAllForwardings=yes"]);
354    }
355
356    args.extend(["--", alias, remote_command]);
357
358    debug!("tmux exec args: {:?}", args);
359
360    let status = Command::new("tmux")
361        .args(&args)
362        .status()
363        .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
364
365    if status.success() {
366        info!("tmux exec window created: {alias}");
367        Ok(())
368    } else {
369        let code = status.code().unwrap_or(-1);
370        error!("[external] tmux exec window failed for {alias} (exit {code})");
371        anyhow::bail!("tmux new-window exited with code {code}")
372    }
373}
374
375/// Extract a concise reason from SSH stderr for display in the toast.
376/// Joins all non-empty, non-banner lines with ` | ` so the full context
377/// is visible. Truncates to 200 chars (char-safe) if needed.
378pub fn stderr_summary(stderr: &str) -> Option<String> {
379    let summary: String = stderr
380        .lines()
381        .map(str::trim)
382        .filter(|l| !l.is_empty() && !l.starts_with('@'))
383        .collect::<Vec<_>>()
384        .join(" | ");
385    if summary.is_empty() {
386        return None;
387    }
388    if summary.len() > 200 {
389        let truncated: String = summary.chars().take(197).collect();
390        Some(format!("{truncated}..."))
391    } else {
392        Some(summary)
393    }
394}
395
396/// Parse host key verification error from SSH stderr output.
397/// Returns (hostname, known_hosts_path) if the error is a changed host key.
398///
399/// Uses two detection strategies:
400/// 1. English string matching for hostname and known_hosts path extraction.
401/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
402///    regardless of locale, combined with a known_hosts path from "Offending" line.
403///    When the English hostname line is missing, falls back to extracting the
404///    hostname from the known_hosts file path.
405pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
406    // Primary: English locale detection
407    let has_english_error = stderr.contains("Host key verification failed.");
408    // Fallback: the @@@ banner is locale-independent and always present for host key errors
409    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
410
411    if !has_english_error && !has_banner {
412        return None;
413    }
414
415    // Parse hostname from "Host key for <hostname> has changed"
416    let hostname = stderr
417        .lines()
418        .find(|l| l.contains("Host key for") && l.contains("has changed"))
419        .and_then(|l| {
420            let start = l.find("Host key for ")? + "Host key for ".len();
421            let rest = &l[start..];
422            let end = rest.find(" has changed")?;
423            Some(rest[..end].to_string())
424        });
425
426    // Parse known_hosts path from "Offending ... key in <path>:<line>"
427    let known_hosts_path = stderr
428        .lines()
429        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
430        .and_then(|l| {
431            let start = l.find(" key in ")? + " key in ".len();
432            let rest = &l[start..];
433            let end = rest.rfind(':')?;
434            Some(rest[..end].to_string())
435        });
436
437    // We need at least the known_hosts path to be useful
438    let known_hosts_path = known_hosts_path?;
439
440    // If we couldn't parse the hostname (non-English locale), derive it from
441    // the known_hosts path by running ssh-keygen -F would be complex.
442    // Instead, use a reasonable default: the user will see the confirmation dialog
443    // with the known_hosts path, which is the critical piece for the reset.
444    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
445
446    Some((hostname, known_hosts_path))
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn connect_fails_with_nonexistent_config() {
455        // connect() should return an error when the config file doesn't exist and
456        let result = connect(
457            "nonexistent-host",
458            Path::new("/tmp/__purple_test_nonexistent_config__"),
459            None,
460            None,
461            false,
462        );
463        // SSH should exit with a non-zero status (config file not found)
464        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
465        let r = result.unwrap();
466        assert!(!r.status.success());
467    }
468
469    #[test]
470    fn connect_with_tunnel_flag_does_not_panic() {
471        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
472        let result = connect(
473            "nonexistent-host",
474            Path::new("/tmp/__purple_test_nonexistent_config__"),
475            None,
476            None,
477            true,
478        );
479        assert!(result.is_ok());
480        assert!(!result.unwrap().status.success());
481    }
482
483    #[test]
484    fn connect_captures_stderr() {
485        // SSH should produce some stderr output when failing.
486        let result = connect(
487            "nonexistent-host",
488            Path::new("/tmp/__purple_test_nonexistent_config__"),
489            None,
490            None,
491            false,
492        );
493        assert!(result.is_ok());
494        // SSH writes errors to stderr; we should have captured something
495        // (either "Can't open user config file" or a connection error)
496        let r = result.unwrap();
497        assert!(
498            !r.stderr_output.is_empty() || !r.status.success(),
499            "SSH should produce stderr or fail"
500        );
501    }
502
503    // --- parse_host_key_error tests ---
504
505    #[test]
506    fn parse_host_key_error_detects_changed_key() {
507        let stderr = "\
508@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
509@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
510@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
511IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
512Someone could be eavesdropping on you right now (man-in-the-middle attack)!
513It is also possible that a host key has just been changed.
514The fingerprint for the ED25519 key sent by the remote host is
515SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
516Please contact your system administrator.
517Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
518Offending ECDSA key in /Users/user/.ssh/known_hosts:55
519Host key for example.com has changed and you have requested strict checking.
520Host key verification failed.
521";
522        let result = parse_host_key_error(stderr);
523        assert!(result.is_some());
524        let (hostname, path) = result.unwrap();
525        assert_eq!(hostname, "example.com");
526        assert_eq!(path, "/Users/user/.ssh/known_hosts");
527    }
528
529    #[test]
530    fn parse_host_key_error_returns_none_for_other_errors() {
531        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
532        assert!(parse_host_key_error(stderr).is_none());
533    }
534
535    #[test]
536    fn parse_host_key_error_returns_none_for_empty() {
537        assert!(parse_host_key_error("").is_none());
538    }
539
540    #[test]
541    fn parse_host_key_error_handles_ip_address() {
542        let stderr = "\
543Offending ECDSA key in /home/user/.ssh/known_hosts:12
544Host key for 10.0.0.1 has changed and you have requested strict checking.
545Host key verification failed.
546";
547        let result = parse_host_key_error(stderr);
548        assert!(result.is_some());
549        let (hostname, path) = result.unwrap();
550        assert_eq!(hostname, "10.0.0.1");
551        assert_eq!(path, "/home/user/.ssh/known_hosts");
552    }
553
554    #[test]
555    fn parse_host_key_error_handles_custom_known_hosts_path() {
556        let stderr = "\
557Offending RSA key in /etc/ssh/known_hosts:3
558Host key for server.local has changed and you have requested strict checking.
559Host key verification failed.
560";
561        let result = parse_host_key_error(stderr);
562        assert!(result.is_some());
563        let (hostname, path) = result.unwrap();
564        assert_eq!(hostname, "server.local");
565        assert_eq!(path, "/etc/ssh/known_hosts");
566    }
567
568    #[test]
569    fn parse_host_key_error_handles_ipv6() {
570        let stderr = "\
571Offending ED25519 key in /Users/user/.ssh/known_hosts:7
572Host key for ::1 has changed and you have requested strict checking.
573Host key verification failed.
574";
575        let result = parse_host_key_error(stderr);
576        assert!(result.is_some());
577        let (hostname, _) = result.unwrap();
578        assert_eq!(hostname, "::1");
579    }
580
581    #[test]
582    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
583        // When no tmux server is running (or tmux is absent), should return an error.
584        // Skip if we're actually inside a live tmux session (the command would succeed).
585        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
586        // the guard read and the call to connect_tmux_window.
587        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
588        if std::env::var("TMUX").is_ok() {
589            return;
590        }
591        let result = connect_tmux_window(
592            "test-host",
593            Path::new("/tmp/__purple_test_nonexistent_config__"),
594            false,
595        );
596        assert!(result.is_err());
597        let err = result.unwrap_err().to_string();
598        assert!(
599            err.contains("tmux") || err.contains("No such file"),
600            "unexpected error: {err}"
601        );
602    }
603
604    #[test]
605    fn connect_tmux_window_with_tunnel_does_not_panic() {
606        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
607        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
608        // tests from racing this guard read.
609        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
610        if std::env::var("TMUX").is_ok() {
611            return;
612        }
613        let result = connect_tmux_window(
614            "tunnel-host",
615            Path::new("/tmp/__purple_test_nonexistent_config__"),
616            true,
617        );
618        assert!(result.is_err());
619    }
620
621    /// Mutex to serialise tests that mutate the TMUX env var.
622    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
623
624    #[test]
625    fn is_in_tmux_returns_true_when_set() {
626        let env = crate::runtime::env::Env::for_test("/tmp/x")
627            .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
628        assert!(is_in_tmux(&env));
629    }
630
631    #[test]
632    fn is_in_tmux_returns_false_when_unset() {
633        let env = crate::runtime::env::Env::for_test("/tmp/x");
634        assert!(!is_in_tmux(&env));
635    }
636
637    // --- first_stderr_line tests ---
638
639    #[test]
640    fn stderr_summary_joins_all_lines() {
641        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
642                      stdio forwarding failed\n\
643                      Connection closed by UNKNOWN port 65535\n";
644        let result = stderr_summary(stderr);
645        assert_eq!(
646            result.as_deref(),
647            Some(
648                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
649            )
650        );
651    }
652
653    #[test]
654    fn stderr_summary_skips_banner_lines() {
655        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
656                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
657                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
658                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
659        let result = stderr_summary(stderr);
660        assert_eq!(
661            result.as_deref(),
662            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
663        );
664    }
665
666    #[test]
667    fn stderr_summary_returns_none_for_empty() {
668        assert!(stderr_summary("").is_none());
669        assert!(stderr_summary("   \n  \n").is_none());
670        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
671    }
672
673    #[test]
674    fn stderr_summary_truncates_long_output() {
675        let long = "x".repeat(250);
676        let result = stderr_summary(&long).unwrap();
677        assert_eq!(result.len(), 200);
678        assert!(result.ends_with("..."));
679    }
680
681    #[test]
682    fn stderr_summary_truncates_multibyte_safely() {
683        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
684        let long = "日".repeat(100);
685        let result = stderr_summary(&long).unwrap();
686        assert!(result.ends_with("..."));
687        // Must not panic and must be valid UTF-8
688        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
689    }
690
691    #[test]
692    fn stderr_summary_simple_errors() {
693        assert_eq!(
694            stderr_summary("Connection refused\n").as_deref(),
695            Some("Connection refused")
696        );
697        assert_eq!(
698            stderr_summary("Permission denied (publickey).\n").as_deref(),
699            Some("Permission denied (publickey).")
700        );
701    }
702}