use anyhow::{bail, Context, Result};
use expanduser::expanduser;
use log::{debug, warn};
use std::collections::HashMap;
use std::default::Default;
use std::fmt::Display;
use std::fs::{read_dir, read_to_string};
use std::path::PathBuf;
use yaml_rust::{yaml::Hash, Yaml, YamlLoader};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
const CONTROLS_ENHANCED: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
use crate::util::*;
#[derive(Debug)]
pub struct Config {
pub auth: Auth,
default_host: Option<String>,
pub details: bool,
pub expire: Option<String>,
hosts: HashMap<String, Host>,
pub loglevel: log::LevelFilter,
pub prefix_length: u8,
pub verify_via_hash: bool,
}
#[derive(Debug, Clone)]
pub struct Auth {
pub from_openssh: bool,
pub interactive: bool,
pub private_key_file: Option<String>,
pub private_key_file_password: Option<String>,
pub use_agent: bool,
}
#[derive(Debug)]
pub struct Host {
pub alias: String,
pub auth: Auth,
pub expire: Option<String>,
pub folder: PathBuf,
pub group: Option<String>,
pub hostname: Option<String>,
pub password: Option<String>,
pub prefix_length: u8,
pub url: String,
pub user: Option<String>,
}
fn default_config_directories() -> Vec<&'static str> {
vec!["~/.config/asfa", "/etc/asfa"]
}
pub fn load<T: AsRef<str> + Display>(path: &Option<T>) -> Result<Config> {
let possible_paths: Vec<&str> = match path {
Some(path) => vec![path.as_ref()],
None => default_config_directories(),
};
for path in possible_paths.iter() {
match Config::load(path)? {
None => continue,
Some(cfg) => return Ok(cfg),
}
}
bail!("Did not find valid configuration!");
}
#[allow(dead_code)]
pub fn dummy_host_str() -> &'static str {
include_str!("dummy_host.yml")
}
#[allow(dead_code)]
pub fn dummy_host() -> Host {
Host::from_yaml(
"dummy_host".to_string(),
&YamlLoader::load_from_str(dummy_host_str()).unwrap()[0],
)
.unwrap()
}
impl Default for Config {
fn default() -> Self {
Config {
auth: Auth::default(),
default_host: None,
details: false,
expire: None,
hosts: HashMap::new(),
loglevel: log::LevelFilter::Info,
prefix_length: 32,
verify_via_hash: true,
}
}
}
impl Config {
pub fn load<T: AsRef<str> + Display>(dir: T) -> Result<Option<Config>> {
let config_dir = match expanduser(dir.as_ref()) {
Ok(p) => p,
Err(e) => {
bail!("Error when expanding path to config file: {}", e);
}
};
let global = {
let mut global = config_dir.clone();
global.push("config.yaml");
global
};
let raw: String = match read_to_string(&global) {
Err(e) => {
debug!(
"Could not read configuration file '{}', error: {}",
global.to_str().unwrap_or("invalid"),
e
);
return Ok(None);
}
Ok(raw) => raw,
};
let mut config = Self::from_yaml(&raw)?;
let hosts_dir = {
let mut hosts_dir = config_dir;
hosts_dir.push("hosts");
hosts_dir
};
if hosts_dir.is_dir() {
for entry in read_dir(&hosts_dir)? {
let possible_host = entry?.path();
match possible_host.extension() {
None => {
continue;
}
Some(ext) => {
if ext != "yaml" {
continue;
}
}
};
let alias = match possible_host.file_stem() {
None => {
warn!(
"Could not extract file stem for: {}",
possible_host.display()
);
continue;
}
Some(alias) => alias
.to_str()
.context("Could not convert host file name to String.")?
.to_string(),
};
if config.hosts.contains_key(&alias) {
bail!("Host {} configured in config.yaml and as host-file.", alias);
};
let host_yaml = YamlLoader::load_from_str(&read_to_string(&possible_host)?)?;
let error = format!("Invalid host-file for host {}", &alias);
let host =
Host::from_yaml_with_config(alias, &host_yaml[0], &config).context(error)?;
config.hosts.insert(host.alias.clone(), host);
}
}
Ok(Some(config))
}
pub fn from_yaml(input: &str) -> Result<Config> {
let documents = match YamlLoader::load_from_str(input) {
Ok(data) => data,
Err(e) => {
bail!("Error while loading config file: {}", e);
}
};
let mut config = Config::default();
let config_yaml = match &documents[0] {
Yaml::Hash(h) => h,
_ => {
bail!("Root object in configuration file is no dictionary!");
}
};
config.prefix_length = {
let length = get_int_from(config_yaml, "prefix_length")?
.cloned()
.unwrap_or(config.prefix_length as i64);
check_prefix_length(length)?;
length as u8
};
config.auth = if let Some(Yaml::Hash(auth)) = config_yaml.get(&yaml_string("auth")) {
match Auth::from_yaml(&auth, None) {
Ok(auth) => auth,
Err(e) => {
bail!("Could not read global authentication settings: {}", e);
}
}
} else {
config.auth
};
config.default_host =
std::env::var("ASFA_HOST")
.ok()
.or(get_string_from(config_yaml, "default_host")?.cloned());
if let Some(details) = get_bool_from(config_yaml, "details")?.cloned() {
config.details = details;
}
config.expire = get_string_from(config_yaml, "expire")?.cloned();
config.verify_via_hash = get_bool_from(config_yaml, "verify_via_hash")?
.cloned()
.unwrap_or(config.verify_via_hash);
match config_yaml.get(&yaml_string("hosts")) {
Some(Yaml::Hash(dict)) => {
for entry in dict.clone().entries() {
let alias = match entry.key() {
Yaml::String(alias) => alias.to_string(),
invalid => {
warn!("Found invalid alias for host entry: {:?}", invalid);
continue;
}
};
let host_yaml = entry.get();
let host = Host::from_yaml_with_config(alias.clone(), host_yaml, &config)?;
config.hosts.insert(alias, host);
}
}
Some(_) => {
bail!("'hosts' entry in config file needs to be dictionary mapping host-alias to configuration!");
}
None => {
debug!("No 'hosts'-entry in config file.");
}
};
Ok(config)
}
pub fn get_host<T: AsRef<str>>(&self, alias: Option<T>) -> Result<&Host> {
match alias
.as_ref()
.map(|a| a.as_ref())
.or_else(|| self.default_host.as_deref())
{
None => match self.hosts.len() {
0 => {
bail!("No hosts configured, define some!");
}
1 => Ok(self.hosts.values().next().unwrap()),
_ => {
bail!("More than one host entry defined but neither `default_host` set in config or --config given via command line.");
}
},
Some(alias) => Ok(self
.hosts
.get(alias)
.with_context(|| format!("Did not find alias: {}", alias))?),
}
}
pub fn is_silent(&self) -> bool {
matches!(self.loglevel, log::LevelFilter::Off)
}
}
impl Host {
fn from_yaml(alias: String, input: &Yaml) -> Result<Host> {
Self::from_yaml_with_config(alias, input, &Config::default())
}
fn from_yaml_with_config(alias: String, input: &Yaml, config: &Config) -> Result<Host> {
log::trace!("Reading host: {}", alias);
if let Yaml::Hash(dict) = input {
let url = get_required(dict, "url", get_string_from)?.clone();
let hostname = get_string_from(dict, "hostname")?.cloned();
let user = get_string_from(dict, "user")?.cloned();
let expire = get_string_from(dict, "expire")?
.cloned()
.or_else(|| config.expire.clone());
let folder = expanduser(get_required(dict, "folder", get_string_from)?)?;
let group = get_string_from(dict, "group")?.cloned();
let auth = match get_dict_from(dict, "auth")? {
Some(auth) => Auth::from_yaml(auth, Some(&config.auth))?,
None => config.auth.clone(),
};
let prefix_length = match get_int_from(dict, "prefix_length")? {
Some(prefix) => {
check_prefix_length(*prefix)?;
*prefix as u8
}
None => config.prefix_length,
};
let password = get_string_from(dict, "password")?.cloned();
Ok(Host {
alias,
auth,
expire,
folder,
group,
hostname,
password,
prefix_length,
url,
user,
})
} else {
bail!("Invalid yaml data for Host-alias '{}'", alias);
}
}
pub fn get_url(&self, file: &str) -> Result<String> {
Ok(format!(
"{}/{}",
&self.url,
utf8_percent_encode(file, CONTROLS_ENHANCED)
))
}
}
impl Auth {
fn from_yaml(dict: &Hash, default: Option<&Auth>) -> Result<Auth, InvalidYamlTypeError> {
let auth_default = Self::default();
let default = default.unwrap_or(&auth_default);
let use_agent = get_bool_from(dict, "use_agent")?
.cloned()
.unwrap_or(default.use_agent);
let interactive = get_bool_from(dict, "interactive")?
.cloned()
.unwrap_or(default.interactive);
let private_key_file = get_string_from(dict, "private_key_file")?
.cloned()
.or_else(|| default.private_key_file.clone());
let private_key_file_password = get_string_from(dict, "private_key_file_password")?
.cloned()
.or_else(|| default.private_key_file_password.clone());
let from_openssh = get_bool_from(dict, "from_openssh")?
.cloned()
.unwrap_or(default.from_openssh);
Ok(Auth {
from_openssh,
interactive,
private_key_file,
private_key_file_password,
use_agent,
})
}
}
impl Default for Auth {
fn default() -> Self {
Auth {
from_openssh: true,
interactive: true,
private_key_file: None,
private_key_file_password: None,
use_agent: true,
}
}
}
fn check_prefix_length(length: i64) -> Result<()> {
if !(8..=128).contains(&length) {
bail! {"Prefix needs to be between 8 and 128 characters."};
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::util;
#[test]
fn load_example_config() {
util::test::init().unwrap();
let cfg = crate::cfg::Config::load("example-config/asfa")
.unwrap()
.unwrap();
log::debug!("Loaded: {:?}", cfg);
assert_eq!(&cfg.hosts.len(), &2);
assert_eq!(&cfg.default_host.clone().unwrap(), &"my-remote-site");
assert_eq!(
&cfg.get_host(Some("my-remote-site-2")).unwrap().hostname,
&Some("my-hostname-2.eu".to_string())
);
}
}