use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use log::{debug, error, info, warn};
pub struct ConnectResult {
pub status: std::process::ExitStatus,
pub stderr_output: String,
}
#[cfg(unix)]
pub fn is_in_tmux() -> bool {
std::env::var("TMUX").is_ok()
}
#[cfg(not(unix))]
pub fn is_in_tmux() -> bool {
false
}
pub fn connect_tmux_window(alias: &str, config_path: &Path, has_active_tunnel: bool) -> Result<()> {
info!("SSH connection via tmux: {alias}");
let config_str = config_path
.to_str()
.context("SSH config path is not valid UTF-8")?;
let mut args = vec!["new-window", "-n", alias, "--", "ssh", "-F", config_str];
if has_active_tunnel {
args.extend(["-o", "ClearAllForwardings=yes"]);
}
args.extend(["--", alias]);
debug!("tmux args: {:?}", args);
let status = Command::new("tmux")
.args(&args)
.status()
.with_context(|| format!("Failed to launch tmux new-window for '{alias}'"))?;
if status.success() {
info!("tmux window created: {alias}");
Ok(())
} else {
let code = status.code().unwrap_or(-1);
error!("tmux new-window failed for {alias} (exit {code})");
anyhow::bail!("tmux new-window exited with code {code}")
}
}
#[cfg(unix)]
struct SignalMaskGuard {
old: libc::sigset_t,
}
#[cfg(unix)]
impl SignalMaskGuard {
fn block_interactive() -> Self {
unsafe {
let mut old: libc::sigset_t = std::mem::zeroed();
let mut mask: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut mask);
libc::sigaddset(&mut mask, libc::SIGINT);
libc::sigaddset(&mut mask, libc::SIGTSTP);
libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old);
Self { old }
}
}
}
#[cfg(unix)]
impl Drop for SignalMaskGuard {
fn drop(&mut self) {
unsafe {
let mut pending: libc::sigset_t = std::mem::zeroed();
libc::sigpending(&mut pending);
let has_sigint = libc::sigismember(&pending, libc::SIGINT) == 1;
let has_sigtstp = libc::sigismember(&pending, libc::SIGTSTP) == 1;
if has_sigint {
libc::signal(libc::SIGINT, libc::SIG_IGN);
}
if has_sigtstp {
libc::signal(libc::SIGTSTP, libc::SIG_IGN);
}
libc::sigprocmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut());
if has_sigint {
libc::signal(libc::SIGINT, libc::SIG_DFL);
}
if has_sigtstp {
libc::signal(libc::SIGTSTP, libc::SIG_DFL);
}
}
}
}
pub fn connect(
alias: &str,
config_path: &Path,
askpass: Option<&str>,
bw_session: Option<&str>,
has_active_tunnel: bool,
) -> Result<ConnectResult> {
info!("SSH connection started: {alias}");
debug!("SSH command: ssh -F {} -- {alias}", config_path.display());
let mut cmd = Command::new("ssh");
cmd.arg("-F").arg(config_path);
if has_active_tunnel {
cmd.arg("-o").arg("ClearAllForwardings=yes");
}
cmd.arg("--")
.arg(alias)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::piped());
if askpass.is_some() {
crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
}
if let Some(token) = bw_session {
cmd.env("BW_SESSION", token);
}
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let mut mask: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut mask);
libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
Ok(())
});
}
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to launch ssh for '{}'", alias))?;
#[cfg(unix)]
let _signal_guard = SignalMaskGuard::block_interactive();
let stderr_pipe = child.stderr.take().expect("stderr was piped");
let stderr_thread = std::thread::spawn(move || {
use std::io::{Read, Write};
let mut captured = Vec::new();
let mut buf = [0u8; 4096];
let mut reader = stderr_pipe;
let mut stderr_out = std::io::stderr();
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = stderr_out.write_all(&buf[..n]);
let _ = stderr_out.flush();
captured.extend_from_slice(&buf[..n]);
}
Err(_) => break,
}
}
String::from_utf8_lossy(&captured).to_string()
});
let status = child
.wait()
.with_context(|| format!("Failed to wait for ssh for '{}'", alias))?;
let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
warn!("[purple] Stderr capture thread panicked for {alias}");
String::new()
});
let code = status.code().unwrap_or(-1);
if code == 0 {
info!("SSH connection ended: {alias} (exit 0)");
} else {
error!("[external] SSH connection failed: {alias} (exit {code})");
if !stderr_output.is_empty() {
let stderr = stderr_output.trim();
let lower = stderr.to_lowercase();
if lower.contains("are too open") || lower.contains("bad permissions") {
warn!("[config] SSH key permission issue: {stderr}");
} else {
debug!("[external] SSH stderr: {stderr}");
}
}
}
Ok(ConnectResult {
status,
stderr_output,
})
}
pub fn stderr_summary(stderr: &str) -> Option<String> {
let summary: String = stderr
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('@'))
.collect::<Vec<_>>()
.join(" | ");
if summary.is_empty() {
return None;
}
if summary.len() > 200 {
let truncated: String = summary.chars().take(197).collect();
Some(format!("{truncated}..."))
} else {
Some(summary)
}
}
pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
let has_english_error = stderr.contains("Host key verification failed.");
let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
if !has_english_error && !has_banner {
return None;
}
let hostname = stderr
.lines()
.find(|l| l.contains("Host key for") && l.contains("has changed"))
.and_then(|l| {
let start = l.find("Host key for ")? + "Host key for ".len();
let rest = &l[start..];
let end = rest.find(" has changed")?;
Some(rest[..end].to_string())
});
let known_hosts_path = stderr
.lines()
.find(|l| l.starts_with("Offending") && l.contains(" key in "))
.and_then(|l| {
let start = l.find(" key in ")? + " key in ".len();
let rest = &l[start..];
let end = rest.rfind(':')?;
Some(rest[..end].to_string())
});
let known_hosts_path = known_hosts_path?;
let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
Some((hostname, known_hosts_path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_fails_with_nonexistent_config() {
let result = connect(
"nonexistent-host",
Path::new("/tmp/__purple_test_nonexistent_config__"),
None,
None,
false,
);
assert!(result.is_ok()); let r = result.unwrap();
assert!(!r.status.success());
}
#[test]
fn connect_with_tunnel_flag_does_not_panic() {
let result = connect(
"nonexistent-host",
Path::new("/tmp/__purple_test_nonexistent_config__"),
None,
None,
true,
);
assert!(result.is_ok());
assert!(!result.unwrap().status.success());
}
#[test]
fn connect_captures_stderr() {
let result = connect(
"nonexistent-host",
Path::new("/tmp/__purple_test_nonexistent_config__"),
None,
None,
false,
);
assert!(result.is_ok());
let r = result.unwrap();
assert!(
!r.stderr_output.is_empty() || !r.status.success(),
"SSH should produce stderr or fail"
);
}
#[test]
fn parse_host_key_error_detects_changed_key() {
let stderr = "\
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
Please contact your system administrator.
Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
Offending ECDSA key in /Users/user/.ssh/known_hosts:55
Host key for example.com has changed and you have requested strict checking.
Host key verification failed.
";
let result = parse_host_key_error(stderr);
assert!(result.is_some());
let (hostname, path) = result.unwrap();
assert_eq!(hostname, "example.com");
assert_eq!(path, "/Users/user/.ssh/known_hosts");
}
#[test]
fn parse_host_key_error_returns_none_for_other_errors() {
let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
assert!(parse_host_key_error(stderr).is_none());
}
#[test]
fn parse_host_key_error_returns_none_for_empty() {
assert!(parse_host_key_error("").is_none());
}
#[test]
fn parse_host_key_error_handles_ip_address() {
let stderr = "\
Offending ECDSA key in /home/user/.ssh/known_hosts:12
Host key for 10.0.0.1 has changed and you have requested strict checking.
Host key verification failed.
";
let result = parse_host_key_error(stderr);
assert!(result.is_some());
let (hostname, path) = result.unwrap();
assert_eq!(hostname, "10.0.0.1");
assert_eq!(path, "/home/user/.ssh/known_hosts");
}
#[test]
fn parse_host_key_error_handles_custom_known_hosts_path() {
let stderr = "\
Offending RSA key in /etc/ssh/known_hosts:3
Host key for server.local has changed and you have requested strict checking.
Host key verification failed.
";
let result = parse_host_key_error(stderr);
assert!(result.is_some());
let (hostname, path) = result.unwrap();
assert_eq!(hostname, "server.local");
assert_eq!(path, "/etc/ssh/known_hosts");
}
#[test]
fn parse_host_key_error_handles_ipv6() {
let stderr = "\
Offending ED25519 key in /Users/user/.ssh/known_hosts:7
Host key for ::1 has changed and you have requested strict checking.
Host key verification failed.
";
let result = parse_host_key_error(stderr);
assert!(result.is_some());
let (hostname, _) = result.unwrap();
assert_eq!(hostname, "::1");
}
#[test]
fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
if std::env::var("TMUX").is_ok() {
return;
}
let result = connect_tmux_window(
"test-host",
Path::new("/tmp/__purple_test_nonexistent_config__"),
false,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("tmux") || err.contains("No such file"),
"unexpected error: {err}"
);
}
#[test]
fn connect_tmux_window_with_tunnel_does_not_panic() {
let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
if std::env::var("TMUX").is_ok() {
return;
}
let result = connect_tmux_window(
"tunnel-host",
Path::new("/tmp/__purple_test_nonexistent_config__"),
true,
);
assert!(result.is_err());
}
static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn is_in_tmux_returns_true_when_set() {
let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var("TMUX").ok();
unsafe { std::env::set_var("TMUX", "/tmp/tmux-1000/default,12345,0") };
let result = is_in_tmux();
match prev {
Some(v) => unsafe { std::env::set_var("TMUX", v) },
None => unsafe { std::env::remove_var("TMUX") },
}
assert!(result);
}
#[test]
fn is_in_tmux_returns_false_when_unset() {
let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var("TMUX").ok();
unsafe { std::env::remove_var("TMUX") };
let result = is_in_tmux();
if let Some(v) = prev {
unsafe { std::env::set_var("TMUX", v) };
}
assert!(!result);
}
#[test]
fn stderr_summary_joins_all_lines() {
let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
stdio forwarding failed\n\
Connection closed by UNKNOWN port 65535\n";
let result = stderr_summary(stderr);
assert_eq!(
result.as_deref(),
Some(
"channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
)
);
}
#[test]
fn stderr_summary_skips_banner_lines() {
let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n\
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
let result = stderr_summary(stderr);
assert_eq!(
result.as_deref(),
Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
);
}
#[test]
fn stderr_summary_returns_none_for_empty() {
assert!(stderr_summary("").is_none());
assert!(stderr_summary(" \n \n").is_none());
assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
}
#[test]
fn stderr_summary_truncates_long_output() {
let long = "x".repeat(250);
let result = stderr_summary(&long).unwrap();
assert_eq!(result.len(), 200);
assert!(result.ends_with("..."));
}
#[test]
fn stderr_summary_truncates_multibyte_safely() {
let long = "日".repeat(100);
let result = stderr_summary(&long).unwrap();
assert!(result.ends_with("..."));
assert!(result.len() <= 600); }
#[test]
fn stderr_summary_simple_errors() {
assert_eq!(
stderr_summary("Connection refused\n").as_deref(),
Some("Connection refused")
);
assert_eq!(
stderr_summary("Permission denied (publickey).\n").as_deref(),
Some("Permission denied (publickey).")
);
}
}