use pest::Parser;
use std::fs;
use std::io::prelude::*;
use std::path;
#[derive(Parser)]
#[grammar = "ssh_config.pest"]
#[allow(dead_code)]
pub struct SSHConfigParser;
fn find_username_in_ssh_config(host: &str) -> Result<Option<String>, git2::Error> {
match read_ssh_config_as_string()? {
Some(content) => find_username_for_host_in_config(host, &content),
_ => Ok(None),
}
}
fn find_ssh_key_in_ssh_config(host: &str) -> Result<Option<String>, git2::Error> {
match read_ssh_config_as_string()? {
Some(content) => find_ssh_key_for_host_in_config(host, &content),
_ => Ok(None),
}
}
fn read_ssh_config_as_string() -> Result<Option<String>, git2::Error> {
dirs::home_dir()
.map(|home_path| {
let mut ssh_config_path = home_path;
ssh_config_path.push(".ssh");
ssh_config_path.push("config");
ssh_config_path
})
.filter(|p| p.exists())
.map(|ssh_config_path| {
let mut f = fs::File::open(&ssh_config_path).map_err(|source| {
git2::Error::from_str(&format!(
"failed to open {:?}: {:#?}",
ssh_config_path, source
))
})?;
let mut contents = String::new();
f.read_to_string(&mut contents).map_err(|source| {
git2::Error::from_str(&format!(
"failed to read {:?}: {:#?}",
ssh_config_path, source
))
})?;
Ok(Some(contents))
})
.unwrap_or(Ok(None))
}
fn find_username_for_host_in_config(
host: &str,
ssh_config_str: &str,
) -> Result<Option<String>, git2::Error> {
find_entry_for_host_in_config(host, ssh_config_str, "User")
}
fn find_ssh_key_for_host_in_config(
host: &str,
ssh_config_str: &str,
) -> Result<Option<String>, git2::Error> {
find_entry_for_host_in_config(host, ssh_config_str, "IdentityFile")
}
fn find_entry_for_host_in_config(
host: &str,
ssh_config_str: &str,
name: &str,
) -> Result<Option<String>, git2::Error> {
let pairs = SSHConfigParser::parse(Rule::config, ssh_config_str).map_err(|source| {
git2::Error::from_str(&format!("failed to parse .ssh/config: {:#?}", source))
})?;
for pair in pairs {
let mut inner_pairs = pair.into_inner().flatten();
let pattern = inner_pairs.find(|p| -> bool {
let pattern_str = String::from(p.as_str());
match pattern_str.contains('*') {
true => {
let pattern_str = pattern_str.replace('.', "\\.");
let pattern_str = pattern_str.replace('*', ".*");
let regexp = regex::Regex::new(pattern_str.as_str()).unwrap_or_else(|_| {
panic!(
"failed to parse converted regexp({}) from .ssh/config",
pattern_str
)
});
p.as_rule() == Rule::pattern && regexp.is_match(host)
}
false => p.as_rule() == Rule::pattern && p.as_str() == host,
}
});
match pattern {
Some(pattern) => {
let options = inner_pairs.filter(|p| -> bool { p.as_rule() == Rule::option });
for option in options {
let mut key_and_value = option.into_inner().flatten();
let key = key_and_value
.find(|p| -> bool { p.as_rule() == Rule::key })
.ok_or_else(|| {
git2::Error::from_str(&format!(
"key not found on .ssh/config for host {}",
pattern.as_str()
))
})?;
let value = key_and_value
.find(|p| -> bool { p.as_rule() == Rule::value_unquoted })
.ok_or_else(|| {
git2::Error::from_str(&format!(
"value not found on .ssh/config for host {} and key '{}'",
pattern.as_str(),
key
))
})?;
if key.as_str().eq_ignore_ascii_case(name) {
let found_value = value.as_str().to_string();
return Ok(Some(found_value));
}
}
}
None => continue,
};
}
Ok(None)
}
pub(crate) fn find_username_candidates(host: Option<&str>) -> Result<Vec<String>, git2::Error> {
let mut candidates = vec![];
if let Some(host) = host {
if let Some(username_host) = find_username_in_ssh_config(host)? {
candidates.push(username_host);
}
}
candidates.push("git".to_string());
if let Ok(s) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
candidates.push(s);
}
Ok(candidates)
}
pub(crate) fn find_ssh_key_candidates(
host: Option<&str>,
) -> Result<Vec<path::PathBuf>, git2::Error> {
let mut candidates = vec![];
if let Some(host) = host {
if let Some(key_for_host) = find_ssh_key_in_ssh_config(host)? {
candidates.push(key_for_host);
}
}
candidates.extend_from_slice(&[
"~/.ssh/id_dsa".to_string(),
"~/.ssh/id_ecdsa".to_string(),
"~/.ssh/id_ecdsa_sk".to_string(),
"~/.ssh/id_ed25519".to_string(),
"~/.ssh/id_ed25519_sk".to_string(),
"~/.ssh/id_rsa".to_string(),
]);
let hds = dirs::home_dir()
.map(|p| p.display().to_string())
.ok_or_else(|| git2::Error::from_str("could not get home directory"))?;
let candidates_path = candidates
.iter()
.map(|p| path::PathBuf::from(p.replace('~', &hds)))
.filter(|p| p.exists() && p.is_file())
.collect();
Ok(candidates_path)
}
pub(crate) fn get_ssh_key(
candidates: &[path::PathBuf],
candidate_idx: usize,
) -> Result<Option<path::PathBuf>, git2::Error> {
let key = candidates.get(candidate_idx);
match key {
Some(key_path) => {
let mut f = fs::File::open(key_path).unwrap();
let mut key = String::new();
f.read_to_string(&mut key).map_err(|source| {
git2::Error::from_str(&format!("failed to read {:?}: {:#?}", key_path, source))
})?;
f.rewind().map_err(|source| {
git2::Error::from_str(&format!(
"failed to set seek to 0 in {:?}: {:#?}",
key_path, source
))
})?;
Ok(Some(key_path.to_owned()))
}
None => {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_ssh_config_parser_no_failure() -> Result<(), Box<dyn std::error::Error>> {
let ssh_config_str = r#"
Host x
ff fff
zz " fofo bar "
Host z
z1 v
"#;
SSHConfigParser::parse(Rule::config, ssh_config_str)?;
Ok(())
}
#[test]
fn find_ssh_key_for_host_in_config_empty() {
let actual = find_ssh_key_for_host_in_config("github.com", r#""#);
assert_eq!(actual, Ok(None));
}
#[test]
fn find_ssh_key_for_host_in_config_not_defined() {
let actual = find_ssh_key_for_host_in_config(
"github.com",
r#"
host bitbucket.org
IdentityFile ~/.ssh/id_rsa_bitbucket
IdentitiesOnly "yes"
"#,
);
assert_eq!(actual, Ok(None));
}
#[test]
fn find_ssh_key_for_host_in_config_defined() {
let actual = find_ssh_key_for_host_in_config(
"bitbucket.org",
r#"
host bitbucket.org
IdentityFile ~/.ssh/id_rsa_bitbucket
IdentitiesOnly "yes"
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/id_rsa_bitbucket".to_string())));
}
#[test]
fn find_ssh_key_for_host_in_config_defined_multi_match() {
let actual = find_ssh_key_for_host_in_config(
"bitbucket.org",
r#"
host bitbucket.org
IdentityFile ~/.ssh/id_rsa_bitbucket
IdentitiesOnly "yes"
Host b*
IdentityFile ~/.ssh/id_rsa_b
IdentitiesOnly "yes"
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/id_rsa_bitbucket".to_string())));
}
#[test]
fn find_ssh_key_for_host_in_config_defined_pattern() {
let actual = find_ssh_key_for_host_in_config(
"bitbucket.org",
r#"
Host b*
IdentityFile ~/.ssh/id_rsa_b
IdentitiesOnly "yes"
host bitbucket.org
IdentityFile ~/.ssh/id_rsa_bitbucket
IdentitiesOnly "yes"
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/id_rsa_b".to_string())));
}
#[test]
fn find_ssh_key_for_host_in_config_nofailed_on_kexalgorithms() {
let actual = find_ssh_key_for_host_in_config(
"github.com",
r#"
KexAlgorithms +diffie-hellman-group1-sha1
Host github.com
HostName github.com
IdentityFile ~/.ssh/me
Host *
ServerAliveInterval 1
ServerAliveCountMax 300
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/me".to_string())));
}
#[test]
fn find_ssh_key_for_host_in_config_nofailed_on_comments() {
let actual = find_ssh_key_for_host_in_config(
"bitbucket.org",
r#"
# comment before
Host bitbucket.org
# comments
IdentityFile ~/.ssh/me # comments
IdentitiesOnly "yes"
# comments after last host ok too
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/me".to_string())));
}
#[test]
fn case_insensitive_keys() {
let actual = find_ssh_key_for_host_in_config(
"bitbucket.org",
r#"
host bitbucket.org
identityFILE ~/.ssh/me
IdentitiesOnly "yes"
"#,
);
assert_eq!(actual, Ok(Some("~/.ssh/me".to_string())));
}
}