1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use log::{debug, error, info, warn};
6
7pub struct ConnectResult {
9 pub status: std::process::ExitStatus,
10 pub stderr_output: String,
11}
12
13#[cfg(unix)]
17pub fn is_in_tmux(env: &crate::runtime::env::Env) -> bool {
18 env.in_tmux()
19}
20
21#[cfg(not(unix))]
23pub fn is_in_tmux(_env: &crate::runtime::env::Env) -> bool {
24 false
25}
26
27pub fn connect_tmux_window(alias: &str, config_path: &Path, has_active_tunnel: bool) -> Result<()> {
39 info!("[external] SSH connection via tmux: {alias}");
40
41 let config_str = config_path
42 .to_str()
43 .context("SSH config path is not valid UTF-8")?;
44
45 let mut args = vec!["new-window", "-n", alias, "--", "ssh", "-F", config_str];
46
47 if has_active_tunnel {
48 args.extend(["-o", "ClearAllForwardings=yes"]);
49 }
50
51 args.extend(["--", alias]);
52
53 debug!("[external] tmux args: {:?}", args);
54
55 let status = Command::new("tmux")
56 .args(&args)
57 .status()
58 .with_context(|| format!("Failed to launch tmux new-window for '{alias}'"))?;
59
60 if status.success() {
61 info!("[external] tmux window created: {alias}");
62 Ok(())
63 } else {
64 let code = status.code().unwrap_or(-1);
65 error!("[external] tmux new-window failed for {alias} (exit {code})");
66 anyhow::bail!("tmux new-window exited with code {code}")
67 }
68}
69
70#[cfg(unix)]
73struct SignalMaskGuard {
74 old: libc::sigset_t,
75}
76
77#[cfg(unix)]
78impl SignalMaskGuard {
79 fn block_interactive() -> Self {
81 unsafe {
86 let mut old: libc::sigset_t = std::mem::zeroed();
87 let mut mask: libc::sigset_t = std::mem::zeroed();
88 libc::sigemptyset(&mut mask);
89 libc::sigaddset(&mut mask, libc::SIGINT);
90 libc::sigaddset(&mut mask, libc::SIGTSTP);
91 libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old);
92 Self { old }
93 }
94 }
95}
96
97#[cfg(unix)]
98impl Drop for SignalMaskGuard {
99 fn drop(&mut self) {
100 unsafe {
106 let mut pending: libc::sigset_t = std::mem::zeroed();
110 libc::sigpending(&mut pending);
111 let has_sigint = libc::sigismember(&pending, libc::SIGINT) == 1;
112 let has_sigtstp = libc::sigismember(&pending, libc::SIGTSTP) == 1;
113 if has_sigint {
115 libc::signal(libc::SIGINT, libc::SIG_IGN);
116 }
117 if has_sigtstp {
118 libc::signal(libc::SIGTSTP, libc::SIG_IGN);
119 }
120 libc::sigprocmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut());
121 if has_sigint {
123 libc::signal(libc::SIGINT, libc::SIG_DFL);
124 }
125 if has_sigtstp {
126 libc::signal(libc::SIGTSTP, libc::SIG_DFL);
127 }
128 }
129 }
130}
131
132pub(crate) fn is_ssh_transport_failure(code: i32) -> bool {
145 code == 255
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub(crate) enum SshExitClass {
153 Success,
155 TransportFailure,
157 RemoteStatus,
159}
160
161pub(crate) fn classify_ssh_exit(code: i32) -> SshExitClass {
162 if code == 0 {
163 SshExitClass::Success
164 } else if is_ssh_transport_failure(code) {
165 SshExitClass::TransportFailure
166 } else {
167 SshExitClass::RemoteStatus
168 }
169}
170
171pub(crate) fn remote_exit_log_level(code: i32) -> log::Level {
177 match classify_ssh_exit(code) {
178 SshExitClass::TransportFailure => log::Level::Error,
179 SshExitClass::Success | SshExitClass::RemoteStatus => log::Level::Debug,
180 }
181}
182
183fn spawn_ssh_and_wait(mut cmd: Command, alias: &str, log_label: &str) -> Result<ConnectResult> {
184 cmd.stdin(std::process::Stdio::inherit())
185 .stdout(std::process::Stdio::inherit())
186 .stderr(std::process::Stdio::piped());
187
188 #[cfg(unix)]
192 unsafe {
193 use std::os::unix::process::CommandExt;
194 cmd.pre_exec(|| {
195 let mut mask: libc::sigset_t = std::mem::zeroed();
196 libc::sigemptyset(&mut mask);
197 libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
198 Ok(())
199 });
200 }
201
202 let mut child = cmd
203 .spawn()
204 .with_context(|| format!("Failed to launch ssh {} for '{}'", log_label, alias))?;
205
206 #[cfg(unix)]
210 let _signal_guard = SignalMaskGuard::block_interactive();
211
212 let stderr_pipe = child.stderr.take().expect("stderr was piped");
213 let stderr_thread = std::thread::spawn(move || {
214 use std::io::{Read, Write};
215 let mut captured = Vec::new();
216 let mut buf = [0u8; 4096];
217 let mut reader = stderr_pipe;
218 let mut stderr_out = std::io::stderr();
219 loop {
220 match reader.read(&mut buf) {
221 Ok(0) => break,
222 Ok(n) => {
223 let _ = stderr_out.write_all(&buf[..n]);
224 let _ = stderr_out.flush();
225 captured.extend_from_slice(&buf[..n]);
226 }
227 Err(_) => break,
228 }
229 }
230 String::from_utf8_lossy(&captured).to_string()
231 });
232
233 let status = child
234 .wait()
235 .with_context(|| format!("Failed to wait for ssh {} for '{}'", log_label, alias))?;
236 let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
237 warn!("[purple] Stderr capture thread panicked for {alias}");
238 String::new()
239 });
240
241 let code = status.code().unwrap_or(-1);
242 match classify_ssh_exit(code) {
243 SshExitClass::Success => {
244 info!("[external] SSH {} ended: {alias} (exit 0)", log_label);
245 }
246 SshExitClass::TransportFailure => {
247 error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
248 if !stderr_output.is_empty() {
249 let stderr = stderr_output.trim();
250 let lower = stderr.to_lowercase();
251 if lower.contains("are too open") || lower.contains("bad permissions") {
252 warn!("[config] SSH key permission issue: {stderr}");
253 } else {
254 debug!("[external] SSH stderr: {stderr}");
255 }
256 }
257 }
258 SshExitClass::RemoteStatus => {
259 debug!("[external] SSH {} ended: {alias} (exit {code})", log_label);
262 }
263 }
264
265 Ok(ConnectResult {
266 status,
267 stderr_output,
268 })
269}
270
271pub fn connect(
278 alias: &str,
279 config_path: &Path,
280 askpass: Option<&str>,
281 bw_session: Option<&str>,
282 has_active_tunnel: bool,
283) -> Result<ConnectResult> {
284 info!("[external] SSH connection started: {alias}");
285 debug!(
286 "[external] SSH command: ssh -F {} -- {alias}",
287 config_path.display()
288 );
289
290 let mut cmd = Command::new("ssh");
291 cmd.arg("-F").arg(config_path);
292
293 if has_active_tunnel {
296 cmd.arg("-o").arg("ClearAllForwardings=yes");
297 }
298
299 cmd.arg("--").arg(alias);
300
301 if askpass.is_some() {
302 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
303 }
304
305 if let Some(token) = bw_session {
306 cmd.env("BW_SESSION", token);
307 }
308
309 spawn_ssh_and_wait(cmd, alias, "connection")
310}
311
312pub fn connect_with_remote_command(
325 alias: &str,
326 config_path: &Path,
327 env: &crate::runtime::env::Env,
328 askpass: Option<&str>,
329 bw_session: Option<&str>,
330 has_active_tunnel: bool,
331 remote_command: &str,
332) -> Result<ConnectResult> {
333 info!("[external] SSH exec started: {alias}");
334 debug!(
335 "[external] SSH command: ssh -F {} -t -- {alias} {}",
336 config_path.display(),
337 remote_command
338 );
339
340 crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
344
345 let mut cmd = Command::new("ssh");
346 cmd.arg("-F").arg(config_path).arg("-t");
347
348 if has_active_tunnel {
349 cmd.arg("-o").arg("ClearAllForwardings=yes");
350 }
351
352 cmd.arg("--").arg(alias).arg(remote_command);
353
354 if askpass.is_some() {
355 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
356 }
357
358 if let Some(token) = bw_session {
359 cmd.env("BW_SESSION", token);
360 }
361
362 spawn_ssh_and_wait(cmd, alias, "exec")
363}
364
365pub fn connect_tmux_window_with_remote_command(
370 alias: &str,
371 config_path: &Path,
372 env: &crate::runtime::env::Env,
373 has_active_tunnel: bool,
374 remote_command: &str,
375 window_label: &str,
376) -> Result<()> {
377 info!("[external] SSH exec via tmux: {alias}");
378
379 crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
383
384 let config_str = config_path
385 .to_str()
386 .context("SSH config path is not valid UTF-8")?;
387
388 let mut args = vec![
389 "new-window",
390 "-n",
391 window_label,
392 "--",
393 "ssh",
394 "-F",
395 config_str,
396 "-t",
397 ];
398
399 if has_active_tunnel {
400 args.extend(["-o", "ClearAllForwardings=yes"]);
401 }
402
403 args.extend(["--", alias, remote_command]);
404
405 debug!("[external] tmux exec args: {:?}", args);
406
407 let status = Command::new("tmux")
408 .args(&args)
409 .status()
410 .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
411
412 if status.success() {
413 info!("[external] tmux exec window created: {alias}");
414 Ok(())
415 } else {
416 let code = status.code().unwrap_or(-1);
417 error!("[external] tmux exec window failed for {alias} (exit {code})");
418 anyhow::bail!("tmux new-window exited with code {code}")
419 }
420}
421
422pub fn stderr_summary(stderr: &str) -> Option<String> {
426 let summary: String = stderr
427 .lines()
428 .map(str::trim)
429 .filter(|l| !l.is_empty() && !l.starts_with('@'))
430 .collect::<Vec<_>>()
431 .join(" | ");
432 if summary.is_empty() {
433 return None;
434 }
435 if summary.len() > 200 {
436 let truncated: String = summary.chars().take(197).collect();
437 Some(format!("{truncated}..."))
438 } else {
439 Some(summary)
440 }
441}
442
443pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
453 let has_english_error = stderr.contains("Host key verification failed.");
455 let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
457
458 if !has_english_error && !has_banner {
459 return None;
460 }
461
462 let hostname = stderr
464 .lines()
465 .find(|l| l.contains("Host key for") && l.contains("has changed"))
466 .and_then(|l| {
467 let start = l.find("Host key for ")? + "Host key for ".len();
468 let rest = &l[start..];
469 let end = rest.find(" has changed")?;
470 Some(rest[..end].to_string())
471 });
472
473 let known_hosts_path = stderr
475 .lines()
476 .find(|l| l.starts_with("Offending") && l.contains(" key in "))
477 .and_then(|l| {
478 let start = l.find(" key in ")? + " key in ".len();
479 let rest = &l[start..];
480 let end = rest.rfind(':')?;
481 Some(rest[..end].to_string())
482 });
483
484 let known_hosts_path = known_hosts_path?;
486
487 let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
492
493 Some((hostname, known_hosts_path))
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn transport_failure_is_only_code_255() {
502 assert!(is_ssh_transport_failure(255));
503 assert!(!is_ssh_transport_failure(0));
505 assert!(!is_ssh_transport_failure(1));
506 assert!(!is_ssh_transport_failure(2));
507 assert!(!is_ssh_transport_failure(254));
508 assert!(!is_ssh_transport_failure(-1));
509 }
510
511 #[test]
512 fn classify_ssh_exit_routes_only_255_to_error() {
513 assert_eq!(classify_ssh_exit(0), SshExitClass::Success);
516 assert_eq!(classify_ssh_exit(255), SshExitClass::TransportFailure);
517 assert_eq!(classify_ssh_exit(1), SshExitClass::RemoteStatus);
519 assert_eq!(classify_ssh_exit(2), SshExitClass::RemoteStatus);
520 assert_eq!(classify_ssh_exit(126), SshExitClass::RemoteStatus);
521 assert_eq!(classify_ssh_exit(127), SshExitClass::RemoteStatus);
522 assert_eq!(classify_ssh_exit(254), SshExitClass::RemoteStatus);
523 assert_eq!(classify_ssh_exit(-1), SshExitClass::RemoteStatus);
525 }
526
527 #[test]
528 fn remote_exit_log_level_reserves_error_for_transport_failure() {
529 assert_eq!(remote_exit_log_level(255), log::Level::Error);
534 for code in [0, 1, 2, 126, 127, 130, 254, -1] {
535 assert_eq!(
536 remote_exit_log_level(code),
537 log::Level::Debug,
538 "exit {code} must not log at error level"
539 );
540 }
541 }
542
543 #[test]
544 fn connect_fails_with_nonexistent_config() {
545 let result = connect(
547 "nonexistent-host",
548 Path::new("/tmp/__purple_test_nonexistent_config__"),
549 None,
550 None,
551 false,
552 );
553 assert!(result.is_ok()); let r = result.unwrap();
556 assert!(!r.status.success());
557 }
558
559 #[test]
560 fn connect_with_tunnel_flag_does_not_panic() {
561 let result = connect(
563 "nonexistent-host",
564 Path::new("/tmp/__purple_test_nonexistent_config__"),
565 None,
566 None,
567 true,
568 );
569 assert!(result.is_ok());
570 assert!(!result.unwrap().status.success());
571 }
572
573 #[test]
574 fn connect_captures_stderr() {
575 let result = connect(
577 "nonexistent-host",
578 Path::new("/tmp/__purple_test_nonexistent_config__"),
579 None,
580 None,
581 false,
582 );
583 assert!(result.is_ok());
584 let r = result.unwrap();
587 assert!(
588 !r.stderr_output.is_empty() || !r.status.success(),
589 "SSH should produce stderr or fail"
590 );
591 }
592
593 #[test]
596 fn parse_host_key_error_detects_changed_key() {
597 let stderr = "\
598@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
599@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
600@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
601IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
602Someone could be eavesdropping on you right now (man-in-the-middle attack)!
603It is also possible that a host key has just been changed.
604The fingerprint for the ED25519 key sent by the remote host is
605SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
606Please contact your system administrator.
607Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
608Offending ECDSA key in /Users/user/.ssh/known_hosts:55
609Host key for example.com has changed and you have requested strict checking.
610Host key verification failed.
611";
612 let result = parse_host_key_error(stderr);
613 assert!(result.is_some());
614 let (hostname, path) = result.unwrap();
615 assert_eq!(hostname, "example.com");
616 assert_eq!(path, "/Users/user/.ssh/known_hosts");
617 }
618
619 #[test]
620 fn parse_host_key_error_returns_none_for_other_errors() {
621 let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
622 assert!(parse_host_key_error(stderr).is_none());
623 }
624
625 #[test]
626 fn parse_host_key_error_returns_none_for_empty() {
627 assert!(parse_host_key_error("").is_none());
628 }
629
630 #[test]
631 fn parse_host_key_error_handles_ip_address() {
632 let stderr = "\
633Offending ECDSA key in /home/user/.ssh/known_hosts:12
634Host key for 10.0.0.1 has changed and you have requested strict checking.
635Host key verification failed.
636";
637 let result = parse_host_key_error(stderr);
638 assert!(result.is_some());
639 let (hostname, path) = result.unwrap();
640 assert_eq!(hostname, "10.0.0.1");
641 assert_eq!(path, "/home/user/.ssh/known_hosts");
642 }
643
644 #[test]
645 fn parse_host_key_error_handles_custom_known_hosts_path() {
646 let stderr = "\
647Offending RSA key in /etc/ssh/known_hosts:3
648Host key for server.local has changed and you have requested strict checking.
649Host key verification failed.
650";
651 let result = parse_host_key_error(stderr);
652 assert!(result.is_some());
653 let (hostname, path) = result.unwrap();
654 assert_eq!(hostname, "server.local");
655 assert_eq!(path, "/etc/ssh/known_hosts");
656 }
657
658 #[test]
659 fn parse_host_key_error_handles_ipv6() {
660 let stderr = "\
661Offending ED25519 key in /Users/user/.ssh/known_hosts:7
662Host key for ::1 has changed and you have requested strict checking.
663Host key verification failed.
664";
665 let result = parse_host_key_error(stderr);
666 assert!(result.is_some());
667 let (hostname, _) = result.unwrap();
668 assert_eq!(hostname, "::1");
669 }
670
671 #[test]
672 fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
673 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
678 if std::env::var("TMUX").is_ok() {
679 return;
680 }
681 let result = connect_tmux_window(
682 "test-host",
683 Path::new("/tmp/__purple_test_nonexistent_config__"),
684 false,
685 );
686 assert!(result.is_err());
687 let err = result.unwrap_err().to_string();
688 assert!(
689 err.contains("tmux") || err.contains("No such file"),
690 "unexpected error: {err}"
691 );
692 }
693
694 #[test]
695 fn connect_tmux_window_with_tunnel_does_not_panic() {
696 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
700 if std::env::var("TMUX").is_ok() {
701 return;
702 }
703 let result = connect_tmux_window(
704 "tunnel-host",
705 Path::new("/tmp/__purple_test_nonexistent_config__"),
706 true,
707 );
708 assert!(result.is_err());
709 }
710
711 static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
713
714 #[test]
715 fn is_in_tmux_returns_true_when_set() {
716 let env = crate::runtime::env::Env::for_test("/tmp/x")
717 .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
718 assert!(is_in_tmux(&env));
719 }
720
721 #[test]
722 fn is_in_tmux_returns_false_when_unset() {
723 let env = crate::runtime::env::Env::for_test("/tmp/x");
724 assert!(!is_in_tmux(&env));
725 }
726
727 #[test]
730 fn stderr_summary_joins_all_lines() {
731 let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
732 stdio forwarding failed\n\
733 Connection closed by UNKNOWN port 65535\n";
734 let result = stderr_summary(stderr);
735 assert_eq!(
736 result.as_deref(),
737 Some(
738 "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
739 )
740 );
741 }
742
743 #[test]
744 fn stderr_summary_skips_banner_lines() {
745 let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
746 @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n\
747 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
748 IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
749 let result = stderr_summary(stderr);
750 assert_eq!(
751 result.as_deref(),
752 Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
753 );
754 }
755
756 #[test]
757 fn stderr_summary_returns_none_for_empty() {
758 assert!(stderr_summary("").is_none());
759 assert!(stderr_summary(" \n \n").is_none());
760 assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
761 }
762
763 #[test]
764 fn stderr_summary_truncates_long_output() {
765 let long = "x".repeat(250);
766 let result = stderr_summary(&long).unwrap();
767 assert_eq!(result.len(), 200);
768 assert!(result.ends_with("..."));
769 }
770
771 #[test]
772 fn stderr_summary_truncates_multibyte_safely() {
773 let long = "日".repeat(100);
775 let result = stderr_summary(&long).unwrap();
776 assert!(result.ends_with("..."));
777 assert!(result.len() <= 600); }
780
781 #[test]
782 fn stderr_summary_simple_errors() {
783 assert_eq!(
784 stderr_summary("Connection refused\n").as_deref(),
785 Some("Connection refused")
786 );
787 assert_eq!(
788 stderr_summary("Permission denied (publickey).\n").as_deref(),
789 Some("Permission denied (publickey).")
790 );
791 }
792}