use crate::ssh::ssh_config::include::{combine_included_files, resolve_includes};
use crate::ssh::ssh_config::match_directive::{MatchBlock, MatchCondition};
use crate::ssh::ssh_config::types::{ConfigBlock, SshHostConfig};
use anyhow::{Context, Result};
use std::path::Path;
use super::options;
pub fn parse(content: &str) -> Result<Vec<SshHostConfig>> {
parse_without_includes(content)
}
pub async fn parse_from_file(path: &Path, content: &str) -> Result<Vec<SshHostConfig>> {
let included_files = resolve_includes(path, content)
.await
.with_context(|| format!("Failed to resolve includes for {}", path.display()))?;
let combined_content = combine_included_files(&included_files);
parse_without_includes(&combined_content)
}
pub(super) fn parse_without_includes(content: &str) -> Result<Vec<SshHostConfig>> {
const MAX_LINE_LENGTH: usize = 8192; const MAX_VALUE_LENGTH: usize = 4096;
let mut configs = Vec::new();
let mut current_config: Option<SshHostConfig> = None;
let mut current_match: Option<MatchBlock> = None;
let mut line_number = 0;
let mut in_match_block = false;
for line in content.lines() {
line_number += 1;
if line.starts_with("# Source:") {
continue;
}
if line.len() > MAX_LINE_LENGTH {
anyhow::bail!("Line {line_number} exceeds maximum length of {MAX_LINE_LENGTH} bytes");
}
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let lower_line = line.to_lowercase();
if lower_line.starts_with("include") {
tracing::debug!(
"Skipping Include directive at line {} (not in file mode)",
line_number
);
continue;
}
if lower_line.starts_with("match ")
|| lower_line.starts_with("match\t")
|| lower_line == "match"
|| lower_line.starts_with("match=")
{
if let Some(config) = current_config.take() {
configs.push(config);
}
if let Some(match_block) = current_match.take() {
configs.push(match_block.config);
}
let conditions = MatchCondition::parse_match_line(line, line_number)?;
let mut match_block = MatchBlock::new(line_number);
match_block.conditions = conditions.clone();
let config = SshHostConfig {
block_type: Some(ConfigBlock::Match(conditions)),
..Default::default()
};
match_block.config = config;
current_match = Some(match_block);
current_config = None;
in_match_block = true;
continue;
}
if lower_line.starts_with("host ")
|| lower_line.starts_with("host\t")
|| lower_line == "host"
|| (lower_line.starts_with("host=") && !lower_line.starts_with("hostname="))
{
if let Some(config) = current_config.take() {
configs.push(config);
}
if let Some(match_block) = current_match.take() {
configs.push(match_block.config);
}
let patterns = parse_host_line(line, line_number)?;
let config = SshHostConfig {
host_patterns: patterns.clone(),
block_type: Some(ConfigBlock::Host(patterns)),
..Default::default()
};
current_config = Some(config);
current_match = None;
in_match_block = false;
continue;
}
let (keyword, args) = parse_config_line(line, line_number, MAX_VALUE_LENGTH)?;
if keyword.is_empty() {
continue;
}
if in_match_block {
if let Some(ref mut match_block) = current_match {
options::parse_option(&mut match_block.config, &keyword, &args, line_number)
.with_context(|| format!("Error at line {line_number}: {line}"))?;
}
} else if let Some(ref mut config) = current_config {
options::parse_option(config, &keyword, &args, line_number)
.with_context(|| format!("Error at line {line_number}: {line}"))?;
} else {
tracing::debug!(
"Ignoring global option '{}' at line {}",
keyword,
line_number
);
}
}
if let Some(config) = current_config {
configs.push(config);
}
if let Some(match_block) = current_match {
configs.push(match_block.config);
}
Ok(configs)
}
pub(super) fn parse_host_line(line: &str, line_number: usize) -> Result<Vec<String>> {
let line = line.trim();
let patterns_str = if let Some(pos) = line.find('=') {
if line[..pos].trim().to_lowercase() != "host" {
anyhow::bail!("Invalid Host directive at line {line_number}");
}
line[pos + 1..].trim()
} else {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() || parts[0].to_lowercase() != "host" {
anyhow::bail!("Invalid Host directive at line {line_number}");
}
if parts.len() < 2 {
anyhow::bail!("Host directive requires at least one pattern at line {line_number}");
}
line[parts[0].len()..].trim()
};
if patterns_str.is_empty() {
anyhow::bail!("Host directive requires at least one pattern at line {line_number}");
}
let patterns: Vec<String> = patterns_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
Ok(patterns)
}
pub(super) fn parse_config_line(
line: &str,
line_number: usize,
max_value_length: usize,
) -> Result<(String, Vec<String>)> {
let line = line.trim();
let eq_pos = line.find('=');
let uses_equals_syntax = if let Some(pos) = eq_pos {
let prefix = &line[..pos];
let first_word = prefix
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
!matches!(first_word.as_str(), "host" | "match")
} else {
false
};
let (keyword, args) = if let Some(pos) = eq_pos.filter(|_| uses_equals_syntax) {
let key_part = line[..pos].trim();
let value_part = &line[pos + 1..];
if key_part.is_empty() {
return Ok((String::new(), vec![]));
}
let trimmed_value = value_part.trim();
if trimmed_value.len() > max_value_length {
anyhow::bail!(
"Value at line {line_number} exceeds maximum length of {max_value_length} bytes"
);
}
let args = if trimmed_value.is_empty() {
vec![]
} else {
match key_part.to_lowercase().as_str() {
"ciphers"
| "macs"
| "hostkeyalgorithms"
| "kexalgorithms"
| "preferredauthentications"
| "protocol" => trimmed_value
.split(',')
.map(|s| s.trim().to_string())
.collect(),
_ => vec![trimmed_value.to_string()],
}
};
(key_part.to_lowercase(), args)
} else {
let mut parts = line.split_whitespace();
let keyword = parts.next().unwrap_or("").to_lowercase();
let args: Vec<String> = parts.map(|s| s.to_string()).collect();
(keyword, args)
};
Ok((keyword, args))
}