use std::process::{Child, Command, Stdio};
use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TunnelType {
Local,
Remote,
Dynamic,
}
impl TunnelType {
pub fn label(self) -> &'static str {
match self {
TunnelType::Local => "Local",
TunnelType::Remote => "Remote",
TunnelType::Dynamic => "Dynamic",
}
}
pub fn directive_key(self) -> &'static str {
match self {
TunnelType::Local => "LocalForward",
TunnelType::Remote => "RemoteForward",
TunnelType::Dynamic => "DynamicForward",
}
}
pub fn next(self) -> Self {
match self {
TunnelType::Local => TunnelType::Remote,
TunnelType::Remote => TunnelType::Dynamic,
TunnelType::Dynamic => TunnelType::Local,
}
}
pub fn prev(self) -> Self {
match self {
TunnelType::Local => TunnelType::Dynamic,
TunnelType::Remote => TunnelType::Local,
TunnelType::Dynamic => TunnelType::Remote,
}
}
pub fn from_directive_key(key: &str) -> Option<Self> {
if key.eq_ignore_ascii_case("localforward") {
Some(TunnelType::Local)
} else if key.eq_ignore_ascii_case("remoteforward") {
Some(TunnelType::Remote)
} else if key.eq_ignore_ascii_case("dynamicforward") {
Some(TunnelType::Dynamic)
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TunnelRule {
pub tunnel_type: TunnelType,
pub bind_address: String,
pub bind_port: u16,
pub remote_host: String,
pub remote_port: u16,
}
impl TunnelRule {
pub fn parse_value(key: &str, value: &str) -> Option<Self> {
let tunnel_type = TunnelType::from_directive_key(key)?;
let value = value.trim();
match tunnel_type {
TunnelType::Local | TunnelType::Remote => {
Self::parse_forward_value(tunnel_type, value)
}
TunnelType::Dynamic => Self::parse_dynamic_value(value),
}
}
fn parse_forward_value(tunnel_type: TunnelType, value: &str) -> Option<Self> {
let (bind_part, remote_part) = value.split_once(char::is_whitespace)?;
let remote_part = remote_part.trim();
let (bind_address, bind_port) = Self::parse_bind(bind_part)?;
let (remote_host, remote_port) = Self::parse_host_port(remote_part)?;
Some(TunnelRule {
tunnel_type,
bind_address,
bind_port,
remote_host,
remote_port,
})
}
fn parse_dynamic_value(value: &str) -> Option<Self> {
let (bind_address, bind_port) = Self::parse_bind(value)?;
Some(TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address,
bind_port,
remote_host: String::new(),
remote_port: 0,
})
}
fn parse_bind(s: &str) -> Option<(String, u16)> {
if let Some(rest) = s.strip_prefix('[') {
let bracket_end = rest.find(']')?;
let addr = &rest[..bracket_end];
let after = &rest[bracket_end + 1..];
let port_str = after.strip_prefix(':')?;
let port: u16 = port_str.parse().ok()?;
return Some((addr.to_string(), port));
}
if let Ok(port) = s.parse::<u16>() {
return Some((String::new(), port));
}
let colon = s.rfind(':')?;
let addr = &s[..colon];
let port: u16 = s[colon + 1..].parse().ok()?;
Some((addr.to_string(), port))
}
fn parse_host_port(s: &str) -> Option<(String, u16)> {
if let Some(rest) = s.strip_prefix('[') {
let bracket_end = rest.find(']')?;
let host = &rest[..bracket_end];
let after = &rest[bracket_end + 1..];
let port_str = after.strip_prefix(':')?;
let port: u16 = port_str.parse().ok()?;
return Some((host.to_string(), port));
}
let colon = s.rfind(':')?;
let host = &s[..colon];
let port: u16 = s[colon + 1..].parse().ok()?;
Some((host.to_string(), port))
}
fn format_addr_port(addr: &str, port: u16) -> String {
if addr.contains(':') {
format!("[{}]:{}", addr, port)
} else {
format!("{}:{}", addr, port)
}
}
pub fn to_directive_value(&self) -> String {
match self.tunnel_type {
TunnelType::Local | TunnelType::Remote => {
let bind = if self.bind_address.is_empty() {
self.bind_port.to_string()
} else {
Self::format_addr_port(&self.bind_address, self.bind_port)
};
let remote = Self::format_addr_port(&self.remote_host, self.remote_port);
format!("{} {}", bind, remote)
}
TunnelType::Dynamic => {
if self.bind_address.is_empty() {
self.bind_port.to_string()
} else {
Self::format_addr_port(&self.bind_address, self.bind_port)
}
}
}
}
pub fn display(&self) -> String {
let bind = if self.bind_address.is_empty() {
format!("{}", self.bind_port)
} else {
Self::format_addr_port(&self.bind_address, self.bind_port)
};
match self.tunnel_type {
TunnelType::Local | TunnelType::Remote => {
let remote = Self::format_addr_port(&self.remote_host, self.remote_port);
format!(
"{:<8} {:<6} {}",
self.tunnel_type.label(),
bind,
remote
)
}
TunnelType::Dynamic => {
format!("{:<8} {:<6} (SOCKS proxy)", self.tunnel_type.label(), bind)
}
}
}
pub fn from_cli_spec(spec: &str) -> Result<Self, String> {
let (type_char, rest) = spec.split_once(':').ok_or("Invalid format. Use L:port:host:port or D:port.")?;
let tunnel_type = match type_char {
"L" | "l" => TunnelType::Local,
"R" | "r" => TunnelType::Remote,
"D" | "d" => TunnelType::Dynamic,
_ => return Err(format!("Unknown tunnel type '{}'. Use L (local), R (remote) or D (dynamic).", type_char)),
};
match tunnel_type {
TunnelType::Dynamic => {
let port: u16 = rest.parse().map_err(|_| "Invalid port for dynamic forward.")?;
if port == 0 {
return Err("Bind port can't be 0.".to_string());
}
Ok(TunnelRule {
tunnel_type,
bind_address: String::new(),
bind_port: port,
remote_host: String::new(),
remote_port: 0,
})
}
TunnelType::Local | TunnelType::Remote => {
let (bind_str, host_port) = rest.split_once(':')
.ok_or("Invalid format. Use L:bind_port:host:port.")?;
let bind_port: u16 = bind_str.parse().map_err(|_| "Invalid bind port.")?;
if bind_port == 0 {
return Err("Bind port can't be 0.".to_string());
}
let (remote_host, remote_port) = Self::parse_host_port(host_port)
.ok_or("Invalid remote host:port. Use host:port or [IPv6]:port.")?;
if remote_host.is_empty() {
return Err("Remote host can't be empty.".to_string());
}
if remote_host.contains(char::is_whitespace) {
return Err("Remote host can't contain spaces.".to_string());
}
if remote_port == 0 {
return Err("Remote port can't be 0.".to_string());
}
Ok(TunnelRule {
tunnel_type,
bind_address: String::new(),
bind_port,
remote_host: remote_host.to_string(),
remote_port,
})
}
}
}
}
pub struct ActiveTunnel {
pub child: Child,
}
pub fn start_tunnel(alias: &str, config_path: &std::path::Path, askpass: Option<&str>) -> Result<Child> {
let mut cmd = Command::new("ssh");
cmd.arg("-F")
.arg(config_path)
.arg("-N")
.arg("--")
.arg(alias)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
if askpass.is_some() {
let exe = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().to_string())
.or_else(|| std::env::args().next())
.unwrap_or_else(|| "purple".to_string());
cmd.env("SSH_ASKPASS", &exe)
.env("SSH_ASKPASS_REQUIRE", "prefer")
.env("PURPLE_ASKPASS_MODE", "1")
.env("PURPLE_HOST_ALIAS", alias)
.env("PURPLE_CONFIG_PATH", config_path.as_os_str());
}
cmd.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start tunnel: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tunnel_type_from_directive_key() {
assert_eq!(TunnelType::from_directive_key("LocalForward"), Some(TunnelType::Local));
assert_eq!(TunnelType::from_directive_key("localforward"), Some(TunnelType::Local));
assert_eq!(TunnelType::from_directive_key("RemoteForward"), Some(TunnelType::Remote));
assert_eq!(TunnelType::from_directive_key("DynamicForward"), Some(TunnelType::Dynamic));
assert_eq!(TunnelType::from_directive_key("HostName"), None);
}
#[test]
fn tunnel_type_cycle() {
assert_eq!(TunnelType::Local.next(), TunnelType::Remote);
assert_eq!(TunnelType::Remote.next(), TunnelType::Dynamic);
assert_eq!(TunnelType::Dynamic.next(), TunnelType::Local);
assert_eq!(TunnelType::Local.prev(), TunnelType::Dynamic);
}
#[test]
fn parse_local_forward_port_only() {
let rule = TunnelRule::parse_value("LocalForward", "8080 localhost:80").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Local);
assert_eq!(rule.bind_address, "");
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "localhost");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn parse_local_forward_with_bind_address() {
let rule = TunnelRule::parse_value("LocalForward", "127.0.0.1:8080 localhost:80").unwrap();
assert_eq!(rule.bind_address, "127.0.0.1");
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "localhost");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn parse_remote_forward() {
let rule = TunnelRule::parse_value("RemoteForward", "9090 localhost:3000").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Remote);
assert_eq!(rule.bind_port, 9090);
assert_eq!(rule.remote_host, "localhost");
assert_eq!(rule.remote_port, 3000);
}
#[test]
fn parse_dynamic_forward_port_only() {
let rule = TunnelRule::parse_value("DynamicForward", "1080").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Dynamic);
assert_eq!(rule.bind_address, "");
assert_eq!(rule.bind_port, 1080);
assert_eq!(rule.remote_host, "");
assert_eq!(rule.remote_port, 0);
}
#[test]
fn parse_dynamic_forward_with_bind_address() {
let rule = TunnelRule::parse_value("DynamicForward", "127.0.0.1:1080").unwrap();
assert_eq!(rule.bind_address, "127.0.0.1");
assert_eq!(rule.bind_port, 1080);
}
#[test]
fn parse_unknown_directive_returns_none() {
assert!(TunnelRule::parse_value("HostName", "example.com").is_none());
}
#[test]
fn parse_invalid_value_returns_none() {
assert!(TunnelRule::parse_value("LocalForward", "not_a_number").is_none());
assert!(TunnelRule::parse_value("LocalForward", "").is_none());
}
#[test]
fn parse_ipv6_bind_address() {
let rule = TunnelRule::parse_value("LocalForward", "[::1]:8080 localhost:80").unwrap();
assert_eq!(rule.bind_address, "::1");
assert_eq!(rule.bind_port, 8080);
}
#[test]
fn parse_high_port_numbers() {
let rule = TunnelRule::parse_value("LocalForward", "65535 localhost:65535").unwrap();
assert_eq!(rule.bind_port, 65535);
assert_eq!(rule.remote_port, 65535);
}
#[test]
fn to_directive_value_local() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: String::new(),
bind_port: 8080,
remote_host: "localhost".to_string(),
remote_port: 80,
};
assert_eq!(rule.to_directive_value(), "8080 localhost:80");
}
#[test]
fn to_directive_value_local_with_bind() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: "127.0.0.1".to_string(),
bind_port: 8080,
remote_host: "localhost".to_string(),
remote_port: 80,
};
assert_eq!(rule.to_directive_value(), "127.0.0.1:8080 localhost:80");
}
#[test]
fn to_directive_value_dynamic() {
let rule = TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address: String::new(),
bind_port: 1080,
remote_host: String::new(),
remote_port: 0,
};
assert_eq!(rule.to_directive_value(), "1080");
}
#[test]
fn roundtrip_local_forward() {
let original = "8080 localhost:80";
let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_local_forward_with_bind() {
let original = "127.0.0.1:8080 localhost:80";
let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_dynamic_forward() {
let original = "1080";
let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn from_cli_spec_local() {
let rule = TunnelRule::from_cli_spec("L:8080:localhost:80").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Local);
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "localhost");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn from_cli_spec_remote() {
let rule = TunnelRule::from_cli_spec("R:9090:localhost:3000").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Remote);
assert_eq!(rule.bind_port, 9090);
}
#[test]
fn from_cli_spec_dynamic() {
let rule = TunnelRule::from_cli_spec("D:1080").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Dynamic);
assert_eq!(rule.bind_port, 1080);
}
#[test]
fn from_cli_spec_lowercase() {
let rule = TunnelRule::from_cli_spec("l:8080:localhost:80").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Local);
}
#[test]
fn from_cli_spec_invalid() {
assert!(TunnelRule::from_cli_spec("X:8080").is_err());
assert!(TunnelRule::from_cli_spec("L:abc:localhost:80").is_err());
assert!(TunnelRule::from_cli_spec("garbage").is_err());
}
#[test]
fn display_local() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: String::new(),
bind_port: 8080,
remote_host: "localhost".to_string(),
remote_port: 80,
};
let d = rule.display();
assert!(d.contains("Local"));
assert!(d.contains("8080"));
assert!(d.contains("localhost:80"));
}
#[test]
fn display_dynamic() {
let rule = TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address: String::new(),
bind_port: 1080,
remote_host: String::new(),
remote_port: 0,
};
let d = rule.display();
assert!(d.contains("Dynamic"));
assert!(d.contains("SOCKS proxy"));
}
#[test]
fn to_directive_value_ipv6_bind() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: "::1".to_string(),
bind_port: 8080,
remote_host: "localhost".to_string(),
remote_port: 80,
};
assert_eq!(rule.to_directive_value(), "[::1]:8080 localhost:80");
}
#[test]
fn to_directive_value_ipv6_remote() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: String::new(),
bind_port: 8080,
remote_host: "fe80::1".to_string(),
remote_port: 80,
};
assert_eq!(rule.to_directive_value(), "8080 [fe80::1]:80");
}
#[test]
fn to_directive_value_ipv6_both() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: "::1".to_string(),
bind_port: 8080,
remote_host: "::1".to_string(),
remote_port: 80,
};
assert_eq!(rule.to_directive_value(), "[::1]:8080 [::1]:80");
}
#[test]
fn roundtrip_ipv6_bind() {
let original = "[::1]:8080 localhost:80";
let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
assert_eq!(rule.bind_address, "::1");
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_ipv6_remote() {
let original = "8080 [fe80::1]:80";
let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
assert_eq!(rule.remote_host, "fe80::1");
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_ipv6_both() {
let original = "[::1]:8080 [::1]:80";
let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_ipv6_dynamic() {
let original = "[::1]:1080";
let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
assert_eq!(rule.bind_address, "::1");
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn to_directive_value_ipv6_dynamic() {
let rule = TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address: "::1".to_string(),
bind_port: 1080,
remote_host: String::new(),
remote_port: 0,
};
assert_eq!(rule.to_directive_value(), "[::1]:1080");
}
#[test]
fn display_ipv6_brackets() {
let rule = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: "::1".to_string(),
bind_port: 8080,
remote_host: "::1".to_string(),
remote_port: 80,
};
let d = rule.display();
assert!(d.contains("[::1]:8080"));
assert!(d.contains("[::1]:80"));
}
#[test]
fn parse_port_1_minimum() {
let rule = TunnelRule::parse_value("LocalForward", "1 localhost:1").unwrap();
assert_eq!(rule.bind_port, 1);
assert_eq!(rule.remote_port, 1);
}
#[test]
fn parse_port_0_accepted() {
let rule = TunnelRule::parse_value("DynamicForward", "0");
assert!(rule.is_some());
}
#[test]
fn parse_port_65536_rejected() {
assert!(TunnelRule::parse_value("DynamicForward", "65536").is_none());
}
#[test]
fn parse_port_negative_rejected() {
assert!(TunnelRule::parse_value("DynamicForward", "-1").is_none());
}
#[test]
fn parse_multiple_spaces_between_parts() {
let rule = TunnelRule::parse_value("LocalForward", "8080 localhost:80").unwrap();
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "localhost");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn parse_tab_between_parts() {
let rule = TunnelRule::parse_value("LocalForward", "8080\tlocalhost:80").unwrap();
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "localhost");
}
#[test]
fn parse_leading_trailing_whitespace() {
let rule = TunnelRule::parse_value("LocalForward", " 8080 localhost:80 ").unwrap();
assert_eq!(rule.bind_port, 8080);
}
#[test]
fn parse_empty_string() {
assert!(TunnelRule::parse_value("LocalForward", "").is_none());
}
#[test]
fn parse_single_word() {
assert!(TunnelRule::parse_value("LocalForward", "garbage").is_none());
}
#[test]
fn parse_missing_remote_port() {
assert!(TunnelRule::parse_value("LocalForward", "8080 localhost").is_none());
}
#[test]
fn parse_missing_remote_host() {
let rule = TunnelRule::parse_value("LocalForward", "8080 :80").unwrap();
assert_eq!(rule.remote_host, "");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn parse_empty_brackets() {
let rule = TunnelRule::parse_value("LocalForward", "[]:8080 localhost:80").unwrap();
assert_eq!(rule.bind_address, "");
}
#[test]
fn parse_mismatched_bracket() {
assert!(TunnelRule::parse_value("LocalForward", "[::1:8080 localhost:80").is_none());
}
#[test]
fn from_cli_spec_empty_bind_port() {
assert!(TunnelRule::from_cli_spec("L::localhost:80").is_err());
}
#[test]
fn from_cli_spec_extra_colons() {
assert!(TunnelRule::from_cli_spec("R:8080:host:port:extra").is_err());
}
#[test]
fn from_cli_spec_dynamic_non_numeric() {
assert!(TunnelRule::from_cli_spec("D:abc").is_err());
}
#[test]
fn from_cli_spec_no_colons() {
assert!(TunnelRule::from_cli_spec("L8080").is_err());
}
#[test]
fn from_cli_spec_missing_parts() {
assert!(TunnelRule::from_cli_spec("L:8080").is_err());
assert!(TunnelRule::from_cli_spec("L:8080:localhost").is_err());
}
#[test]
fn from_cli_spec_empty_remote_host() {
assert!(TunnelRule::from_cli_spec("L:8080::80").is_err());
assert!(TunnelRule::from_cli_spec("R:9090::3000").is_err());
}
#[test]
fn roundtrip_remote_forward() {
let original = "9090 localhost:3000";
let rule = TunnelRule::parse_value("RemoteForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_remote_forward_with_bind() {
let original = "0.0.0.0:9090 localhost:3000";
let rule = TunnelRule::parse_value("RemoteForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn roundtrip_dynamic_with_bind() {
let original = "127.0.0.1:1080";
let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
assert_eq!(rule.to_directive_value(), original);
}
#[test]
fn from_cli_spec_local_ipv6_remote() {
let rule = TunnelRule::from_cli_spec("L:8080:[::1]:80").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Local);
assert_eq!(rule.bind_port, 8080);
assert_eq!(rule.remote_host, "::1");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn from_cli_spec_remote_ipv6_remote() {
let rule = TunnelRule::from_cli_spec("R:9090:[fe80::1]:3000").unwrap();
assert_eq!(rule.tunnel_type, TunnelType::Remote);
assert_eq!(rule.bind_port, 9090);
assert_eq!(rule.remote_host, "fe80::1");
assert_eq!(rule.remote_port, 3000);
}
#[test]
fn from_cli_spec_bind_port_0_rejected() {
assert!(TunnelRule::from_cli_spec("L:0:localhost:80").is_err());
assert!(TunnelRule::from_cli_spec("R:0:localhost:80").is_err());
assert!(TunnelRule::from_cli_spec("D:0").is_err());
}
#[test]
fn from_cli_spec_remote_port_0_rejected() {
assert!(TunnelRule::from_cli_spec("L:8080:localhost:0").is_err());
assert!(TunnelRule::from_cli_spec("R:9090:localhost:0").is_err());
}
#[test]
fn from_cli_spec_dynamic_empty_port() {
assert!(TunnelRule::from_cli_spec("D:").is_err());
}
#[test]
fn from_cli_spec_dynamic_trailing_content() {
assert!(TunnelRule::from_cli_spec("D:1080:extra").is_err());
}
#[test]
fn from_cli_spec_port_overflow() {
assert!(TunnelRule::from_cli_spec("L:65536:localhost:80").is_err());
assert!(TunnelRule::from_cli_spec("D:65536").is_err());
}
#[test]
fn from_cli_spec_multi_char_type() {
assert!(TunnelRule::from_cli_spec("LOCAL:8080:localhost:80").is_err());
}
#[test]
fn from_cli_spec_bare_ipv6_remote() {
let rule = TunnelRule::from_cli_spec("L:8080:::1:80").unwrap();
assert_eq!(rule.remote_host, "::1");
assert_eq!(rule.remote_port, 80);
}
#[test]
fn from_cli_spec_error_unknown_type_message() {
let err = TunnelRule::from_cli_spec("X:8080:localhost:80").unwrap_err();
assert!(err.contains("Unknown tunnel type"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_no_colon_message() {
let err = TunnelRule::from_cli_spec("L8080").unwrap_err();
assert!(err.contains("Invalid format"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_bind_port_0_message() {
let err = TunnelRule::from_cli_spec("L:0:localhost:80").unwrap_err();
assert!(err.contains("0"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_remote_port_0_message() {
let err = TunnelRule::from_cli_spec("L:8080:localhost:0").unwrap_err();
assert!(err.contains("0"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_whitespace_in_remote_host() {
let err = TunnelRule::from_cli_spec("L:8080:local host:80").unwrap_err();
assert!(err.contains("spaces"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_empty_remote_host_message() {
let err = TunnelRule::from_cli_spec("L:8080::80").unwrap_err();
assert!(err.contains("empty"), "got: {}", err);
}
#[test]
fn from_cli_spec_error_dynamic_invalid_port_message() {
let err = TunnelRule::from_cli_spec("D:abc").unwrap_err();
assert!(err.contains("port"), "got: {}", err);
}
#[test]
fn start_tunnel_askpass_none_does_not_set_env() {
let askpass: Option<&str> = None;
assert!(askpass.is_none());
}
#[test]
fn start_tunnel_askpass_some_triggers_env_setup() {
let askpass: Option<&str> = Some("keychain");
assert!(askpass.is_some());
}
#[test]
fn start_tunnel_askpass_empty_string_still_triggers() {
let askpass: Option<&str> = Some("");
assert!(askpass.is_some());
}
#[test]
fn start_tunnel_askpass_all_source_types_trigger() {
let sources = [
"keychain", "op://Vault/Item/pw", "bw:my-item",
"pass:ssh/server", "vault:secret/ssh#pw", "my-script %h",
];
for source in &sources {
let askpass: Option<&str> = Some(source);
assert!(askpass.is_some(), "askpass '{}' should trigger env setup", source);
}
}
#[test]
fn start_tunnel_env_var_names_match_connection() {
let expected = ["SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", "PURPLE_ASKPASS_MODE", "PURPLE_HOST_ALIAS"];
assert_eq!(expected.len(), 4);
assert_eq!(expected[2], "PURPLE_ASKPASS_MODE");
}
#[test]
fn start_tunnel_askpass_require_is_prefer() {
let value = "prefer";
assert_eq!(value, "prefer");
}
#[test]
fn start_tunnel_sets_config_path_env() {
let env_vars = ["SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", "PURPLE_ASKPASS_MODE",
"PURPLE_HOST_ALIAS", "PURPLE_CONFIG_PATH"];
assert!(env_vars.contains(&"PURPLE_CONFIG_PATH"));
}
#[test]
fn start_tunnel_does_not_set_bw_session() {
let tunnel_env_vars = ["SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", "PURPLE_ASKPASS_MODE",
"PURPLE_HOST_ALIAS", "PURPLE_CONFIG_PATH"];
assert!(!tunnel_env_vars.contains(&"BW_SESSION"));
}
#[test]
fn start_tunnel_stdin_is_null() {
let stdin_mode = "null";
assert_eq!(stdin_mode, "null");
}
#[test]
fn start_tunnel_uses_dash_n_flag() {
let flag = "-N";
assert_eq!(flag, "-N");
}
}