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#[derive(Debug)]
11pub struct ConnectResult {
12 pub status: std::process::ExitStatus,
13 pub stderr_output: String,
14}
15
16#[cfg(unix)]
20pub fn is_in_tmux(env: &crate::runtime::env::Env) -> bool {
21 env.in_tmux()
22}
23
24#[cfg(not(unix))]
26pub fn is_in_tmux(_env: &crate::runtime::env::Env) -> bool {
27 false
28}
29
30pub 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
72fn 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#[cfg(unix)]
107struct SignalMaskGuard {
108 old: libc::sigset_t,
109}
110
111#[cfg(unix)]
112impl SignalMaskGuard {
113 fn block_interactive() -> Self {
115 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 unsafe {
140 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 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 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
166pub(crate) fn is_ssh_transport_failure(code: i32) -> bool {
179 code == 255
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub(crate) enum SshExitClass {
187 Success,
189 TransportFailure,
191 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
205pub(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 #[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 #[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 debug!("[external] SSH {} ended: {alias} (exit {code})", log_label);
301 }
302 }
303
304 Ok(ConnectResult {
305 status,
306 stderr_output,
307 })
308}
309
310pub 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 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#[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 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
410pub 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 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
460pub 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
481pub 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
495pub fn auth_refused(stderr: &str) -> bool {
499 stderr.lines().any(|l| l.contains(": Permission denied ("))
500}
501
502pub fn is_unknown_host_key(stderr: &str) -> bool {
506 stderr.contains("host key is known for") && stderr.contains("requested strict checking")
507}
508
509pub 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
525pub 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
543pub fn bare_host(host: &str) -> &str {
546 host.strip_prefix('[')
547 .and_then(|rest| rest.split(']').next())
548 .unwrap_or(host)
549}
550
551pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
561 let has_english_error = stderr.contains("Host key verification failed.");
563 let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
565
566 if !has_english_error && !has_banner {
567 return None;
568 }
569
570 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 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 let known_hosts_path = known_hosts_path?;
594
595 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 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 assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
624 assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
625 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 assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
633 }
634
635 #[test]
636 fn remote_exit_log_level_reserves_error_for_transport_failure() {
637 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 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 assert!(result.is_ok()); let r = result.unwrap();
665 assert!(!r.status.success());
666 }
667
668 #[test]
669 fn connect_with_tunnel_flag_does_not_panic() {
670 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 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 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 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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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 let long = "日".repeat(100);
1164 let result = stderr_summary(&long).unwrap();
1165 assert!(result.ends_with("..."));
1166 assert!(result.len() <= 600); }
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}