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