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    // Renew the Vault SSH cert before exec'ing into a container so an
285    // expired cert is refreshed, mirroring the interactive connect path.
286    // No-op for non-vault hosts.
287    crate::runtime::helpers::ensure_vault_cert_for_alias(alias, config_path);
288
289    let mut cmd = Command::new("ssh");
290    cmd.arg("-F").arg(config_path).arg("-t");
291
292    if has_active_tunnel {
293        cmd.arg("-o").arg("ClearAllForwardings=yes");
294    }
295
296    cmd.arg("--").arg(alias).arg(remote_command);
297
298    if askpass.is_some() {
299        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
300    }
301
302    if let Some(token) = bw_session {
303        cmd.env("BW_SESSION", token);
304    }
305
306    spawn_ssh_and_wait(cmd, alias, "exec")
307}
308
309/// tmux variant of `connect_with_remote_command`. Opens a new tmux
310/// window running `ssh -t <alias> <remote_command>` so the TUI stays
311/// alive in the original window. Same askpass-incompatible caveat as
312/// `connect_tmux_window`.
313pub fn connect_tmux_window_with_remote_command(
314    alias: &str,
315    config_path: &Path,
316    has_active_tunnel: bool,
317    remote_command: &str,
318    window_label: &str,
319) -> Result<()> {
320    info!("SSH exec via tmux: {alias}");
321
322    // Renew the Vault SSH cert before exec'ing into a container so an
323    // expired cert is refreshed, mirroring the interactive connect path.
324    // No-op for non-vault hosts.
325    crate::runtime::helpers::ensure_vault_cert_for_alias(alias, config_path);
326
327    let config_str = config_path
328        .to_str()
329        .context("SSH config path is not valid UTF-8")?;
330
331    let mut args = vec![
332        "new-window",
333        "-n",
334        window_label,
335        "--",
336        "ssh",
337        "-F",
338        config_str,
339        "-t",
340    ];
341
342    if has_active_tunnel {
343        args.extend(["-o", "ClearAllForwardings=yes"]);
344    }
345
346    args.extend(["--", alias, remote_command]);
347
348    debug!("tmux exec args: {:?}", args);
349
350    let status = Command::new("tmux")
351        .args(&args)
352        .status()
353        .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
354
355    if status.success() {
356        info!("tmux exec window created: {alias}");
357        Ok(())
358    } else {
359        let code = status.code().unwrap_or(-1);
360        error!("[external] tmux exec window failed for {alias} (exit {code})");
361        anyhow::bail!("tmux new-window exited with code {code}")
362    }
363}
364
365/// Extract a concise reason from SSH stderr for display in the toast.
366/// Joins all non-empty, non-banner lines with ` | ` so the full context
367/// is visible. Truncates to 200 chars (char-safe) if needed.
368pub fn stderr_summary(stderr: &str) -> Option<String> {
369    let summary: String = stderr
370        .lines()
371        .map(str::trim)
372        .filter(|l| !l.is_empty() && !l.starts_with('@'))
373        .collect::<Vec<_>>()
374        .join(" | ");
375    if summary.is_empty() {
376        return None;
377    }
378    if summary.len() > 200 {
379        let truncated: String = summary.chars().take(197).collect();
380        Some(format!("{truncated}..."))
381    } else {
382        Some(summary)
383    }
384}
385
386/// Parse host key verification error from SSH stderr output.
387/// Returns (hostname, known_hosts_path) if the error is a changed host key.
388///
389/// Uses two detection strategies:
390/// 1. English string matching for hostname and known_hosts path extraction.
391/// 2. Locale-independent fallback: the `@@@@@` warning banner is always present
392///    regardless of locale, combined with a known_hosts path from "Offending" line.
393///    When the English hostname line is missing, falls back to extracting the
394///    hostname from the known_hosts file path.
395pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
396    // Primary: English locale detection
397    let has_english_error = stderr.contains("Host key verification failed.");
398    // Fallback: the @@@ banner is locale-independent and always present for host key errors
399    let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
400
401    if !has_english_error && !has_banner {
402        return None;
403    }
404
405    // Parse hostname from "Host key for <hostname> has changed"
406    let hostname = stderr
407        .lines()
408        .find(|l| l.contains("Host key for") && l.contains("has changed"))
409        .and_then(|l| {
410            let start = l.find("Host key for ")? + "Host key for ".len();
411            let rest = &l[start..];
412            let end = rest.find(" has changed")?;
413            Some(rest[..end].to_string())
414        });
415
416    // Parse known_hosts path from "Offending ... key in <path>:<line>"
417    let known_hosts_path = stderr
418        .lines()
419        .find(|l| l.starts_with("Offending") && l.contains(" key in "))
420        .and_then(|l| {
421            let start = l.find(" key in ")? + " key in ".len();
422            let rest = &l[start..];
423            let end = rest.rfind(':')?;
424            Some(rest[..end].to_string())
425        });
426
427    // We need at least the known_hosts path to be useful
428    let known_hosts_path = known_hosts_path?;
429
430    // If we couldn't parse the hostname (non-English locale), derive it from
431    // the known_hosts path by running ssh-keygen -F would be complex.
432    // Instead, use a reasonable default: the user will see the confirmation dialog
433    // with the known_hosts path, which is the critical piece for the reset.
434    let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
435
436    Some((hostname, known_hosts_path))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn connect_fails_with_nonexistent_config() {
445        // connect() should return an error when the config file doesn't exist and
446        // SSH cannot be spawned (or fails immediately). Hold ENV_LOCK so a
447        // sibling test that sets PATH to a non-existent directory cannot race
448        // with the `Command::new("ssh")` spawn below and make it fail to find
449        // the binary.
450        let _guard = crate::vault_ssh::tests::ENV_LOCK
451            .lock()
452            .unwrap_or_else(|p| p.into_inner());
453        let result = connect(
454            "nonexistent-host",
455            Path::new("/tmp/__purple_test_nonexistent_config__"),
456            None,
457            None,
458            false,
459        );
460        // SSH should exit with a non-zero status (config file not found)
461        assert!(result.is_ok()); // spawn succeeds, SSH exits with error
462        let r = result.unwrap();
463        assert!(!r.status.success());
464    }
465
466    #[test]
467    fn connect_with_tunnel_flag_does_not_panic() {
468        // Verify has_active_tunnel=true adds the ClearAllForwardings arg without panic.
469        // ENV_LOCK guards the ssh spawn against PATH-mutating siblings.
470        let _guard = crate::vault_ssh::tests::ENV_LOCK
471            .lock()
472            .unwrap_or_else(|p| p.into_inner());
473        let result = connect(
474            "nonexistent-host",
475            Path::new("/tmp/__purple_test_nonexistent_config__"),
476            None,
477            None,
478            true,
479        );
480        assert!(result.is_ok());
481        assert!(!result.unwrap().status.success());
482    }
483
484    #[test]
485    fn connect_captures_stderr() {
486        // SSH should produce some stderr output when failing. ENV_LOCK guards
487        // the ssh spawn against PATH-mutating siblings.
488        let _guard = crate::vault_ssh::tests::ENV_LOCK
489            .lock()
490            .unwrap_or_else(|p| p.into_inner());
491        let result = connect(
492            "nonexistent-host",
493            Path::new("/tmp/__purple_test_nonexistent_config__"),
494            None,
495            None,
496            false,
497        );
498        assert!(result.is_ok());
499        // SSH writes errors to stderr; we should have captured something
500        // (either "Can't open user config file" or a connection error)
501        let r = result.unwrap();
502        assert!(
503            !r.stderr_output.is_empty() || !r.status.success(),
504            "SSH should produce stderr or fail"
505        );
506    }
507
508    // --- parse_host_key_error tests ---
509
510    #[test]
511    fn parse_host_key_error_detects_changed_key() {
512        let stderr = "\
513@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
514@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
515@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
516IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
517Someone could be eavesdropping on you right now (man-in-the-middle attack)!
518It is also possible that a host key has just been changed.
519The fingerprint for the ED25519 key sent by the remote host is
520SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
521Please contact your system administrator.
522Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
523Offending ECDSA key in /Users/user/.ssh/known_hosts:55
524Host key for example.com has changed and you have requested strict checking.
525Host key verification failed.
526";
527        let result = parse_host_key_error(stderr);
528        assert!(result.is_some());
529        let (hostname, path) = result.unwrap();
530        assert_eq!(hostname, "example.com");
531        assert_eq!(path, "/Users/user/.ssh/known_hosts");
532    }
533
534    #[test]
535    fn parse_host_key_error_returns_none_for_other_errors() {
536        let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
537        assert!(parse_host_key_error(stderr).is_none());
538    }
539
540    #[test]
541    fn parse_host_key_error_returns_none_for_empty() {
542        assert!(parse_host_key_error("").is_none());
543    }
544
545    #[test]
546    fn parse_host_key_error_handles_ip_address() {
547        let stderr = "\
548Offending ECDSA key in /home/user/.ssh/known_hosts:12
549Host key for 10.0.0.1 has changed and you have requested strict checking.
550Host key verification failed.
551";
552        let result = parse_host_key_error(stderr);
553        assert!(result.is_some());
554        let (hostname, path) = result.unwrap();
555        assert_eq!(hostname, "10.0.0.1");
556        assert_eq!(path, "/home/user/.ssh/known_hosts");
557    }
558
559    #[test]
560    fn parse_host_key_error_handles_custom_known_hosts_path() {
561        let stderr = "\
562Offending RSA key in /etc/ssh/known_hosts:3
563Host key for server.local has changed and you have requested strict checking.
564Host key verification failed.
565";
566        let result = parse_host_key_error(stderr);
567        assert!(result.is_some());
568        let (hostname, path) = result.unwrap();
569        assert_eq!(hostname, "server.local");
570        assert_eq!(path, "/etc/ssh/known_hosts");
571    }
572
573    #[test]
574    fn parse_host_key_error_handles_ipv6() {
575        let stderr = "\
576Offending ED25519 key in /Users/user/.ssh/known_hosts:7
577Host key for ::1 has changed and you have requested strict checking.
578Host key verification failed.
579";
580        let result = parse_host_key_error(stderr);
581        assert!(result.is_some());
582        let (hostname, _) = result.unwrap();
583        assert_eq!(hostname, "::1");
584    }
585
586    #[test]
587    fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
588        // When no tmux server is running (or tmux is absent), should return an error.
589        // Skip if we're actually inside a live tmux session (the command would succeed).
590        // Holds TMUX_LOCK so the env-mutating tests below cannot flip TMUX between
591        // the guard read and the call to connect_tmux_window.
592        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
593        if std::env::var("TMUX").is_ok() {
594            return;
595        }
596        let result = connect_tmux_window(
597            "test-host",
598            Path::new("/tmp/__purple_test_nonexistent_config__"),
599            false,
600        );
601        assert!(result.is_err());
602        let err = result.unwrap_err().to_string();
603        assert!(
604            err.contains("tmux") || err.contains("No such file"),
605            "unexpected error: {err}"
606        );
607    }
608
609    #[test]
610    fn connect_tmux_window_with_tunnel_does_not_panic() {
611        // Verify has_active_tunnel=true doesn't panic and fails gracefully.
612        // Skip if inside a live tmux session. TMUX_LOCK prevents the env-mutating
613        // tests from racing this guard read.
614        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
615        if std::env::var("TMUX").is_ok() {
616            return;
617        }
618        let result = connect_tmux_window(
619            "tunnel-host",
620            Path::new("/tmp/__purple_test_nonexistent_config__"),
621            true,
622        );
623        assert!(result.is_err());
624    }
625
626    /// Mutex to serialise tests that mutate the TMUX env var.
627    static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
628
629    #[test]
630    fn is_in_tmux_returns_true_when_set() {
631        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
632        let prev = std::env::var("TMUX").ok();
633        // SAFETY: TMUX_LOCK serialises all env mutations in this test suite.
634        unsafe { std::env::set_var("TMUX", "/tmp/tmux-1000/default,12345,0") };
635        let result = is_in_tmux();
636        // SAFETY: TMUX_LOCK held, restoring previous value.
637        match prev {
638            Some(v) => unsafe { std::env::set_var("TMUX", v) },
639            None => unsafe { std::env::remove_var("TMUX") },
640        }
641        assert!(result);
642    }
643
644    #[test]
645    fn is_in_tmux_returns_false_when_unset() {
646        let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
647        let prev = std::env::var("TMUX").ok();
648        // SAFETY: TMUX_LOCK serialises all env mutations in this test suite.
649        unsafe { std::env::remove_var("TMUX") };
650        let result = is_in_tmux();
651        // SAFETY: TMUX_LOCK held, restoring previous value.
652        if let Some(v) = prev {
653            unsafe { std::env::set_var("TMUX", v) };
654        }
655        assert!(!result);
656    }
657
658    // --- first_stderr_line tests ---
659
660    #[test]
661    fn stderr_summary_joins_all_lines() {
662        let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
663                      stdio forwarding failed\n\
664                      Connection closed by UNKNOWN port 65535\n";
665        let result = stderr_summary(stderr);
666        assert_eq!(
667            result.as_deref(),
668            Some(
669                "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
670            )
671        );
672    }
673
674    #[test]
675    fn stderr_summary_skips_banner_lines() {
676        let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
677                      @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
678                      @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
679                      IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
680        let result = stderr_summary(stderr);
681        assert_eq!(
682            result.as_deref(),
683            Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
684        );
685    }
686
687    #[test]
688    fn stderr_summary_returns_none_for_empty() {
689        assert!(stderr_summary("").is_none());
690        assert!(stderr_summary("   \n  \n").is_none());
691        assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
692    }
693
694    #[test]
695    fn stderr_summary_truncates_long_output() {
696        let long = "x".repeat(250);
697        let result = stderr_summary(&long).unwrap();
698        assert_eq!(result.len(), 200);
699        assert!(result.ends_with("..."));
700    }
701
702    #[test]
703    fn stderr_summary_truncates_multibyte_safely() {
704        // Each '日' is 3 bytes. 100 chars = 300 bytes, exceeds the 200-char limit.
705        let long = "日".repeat(100);
706        let result = stderr_summary(&long).unwrap();
707        assert!(result.ends_with("..."));
708        // Must not panic and must be valid UTF-8
709        assert!(result.len() <= 600); // 197 chars * 3 bytes + 3 bytes for "..."
710    }
711
712    #[test]
713    fn stderr_summary_simple_errors() {
714        assert_eq!(
715            stderr_summary("Connection refused\n").as_deref(),
716            Some("Connection refused")
717        );
718        assert_eq!(
719            stderr_summary("Permission denied (publickey).\n").as_deref(),
720            Some("Permission denied (publickey).")
721        );
722    }
723}