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 is_unknown_host_key(stderr: &str) -> bool {
499 stderr.contains("host key is known for") && stderr.contains("requested strict checking")
500}
501
502pub fn denied_host(stderr: &str) -> Option<&str> {
507 stderr
508 .lines()
509 .map(str::trim)
510 .filter(|l| l.contains(": Permission denied ("))
511 .filter_map(|l| {
512 let prefix = l.split(": Permission denied (").next()?;
513 prefix.rsplit('@').next().filter(|h| !h.is_empty())
514 })
515 .next_back()
516}
517
518pub fn unknown_host_key_host(stderr: &str) -> Option<&str> {
523 stderr
524 .lines()
525 .map(str::trim)
526 .find(|l| l.contains("host key is known for") && l.contains("requested strict checking"))
527 .and_then(|l| {
528 let start = l.find("host key is known for ")? + "host key is known for ".len();
529 let rest = &l[start..];
530 let end = rest.find(" and you have requested")?;
531 Some(rest[..end].trim())
532 })
533 .filter(|h| !h.is_empty())
534}
535
536pub fn bare_host(host: &str) -> &str {
539 host.strip_prefix('[')
540 .and_then(|rest| rest.split(']').next())
541 .unwrap_or(host)
542}
543
544pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
554 let has_english_error = stderr.contains("Host key verification failed.");
556 let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
558
559 if !has_english_error && !has_banner {
560 return None;
561 }
562
563 let hostname = stderr
565 .lines()
566 .find(|l| l.contains("Host key for") && l.contains("has changed"))
567 .and_then(|l| {
568 let start = l.find("Host key for ")? + "Host key for ".len();
569 let rest = &l[start..];
570 let end = rest.find(" has changed")?;
571 Some(rest[..end].to_string())
572 });
573
574 let known_hosts_path = stderr
576 .lines()
577 .find(|l| l.starts_with("Offending") && l.contains(" key in "))
578 .and_then(|l| {
579 let start = l.find(" key in ")? + " key in ".len();
580 let rest = &l[start..];
581 let end = rest.rfind(':')?;
582 Some(rest[..end].to_string())
583 });
584
585 let known_hosts_path = known_hosts_path?;
587
588 let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
593
594 Some((hostname, known_hosts_path))
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn transport_failure_is_only_code_255() {
603 assert!(is_ssh_transport_failure(255));
604 assert!(!is_ssh_transport_failure(0));
606 assert!(!is_ssh_transport_failure(1));
607 assert!(!is_ssh_transport_failure(2));
608 assert!(!is_ssh_transport_failure(254));
609 assert!(!is_ssh_transport_failure(-1));
610 }
611
612 #[test]
613 fn classify_ssh_exit_routes_only_255_to_error() {
614 assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
617 assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
618 assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
620 assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
621 assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
622 assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
623 assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
624 assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
626 }
627
628 #[test]
629 fn remote_exit_log_level_reserves_error_for_transport_failure() {
630 assert_eq!(remote_exit_log_level(255), log::Level::Error);
635 for code in [0, 1, 2, 126, 127, 130, 254, -1] {
636 assert_eq!(
637 remote_exit_log_level(code),
638 log::Level::Debug,
639 "exit {code} must not log at error level"
640 );
641 }
642 }
643
644 #[test]
645 fn connect_fails_with_nonexistent_config() {
646 let result = connect(
648 "nonexistent-host",
649 Path::new("/tmp/__purple_test_nonexistent_config__"),
650 None,
651 None,
652 false,
653 &SshLauncher::default(),
654 );
655 assert!(result.is_ok()); let r = result.unwrap();
658 assert!(!r.status.success());
659 }
660
661 #[test]
662 fn connect_with_tunnel_flag_does_not_panic() {
663 let result = connect(
665 "nonexistent-host",
666 Path::new("/tmp/__purple_test_nonexistent_config__"),
667 None,
668 None,
669 true,
670 &SshLauncher::default(),
671 );
672 assert!(result.is_ok());
673 assert!(!result.unwrap().status.success());
674 }
675
676 #[test]
677 fn connect_runs_the_configured_launcher() {
678 let launcher =
681 SshLauncher::parse("sh -c 'printf \"%s\\n\" \"$@\" >&2; exit 7' sh").unwrap();
682 let result = connect(
683 "launcher-host",
684 Path::new("/tmp/__purple_test_cfg__"),
685 None,
686 None,
687 true,
688 &launcher,
689 )
690 .unwrap();
691 assert_eq!(result.status.code(), Some(7));
692 let lines: Vec<&str> = result.stderr_output.lines().collect();
693 assert_eq!(
694 lines,
695 [
696 "-F",
697 "/tmp/__purple_test_cfg__",
698 "-o",
699 "ClearAllForwardings=yes",
700 "--",
701 "launcher-host"
702 ]
703 );
704 }
705
706 #[test]
707 fn connect_reports_missing_launcher_program() {
708 let launcher = SshLauncher::parse("/nonexistent/purple-test-launcher ssh").unwrap();
709 let err = connect(
710 "launcher-host",
711 Path::new("/tmp/__purple_test_cfg__"),
712 None,
713 None,
714 false,
715 &launcher,
716 )
717 .unwrap_err();
718 assert!(
719 err.to_string()
720 .contains("Failed to launch /nonexistent/purple-test-launcher connection"),
721 "unexpected error: {err}"
722 );
723 }
724
725 #[test]
726 fn tmux_window_args_wrap_launcher_words() {
727 let launcher = SshLauncher::parse("kitten ssh").unwrap();
728 let args = tmux_window_args(
729 "web1",
730 &launcher,
731 "/home/u/.ssh/config",
732 false,
733 "web1",
734 None,
735 );
736 assert_eq!(
737 args,
738 [
739 "new-window",
740 "-n",
741 "web1",
742 "--",
743 "kitten",
744 "ssh",
745 "-F",
746 "/home/u/.ssh/config",
747 "--",
748 "web1"
749 ]
750 );
751 let args = tmux_window_args(
752 "web1/app",
753 &SshLauncher::default(),
754 "/home/u/.ssh/config",
755 true,
756 "web1",
757 Some("docker exec -it abc sh"),
758 );
759 assert_eq!(
760 args,
761 [
762 "new-window",
763 "-n",
764 "web1/app",
765 "--",
766 "ssh",
767 "-F",
768 "/home/u/.ssh/config",
769 "-t",
770 "-o",
771 "ClearAllForwardings=yes",
772 "--",
773 "web1",
774 "docker exec -it abc sh"
775 ]
776 );
777 }
778
779 #[test]
780 fn connect_captures_stderr() {
781 let result = connect(
783 "nonexistent-host",
784 Path::new("/tmp/__purple_test_nonexistent_config__"),
785 None,
786 None,
787 false,
788 &SshLauncher::default(),
789 );
790 assert!(result.is_ok());
791 let r = result.unwrap();
794 assert!(
795 !r.stderr_output.is_empty() || !r.status.success(),
796 "SSH should produce stderr or fail"
797 );
798 }
799
800 #[test]
806 fn needs_password_true_for_password_method() {
807 assert!(needs_password(
808 "test@localhost: Permission denied (password).\n"
809 ));
810 }
811
812 #[test]
813 fn needs_password_true_when_password_is_one_of_several_methods() {
814 assert!(needs_password(
815 "test@localhost: Permission denied (publickey,password).\n"
816 ));
817 assert!(needs_password(
818 "test@localhost: Permission denied (publickey,keyboard-interactive).\n"
819 ));
820 }
821
822 #[test]
823 fn needs_password_false_for_publickey_only() {
824 assert!(!needs_password(
826 "test@localhost: Permission denied (publickey).\n"
827 ));
828 }
829
830 #[test]
831 fn needs_password_false_for_unrelated_errors() {
832 assert!(!needs_password(""));
833 assert!(!needs_password(
834 "ssh: connect to host example.com port 22: Connection refused\n"
835 ));
836 assert!(!needs_password("Permission denied, please try again.\n"));
837 }
838
839 #[test]
840 fn needs_password_survives_the_post_quantum_warning_prefix() {
841 let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
842 ** This session may be vulnerable to \"store now, decrypt later\" attacks.\n\
843 test@10.0.0.5: Permission denied (publickey,password).\n";
844 assert!(needs_password(stderr));
845 }
846
847 #[test]
848 fn is_unknown_host_key_true_for_strict_checking_refusal() {
849 let stderr = "No ED25519 host key is known for [localhost]:12222 and you have requested strict checking.\n\
850 Host key verification failed.\n";
851 assert!(is_unknown_host_key(stderr));
852 }
853
854 #[test]
855 fn is_unknown_host_key_false_for_changed_key_banner() {
856 let stderr = "\
859@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
860@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
861@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
862Offending ECDSA key in /Users/user/.ssh/known_hosts:55
863Host key for example.com has changed and you have requested strict checking.
864Host key verification failed.
865";
866 assert!(!is_unknown_host_key(stderr));
867 assert!(parse_host_key_error(stderr).is_some());
868 }
869
870 #[test]
871 fn is_unknown_host_key_false_for_plain_permission_denied() {
872 assert!(!is_unknown_host_key(
873 "test@localhost: Permission denied (password).\n"
874 ));
875 assert!(!is_unknown_host_key(""));
876 }
877
878 #[test]
885 fn denied_host_takes_the_host_and_drops_the_user() {
886 assert_eq!(
887 denied_host(
888 "nobodyuser@127.0.0.1: Permission denied (publickey,password,keyboard-interactive).\n"
889 ),
890 Some("127.0.0.1")
891 );
892 }
893
894 #[test]
895 fn denied_host_reads_a_line_carrying_no_user() {
896 assert_eq!(
897 denied_host("bastion.example.com: Permission denied (password).\n"),
898 Some("bastion.example.com")
899 );
900 }
901
902 #[test]
903 fn denied_host_survives_a_warning_prefix_and_trailing_lines() {
904 let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n\
905 ops@10.0.0.5: Permission denied (publickey,password).\n\
906 Connection closed by 10.0.0.5 port 22\n";
907 assert_eq!(denied_host(stderr), Some("10.0.0.5"));
908 }
909
910 #[test]
911 fn denied_host_takes_the_last_line_when_several_carry_the_prefix() {
912 let stderr = "ops@bastion.example.com: Permission denied (password).\n\
915 ops@target.example.com: Permission denied (password).\n";
916 assert_eq!(denied_host(stderr), Some("target.example.com"));
917 }
918
919 #[test]
920 fn denied_host_is_none_without_the_line() {
921 assert_eq!(denied_host(""), None);
922 assert_eq!(denied_host("Permission denied, please try again.\n"), None);
923 assert_eq!(
924 denied_host("ssh: connect to host x port 22: Connection refused\n"),
925 None
926 );
927 }
928
929 #[test]
930 fn unknown_host_key_host_keeps_the_bracketed_port_form() {
931 let stderr = "No ED25519 host key is known for [127.0.0.1]:12222 and you have requested strict checking.\n\
932 Host key verification failed.\n";
933 assert_eq!(unknown_host_key_host(stderr), Some("[127.0.0.1]:12222"));
934 }
935
936 #[test]
937 fn unknown_host_key_host_reads_a_default_port_host() {
938 let stderr =
939 "No RSA host key is known for db.example.com and you have requested strict checking.\n";
940 assert_eq!(unknown_host_key_host(stderr), Some("db.example.com"));
941 }
942
943 #[test]
944 fn unknown_host_key_host_is_none_for_other_failures() {
945 assert_eq!(unknown_host_key_host(""), None);
946 assert_eq!(
947 unknown_host_key_host("test@localhost: Permission denied (password).\n"),
948 None
949 );
950 }
951
952 #[test]
953 fn bare_host_strips_the_bracketed_port() {
954 assert_eq!(bare_host("[127.0.0.1]:12222"), "127.0.0.1");
955 assert_eq!(bare_host("[2001:db8::1]:2222"), "2001:db8::1");
956 assert_eq!(bare_host("db.example.com"), "db.example.com");
957 assert_eq!(bare_host(""), "");
958 }
959
960 #[test]
963 fn parse_host_key_error_detects_changed_key() {
964 let stderr = "\
965@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
966@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
967@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
968IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
969Someone could be eavesdropping on you right now (man-in-the-middle attack)!
970It is also possible that a host key has just been changed.
971The fingerprint for the ED25519 key sent by the remote host is
972SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
973Please contact your system administrator.
974Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
975Offending ECDSA key in /Users/user/.ssh/known_hosts:55
976Host key for example.com has changed and you have requested strict checking.
977Host key verification failed.
978";
979 let result = parse_host_key_error(stderr);
980 assert!(result.is_some());
981 let (hostname, path) = result.unwrap();
982 assert_eq!(hostname, "example.com");
983 assert_eq!(path, "/Users/user/.ssh/known_hosts");
984 }
985
986 #[test]
987 fn parse_host_key_error_returns_none_for_other_errors() {
988 let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
989 assert!(parse_host_key_error(stderr).is_none());
990 }
991
992 #[test]
993 fn parse_host_key_error_returns_none_for_empty() {
994 assert!(parse_host_key_error("").is_none());
995 }
996
997 #[test]
998 fn parse_host_key_error_handles_ip_address() {
999 let stderr = "\
1000Offending ECDSA key in /home/user/.ssh/known_hosts:12
1001Host key for 10.0.0.1 has changed and you have requested strict checking.
1002Host key verification failed.
1003";
1004 let result = parse_host_key_error(stderr);
1005 assert!(result.is_some());
1006 let (hostname, path) = result.unwrap();
1007 assert_eq!(hostname, "10.0.0.1");
1008 assert_eq!(path, "/home/user/.ssh/known_hosts");
1009 }
1010
1011 #[test]
1012 fn parse_host_key_error_handles_custom_known_hosts_path() {
1013 let stderr = "\
1014Offending RSA key in /etc/ssh/known_hosts:3
1015Host key for server.local has changed and you have requested strict checking.
1016Host key verification failed.
1017";
1018 let result = parse_host_key_error(stderr);
1019 assert!(result.is_some());
1020 let (hostname, path) = result.unwrap();
1021 assert_eq!(hostname, "server.local");
1022 assert_eq!(path, "/etc/ssh/known_hosts");
1023 }
1024
1025 #[test]
1026 fn parse_host_key_error_handles_ipv6() {
1027 let stderr = "\
1028Offending ED25519 key in /Users/user/.ssh/known_hosts:7
1029Host key for ::1 has changed and you have requested strict checking.
1030Host key verification failed.
1031";
1032 let result = parse_host_key_error(stderr);
1033 assert!(result.is_some());
1034 let (hostname, _) = result.unwrap();
1035 assert_eq!(hostname, "::1");
1036 }
1037
1038 #[test]
1039 fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
1040 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1045 if std::env::var("TMUX").is_ok() {
1046 return;
1047 }
1048 let result = connect_tmux_window(
1049 "test-host",
1050 Path::new("/tmp/__purple_test_nonexistent_config__"),
1051 false,
1052 &SshLauncher::default(),
1053 );
1054 assert!(result.is_err());
1055 let err = result.unwrap_err().to_string();
1056 assert!(
1057 err.contains("tmux") || err.contains("No such file"),
1058 "unexpected error: {err}"
1059 );
1060 }
1061
1062 #[test]
1063 fn connect_tmux_window_with_tunnel_does_not_panic() {
1064 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1068 if std::env::var("TMUX").is_ok() {
1069 return;
1070 }
1071 let result = connect_tmux_window(
1072 "tunnel-host",
1073 Path::new("/tmp/__purple_test_nonexistent_config__"),
1074 true,
1075 &SshLauncher::default(),
1076 );
1077 assert!(result.is_err());
1078 }
1079
1080 static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1082
1083 #[test]
1084 fn is_in_tmux_returns_true_when_set() {
1085 let env = crate::runtime::env::Env::for_test("/tmp/x")
1086 .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
1087 assert!(is_in_tmux(&env));
1088 }
1089
1090 #[test]
1091 fn is_in_tmux_returns_false_when_unset() {
1092 let env = crate::runtime::env::Env::for_test("/tmp/x");
1093 assert!(!is_in_tmux(&env));
1094 }
1095
1096 #[test]
1099 fn stderr_summary_joins_all_lines() {
1100 let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
1101 stdio forwarding failed\n\
1102 Connection closed by UNKNOWN port 65535\n";
1103 let result = stderr_summary(stderr);
1104 assert_eq!(
1105 result.as_deref(),
1106 Some(
1107 "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
1108 )
1109 );
1110 }
1111
1112 #[test]
1113 fn stderr_summary_skips_banner_lines() {
1114 let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1115 @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n\
1116 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
1117 IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
1118 let result = stderr_summary(stderr);
1119 assert_eq!(
1120 result.as_deref(),
1121 Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
1122 );
1123 }
1124
1125 #[test]
1126 fn stderr_summary_returns_none_for_empty() {
1127 assert!(stderr_summary("").is_none());
1128 assert!(stderr_summary(" \n \n").is_none());
1129 assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
1130 }
1131
1132 #[test]
1133 fn stderr_summary_truncates_long_output() {
1134 let long = "x".repeat(250);
1135 let result = stderr_summary(&long).unwrap();
1136 assert_eq!(result.len(), 200);
1137 assert!(result.ends_with("..."));
1138 }
1139
1140 #[test]
1141 fn stderr_summary_truncates_multibyte_safely() {
1142 let long = "日".repeat(100);
1144 let result = stderr_summary(&long).unwrap();
1145 assert!(result.ends_with("..."));
1146 assert!(result.len() <= 600); }
1149
1150 #[test]
1151 fn stderr_summary_simple_errors() {
1152 assert_eq!(
1153 stderr_summary("Connection refused\n").as_deref(),
1154 Some("Connection refused")
1155 );
1156 assert_eq!(
1157 stderr_summary("Permission denied (publickey).\n").as_deref(),
1158 Some("Permission denied (publickey).")
1159 );
1160 }
1161}