use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::model::{
ConfigElement, Directive, HostBlock, IncludeDirective, IncludedFile, SshConfigFile,
};
const MAX_INCLUDE_DEPTH: usize = 5;
impl SshConfigFile {
pub fn parse(path: &Path) -> Result<Self> {
Self::parse_with_depth(path, 0)
}
fn parse_with_depth(path: &Path, depth: usize) -> Result<Self> {
let content = if path.exists() {
std::fs::read_to_string(path)
.with_context(|| format!("Failed to read SSH config at {}", path.display()))?
} else {
String::new()
};
let crlf = content.contains("\r\n");
let config_dir = path.parent().map(|p| p.to_path_buf());
let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
Ok(SshConfigFile {
elements,
path: path.to_path_buf(),
crlf,
})
}
#[allow(dead_code)]
pub fn parse_content(content: &str) -> Vec<ConfigElement> {
Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
}
fn parse_content_with_includes(
content: &str,
config_dir: Option<&Path>,
depth: usize,
) -> Vec<ConfigElement> {
let mut elements = Vec::new();
let mut current_block: Option<HostBlock> = None;
for line in content.lines() {
let trimmed = line.trim();
let is_indented = line.starts_with(' ') || line.starts_with('\t');
if !(current_block.is_some() && is_indented) {
if let Some(pattern) = Self::parse_include_line(trimmed) {
if let Some(block) = current_block.take() {
elements.push(ConfigElement::HostBlock(block));
}
let resolved = if depth < MAX_INCLUDE_DEPTH {
Self::resolve_include(pattern, config_dir, depth)
} else {
Vec::new()
};
elements.push(ConfigElement::Include(IncludeDirective {
raw_line: line.to_string(),
pattern: pattern.to_string(),
resolved_files: resolved,
}));
continue;
}
}
if !is_indented && Self::is_match_line(trimmed) {
if let Some(block) = current_block.take() {
elements.push(ConfigElement::HostBlock(block));
}
elements.push(ConfigElement::GlobalLine(line.to_string()));
continue;
}
if let Some(pattern) = Self::parse_host_line(trimmed) {
if let Some(block) = current_block.take() {
elements.push(ConfigElement::HostBlock(block));
}
current_block = Some(HostBlock {
host_pattern: pattern,
raw_host_line: line.to_string(),
directives: Vec::new(),
});
continue;
}
if let Some(ref mut block) = current_block {
if trimmed.is_empty() || trimmed.starts_with('#') {
block.directives.push(Directive {
key: String::new(),
value: String::new(),
raw_line: line.to_string(),
is_non_directive: true,
});
} else if let Some((key, value)) = Self::parse_directive(trimmed) {
block.directives.push(Directive {
key,
value,
raw_line: line.to_string(),
is_non_directive: false,
});
} else {
block.directives.push(Directive {
key: String::new(),
value: String::new(),
raw_line: line.to_string(),
is_non_directive: true,
});
}
} else {
elements.push(ConfigElement::GlobalLine(line.to_string()));
}
}
if let Some(block) = current_block {
elements.push(ConfigElement::HostBlock(block));
}
elements
}
fn parse_include_line(trimmed: &str) -> Option<&str> {
let bytes = trimmed.as_bytes();
if bytes.len() > 8
&& bytes[..7].eq_ignore_ascii_case(b"include")
&& bytes[7].is_ascii_whitespace()
{
let pattern = trimmed[8..].trim();
if !pattern.is_empty() {
return Some(pattern);
}
}
None
}
fn resolve_include(
pattern: &str,
config_dir: Option<&Path>,
depth: usize,
) -> Vec<IncludedFile> {
let mut files = Vec::new();
let mut seen = std::collections::HashSet::new();
for single in pattern.split_whitespace() {
let expanded = Self::expand_tilde(single);
let glob_pattern = if expanded.starts_with('/') {
expanded
} else if let Some(dir) = config_dir {
dir.join(&expanded).to_string_lossy().to_string()
} else {
continue;
};
if let Ok(paths) = glob::glob(&glob_pattern) {
let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
matched.sort();
for path in matched {
if path.is_file() && seen.insert(path.clone()) {
match std::fs::read_to_string(&path) {
Ok(content) => {
let elements = Self::parse_content_with_includes(
&content,
path.parent(),
depth + 1,
);
files.push(IncludedFile {
path: path.clone(),
elements,
});
}
Err(e) => {
eprintln!(
"! Could not read Include file {}: {}",
path.display(),
e
);
}
}
}
}
}
}
files
}
pub(crate) fn expand_tilde(pattern: &str) -> String {
if let Some(rest) = pattern.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return format!("{}/{}", home.display(), rest);
}
}
pattern.to_string()
}
fn parse_host_line(trimmed: &str) -> Option<String> {
let mut parts = trimmed.splitn(2, [' ', '\t']);
let keyword = parts.next()?;
if !keyword.eq_ignore_ascii_case("host") {
return None;
}
let raw_pattern = parts.next()?.trim();
let pattern = strip_inline_comment(raw_pattern).to_string();
if !pattern.is_empty() {
return Some(pattern);
}
None
}
fn is_match_line(trimmed: &str) -> bool {
let mut parts = trimmed.splitn(2, [' ', '\t']);
let keyword = parts.next().unwrap_or("");
keyword.eq_ignore_ascii_case("match")
}
fn parse_directive(trimmed: &str) -> Option<(String, String)> {
let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
let key = &trimmed[..key_end];
if key.is_empty() {
return None;
}
let rest = trimmed[key_end..].trim_start();
let rest = rest.strip_prefix('=').unwrap_or(rest);
let value = rest.trim_start();
let value = strip_inline_comment(value);
Some((key.to_string(), value.to_string()))
}
}
fn strip_inline_comment(value: &str) -> &str {
let bytes = value.as_bytes();
let mut in_quote = false;
for i in 0..bytes.len() {
if bytes[i] == b'"' {
in_quote = !in_quote;
} else if !in_quote
&& bytes[i] == b'#'
&& i > 0
&& (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
{
return value[..i].trim_end();
}
}
value
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn parse_str(content: &str) -> SshConfigFile {
SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: content.contains("\r\n"),
}
}
#[test]
fn test_empty_config() {
let config = parse_str("");
assert!(config.host_entries().is_empty());
}
#[test]
fn test_basic_host() {
let config = parse_str(
"Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n",
);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "myserver");
assert_eq!(entries[0].hostname, "192.168.1.10");
assert_eq!(entries[0].user, "admin");
assert_eq!(entries[0].port, 2222);
}
#[test]
fn test_multiple_hosts() {
let content = "\
Host alpha
HostName alpha.example.com
User deploy
Host beta
HostName beta.example.com
User root
Port 22022
";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].alias, "alpha");
assert_eq!(entries[1].alias, "beta");
assert_eq!(entries[1].port, 22022);
}
#[test]
fn test_wildcard_host_filtered() {
let content = "\
Host *
ServerAliveInterval 60
Host myserver
HostName 10.0.0.1
";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "myserver");
}
#[test]
fn test_comments_preserved() {
let content = "\
# Global comment
Host myserver
# This is a comment
HostName 10.0.0.1
User admin
";
let config = parse_str(content);
assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
if let ConfigElement::HostBlock(block) = &config.elements[1] {
assert!(block.directives[0].is_non_directive);
assert_eq!(block.directives[0].raw_line, " # This is a comment");
} else {
panic!("Expected HostBlock");
}
}
#[test]
fn test_identity_file_and_proxy_jump() {
let content = "\
Host bastion
HostName bastion.example.com
User admin
IdentityFile ~/.ssh/id_ed25519
ProxyJump gateway
";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
assert_eq!(entries[0].proxy_jump, "gateway");
}
#[test]
fn test_unknown_directives_preserved() {
let content = "\
Host myserver
HostName 10.0.0.1
ForwardAgent yes
LocalForward 8080 localhost:80
";
let config = parse_str(content);
if let ConfigElement::HostBlock(block) = &config.elements[0] {
assert_eq!(block.directives.len(), 3);
assert_eq!(block.directives[1].key, "ForwardAgent");
assert_eq!(block.directives[1].value, "yes");
assert_eq!(block.directives[2].key, "LocalForward");
} else {
panic!("Expected HostBlock");
}
}
#[test]
fn test_include_directive_parsed() {
let content = "\
Include config.d/*
Host myserver
HostName 10.0.0.1
";
let config = parse_str(content);
assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
}
#[test]
fn test_include_round_trip() {
let content = "\
Include ~/.ssh/config.d/*
Host myserver
HostName 10.0.0.1
";
let config = parse_str(content);
assert_eq!(config.serialize(), content);
}
#[test]
fn test_ssh_command() {
use crate::ssh_config::model::HostEntry;
let entry = HostEntry {
alias: "myserver".to_string(),
hostname: "10.0.0.1".to_string(),
..Default::default()
};
assert_eq!(entry.ssh_command(), "ssh -- 'myserver'");
}
#[test]
fn test_unicode_comment_no_panic() {
let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "myserver");
}
#[test]
fn test_unicode_multibyte_line_no_panic() {
let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
}
#[test]
fn test_host_with_tab_separator() {
let content = "Host\tmyserver\n HostName 10.0.0.1\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "myserver");
}
#[test]
fn test_include_with_tab_separator() {
let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
let config = parse_str(content);
assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
}
#[test]
fn test_hostname_not_confused_with_host() {
let content = "Host myserver\n HostName example.com\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].hostname, "example.com");
}
#[test]
fn test_equals_in_value_not_treated_as_separator() {
let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
}
#[test]
fn test_equals_syntax_key_value() {
let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].hostname, "10.0.0.1");
assert_eq!(entries[0].user, "admin");
}
#[test]
fn test_inline_comment_inside_quotes_preserved() {
let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
if let ConfigElement::HostBlock(block) = &config.elements[0] {
let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
} else {
panic!("Expected HostBlock");
}
}
#[test]
fn test_inline_comment_outside_quotes_stripped() {
let content = "Host myserver\n HostName 10.0.0.1 # production\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries[0].hostname, "10.0.0.1");
}
#[test]
fn test_host_inline_comment_stripped() {
let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
let config = parse_str(content);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "alpha");
if let ConfigElement::HostBlock(block) = &config.elements[0] {
assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
assert_eq!(block.host_pattern, "alpha");
} else {
panic!("Expected HostBlock");
}
}
#[test]
fn test_match_block_is_global_line() {
let content = "\
Host myserver
HostName 10.0.0.1
Match host *.example.com
ForwardAgent yes
";
let config = parse_str(content);
let host_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
assert_eq!(host_count, 1);
assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")));
assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent"))));
}
#[test]
fn test_match_block_survives_host_deletion() {
let content = "\
Host myserver
HostName 10.0.0.1
Match host *.example.com
ForwardAgent yes
Host other
HostName 10.0.0.2
";
let mut config = parse_str(content);
config.delete_host("myserver");
let output = config.serialize();
assert!(output.contains("Match host *.example.com"));
assert!(output.contains("ForwardAgent yes"));
assert!(output.contains("Host other"));
assert!(!output.contains("Host myserver"));
}
#[test]
fn test_match_block_round_trip() {
let content = "\
Host myserver
HostName 10.0.0.1
Match host *.example.com
ForwardAgent yes
";
let config = parse_str(content);
assert_eq!(config.serialize(), content);
}
#[test]
fn test_match_at_start_of_file() {
let content = "\
Match all
ServerAliveInterval 60
Host myserver
HostName 10.0.0.1
";
let config = parse_str(content);
assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval")));
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "myserver");
}
#[test]
fn test_host_multi_pattern_with_inline_comment() {
let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
let config = parse_str(content);
if let ConfigElement::HostBlock(block) = &config.elements[0] {
assert_eq!(block.host_pattern, "prod staging");
} else {
panic!("Expected HostBlock");
}
assert_eq!(config.host_entries().len(), 0);
}
}