use std::path::{Path, PathBuf};
use crate::error::AnvilError;
#[derive(Debug, Clone)]
pub(crate) struct TokenLine {
pub(crate) keyword: String,
pub(crate) args: Vec<String>,
pub(crate) file: PathBuf,
pub(crate) line_no: u32,
}
pub(crate) fn tokenize(content: &str, file: &Path) -> Result<Vec<TokenLine>, AnvilError> {
let mut tokens = Vec::new();
let mut accum = String::new();
let mut accum_line: u32 = 0;
for (idx, raw_line) in content.lines().enumerate() {
let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
let trimmed_end = raw_line.trim_end();
if let Some(stripped) = trimmed_end.strip_suffix('\\') {
if accum.is_empty() {
accum_line = line_no;
}
accum.push_str(stripped);
accum.push(' '); continue;
}
if accum.is_empty() {
accum_line = line_no;
}
accum.push_str(raw_line);
let logical_line = std::mem::take(&mut accum);
let start_line = accum_line;
if let Some(token) = tokenize_line(&logical_line, file, start_line)? {
tokens.push(token);
}
}
if !accum.is_empty() {
if let Some(token) = tokenize_line(&accum, file, accum_line)? {
tokens.push(token);
}
}
Ok(tokens)
}
fn tokenize_line(line: &str, file: &Path, line_no: u32) -> Result<Option<TokenLine>, AnvilError> {
let mut args: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut have_token = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if !in_quotes && c == '#' {
break;
}
if c == '"' {
in_quotes = !in_quotes;
have_token = true;
continue;
}
if in_quotes {
if c == '\\' {
if let Some(&next) = chars.peek() {
if next == '"' || next == '\\' {
current.push(next);
chars.next();
continue;
}
}
}
current.push(c);
have_token = true;
continue;
}
if c.is_whitespace() || c == '=' {
if have_token {
args.push(std::mem::take(&mut current));
have_token = false;
}
continue;
}
current.push(c);
have_token = true;
}
if in_quotes {
return Err(AnvilError::invalid_config(format!(
"ssh_config: unterminated quoted string at {}:{}",
file.display(),
line_no,
)));
}
if have_token {
args.push(current);
}
if args.is_empty() {
return Ok(None);
}
let keyword = args.remove(0).to_ascii_lowercase();
Ok(Some(TokenLine {
keyword,
args,
file: file.to_path_buf(),
line_no,
}))
}
pub(crate) fn expand_tilde(value: &str) -> String {
if value == "~" {
return dirs::home_dir()
.map_or_else(|| value.to_owned(), |h| h.to_string_lossy().into_owned());
}
if let Some(rest) = value.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
let mut p = home;
p.push(rest);
return p.to_string_lossy().into_owned();
}
return value.to_owned();
}
if value.starts_with('~') {
log::warn!("ssh_config: `~user/` syntax is not supported; treating literally: {value}",);
}
value.to_owned()
}
pub(crate) fn expand_env(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
match chars.peek().copied() {
Some('{') => {
chars.next();
let mut name = String::new();
let mut closed = false;
for inner in chars.by_ref() {
if inner == '}' {
closed = true;
break;
}
name.push(inner);
}
if closed {
if let Ok(val) = std::env::var(&name) {
out.push_str(&val);
}
} else {
out.push('$');
out.push('{');
out.push_str(&name);
}
}
Some(next) if next.is_ascii_alphabetic() || next == '_' => {
let mut name = String::new();
while let Some(&peek) = chars.peek() {
if peek.is_ascii_alphanumeric() || peek == '_' {
name.push(peek);
chars.next();
} else {
break;
}
}
if let Ok(val) = std::env::var(&name) {
out.push_str(&val);
}
}
_ => out.push('$'),
}
}
out
}
pub(crate) fn wildcard_match(pattern: &str, value: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let val: Vec<char> = value.chars().collect();
let mut i = 0_usize;
let mut j = 0_usize;
let mut star_i: Option<usize> = None;
let mut match_j: usize = 0;
while j < val.len() {
if i < pat.len() && (pat[i] == '?' || pat[i] == val[j]) {
i += 1;
j += 1;
} else if i < pat.len() && pat[i] == '*' {
star_i = Some(i);
match_j = j;
i += 1;
} else if let Some(si) = star_i {
i = si + 1;
match_j += 1;
j = match_j;
} else {
return false;
}
}
while i < pat.len() && pat[i] == '*' {
i += 1;
}
i == pat.len()
}
#[cfg(test)]
mod tests {
use super::*;
fn p() -> PathBuf {
PathBuf::from("test")
}
#[test]
fn simple_directive() {
let toks = tokenize("Host github.com", &p()).expect("tokenize");
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].keyword, "host");
assert_eq!(toks[0].args, vec!["github.com"]);
assert_eq!(toks[0].line_no, 1);
}
#[test]
fn keyword_lowercased() {
let toks = tokenize("HOSTname Example.COM", &p()).expect("tokenize");
assert_eq!(toks[0].keyword, "hostname");
assert_eq!(toks[0].args, vec!["Example.COM"]);
}
#[test]
fn strips_comments() {
let input = "# leading\n # indented\nHost gh # trailing\n";
let toks = tokenize(input, &p()).expect("tokenize");
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].args, vec!["gh"]);
assert_eq!(toks[0].line_no, 3);
}
#[test]
fn line_continuation_joins_next_line() {
let input = "Host \\\n gh1 gh2\n";
let toks = tokenize(input, &p()).expect("tokenize");
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].keyword, "host");
assert_eq!(toks[0].args, vec!["gh1", "gh2"]);
assert_eq!(toks[0].line_no, 1);
}
#[test]
fn line_continuation_at_eof() {
let toks = tokenize("Host gh \\", &p()).expect("tokenize");
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].args, vec!["gh"]);
}
#[test]
fn quoted_argument_preserves_spaces() {
let toks = tokenize(r#"ProxyCommand "ssh -W %h:%p bastion""#, &p()).expect("tokenize");
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].keyword, "proxycommand");
assert_eq!(toks[0].args, vec!["ssh -W %h:%p bastion"]);
}
#[test]
fn quoted_argument_handles_escapes() {
let toks = tokenize(r#"Host "with \"quote\"""#, &p()).expect("tokenize");
assert_eq!(toks[0].args, vec![r#"with "quote""#]);
}
#[test]
fn keyword_equals_value_form() {
let toks = tokenize("Port=2222", &p()).expect("tokenize");
assert_eq!(toks[0].keyword, "port");
assert_eq!(toks[0].args, vec!["2222"]);
}
#[test]
fn keyword_equals_with_spaces() {
let toks = tokenize("Port = 2222", &p()).expect("tokenize");
assert_eq!(toks[0].args, vec!["2222"]);
}
#[test]
fn unterminated_quote_errors() {
let err = tokenize(r#"Host "unclosed"#, &p()).expect_err("should fail");
let msg = format!("{err}");
assert!(
msg.contains("unterminated"),
"expected message about unterminated quote, got: {msg}"
);
}
#[test]
fn empty_input_yields_no_tokens() {
let toks = tokenize("", &p()).expect("tokenize");
assert!(toks.is_empty());
}
#[test]
fn blank_and_comment_lines_yield_no_tokens() {
let toks = tokenize("\n# c\n \n# more\n", &p()).expect("tokenize");
assert!(toks.is_empty());
}
#[test]
fn multiple_arguments() {
let toks = tokenize("Host alpha beta gamma", &p()).expect("tokenize");
assert_eq!(toks[0].args, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn comment_inside_quotes_is_preserved() {
let toks = tokenize(r#"ProxyCommand "echo #not-a-comment""#, &p()).expect("tokenize");
assert_eq!(toks[0].args, vec!["echo #not-a-comment"]);
}
#[test]
fn crlf_line_endings_are_handled() {
let toks = tokenize("Host gh\r\nUser git\r\n", &p()).expect("tokenize");
assert_eq!(toks.len(), 2);
assert_eq!(toks[0].keyword, "host");
assert_eq!(toks[1].keyword, "user");
assert_eq!(toks[1].line_no, 2);
}
#[test]
fn line_numbers_track_per_logical_line() {
let input = "Host a\n\n# c\nHost b\n";
let toks = tokenize(input, &p()).expect("tokenize");
assert_eq!(toks.len(), 2);
assert_eq!(toks[0].line_no, 1);
assert_eq!(toks[1].line_no, 4);
}
#[test]
fn empty_quoted_argument_is_present() {
let toks = tokenize(r#"User """#, &p()).expect("tokenize");
assert_eq!(toks[0].keyword, "user");
assert_eq!(toks[0].args, vec![""]);
}
#[test]
fn expand_tilde_replaces_leading_slash_form() {
let home = dirs::home_dir().expect("home dir available in test env");
let expected = home.join(".ssh").join("config");
let actual = expand_tilde("~/.ssh/config");
assert_eq!(Path::new(&actual), expected);
}
#[test]
fn expand_tilde_alone_replaces_to_home() {
let home = dirs::home_dir().expect("home dir available in test env");
assert_eq!(expand_tilde("~"), home.to_string_lossy().into_owned());
}
#[test]
fn expand_tilde_in_middle_unchanged() {
assert_eq!(expand_tilde("/path/~name"), "/path/~name");
}
#[test]
fn expand_tilde_user_form_treated_literally() {
assert_eq!(expand_tilde("~root/foo"), "~root/foo");
}
#[test]
fn expand_tilde_no_tilde_unchanged() {
assert_eq!(expand_tilde("/etc/ssh/ssh_config"), "/etc/ssh/ssh_config");
assert_eq!(expand_tilde(""), "");
}
#[test]
fn expand_env_braced_known_var() {
let result = expand_env("${PATH}");
let path = std::env::var("PATH").expect("PATH set in test env");
assert_eq!(result, path);
}
#[test]
fn expand_env_unbraced_known_var() {
let result = expand_env("$PATH");
let path = std::env::var("PATH").expect("PATH set in test env");
assert_eq!(result, path);
}
#[test]
fn expand_env_braced_unknown_var_is_empty() {
assert_eq!(expand_env("${ANVIL_DEFINITELY_UNSET_XYZZY_42}"), "");
assert_eq!(expand_env("a-${ANVIL_DEFINITELY_UNSET_XYZZY_42}-b"), "a--b",);
}
#[test]
fn expand_env_dollar_alone_preserved() {
assert_eq!(expand_env("price: $"), "price: $");
assert_eq!(expand_env("$5 dollars"), "$5 dollars");
}
#[test]
fn expand_env_unterminated_brace_preserved() {
assert_eq!(expand_env("${UNCLOSED"), "${UNCLOSED");
}
#[test]
fn expand_env_no_dollar_unchanged() {
assert_eq!(expand_env("/etc/ssh/ssh_config"), "/etc/ssh/ssh_config");
}
#[test]
fn wildcard_match_exact() {
assert!(wildcard_match("github.com", "github.com"));
assert!(!wildcard_match("github.com", "gitlab.com"));
}
#[test]
fn wildcard_match_star_anything() {
assert!(wildcard_match("*", "anything"));
assert!(wildcard_match("*", ""));
}
#[test]
fn wildcard_match_star_suffix() {
assert!(wildcard_match("*.com", "github.com"));
assert!(wildcard_match("*.com", ".com"));
assert!(!wildcard_match("*.com", "github.org"));
}
#[test]
fn wildcard_match_question() {
assert!(wildcard_match("git?ub.com", "github.com"));
assert!(!wildcard_match("g?", "github"));
assert!(wildcard_match("g?", "gh"));
}
#[test]
fn wildcard_match_combined() {
assert!(wildcard_match("*.example.???", "foo.example.com"));
assert!(!wildcard_match("*.example.???", "foo.example.online"));
assert!(wildcard_match("a*b*c", "axxxbyyyc"));
}
#[test]
fn wildcard_match_empty() {
assert!(wildcard_match("", ""));
assert!(!wildcard_match("", "x"));
assert!(!wildcard_match("x", ""));
}
#[test]
fn wildcard_match_consecutive_stars_collapse() {
assert!(wildcard_match("**", "anything"));
assert!(wildcard_match("a**b", "ab"));
assert!(wildcard_match("a**b", "axyzb"));
}
}