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!("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!("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!("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
132fn spawn_ssh_and_wait(mut cmd: Command, alias: &str, log_label: &str) -> Result<ConnectResult> {
142 cmd.stdin(std::process::Stdio::inherit())
143 .stdout(std::process::Stdio::inherit())
144 .stderr(std::process::Stdio::piped());
145
146 #[cfg(unix)]
150 unsafe {
151 use std::os::unix::process::CommandExt;
152 cmd.pre_exec(|| {
153 let mut mask: libc::sigset_t = std::mem::zeroed();
154 libc::sigemptyset(&mut mask);
155 libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
156 Ok(())
157 });
158 }
159
160 let mut child = cmd
161 .spawn()
162 .with_context(|| format!("Failed to launch ssh {} for '{}'", log_label, alias))?;
163
164 #[cfg(unix)]
168 let _signal_guard = SignalMaskGuard::block_interactive();
169
170 let stderr_pipe = child.stderr.take().expect("stderr was piped");
171 let stderr_thread = std::thread::spawn(move || {
172 use std::io::{Read, Write};
173 let mut captured = Vec::new();
174 let mut buf = [0u8; 4096];
175 let mut reader = stderr_pipe;
176 let mut stderr_out = std::io::stderr();
177 loop {
178 match reader.read(&mut buf) {
179 Ok(0) => break,
180 Ok(n) => {
181 let _ = stderr_out.write_all(&buf[..n]);
182 let _ = stderr_out.flush();
183 captured.extend_from_slice(&buf[..n]);
184 }
185 Err(_) => break,
186 }
187 }
188 String::from_utf8_lossy(&captured).to_string()
189 });
190
191 let status = child
192 .wait()
193 .with_context(|| format!("Failed to wait for ssh {} for '{}'", log_label, alias))?;
194 let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
195 warn!("[purple] Stderr capture thread panicked for {alias}");
196 String::new()
197 });
198
199 let code = status.code().unwrap_or(-1);
200 if code == 0 {
201 info!("SSH {} ended: {alias} (exit 0)", log_label);
202 } else {
203 error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
204 if !stderr_output.is_empty() {
205 let stderr = stderr_output.trim();
206 let lower = stderr.to_lowercase();
207 if lower.contains("are too open") || lower.contains("bad permissions") {
208 warn!("[config] SSH key permission issue: {stderr}");
209 } else {
210 debug!("[external] SSH stderr: {stderr}");
211 }
212 }
213 }
214
215 Ok(ConnectResult {
216 status,
217 stderr_output,
218 })
219}
220
221pub fn connect(
228 alias: &str,
229 config_path: &Path,
230 askpass: Option<&str>,
231 bw_session: Option<&str>,
232 has_active_tunnel: bool,
233) -> Result<ConnectResult> {
234 info!("SSH connection started: {alias}");
235 debug!("SSH command: ssh -F {} -- {alias}", config_path.display());
236
237 let mut cmd = Command::new("ssh");
238 cmd.arg("-F").arg(config_path);
239
240 if has_active_tunnel {
243 cmd.arg("-o").arg("ClearAllForwardings=yes");
244 }
245
246 cmd.arg("--").arg(alias);
247
248 if askpass.is_some() {
249 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
250 }
251
252 if let Some(token) = bw_session {
253 cmd.env("BW_SESSION", token);
254 }
255
256 spawn_ssh_and_wait(cmd, alias, "connection")
257}
258
259pub fn connect_with_remote_command(
272 alias: &str,
273 config_path: &Path,
274 askpass: Option<&str>,
275 bw_session: Option<&str>,
276 has_active_tunnel: bool,
277 remote_command: &str,
278) -> Result<ConnectResult> {
279 info!("SSH exec started: {alias}");
280 debug!(
281 "SSH command: ssh -F {} -t -- {alias} {}",
282 config_path.display(),
283 remote_command
284 );
285
286 crate::runtime::helpers::ensure_vault_cert_for_alias(
290 &crate::runtime::env::Env::from_process(),
291 alias,
292 config_path,
293 );
294
295 let mut cmd = Command::new("ssh");
296 cmd.arg("-F").arg(config_path).arg("-t");
297
298 if has_active_tunnel {
299 cmd.arg("-o").arg("ClearAllForwardings=yes");
300 }
301
302 cmd.arg("--").arg(alias).arg(remote_command);
303
304 if askpass.is_some() {
305 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
306 }
307
308 if let Some(token) = bw_session {
309 cmd.env("BW_SESSION", token);
310 }
311
312 spawn_ssh_and_wait(cmd, alias, "exec")
313}
314
315pub fn connect_tmux_window_with_remote_command(
320 alias: &str,
321 config_path: &Path,
322 has_active_tunnel: bool,
323 remote_command: &str,
324 window_label: &str,
325) -> Result<()> {
326 info!("SSH exec via tmux: {alias}");
327
328 crate::runtime::helpers::ensure_vault_cert_for_alias(
332 &crate::runtime::env::Env::from_process(),
333 alias,
334 config_path,
335 );
336
337 let config_str = config_path
338 .to_str()
339 .context("SSH config path is not valid UTF-8")?;
340
341 let mut args = vec![
342 "new-window",
343 "-n",
344 window_label,
345 "--",
346 "ssh",
347 "-F",
348 config_str,
349 "-t",
350 ];
351
352 if has_active_tunnel {
353 args.extend(["-o", "ClearAllForwardings=yes"]);
354 }
355
356 args.extend(["--", alias, remote_command]);
357
358 debug!("tmux exec args: {:?}", args);
359
360 let status = Command::new("tmux")
361 .args(&args)
362 .status()
363 .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
364
365 if status.success() {
366 info!("tmux exec window created: {alias}");
367 Ok(())
368 } else {
369 let code = status.code().unwrap_or(-1);
370 error!("[external] tmux exec window failed for {alias} (exit {code})");
371 anyhow::bail!("tmux new-window exited with code {code}")
372 }
373}
374
375pub fn stderr_summary(stderr: &str) -> Option<String> {
379 let summary: String = stderr
380 .lines()
381 .map(str::trim)
382 .filter(|l| !l.is_empty() && !l.starts_with('@'))
383 .collect::<Vec<_>>()
384 .join(" | ");
385 if summary.is_empty() {
386 return None;
387 }
388 if summary.len() > 200 {
389 let truncated: String = summary.chars().take(197).collect();
390 Some(format!("{truncated}..."))
391 } else {
392 Some(summary)
393 }
394}
395
396pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
406 let has_english_error = stderr.contains("Host key verification failed.");
408 let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
410
411 if !has_english_error && !has_banner {
412 return None;
413 }
414
415 let hostname = stderr
417 .lines()
418 .find(|l| l.contains("Host key for") && l.contains("has changed"))
419 .and_then(|l| {
420 let start = l.find("Host key for ")? + "Host key for ".len();
421 let rest = &l[start..];
422 let end = rest.find(" has changed")?;
423 Some(rest[..end].to_string())
424 });
425
426 let known_hosts_path = stderr
428 .lines()
429 .find(|l| l.starts_with("Offending") && l.contains(" key in "))
430 .and_then(|l| {
431 let start = l.find(" key in ")? + " key in ".len();
432 let rest = &l[start..];
433 let end = rest.rfind(':')?;
434 Some(rest[..end].to_string())
435 });
436
437 let known_hosts_path = known_hosts_path?;
439
440 let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
445
446 Some((hostname, known_hosts_path))
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn connect_fails_with_nonexistent_config() {
455 let result = connect(
457 "nonexistent-host",
458 Path::new("/tmp/__purple_test_nonexistent_config__"),
459 None,
460 None,
461 false,
462 );
463 assert!(result.is_ok()); let r = result.unwrap();
466 assert!(!r.status.success());
467 }
468
469 #[test]
470 fn connect_with_tunnel_flag_does_not_panic() {
471 let result = connect(
473 "nonexistent-host",
474 Path::new("/tmp/__purple_test_nonexistent_config__"),
475 None,
476 None,
477 true,
478 );
479 assert!(result.is_ok());
480 assert!(!result.unwrap().status.success());
481 }
482
483 #[test]
484 fn connect_captures_stderr() {
485 let result = connect(
487 "nonexistent-host",
488 Path::new("/tmp/__purple_test_nonexistent_config__"),
489 None,
490 None,
491 false,
492 );
493 assert!(result.is_ok());
494 let r = result.unwrap();
497 assert!(
498 !r.stderr_output.is_empty() || !r.status.success(),
499 "SSH should produce stderr or fail"
500 );
501 }
502
503 #[test]
506 fn parse_host_key_error_detects_changed_key() {
507 let stderr = "\
508@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
509@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
510@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
511IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
512Someone could be eavesdropping on you right now (man-in-the-middle attack)!
513It is also possible that a host key has just been changed.
514The fingerprint for the ED25519 key sent by the remote host is
515SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
516Please contact your system administrator.
517Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
518Offending ECDSA key in /Users/user/.ssh/known_hosts:55
519Host key for example.com has changed and you have requested strict checking.
520Host key verification failed.
521";
522 let result = parse_host_key_error(stderr);
523 assert!(result.is_some());
524 let (hostname, path) = result.unwrap();
525 assert_eq!(hostname, "example.com");
526 assert_eq!(path, "/Users/user/.ssh/known_hosts");
527 }
528
529 #[test]
530 fn parse_host_key_error_returns_none_for_other_errors() {
531 let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
532 assert!(parse_host_key_error(stderr).is_none());
533 }
534
535 #[test]
536 fn parse_host_key_error_returns_none_for_empty() {
537 assert!(parse_host_key_error("").is_none());
538 }
539
540 #[test]
541 fn parse_host_key_error_handles_ip_address() {
542 let stderr = "\
543Offending ECDSA key in /home/user/.ssh/known_hosts:12
544Host key for 10.0.0.1 has changed and you have requested strict checking.
545Host key verification failed.
546";
547 let result = parse_host_key_error(stderr);
548 assert!(result.is_some());
549 let (hostname, path) = result.unwrap();
550 assert_eq!(hostname, "10.0.0.1");
551 assert_eq!(path, "/home/user/.ssh/known_hosts");
552 }
553
554 #[test]
555 fn parse_host_key_error_handles_custom_known_hosts_path() {
556 let stderr = "\
557Offending RSA key in /etc/ssh/known_hosts:3
558Host key for server.local has changed and you have requested strict checking.
559Host key verification failed.
560";
561 let result = parse_host_key_error(stderr);
562 assert!(result.is_some());
563 let (hostname, path) = result.unwrap();
564 assert_eq!(hostname, "server.local");
565 assert_eq!(path, "/etc/ssh/known_hosts");
566 }
567
568 #[test]
569 fn parse_host_key_error_handles_ipv6() {
570 let stderr = "\
571Offending ED25519 key in /Users/user/.ssh/known_hosts:7
572Host key for ::1 has changed and you have requested strict checking.
573Host key verification failed.
574";
575 let result = parse_host_key_error(stderr);
576 assert!(result.is_some());
577 let (hostname, _) = result.unwrap();
578 assert_eq!(hostname, "::1");
579 }
580
581 #[test]
582 fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
583 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
588 if std::env::var("TMUX").is_ok() {
589 return;
590 }
591 let result = connect_tmux_window(
592 "test-host",
593 Path::new("/tmp/__purple_test_nonexistent_config__"),
594 false,
595 );
596 assert!(result.is_err());
597 let err = result.unwrap_err().to_string();
598 assert!(
599 err.contains("tmux") || err.contains("No such file"),
600 "unexpected error: {err}"
601 );
602 }
603
604 #[test]
605 fn connect_tmux_window_with_tunnel_does_not_panic() {
606 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
610 if std::env::var("TMUX").is_ok() {
611 return;
612 }
613 let result = connect_tmux_window(
614 "tunnel-host",
615 Path::new("/tmp/__purple_test_nonexistent_config__"),
616 true,
617 );
618 assert!(result.is_err());
619 }
620
621 static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
623
624 #[test]
625 fn is_in_tmux_returns_true_when_set() {
626 let env = crate::runtime::env::Env::for_test("/tmp/x")
627 .with_var("TMUX", "/tmp/tmux-1000/default,12345,0");
628 assert!(is_in_tmux(&env));
629 }
630
631 #[test]
632 fn is_in_tmux_returns_false_when_unset() {
633 let env = crate::runtime::env::Env::for_test("/tmp/x");
634 assert!(!is_in_tmux(&env));
635 }
636
637 #[test]
640 fn stderr_summary_joins_all_lines() {
641 let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
642 stdio forwarding failed\n\
643 Connection closed by UNKNOWN port 65535\n";
644 let result = stderr_summary(stderr);
645 assert_eq!(
646 result.as_deref(),
647 Some(
648 "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
649 )
650 );
651 }
652
653 #[test]
654 fn stderr_summary_skips_banner_lines() {
655 let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
656 @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n\
657 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
658 IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
659 let result = stderr_summary(stderr);
660 assert_eq!(
661 result.as_deref(),
662 Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
663 );
664 }
665
666 #[test]
667 fn stderr_summary_returns_none_for_empty() {
668 assert!(stderr_summary("").is_none());
669 assert!(stderr_summary(" \n \n").is_none());
670 assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
671 }
672
673 #[test]
674 fn stderr_summary_truncates_long_output() {
675 let long = "x".repeat(250);
676 let result = stderr_summary(&long).unwrap();
677 assert_eq!(result.len(), 200);
678 assert!(result.ends_with("..."));
679 }
680
681 #[test]
682 fn stderr_summary_truncates_multibyte_safely() {
683 let long = "日".repeat(100);
685 let result = stderr_summary(&long).unwrap();
686 assert!(result.ends_with("..."));
687 assert!(result.len() <= 600); }
690
691 #[test]
692 fn stderr_summary_simple_errors() {
693 assert_eq!(
694 stderr_summary("Connection refused\n").as_deref(),
695 Some("Connection refused")
696 );
697 assert_eq!(
698 stderr_summary("Permission denied (publickey).\n").as_deref(),
699 Some("Permission denied (publickey).")
700 );
701 }
702}