use crate::error::{Result, SyncError};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)] pub struct SshConfig {
pub hostname: String,
pub port: u16,
pub user: String,
pub identity_file: Vec<PathBuf>,
pub proxy_jump: Option<String>,
pub control_master: bool,
pub control_path: Option<PathBuf>,
pub control_persist: Option<Duration>,
pub compression: bool,
}
impl Default for SshConfig {
fn default() -> Self {
Self {
hostname: String::new(),
port: 22,
user: whoami::username(),
identity_file: Vec::new(),
proxy_jump: None,
control_master: false,
control_path: None,
control_persist: None,
compression: false,
}
}
}
impl SshConfig {
pub fn new(host: &str) -> Self {
Self {
hostname: host.to_string(),
port: 22,
user: whoami::username(),
identity_file: Vec::new(),
proxy_jump: None,
control_master: false,
control_path: None,
control_persist: None,
compression: false,
}
}
fn expand_path(path: &str) -> PathBuf {
if let Some(home) = dirs::home_dir() {
PathBuf::from(path.replace('~', &home.display().to_string()))
} else {
PathBuf::from(path)
}
}
}
#[allow(dead_code)] pub fn parse_ssh_config(host: &str) -> Result<SshConfig> {
let config_path = dirs::home_dir()
.ok_or_else(|| SyncError::Io(std::io::Error::other("Cannot find home directory")))?
.join(".ssh/config");
if !config_path.exists() {
return Ok(SshConfig::new(host));
}
let content = fs::read_to_string(&config_path).map_err(|e| SyncError::ReadDirError {
path: config_path,
source: e,
})?;
parse_ssh_config_from_str(host, &content)
}
pub fn parse_ssh_config_from_str(host: &str, content: &str) -> Result<SshConfig> {
let mut config = SshConfig::new(host);
let mut in_matching_host = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let keyword = parts[0].to_lowercase();
match keyword.as_str() {
"host" => {
let host_patterns: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
in_matching_host = host_patterns
.iter()
.any(|pattern| host_matches(host, pattern));
}
_ if !in_matching_host => {
continue;
}
"hostname" => {
if let Some(value) = parts.get(1) {
config.hostname = value.to_string();
}
}
"port" => {
if let Some(value) = parts.get(1)
&& let Ok(port) = value.parse::<u16>() {
config.port = port;
}
}
"user" => {
if let Some(value) = parts.get(1) {
config.user = value.to_string();
}
}
"identityfile" => {
if let Some(value) = parts.get(1) {
config.identity_file.push(SshConfig::expand_path(value));
}
}
"proxyjump" => {
if let Some(value) = parts.get(1) {
config.proxy_jump = Some(value.to_string());
}
}
"controlmaster" => {
if let Some(value) = parts.get(1) {
config.control_master = matches!(value.to_lowercase().as_str(), "yes" | "auto");
}
}
"controlpath" => {
if let Some(value) = parts.get(1) {
config.control_path = Some(SshConfig::expand_path(value));
}
}
"controlpersist" => {
if let Some(value) = parts.get(1) {
config.control_persist = parse_duration(value);
}
}
"compression" => {
if let Some(value) = parts.get(1) {
config.compression = value.to_lowercase() == "yes";
}
}
_ => {
}
}
}
Ok(config)
}
fn host_matches(host: &str, pattern: &str) -> bool {
if let Some(negated_pattern) = pattern.strip_prefix('!') {
return !host_matches(host, negated_pattern);
}
let pattern = pattern.replace('.', r"\.");
let pattern = pattern.replace('*', ".*");
let pattern = pattern.replace('?', ".");
regex::Regex::new(&format!("^{}$", pattern))
.map(|re| re.is_match(host))
.unwrap_or(false)
}
fn parse_duration(value: &str) -> Option<Duration> {
let value = value.trim();
if value == "yes" {
return Some(Duration::from_secs(365 * 24 * 60 * 60));
}
if value == "no" {
return None;
}
let (num_str, unit) = if let Some(num) = value.strip_suffix('s') {
(num, "s")
} else if let Some(num) = value.strip_suffix('m') {
(num, "m")
} else if let Some(num) = value.strip_suffix('h') {
(num, "h")
} else if let Some(num) = value.strip_suffix('d') {
(num, "d")
} else {
(value, "s") };
let num: u64 = num_str.parse().ok()?;
let seconds = match unit {
"s" => num,
"m" => num * 60,
"h" => num * 60 * 60,
"d" => num * 24 * 60 * 60,
_ => return None,
};
Some(Duration::from_secs(seconds))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_config_defaults() {
let config = SshConfig::new("example.com");
assert_eq!(config.hostname, "example.com");
assert_eq!(config.port, 22);
assert_eq!(config.user, whoami::username());
assert!(config.identity_file.is_empty());
assert!(!config.control_master);
}
#[test]
fn test_parse_simple_config() {
let content = r#"
Host example
HostName example.com
Port 2222
User admin
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert_eq!(config.hostname, "example.com");
assert_eq!(config.port, 2222);
assert_eq!(config.user, "admin");
}
#[test]
fn test_parse_wildcard_host() {
let content = r#"
Host *.example.com
User admin
Port 2222
Host specific.example.com
Port 3333
"#;
let config = parse_ssh_config_from_str("test.example.com", content).unwrap();
assert_eq!(config.user, "admin");
assert_eq!(config.port, 2222);
let config2 = parse_ssh_config_from_str("specific.example.com", content).unwrap();
assert_eq!(config2.port, 3333);
}
#[test]
fn test_parse_identity_file() {
let content = r#"
Host example
IdentityFile ~/.ssh/id_rsa
IdentityFile ~/.ssh/id_ed25519
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert_eq!(config.identity_file.len(), 2);
}
#[test]
fn test_parse_proxy_jump() {
let content = r#"
Host internal
ProxyJump bastion.example.com
"#;
let config = parse_ssh_config_from_str("internal", content).unwrap();
assert_eq!(config.proxy_jump, Some("bastion.example.com".to_string()));
}
#[test]
fn test_parse_control_master() {
let content = r#"
Host example
ControlMaster auto
ControlPath ~/.ssh/control-%r@%h:%p
ControlPersist 10m
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert!(config.control_master);
assert!(config.control_path.is_some());
assert_eq!(config.control_persist, Some(Duration::from_secs(600)));
}
#[test]
fn test_parse_compression() {
let content = r#"
Host example
Compression yes
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert!(config.compression);
}
#[test]
fn test_host_matching() {
assert!(host_matches("example.com", "example.com"));
assert!(host_matches("test.example.com", "*.example.com"));
assert!(host_matches("example.com", "*.com"));
assert!(!host_matches("example.org", "*.com"));
assert!(host_matches("test", "?est"));
assert!(!host_matches("example.com", "!example.com"));
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration("10m"), Some(Duration::from_secs(600)));
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
assert_eq!(
parse_duration("yes"),
Some(Duration::from_secs(365 * 24 * 60 * 60))
);
assert_eq!(parse_duration("no"), None);
}
#[test]
fn test_non_matching_host() {
let content = r#"
Host other
Port 2222
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert_eq!(config.port, 22);
}
#[test]
fn test_comments_and_empty_lines() {
let content = r#"
# This is a comment
Host example
# Another comment
Port 2222
User admin
"#;
let config = parse_ssh_config_from_str("example", content).unwrap();
assert_eq!(config.port, 2222);
assert_eq!(config.user, "admin");
}
}