use anyhow::{Context, Result};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct JumpHost {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
impl JumpHost {
pub fn new(host: String, user: Option<String>, port: Option<u16>) -> Self {
Self { user, host, port }
}
pub fn effective_user(&self) -> String {
self.user.clone().unwrap_or_else(whoami::username)
}
pub fn effective_port(&self) -> u16 {
self.port.unwrap_or(22)
}
pub fn to_connection_string(&self) -> String {
match (&self.user, &self.port) {
(Some(user), Some(port)) => format!("{}@{}:{}", user, self.host, port),
(Some(user), None) => format!("{}@{}", user, self.host),
(None, Some(port)) => format!("{}:{}", self.host, port),
(None, None) => self.host.clone(),
}
}
}
impl fmt::Display for JumpHost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_connection_string())
}
}
pub fn parse_jump_hosts(jump_spec: &str) -> Result<Vec<JumpHost>> {
if jump_spec.trim().is_empty() {
return Ok(Vec::new());
}
let mut jump_hosts = Vec::new();
for host_spec in jump_spec.split(',') {
let host_spec = host_spec.trim();
if host_spec.is_empty() {
continue;
}
let jump_host = parse_single_jump_host(host_spec)
.with_context(|| format!("Failed to parse jump host specification: '{host_spec}'"))?;
jump_hosts.push(jump_host);
}
if jump_hosts.is_empty() {
anyhow::bail!(
"No valid jump hosts found in specification: '{}'",
jump_spec
);
}
Ok(jump_hosts)
}
fn parse_single_jump_host(host_spec: &str) -> Result<JumpHost> {
if host_spec.is_empty() {
anyhow::bail!("Empty jump host specification");
}
let parts: Vec<&str> = host_spec.splitn(2, '@').collect();
let (user, host_port) = if parts.len() == 2 {
(Some(parts[0].to_string()), parts[1])
} else {
(None, parts[0])
};
let user = if let Some(username) = user {
Some(crate::utils::sanitize_username(&username).with_context(|| {
format!("Invalid username in jump host specification: '{host_spec}'")
})?)
} else {
None
};
let (host, port) = parse_host_port(host_port)
.with_context(|| format!("Invalid host:port specification: '{host_port}'"))?;
let host = crate::utils::sanitize_hostname(&host)
.with_context(|| format!("Invalid hostname in jump host specification: '{host}'"))?;
Ok(JumpHost::new(host, user, port))
}
fn parse_host_port(host_port: &str) -> Result<(String, Option<u16>)> {
if host_port.is_empty() {
anyhow::bail!("Empty host specification");
}
if host_port.starts_with('[') {
if let Some(bracket_end) = host_port.find(']') {
let ipv6_addr = &host_port[1..bracket_end];
if ipv6_addr.is_empty() {
anyhow::bail!("Empty IPv6 address in brackets");
}
let remaining = &host_port[bracket_end + 1..];
if remaining.is_empty() {
return Ok((ipv6_addr.to_string(), None));
} else if let Some(port_str) = remaining.strip_prefix(':') {
if port_str.is_empty() {
anyhow::bail!("Empty port specification after IPv6 address");
}
let port = port_str
.parse::<u16>()
.with_context(|| format!("Invalid port number: '{port_str}'"))?;
if port == 0 {
anyhow::bail!("Port number cannot be zero");
}
return Ok((ipv6_addr.to_string(), Some(port)));
} else {
anyhow::bail!("Invalid characters after IPv6 address: '{}'", remaining);
}
} else {
anyhow::bail!("Unclosed bracket in IPv6 address");
}
}
if let Some(colon_pos) = host_port.rfind(':') {
let host_part = &host_port[..colon_pos];
let port_part = &host_port[colon_pos + 1..];
if host_part.is_empty() {
anyhow::bail!("Empty hostname");
}
if port_part.is_empty() {
anyhow::bail!("Empty port specification");
}
match port_part.parse::<u16>() {
Ok(port) => {
if port == 0 {
anyhow::bail!("Port number cannot be zero");
}
Ok((host_part.to_string(), Some(port)))
}
Err(e) => {
if port_part.chars().all(|c| c.is_ascii_digit()) {
anyhow::bail!("Invalid port number: '{}' ({})", port_part, e);
} else {
Ok((host_port.to_string(), None))
}
}
}
} else {
Ok((host_port.to_string(), None))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_jump_host_hostname_only() {
let result = parse_single_jump_host("example.com").unwrap();
assert_eq!(result.host, "example.com");
assert_eq!(result.user, None);
assert_eq!(result.port, None);
}
#[test]
fn test_parse_single_jump_host_with_user() {
let result = parse_single_jump_host("admin@example.com").unwrap();
assert_eq!(result.host, "example.com");
assert_eq!(result.user, Some("admin".to_string()));
assert_eq!(result.port, None);
}
#[test]
fn test_parse_single_jump_host_with_port() {
let result = parse_single_jump_host("example.com:2222").unwrap();
assert_eq!(result.host, "example.com");
assert_eq!(result.user, None);
assert_eq!(result.port, Some(2222));
}
#[test]
fn test_parse_single_jump_host_with_user_and_port() {
let result = parse_single_jump_host("admin@example.com:2222").unwrap();
assert_eq!(result.host, "example.com");
assert_eq!(result.user, Some("admin".to_string()));
assert_eq!(result.port, Some(2222));
}
#[test]
fn test_parse_single_jump_host_ipv6_brackets() {
let result = parse_single_jump_host("[::1]").unwrap();
assert_eq!(result.host, "::1");
assert_eq!(result.user, None);
assert_eq!(result.port, None);
}
#[test]
fn test_parse_single_jump_host_ipv6_with_port() {
let result = parse_single_jump_host("[::1]:2222").unwrap();
assert_eq!(result.host, "::1");
assert_eq!(result.user, None);
assert_eq!(result.port, Some(2222));
}
#[test]
fn test_parse_single_jump_host_ipv6_with_user_and_port() {
let result = parse_single_jump_host("admin@[::1]:2222").unwrap();
assert_eq!(result.host, "::1");
assert_eq!(result.user, Some("admin".to_string()));
assert_eq!(result.port, Some(2222));
}
#[test]
fn test_parse_jump_hosts_multiple() {
let result = parse_jump_hosts("jump1@host1,user@host2:2222,host3").unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].host, "host1");
assert_eq!(result[0].user, Some("jump1".to_string()));
assert_eq!(result[0].port, None);
assert_eq!(result[1].host, "host2");
assert_eq!(result[1].user, Some("user".to_string()));
assert_eq!(result[1].port, Some(2222));
assert_eq!(result[2].host, "host3");
assert_eq!(result[2].user, None);
assert_eq!(result[2].port, None);
}
#[test]
fn test_parse_jump_hosts_whitespace_handling() {
let result = parse_jump_hosts(" host1 , user@host2:2222 , host3 ").unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].host, "host1");
assert_eq!(result[1].host, "host2");
assert_eq!(result[2].host, "host3");
}
#[test]
fn test_parse_jump_hosts_empty_string() {
let result = parse_jump_hosts("").unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_parse_jump_hosts_only_commas() {
let result = parse_jump_hosts(",,");
assert!(result.is_err()); }
#[test]
fn test_parse_single_jump_host_errors() {
assert!(parse_single_jump_host("").is_err());
assert!(parse_single_jump_host("@host").is_err());
assert!(parse_single_jump_host("user@").is_err());
assert!(parse_single_jump_host("host:").is_err());
assert!(parse_single_jump_host("host:0").is_err());
assert!(parse_single_jump_host("host:99999").is_err());
assert!(parse_single_jump_host("[::1").is_err());
assert!(parse_single_jump_host("[]").is_err());
}
#[test]
fn test_jump_host_display() {
let host = JumpHost::new("example.com".to_string(), None, None);
assert_eq!(format!("{host}"), "example.com");
let host = JumpHost::new("example.com".to_string(), Some("user".to_string()), None);
assert_eq!(format!("{host}"), "user@example.com");
let host = JumpHost::new("example.com".to_string(), None, Some(2222));
assert_eq!(format!("{host}"), "example.com:2222");
let host = JumpHost::new(
"example.com".to_string(),
Some("user".to_string()),
Some(2222),
);
assert_eq!(format!("{host}"), "user@example.com:2222");
}
#[test]
fn test_jump_host_effective_values() {
let host = JumpHost::new("example.com".to_string(), None, None);
assert_eq!(host.effective_port(), 22);
assert!(!host.effective_user().is_empty());
let host = JumpHost::new(
"example.com".to_string(),
Some("testuser".to_string()),
Some(2222),
);
assert_eq!(host.effective_port(), 2222);
assert_eq!(host.effective_user(), "testuser");
}
}